diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 000000000..a21bc7205 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,9 @@ +--- +active: true +iteration: 3 +max_iterations: 40 +completion_promise: "PR_READY" +started_at: "2026-02-03T00:48:37Z" +--- + +TASK: Converge the current open Pull Request to merge-ready by addressing every review comment, replying to each thread, ensuring all CI checks pass, and completing the existing implementation plan.\n\nSUCCESS CRITERIA:\n- PR has ZERO unresolved review threads/conversations.\n- Every reviewer comment has been explicitly acknowledged with a reply explaining what changed (or why no change is needed).\n- All required CI checks are green (no failing or pending required checks).\n- The original plan (the one this PR is based on) is fully completed: all planned tasks are implemented, and any plan checklist/tracker is updated to show completion.\n- Repo validations pass locally where applicable (tests/build/lint/typecheck for this repo), or CI equivalents are confirmed green.\n- PR description is up to date (summarize what changed + how to validate), and no TODO/FIXME markers remain related to review feedback.\n\nPROCESS (repeat until success):\n1) Discover the PR context: identify the PR number/link, read the PR description, commits, files changed, and the plan/tracker the PR references.\n2) Collect all feedback: list every review comment + thread, label each as (a) code change required, (b) question/clarification, (c) optional suggestion, (d) out-of-scope.\n3) Pick the smallest actionable item (ONE thread at a time): implement the minimal change that resolves it.\n4) Run the fastest relevant local validation (targeted tests/lint/typecheck/build). If not available, rely on CI but still do best-effort local checks.\n5) Commit with a clear message referencing the thread/topic. Push.\n6) Reply in the PR thread describing exactly what you changed and where (files/lines), and mark the thread resolved if appropriate.\n7) Re-check CI status. If failing, fix the failure, push, and update any relevant PR replies.\n8) Repeat until all threads are resolved AND all required checks are green AND the plan is complete.\n\nCOMMUNICATION RULES:\n- Always reply politely and concretely. If disagreeing, explain why and propose an alternative.\n- If a comment requires a product/architecture decision that you cannot infer from context, ask a single concise question in the PR and create a TODO note, then continue with other threads.\n\nESCAPE HATCH:\n- After 25 iterations, if not complete, output BLOCKED and include: (1) remaining unresolved threads with links/quotes, (2) latest CI failures with logs summary, (3) what you tried, (4) the minimal questions needed from a human to proceed.\n\nOUTPUT: Only output PR_READY when ALL success criteria are met. diff --git a/apps/web/package.json b/apps/web/package.json index d623da95f..efb1edd3e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,6 +50,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@stripe/react-stripe-js": "^5.4.1", @@ -86,6 +87,7 @@ "next-ws": "^2.1.5", "ollama-ai-provider-v2": "^1.3.1", "prettier": "^3.6.2", + "radix-ui": "^1.4.3", "react": "^19.1.2", "react-day-picker": "^9.11.3", "react-dom": "^19.1.2", 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..ee648fb89 --- /dev/null +++ b/apps/web/src/app/api/calendar/events/[eventId]/attendees/route.ts @@ -0,0 +1,467 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + db, + calendarEvents, + eventAttendees, + eq, + and, +} from '@pagespace/db'; +import { loggers, getDriveMemberUserIds } from '@pagespace/lib/server'; +import { isUserDriveMember } from '@pagespace/lib'; +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 + * + * Access rules: + * - PRIVATE: only creator can view + * - ATTENDEES_ONLY: creator or attendees can view + * - DRIVE: creator, attendees, or drive members can view + */ +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 + 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 }); + } + + const isCreator = event.createdById === userId; + + // PRIVATE events: only creator can access + if (event.visibility === 'PRIVATE') { + if (!isCreator) { + return NextResponse.json( + { error: 'Access denied - this is a private event' }, + { status: 403 } + ); + } + } + // ATTENDEES_ONLY events: creator or attendees can access + else if (event.visibility === 'ATTENDEES_ONLY') { + if (!isCreator) { + const isAttendee = await db.query.eventAttendees.findFirst({ + where: and( + eq(eventAttendees.eventId, eventId), + eq(eventAttendees.userId, userId) + ), + }); + + if (!isAttendee) { + return NextResponse.json( + { error: 'Access denied - you are not an attendee of this event' }, + { status: 403 } + ); + } + } + } + // DRIVE events: creator, attendees, or drive members can access + else if (event.visibility === 'DRIVE') { + if (!isCreator) { + // Check if user is an attendee first (fast path) + const isAttendee = await db.query.eventAttendees.findFirst({ + where: and( + eq(eventAttendees.eventId, eventId), + eq(eventAttendees.userId, userId) + ), + }); + + if (!isAttendee) { + // Must be a drive member + if (!event.driveId) { + return NextResponse.json( + { error: 'Access denied - invalid event configuration' }, + { status: 403 } + ); + } + + const isDriveMember = await isUserDriveMember(userId, event.driveId); + if (!isDriveMember) { + return NextResponse.json( + { error: 'Access denied - you do not have access to this drive' }, + { status: 403 } + ); + } + } + } + } + + // 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) + * + * Constraints: + * - PRIVATE events cannot have attendees (only creator) + * - Drive events: attendees must be drive members + */ +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 } + ); + } + + // PRIVATE events cannot have additional attendees + if (event.visibility === 'PRIVATE') { + return NextResponse.json( + { error: 'Private events cannot have attendees other than the creator' }, + { status: 400 } + ); + } + + 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: rawUserIds, isOptional } = parseResult.data; + + // Deduplicate userIds to prevent unique constraint errors + const userIds = [...new Set(rawUserIds)]; + + // For drive events, verify all proposed attendees are drive members + if (event.driveId) { + const driveMemberIds = await getDriveMemberUserIds(event.driveId); + const driveMemberSet = new Set(driveMemberIds); + const nonMembers = userIds.filter(id => !driveMemberSet.has(id)); + + if (nonMembers.length > 0) { + return NextResponse.json( + { error: 'All attendees must be members of the drive', nonMemberCount: nonMembers.length }, + { status: 400 } + ); + } + } + + // 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..6e7dc17a0 --- /dev/null +++ b/apps/web/src/app/api/calendar/events/route.ts @@ -0,0 +1,407 @@ +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, getDriveMemberUserIds } 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 } + ); + } + + // Get event IDs where user is an attendee (for ATTENDEES_ONLY events) + const attendeeEventsInDrive = await db + .select({ eventId: eventAttendees.eventId }) + .from(eventAttendees) + .innerJoin(calendarEvents, eq(calendarEvents.id, eventAttendees.eventId)) + .where( + and( + eq(eventAttendees.userId, userId), + eq(calendarEvents.driveId, params.driveId) + ) + ); + const attendeeEventIdsInDrive = attendeeEventsInDrive.map(e => e.eventId); + + // Fetch drive events within date range, respecting visibility: + // - DRIVE: visible to all drive members + // - ATTENDEES_ONLY: only visible to creator or attendees + // - PRIVATE: only visible to creator + 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), + or( + // DRIVE visibility - visible to all drive members + eq(calendarEvents.visibility, 'DRIVE'), + // PRIVATE - only creator can see + and( + eq(calendarEvents.visibility, 'PRIVATE'), + eq(calendarEvents.createdById, userId) + ), + // ATTENDEES_ONLY - creator or attendee can see + and( + eq(calendarEvents.visibility, 'ATTENDEES_ONLY'), + or( + eq(calendarEvents.createdById, userId), + attendeeEventIdsInDrive.length > 0 + ? inArray(calendarEvents.id, attendeeEventIdsInDrive) + : eq(calendarEvents.id, '__never_match__') // No attendee events + ) + ) + ) + ), + 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 } + ); + } + + // Validate attendees constraints + const otherAttendees = (data.attendeeIds ?? []).filter(id => id !== userId); + if (otherAttendees.length > 0) { + // PRIVATE events cannot have additional attendees + if (data.visibility === 'PRIVATE') { + return NextResponse.json( + { error: 'Private events cannot have attendees other than the creator' }, + { status: 400 } + ); + } + + // For drive events, verify all proposed attendees are drive members + if (data.driveId) { + const driveMemberIds = await getDriveMemberUserIds(data.driveId); + const driveMemberSet = new Set(driveMemberIds); + const nonMembers = otherAttendees.filter(id => !driveMemberSet.has(id)); + + if (nonMembers.length > 0) { + return NextResponse.json( + { error: 'All attendees must be members of the drive', nonMemberCount: nonMembers.length }, + { 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..43887e401 --- /dev/null +++ b/apps/web/src/components/calendar/CalendarView.tsx @@ -0,0 +1,362 @@ +'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 ? async () => { await handlers.onEventDelete(selectedEvent.id); } : undefined} + driveId={driveId} + context={context} + /> +
+ ); +} +// CI trigger 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..268f48f0e --- /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 + ? (() => { const d = new Date(startDate); d.setHours(0, 0, 0, 0); return d; })() + : buildDateTime(startDate, startTime); + const endAt = allDay + ? (() => { const d = new Date(endDate); d.setHours(23, 59, 59, 999); return d; })() + : 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 */} +
+ +