diff --git a/package-lock.json b/package-lock.json index c14f227..65d3589 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", + "lucide-react": "^0.563.0", "next": "^14.0.0", "next-auth": "^4.24.13", "postcss-cli": "^11.0.1", @@ -7256,6 +7257,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/package.json b/package.json index eda71b4..a0d2e12 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", + "lucide-react": "^0.563.0", "next": "^14.0.0", "next-auth": "^4.24.13", "postcss-cli": "^11.0.1", diff --git a/prisma/migrations/20260211145615_add_focus_timer_duration/migration.sql b/prisma/migrations/20260211145615_add_focus_timer_duration/migration.sql new file mode 100644 index 0000000..fcd29aa --- /dev/null +++ b/prisma/migrations/20260211145615_add_focus_timer_duration/migration.sql @@ -0,0 +1,40 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "isRecurring" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isRolling" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "recurrenceEndDate" TIMESTAMP(3), +ADD COLUMN "recurrenceInterval" INTEGER, +ADD COLUMN "recurrenceTime" TEXT, +ADD COLUMN "recurrenceUnit" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "autoRolling" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "calendarEditMode" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "dateFormat" TEXT NOT NULL DEFAULT 'yyyy-MM-dd', +ADD COLUMN "endHour" INTEGER NOT NULL DEFAULT 22, +ADD COLUMN "focusTimerDuration" INTEGER NOT NULL DEFAULT 25, +ADD COLUMN "language" TEXT NOT NULL DEFAULT 'de', +ADD COLUMN "protectEventTimes" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "showNextTask" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "startHour" INTEGER NOT NULL DEFAULT 8, +ADD COLUMN "timeFormat" TEXT NOT NULL DEFAULT '24h'; + +-- CreateTable +CREATE TABLE "SomedayList" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SomedayList_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SomedayList_userId_idx" ON "SomedayList"("userId"); + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SomedayList" ADD CONSTRAINT "SomedayList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a12e914..e85574d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,11 +24,18 @@ model User { timezone String @default("UTC") autoRolling Boolean @default(false) protectEventTimes Boolean @default(false) - language String @default("en") - dateFormat String @default("MM/dd/yyyy") - timeFormat String @default("12h") - startHour Int @default(6) - endHour Int @default(22) + language String @default("de") + dateFormat String @default("yyyy-MM-dd") + timeFormat String @default("24h") + startHour Int @default(8) + endHour Int @default(18) + showNextTask Boolean @default(false) + calendarEditMode Boolean @default(false) + focusTimerDuration Int @default(25) + showTimeGrid Boolean @default(true) + cellDuration Int @default(30) + viewStyle String @default("grid") + fontSize String @default("M") // "S", "M", "L" accounts Account[] sessions Session[] @@ -87,6 +94,11 @@ model Task { originalDate DateTime? startTime String? endTime String? + isRecurring Boolean @default(false) + recurrenceInterval Int? // Number of units between occurrences + recurrenceUnit String? // "days" or "weeks" + recurrenceTime String? // e.g. "09:00" - time for the recurring task + recurrenceEndDate DateTime? // Optional end date for recurrence createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index d10b01b..f6953a3 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; -import { authOptions } from '../../auth/[...nextauth]/route'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); @@ -98,7 +98,7 @@ export async function PATCH(request: NextRequest) { // Delete a calendar connection export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(); + const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.json( diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts new file mode 100644 index 0000000..af8e59b --- /dev/null +++ b/src/app/api/calendar/events/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; +import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events'; + +const prisma = new PrismaClient(); + +// Helper to find connection by calendarId +async function findConnectionForCalendar(userId: string, calendarId: string) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { calendarConnections: true } + }); + + if (!user) return null; + + for (const conn of user.calendarConnections) { + if (conn.calendars && Array.isArray(conn.calendars)) { + const calendars = conn.calendars as any[]; + if (calendars.some(c => c.id === calendarId)) { + return { + id: conn.id, + provider: conn.provider as 'google' | 'apple', + accessToken: conn.accessToken, + refreshToken: conn.refreshToken || undefined, + expiresAt: conn.expiresAt || undefined, + calendars: conn.calendars + } as CalendarConnection; + } + } + } + return null; +} + +// POST - Create event +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json(); + const { calendarId, title, description, start, end, location } = body; + + console.log('[API] Creating event:', { calendarId, title, start, end }); + + if (!calendarId || !title || !start || !end) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const userId = (session.user as any).id; + const connection = await findConnectionForCalendar(userId, calendarId); + + if (!connection) { + return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 }); + } + + const event = await createCalendarEvent(connection, calendarId, { + title, + description, + start, + end, + location + }); + + return NextResponse.json({ event }); + } catch (error: any) { + console.error('Error creating event:', error); + return NextResponse.json({ error: error.message || 'Failed to create event' }, { status: 500 }); + } +} + +// PATCH - Update event +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json(); + const { calendarId, eventId, title, description, start, end, location } = body; + + console.log('[API] Updating event:', { calendarId, eventId, title }); + + if (!calendarId || !eventId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const userId = (session.user as any).id; + const connection = await findConnectionForCalendar(userId, calendarId); + + if (!connection) { + return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 }); + } + + const event = await updateCalendarEvent(connection, calendarId, eventId, { + title, + description, + start, + end, + location + }); + + return NextResponse.json({ event }); + } catch (error: any) { + console.error('Error updating event:', error); + return NextResponse.json({ error: error.message || 'Failed to update event' }, { status: 500 }); + } +} + +// DELETE - Delete event +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const calendarId = searchParams.get('calendarId'); + const eventId = searchParams.get('eventId'); + + console.log('[API] Deleting event:', { calendarId, eventId }); + + if (!calendarId || !eventId) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const userId = (session.user as any).id; + const connection = await findConnectionForCalendar(userId, calendarId); + + if (!connection) { + return NextResponse.json({ error: 'Calendar connection not found' }, { status: 404 }); + } + + await deleteCalendarEvent(connection, calendarId, eventId); + + return NextResponse.json({ success: true }); + } catch (error: any) { + console.error('Error deleting event:', error); + return NextResponse.json({ error: error.message || 'Failed to delete event' }, { status: 500 }); + } +} diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index 19a7681..10dbabd 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { google } from 'googleapis'; import { PrismaClient } from '@prisma/client'; @@ -8,7 +9,7 @@ const prisma = new PrismaClient(); // Google Calendar OAuth callback endpoint export async function GET(request: NextRequest) { try { - const session = await getServerSession(); + const session = await getServerSession(authOptions); const { searchParams } = new URL(request.url); const code = searchParams.get('code'); const state = searchParams.get('state'); // User email passed from start route diff --git a/src/app/api/calendar/google/start/route.ts b/src/app/api/calendar/google/start/route.ts index fcd7355..4c7a363 100644 --- a/src/app/api/calendar/google/start/route.ts +++ b/src/app/api/calendar/google/start/route.ts @@ -1,11 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { google } from 'googleapis'; // Initiate Google Calendar OAuth flow export async function GET(request: NextRequest) { try { - const session = await getServerSession(); + const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.redirect(new URL('/auth/login', request.url)); @@ -28,8 +29,8 @@ export async function GET(request: NextRequest) { const authUrl = oauth2Client.generateAuthUrl({ access_type: 'offline', scope: [ - 'https://www.googleapis.com/auth/calendar.readonly', - 'https://www.googleapis.com/auth/calendar.events.readonly', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/calendar.events', ], prompt: 'consent', state: session.user.email, // Pass user email to identify in callback diff --git a/src/app/api/calendar/outlook/callback/route.ts b/src/app/api/calendar/outlook/callback/route.ts new file mode 100644 index 0000000..312c426 --- /dev/null +++ b/src/app/api/calendar/outlook/callback/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { prisma } from '@/lib/prisma'; +import { getTokens, getUserCalendars } from '@/lib/outlook-calendar'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.redirect(new URL('/auth/login', request.url)); + } + + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const error = searchParams.get('error'); + + if (error) { + console.error('Outlook OAuth error:', error); + return NextResponse.redirect(new URL('/?error=outlook_auth_failed', request.url)); + } + + if (!code) { + return NextResponse.redirect(new URL('/?error=no_code', request.url)); + } + + // Exchange code for tokens + const tokenData = await getTokens(code); + const accessToken = tokenData.access_token; + const refreshToken = tokenData.refresh_token; + const expiresIn = tokenData.expires_in; + + // Fetch user's calendars to store initial list + const calendars = await getUserCalendars(accessToken); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }); + + if (!user) { + return NextResponse.redirect(new URL('/auth/login', request.url)); + } + + // Calculate expiry date + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + + // Check for existing connection + const existingConnection = await prisma.calendarConnection.findFirst({ + where: { + userId: user.id, + provider: 'outlook' + } + }); + + const calendarData = calendars.map(cal => ({ + id: cal.id, + title: cal.name, + isPrimary: cal.isDefaultCalendar, + selected: true, + editable: cal.canEdit + })); + + if (existingConnection) { + await prisma.calendarConnection.update({ + where: { id: existingConnection.id }, + data: { + accessToken, + refreshToken, + expiresAt, + calendars: calendarData, + updatedAt: new Date() + } + }); + } else { + // Create new connection + await prisma.calendarConnection.create({ + data: { + userId: user.id, + provider: 'outlook', + accessToken, + refreshToken, + expiresAt, + calendars: calendarData, + } + }); + } + + return NextResponse.redirect(new URL('/tasks?calendar=connected', request.url)); + } catch (error) { + console.error('Error in Outlook callback:', error); + return NextResponse.redirect(new URL('/auth/login?error=outlook_callback_failed', request.url)); + } +} diff --git a/src/app/api/calendar/outlook/start/route.ts b/src/app/api/calendar/outlook/start/route.ts new file mode 100644 index 0000000..efa32a3 --- /dev/null +++ b/src/app/api/calendar/outlook/start/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getAuthUrl } from '@/lib/outlook-calendar'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.redirect(new URL('/auth/login', request.url)); + } + + const authUrl = getAuthUrl(); + return NextResponse.redirect(authUrl); + } catch (error) { + console.error('Error initiating Outlook OAuth:', error); + return NextResponse.json( + { error: 'Failed to initiate Outlook Calendar connection. Check server logs.' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts index 680d5d7..b94a5a5 100644 --- a/src/app/api/calendar/sync/route.ts +++ b/src/app/api/calendar/sync/route.ts @@ -56,7 +56,7 @@ export async function POST(request: NextRequest) { // Map to CalendarConnection interface const calendarConnections: CalendarConnection[] = connections.map(conn => ({ id: conn.id, - provider: conn.provider as 'google' | 'apple', + provider: conn.provider as 'google' | 'apple' | 'outlook', accessToken: conn.accessToken, refreshToken: conn.refreshToken || undefined, expiresAt: conn.expiresAt || undefined, diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 08a39f1..f6f3c56 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -87,8 +87,8 @@ export async function POST(request: NextRequest) { const userId = (session.user as any).id; const body = await request.json(); - const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate } = body; - let { isRolling } = body; + const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body; + let { isRolling, isRecurring } = body; if (!title) { return NextResponse.json( @@ -117,7 +117,12 @@ export async function POST(request: NextRequest) { userId, startTime: startTime || null, scheduledDate: scheduledDate ? new Date(scheduledDate) : null, - isRolling: isRolling || false + isRolling: isRolling || false, + isRecurring: isRecurring || false, + recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null, + recurrenceUnit, + recurrenceTime, + recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }, }); @@ -146,7 +151,7 @@ export async function PATCH(request: NextRequest) { const userId = (session.user as any).id; const body = await request.json(); - const { id, title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime } = body; + const { id, title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body; if (!id) { return NextResponse.json( @@ -178,10 +183,72 @@ export async function PATCH(request: NextRequest) { ...(markdownContent !== undefined && { markdownContent }), ...(scheduledDate !== undefined && { scheduledDate: scheduledDate ? new Date(scheduledDate) : null }), ...(startTime !== undefined && { startTime }), - ...(body.isRolling !== undefined && { isRolling: body.isRolling }) + ...(body.isRolling !== undefined && { isRolling: body.isRolling }), + ...(somedayListId !== undefined && { somedayListId: somedayListId || null }), + ...(isRecurring !== undefined && { isRecurring }), + ...(recurrenceInterval !== undefined && { recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null }), + ...(recurrenceUnit !== undefined && { recurrenceUnit }), + ...(recurrenceTime !== undefined && { recurrenceTime }), + ...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }) }, }); + // Validating recurrence logic: + // If task is NOW completed, WAS NOT completed before, and IS recurring -> Create next instance + if (completed === true && !existingTask.completed && task.isRecurring) { + try { + const interval = task.recurrenceInterval || 1; + const unit = task.recurrenceUnit || 'weeks'; + + // Calculate next date based on the task's current scheduled date + // If no scheduled date, use today? Usually recurring tasks have a date. + let baseDate = task.scheduledDate ? new Date(task.scheduledDate) : new Date(); + let nextDate = new Date(baseDate); + + if (unit === 'days') { + nextDate.setDate(baseDate.getDate() + interval); + } else if (unit === 'weeks') { + nextDate.setDate(baseDate.getDate() + (interval * 7)); + } else if (unit === 'months') { + nextDate.setMonth(baseDate.getMonth() + interval); + } + + // Check end date + if (!task.recurrenceEndDate || nextDate <= new Date(task.recurrenceEndDate)) { + + // Create the next task + await prisma.task.create({ + data: { + title: task.title, + description: task.description, + markdownContent: task.markdownContent, + userId: task.userId, + // Set the new date + scheduledDate: nextDate, + dayOfWeek: nextDate.getDay(), + startTime: task.recurrenceTime || task.startTime, // Use specific recurrence time if set, else keep original or null + + // Copy recurrence settings so the chain continues + isRecurring: true, + recurrenceInterval: task.recurrenceInterval, + recurrenceUnit: task.recurrenceUnit, + recurrenceTime: task.recurrenceTime, + recurrenceEndDate: task.recurrenceEndDate, + + // Rolling settings copy + isRolling: task.isRolling, + + order: 0, // Put at top? Or maybe last? 0 is fine for now. + completed: false + } + }); + } + } catch (recError) { + console.error('Error creating next recurring task instance:', recError); + // Don't fail the original update if recurrence fails, just log it. + } + } + return NextResponse.json({ task }); } catch (error) { console.error('Error updating task:', error); diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index 8d593b6..f125dfa 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -1,51 +1,72 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; -import { authOptions } from '../../auth/[...nextauth]/route'; -import { PrismaClient } from '@prisma/client'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { prisma } from '@/lib/prisma'; -const prisma = new PrismaClient(); - -export async function GET(request: NextRequest) { +export async function GET(request: Request) { const session = await getServerSession(authOptions); - if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { searchParams } = new URL(request.url); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!session || !session.user?.email) { + return new NextResponse('Unauthorized', { status: 401 }); + } try { const user = await prisma.user.findUnique({ where: { email: session.user.email }, - include: { - tasks: true, - calendarConnections: true, - accounts: true - } }); - if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (!user) { + return new NextResponse('User not found', { status: 404 }); + } - // Sanitize - const exportData = { - profile: { - name: user.name, - email: user.email, - joined: user.createdAt, - timezone: user.timezone - }, - tasks: user.tasks, - connections: user.calendarConnections.map(c => ({ - provider: c.provider, - connectedAt: c.createdAt, - calendars: c.calendars - })) + const where: any = { + userId: user.id, + completed: true, }; - // Return as file download - return new NextResponse(JSON.stringify(exportData, null, 2), { - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="data-export-${new Date().toISOString().split('T')[0]}.json"` + if (startDate || endDate) { + where.updatedAt = {}; + if (startDate) where.updatedAt.gte = new Date(startDate); + if (endDate) { + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + where.updatedAt.lte = end; } + } + + // Fetch filtered tasks + const tasks = await prisma.task.findMany({ + where, + orderBy: { + updatedAt: 'desc', + }, + }); + + // Generate CSV + const headers = ['Title', 'Description', 'Completed Date', 'Created Date']; + const rows = tasks.map((task: any) => [ + task.title, + task.description || '', + task.updatedAt.toISOString(), + task.createdAt.toISOString(), + ]); + + const csvContent = [ + headers.join(','), + ...rows.map((row: string[]) => row.map((cell: string) => `"${(cell || '').replace(/"/g, '""')}"`).join(',')) + ].join('\n'); + + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="completed_tasks_${new Date().toISOString().split('T')[0]}.csv"`, + }, }); } catch (error) { - console.error('Export failed:', error); - return NextResponse.json({ error: 'Export failed' }, { status: 500 }); + console.error('Export error:', error); + return new NextResponse('Internal Server Error', { status: 500 }); } } diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 6cb79b9..b8ea292 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -24,6 +24,13 @@ export async function GET(request: NextRequest) { timeFormat: true, startHour: true, endHour: true, + showNextTask: true, + calendarEditMode: true, + focusTimerDuration: true, + showTimeGrid: true, + cellDuration: true, + viewStyle: true, + fontSize: true, createdAt: true } }); @@ -45,7 +52,12 @@ export async function PATCH(request: NextRequest) { try { const body = await request.json(); - const { name, timezone, password, autoRolling, protectEventTimes, language, dateFormat, timeFormat, startHour, endHour } = body; + const { + name, timezone, password, autoRolling, protectEventTimes, + language, dateFormat, timeFormat, startHour, endHour, + showNextTask, calendarEditMode, focusTimerDuration, + showTimeGrid, cellDuration, viewStyle, fontSize + } = body; const updateData: any = { ...(name !== undefined && { name }), @@ -55,10 +67,17 @@ export async function PATCH(request: NextRequest) { ...(language !== undefined && { language }), ...(dateFormat !== undefined && { dateFormat }), ...(timeFormat !== undefined && { timeFormat }), - ...(startHour !== undefined && { startHour }), - ...(endHour !== undefined && { endHour }), + ...(startHour !== undefined && !isNaN(startHour) && { startHour }), + ...(endHour !== undefined && !isNaN(endHour) && { endHour }), + ...(showNextTask !== undefined && { showNextTask }), + ...(calendarEditMode !== undefined && { calendarEditMode }), + ...(focusTimerDuration !== undefined && !isNaN(focusTimerDuration) && { focusTimerDuration }), + ...(showTimeGrid !== undefined && { showTimeGrid }), + ...(cellDuration !== undefined && !isNaN(cellDuration) && { cellDuration }), + ...(viewStyle !== undefined && { viewStyle }), + ...(fontSize !== undefined && { fontSize }), }; - if (password) { + if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); } @@ -77,6 +96,13 @@ export async function PATCH(request: NextRequest) { timeFormat: true, startHour: true, endHour: true, + showNextTask: true, + calendarEditMode: true, + focusTimerDuration: true, + showTimeGrid: true, + cellDuration: true, + viewStyle: true, + fontSize: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index 246a9e3..20ac3ca 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -671,7 +671,7 @@ h3 { .weekly-task-text { flex: 1; - font-size: 0.9375rem; + font-size: var(--base-font-size); line-height: 1.5; color: var(--weekly-text); border: none; @@ -903,63 +903,285 @@ h3 { color: var(--weekly-text); } -/* Someday Lists Container */ -/* Someday Lists Container */ -.weekly-someday-lists { +/* Someday Lists Container - Horizontal Scroll */ +.weekly-someday-lists-grid { display: flex; - flex-wrap: nowrap; /* Prevent wrapping */ - gap: 0; - max-height: 400px; /* Keep height constraint */ - overflow-x: auto; /* Enable horizontal scrolling */ - overflow-y: hidden; /* Hide vertical scroll on container */ - border-top: 1px dashed #ccc; - padding-bottom: 1rem; /* Space for scrollbar */ + flex-direction: row; + flex-wrap: nowrap; + gap: 1rem; + border-top: 1px solid var(--weekly-border); + width: 100%; + overflow-x: auto; + overflow-y: hidden; /* Prevent vertical Scrollbar on container */ + padding-bottom: 0.5rem; /* Space for scrollbar */ + align-items: flex-start; + -webkit-overflow-scrolling: touch; } +/* Scrollbar Styling for Someday Container */ +.weekly-someday-lists-grid::-webkit-scrollbar { + height: 8px; +} + +.weekly-someday-lists-grid::-webkit-scrollbar-track { + background: transparent; +} + +.weekly-someday-lists-grid::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; +} + +.weekly-someday-lists-grid::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.2); +} + +/* Remove grid column classes as we are using flex now */ +.weekly-someday-lists-grid.cols-1, +.weekly-someday-lists-grid.cols-2, +.weekly-someday-lists-grid.cols-3, +.weekly-someday-lists-grid.cols-4, +.weekly-someday-lists-grid.cols-5, +.weekly-someday-lists-grid.cols-6, +.weekly-someday-lists-grid.cols-7 { + /* No specfic grid columns, let flex handle it */ + grid-template-columns: none; +} + +/* Old container class - deprecated or unused now? Keeping just in case */ +.weekly-someday-lists { + display: none; +} + +/* Ruled paper lines for someday tasks */ .weekly-someday-list { - border-right: 1px dashed #ccc; - padding: 1rem; + /* border-right: 1px solid var(--weekly-border); Removed for cleaner look */ + padding: 0; min-height: 200px; - width: 260px; /* Fixed width for columns */ - min-width: 260px; - flex-shrink: 0; + flex: 0 0 280px; /* Fixed width for horizontal scrolling */ + width: 280px; + max-width: 100%; display: flex; flex-direction: column; - overflow-y: auto; /* Allow individual list scrolling if needed */ + overflow-y: auto; max-height: 380px; + background-image: repeating-linear-gradient( + transparent, + transparent 31px, + var(--weekly-border) 31px, + var(--weekly-border) 32px + ); + background-attachment: local; + background-position: 0 40px; /* Offset for the header */ +} + +/* Placeholder styling */ +.weekly-someday-list.placeholder-list { + background-image: repeating-linear-gradient( + transparent, + transparent 31px, + #f5f5f5 31px, + #f5f5f5 32px + ); } .weekly-someday-list:last-child { - border-right: 1px dashed #ccc; /* Keep border for consistency */ + border-right: none; } -.weekly-someday-list-title { - font-size: 0.9rem; - font-weight: 700; - text-transform: uppercase; - color: #d12028; /* TeuxDeux Red-ish */ - margin-bottom: 0.75rem; - cursor: pointer; - border-bottom: 2px solid transparent; - display: inline-block; +.weekly-someday-list.is-dragging { + opacity: 0.4; + background-color: #f0f0f0; } -.weekly-someday-list-title:hover { - border-bottom-color: #eee; +.weekly-someday-list .weekly-task-item { + border-bottom: 1px solid transparent; + height: 32px; + padding: 0 1rem; + display: flex; + align-items: center; +} + +.weekly-someday-list .weekly-task-text { + font-size: var(--base-font-size); + line-height: 32px; + max-height: 32px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0; +} + +.weekly-someday-list-title-header { + padding: 0.75rem 1rem 0.25rem; + height: 40px; + display: flex; + align-items: center; } .weekly-someday-list-title-input { - font-size: 0.9rem; + font-size: 1rem; font-weight: 700; text-transform: uppercase; - color: #d12028; - margin-bottom: 0.75rem; + letter-spacing: 0.05em; + color: var(--weekly-text, #333); background: transparent; border: none; - border-bottom: 1px solid #d12028; + border-bottom: 1px solid transparent; width: 100%; - padding: 0; + padding: 2px 0; outline: none; + transition: border-color 0.15s ease; +} + +.weekly-someday .weekly-task-item { + padding-bottom: 0; + margin-bottom: 0; +} + +/* Someday task item divider lines */ +.weekly-someday .weekly-task-item { + padding-bottom: 0; + margin-bottom: 0; +} + +/* Someday add task button */ +.someday-add-task-btn { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 0.85rem; + padding: 4px 0; + text-align: left; + width: 100%; + transition: color 0.15s ease; +} + +.someday-add-task-btn:hover { + color: #666; +} + +/* Preferences Slide-in Panel */ +.preferences-panel { + position: fixed; + top: 0; + right: 0; + width: 280px; + height: 100vh; + background: #1a1a2e; + color: #e0e0e0; + z-index: 2000; + transform: translateX(100%); + transition: transform 0.35s cubic-bezier(0.25, 0.1, 0.25, 1); + box-shadow: -4px 0 20px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; +} + +.preferences-panel.open { + transform: translateX(0); +} + +.preferences-panel-content { + flex: 1; + overflow-y: auto; + padding: 2rem 1.5rem; +} + +.preferences-overlay { + position: fixed; + inset: 0; + z-index: 1999; + background: rgba(0,0,0,0.15); +} + +.pref-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.7rem 0; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.pref-label { + font-size: 0.9rem; + color: #ccc; +} + +.pref-options { + display: flex; + gap: 4px; + align-items: center; +} + +.pref-option-btn { + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.12); + color: #aaa; + padding: 4px 10px; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.pref-option-btn:hover { + background: rgba(255,255,255,0.15); + color: #fff; +} + +.pref-option-btn.active { + background: rgba(255,255,255,0.2); + color: #fff; + border-color: rgba(255,255,255,0.3); +} + +.pref-toggle { + background: none; + border: none; + color: #666; + font-size: 1.2rem; + cursor: pointer; + padding: 2px 6px; + transition: color 0.15s ease; +} + +.pref-toggle.active { + color: #4dd0e1; +} + +.pref-full-settings-btn { + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.12); + color: #ccc; + padding: 8px 16px; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + width: 100%; + text-align: center; + transition: all 0.15s ease; +} + +.pref-full-settings-btn:hover { + background: rgba(255,255,255,0.15); + color: #fff; +} + +.preferences-close-btn { + background: none; + border: none; + color: #888; + font-size: 1.2rem; + padding: 1rem; + cursor: pointer; + text-align: center; + transition: color 0.15s ease; +} + +.preferences-close-btn:hover { + color: #fff; } /* Weekly Footer */ @@ -1132,23 +1354,7 @@ h3 { TIME GRID STYLES ============================================ */ -.time-grid-controls { - display: flex; - align-items: center; - gap: 1rem; - padding: 0.5rem 1.5rem; - background: var(--weekly-bg); - border-bottom: 1px solid var(--weekly-border); - font-size: 0.875rem; -} - -.time-grid-controls label { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--weekly-text-light); - cursor: pointer; -} +/* .time-grid-controls removed, using Tailwind classes */ .time-grid-controls input[type="checkbox"] { accent-color: var(--weekly-teal); @@ -1222,7 +1428,8 @@ h3 { .time-slot { position: relative; - transition: background-color 0.15s ease; + /* Transition for theme switching */ + transition: background-color 0.3s ease, color 0.3s ease; padding: 0px 8px; display: flex; flex-direction: column; @@ -1598,6 +1805,42 @@ h3 { padding: 4px; } +/* Clickable lock/unlock button on calendar events */ +.event-unlock-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 2px; + border-radius: 4px; + opacity: 0.7; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.event-unlock-btn:hover { + opacity: 1; + transform: scale(1.15); +} + +/* All-day chevron positioning */ +.all-day-chevron { + position: absolute; + top: 0.25rem; + right: 1rem; + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + color: #666; + padding: 0.25rem; + transition: color 0.15s ease; +} + +.all-day-chevron:hover { + color: var(--weekly-text); +} + /* ============================================ AUTH PAGES STYLES (Weekly-style) ============================================ */ @@ -2015,28 +2258,38 @@ h3 { } ::view-transition-group(week-grid) { - animation-duration: 0.5s; - animation-timing-function: ease-in-out; + animation-duration: 0.6s; + animation-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); } -/* Next Week: Old slides Left, New enters from Right */ +/* Next (forward): Old slides Left, New enters from Right */ [data-transition-direction="next"]::view-transition-old(week-grid) { - animation: slideOutToLeft 0.5s ease-in-out both; + animation: slideOutToLeft 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both; mix-blend-mode: normal; } [data-transition-direction="next"]::view-transition-new(week-grid) { - animation: slideInFromRight 0.5s ease-in-out both; + animation: slideInFromRight 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both; mix-blend-mode: normal; } -/* Prev Week: Old slides Right, New enters from Left */ +/* Prev (backward): Old slides Right, New enters from Left */ [data-transition-direction="prev"]::view-transition-old(week-grid) { - animation: slideOutToRight 0.5s ease-in-out both; + animation: slideOutToRight 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both; mix-blend-mode: normal; } [data-transition-direction="prev"]::view-transition-new(week-grid) { - animation: slideInFromLeft 0.5s ease-in-out both; + animation: slideInFromLeft 0.6s cubic-bezier(0.25, 0.1, 0.25, 1) both; mix-blend-mode: normal; -} \ No newline at end of file +}/* Smooth View Transitions */ +::view-transition-group(root) { + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +::view-transition-old(root), +::view-transition-new(root) { + /* Ensure they mix/cross-fade or slide as expected */ + /* Default is usually fine, but duration control is key */ +} diff --git a/src/app/view-transitions.css b/src/app/view-transitions.css new file mode 100644 index 0000000..bae5cbf --- /dev/null +++ b/src/app/view-transitions.css @@ -0,0 +1,11 @@ +/* Smooth View Transitions */ +::view-transition-group(root) { + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +::view-transition-old(root), +::view-transition-new(root) { + /* Ensure they mix/cross-fade or slide as expected */ + /* Default is usually fine, but duration control is key */ +} diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx new file mode 100644 index 0000000..eabfeff --- /dev/null +++ b/src/components/CalendarEventModal.tsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect } from 'react'; + +interface CalendarEventModalProps { + event?: any; // Existing event if editing + initialDate?: Date; // If creating new + initialStartTime?: string; // If creating new from slot + connections: any[]; // To select calendar + onClose: () => void; + onSave: (eventData: any) => Promise; + onDelete?: (eventId: string, calendarId: string) => Promise; +} + +export default function CalendarEventModal({ + event, + initialDate, + initialStartTime, + connections, + onClose, + onSave, + onDelete +}: CalendarEventModalProps) { + // Flatten calendars from connections to get selectable options + const availableCalendars = connections + .flatMap(conn => conn.calendars || []) + .filter((cal: any) => cal.editable); // Only editable calendars + + const [title, setTitle] = useState(event?.title || ''); + const [description, setDescription] = useState(event?.description || ''); + const [location, setLocation] = useState(event?.location || ''); + const [calendarId, setCalendarId] = useState(event?.calendarId || (availableCalendars.length > 0 ? availableCalendars[0].id : '')); + + // Date/Time State + // If event exists, use its start/end. + // If new, use initialDate + initialStartTime. + // Default duration: 1 hour. + + const getInitialStart = () => { + if (event?.start?.dateTime) return new Date(event.start.dateTime); + if (initialDate) { + const d = new Date(initialDate); + if (initialStartTime) { + const [h, m] = initialStartTime.split(':').map(Number); + d.setHours(h, m, 0, 0); + } else { + // Default to next hour if no time specified (though usually slot click gives time) + const now = new Date(); + d.setHours(now.getHours() + 1, 0, 0, 0); + } + return d; + } + return new Date(); + }; + + const getInitialEnd = () => { + if (event?.end?.dateTime) return new Date(event.end.dateTime); + const start = getInitialStart(); + return new Date(start.getTime() + 60 * 60 * 1000); // +1 hour + }; + + const [startDate, setStartDate] = useState(getInitialStart()); + const [endDate, setEndDate] = useState(getInitialEnd()); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async () => { + if (!title.trim()) { + setError('Title is required'); + return; + } + if (!calendarId) { + setError('Please select a calendar'); + return; + } + if (endDate <= startDate) { + setError('End time must be after start time'); + return; + } + + setIsSaving(true); + setError(''); + try { + await onSave({ + id: event?.id, + title, + description, + location, + calendarId, + start: { dateTime: startDate.toISOString() }, + end: { dateTime: endDate.toISOString() } + }); + onClose(); + } catch (err: any) { + console.error(err); + setError(err.message || 'Failed to save event'); + setIsSaving(false); + } + }; + + const [isDeleteConfirming, setIsDeleteConfirming] = useState(false); + + const handleDelete = async () => { + if (!event?.id || !onDelete) return; + + if (!isDeleteConfirming) { + setIsDeleteConfirming(true); + setTimeout(() => setIsDeleteConfirming(false), 3000); // Reset after 3 seconds + return; + } + + setIsSaving(true); + try { + await onDelete(event.id, event.calendarId); + onClose(); + } catch (err: any) { + setError(err.message || 'Failed to delete event'); + setIsSaving(false); + setIsDeleteConfirming(false); + } + }; + + // Helper to format date for input type="datetime-local" + // Format: YYYY-MM-DDThh:mm + const toLocalISOString = (date: Date) => { + const offset = date.getTimezoneOffset() * 60000; + const localISOTime = (new Date(date.getTime() - offset)).toISOString().slice(0, 16); + return localISOTime; + }; + + const handleStartDateChange = (val: string) => { + const newStart = new Date(val); + setStartDate(newStart); + // Auto-adjust end date if it becomes before start + if (endDate <= newStart) { + setEndDate(new Date(newStart.getTime() + 60 * 60 * 1000)); + } + }; + + return ( +
+
e.stopPropagation()} style={{ maxWidth: '500px' }}> +

+ {event ? 'Edit Event' : 'New Event'} +

+ + {error &&
{error}
} + +
+ + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Event Title" + autoFocus + style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem' }} + /> +
+ + {/* Calendar Selection */} +
+ + +
+ + {/* Date/Time */} +
+
+ + handleStartDateChange(e.target.value)} + style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} + /> +
+
+ + setEndDate(new Date(e.target.value))} + style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} + /> +
+
+ + {/* Location */} +
+ + setLocation(e.target.value)} + placeholder="Location (optional)" + style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} + /> +
+ + {/* Description */} +
+ +