diff --git a/.dockerignore b/.dockerignore index d20bc04..1af160d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ Dockerfile docker-compose*.yml .env .env.* +!.env.example .planning prisma/dev.db *.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..084e378 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# ============================================================================== +# My Weekly ToDo List - Environment Variables +# ============================================================================== +# Copy this file to .env.docker (production), .env.docker.dev (development), +# or .env.docker.local (local) and fill in your actual values. +# NEVER commit files containing real credentials to version control. +# ============================================================================== + +# --- Database --- +# PostgreSQL connection string (Docker service name "db" as host) +DATABASE_URL=postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@db:5432/My-Weekly-ToDo-List?schema=public +POSTGRES_DB=My-Weekly-ToDo-List +POSTGRES_USER=YOUR_DB_USER +POSTGRES_PASSWORD=YOUR_DB_PASSWORD + +# --- NextAuth --- +# Generate a secret: openssl rand -base64 32 +NEXTAUTH_SECRET=YOUR_NEXTAUTH_SECRET +# The public URL where the app is accessible +NEXTAUTH_URL=https://your-domain.com +NEXT_PUBLIC_BASE_URL=https://your-domain.com + +# --- SMTP (Email) --- +# Required for email verification and password reset +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@example.com +SMTP_PASSWORD=YOUR_SMTP_PASSWORD +SMTP_SECURE=true + +# --- Google OAuth (optional) --- +# Required for Google Calendar and Google Tasks integration +# Set up at: https://console.cloud.google.com/apis/credentials +GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET +GOOGLE_REDIRECT_URI=https://your-domain.com/api/calendar/google/oauth + +# --- Microsoft OAuth (optional) --- +# Required for Outlook Calendar and Microsoft To-Do integration +# Set up at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps +MICROSOFT_CLIENT_ID=YOUR_MICROSOFT_CLIENT_ID +MICROSOFT_CLIENT_SECRET=YOUR_MICROSOFT_CLIENT_SECRET +MICROSOFT_REDIRECT_URI=https://your-domain.com/api/calendar/outlook/callback diff --git a/.gitignore b/.gitignore index f41c800..907f892 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ Inspiration/ .env .env.local .env.production +.env.development +.env.docker +.env.docker.dev +.env.docker.local # Logs and temp files *.log @@ -29,6 +33,10 @@ node_modules/ /playwright/.cache/ /playwright/.auth/ +# Development database +prisma/dev.db +prisma/dev.db-journal + # Next.js .next/ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 477f26a..1fdc5df 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,10 +5,8 @@ services: build: . ports: - "3000:3000" - environment: - - DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public - - NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd - - NEXT_PUBLIC_BASE_URL=http://localhost:3000 + env_file: + - .env.docker.dev depends_on: - db volumes: @@ -18,10 +16,8 @@ services: db: image: postgres:15 - environment: - POSTGRES_DB: My-Weekly-ToDo-List - POSTGRES_USER: root - POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG + env_file: + - .env.docker.dev volumes: - postgres_data:/var/lib/postgresql/data ports: @@ -29,4 +25,4 @@ services: volumes: postgres_data: - cache: \ No newline at end of file + cache: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 20dee3b..7e5964b 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -5,10 +5,8 @@ services: build: . ports: - "3000:3000" - environment: - - DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public - - NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd - - NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de + env_file: + - .env.docker.local depends_on: - db volumes: @@ -16,14 +14,12 @@ services: db: image: postgres:15 - environment: - POSTGRES_DB: My-Weekly-ToDo-List - POSTGRES_USER: root - POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG + env_file: + - .env.docker.local volumes: - postgres_data:/var/lib/postgresql/data ports: - "15432:5432" volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index f3dea61..6df5a6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,22 +5,8 @@ services: image: my-weekly-todo:latest ports: - "13000:3000" - environment: - - DATABASE_URL=postgresql://root:WKE7xeZohxdZit7eObjG@db:5432/My-Weekly-ToDo-List?schema=public - - NEXTAUTH_SECRET=Ca7EoJzZSMJO2CkqwdWd - - NEXTAUTH_URL=https://todo.martin-bierschenk.de - - NEXT_PUBLIC_BASE_URL=https://todo.martin-bierschenk.de - - SMTP_HOST=w00ff033.kasserver.com - - SMTP_PORT=587 - - SMTP_USERNAME=mail@carrylight.de - - SMTP_PASSWORD=QijU8e2A8p3FE8WS8esR - - SMTP_SECURE=true - - GOOGLE_CLIENT_ID=196368743757-1fn17q2ecg5n8rej4tu96khltno173he.apps.googleusercontent.com - - GOOGLE_CLIENT_SECRET=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 - - GOOGLE_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/google/oauth - - MICROSOFT_CLIENT_ID=a6ea3972-2e4a-4a24-b50f-4047867d8de3 - - MICROSOFT_CLIENT_SECRET=9649da1a-9057-4ac5-ad5a-73cf1cef9478 - - MICROSOFT_REDIRECT_URI=https://todo.martin-bierschenk.de/api/calendar/outlook/callback + env_file: + - .env.docker depends_on: - db volumes: @@ -29,10 +15,8 @@ services: db: image: postgres:15 - environment: - POSTGRES_DB: My-Weekly-ToDo-List - POSTGRES_USER: root - POSTGRES_PASSWORD: WKE7xeZohxdZit7eObjG + env_file: + - .env.docker volumes: - postgres_data:/var/lib/postgresql/data ports: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6061332..6e41580 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,80 +8,89 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - passwordHash String? // Optional for SSO users - name String? - image String? - emailVerified DateTime? - verifiedAt DateTime? - emailVerificationToken String? + id String @id @default(cuid()) + email String @unique + passwordHash String? + name String? + image String? + emailVerified DateTime? + verifiedAt DateTime? + emailVerificationToken String? emailVerificationExpires DateTime? - passwordResetToken String? - passwordResetExpires DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - timezone String @default("UTC") - autoRolling Boolean @default(false) - protectEventTimes Boolean @default(false) - 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) - focusBreakDuration Int @default(5) - showTimeGrid Boolean @default(true) - showSomeday Boolean @default(true) - showAllDayEvents Boolean @default(true) - showSchedule Boolean @default(true) - cellDuration Int @default(30) - viewStyle String @default("grid") - viewDays Int @default(7) - fontSize String @default("M") // "S", "M", "L" - goalFallbackType String @default("quote") // "quote" | "next_todo" | "default" - goalDefaultSentence String @default("goal of the week") - goalFontFamily String? @default("Inter") - goalFontSize String? @default("0.9rem") - goalFontWeight String? @default("500") - goalScope String @default("week") // "week" | "day" - headlineFont String @default("Inter") - headlineFontSize String? @default("1.25rem") - headlineFontWeight String? @default("900") - dateFontFamily String? @default("Inter") - dateFontSize String? @default("0.65rem") - dateFontWeight String? @default("400") - timeTaskFontFamily String? @default("Inter") - timeTaskFontSize String? @default("0.75rem") - timeTaskFontWeight String? @default("500") - bodyFont String @default("Inter") - taskFontFamily String? @default("Inter") - taskFontSize String? @default("0.9rem") - taskFontWeight String? @default("400") - eventFontFamily String? @default("Inter") - eventFontSize String? @default("0.85rem") - eventFontWeight String? @default("400") - fontWeight String @default("400") // Legacy/Generic - - // Color Settings - weekdayColor String? @default("#888888") - dateColor String? @default("#888888") - taskColor String? @default("#333333") - todayHighlightColor String? @default("#f0fafa") - - weekendColorSat String? @default("#666666") - weekendColorSun String? @default("#dc2626") - pastDayColor String? @default("#a6a6a7") - - accounts Account[] - sessions Session[] - tasks Task[] - somedayLists SomedayList[] - calendarConnections CalendarConnection[] - cachedCalendarEvents CachedCalendarEvent[] - weeklyGoals WeeklyGoal[] + passwordResetToken String? + passwordResetExpires DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + timezone String @default("UTC") + autoRolling Boolean @default(false) + protectEventTimes Boolean @default(false) + 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) + focusBreakDuration Int @default(5) + showTimeGrid Boolean @default(true) + showSomeday Boolean @default(true) + showAllDayEvents Boolean @default(true) + showSchedule Boolean @default(true) + cellDuration Int @default(30) + viewStyle String @default("grid") + viewDays Int @default(7) + fontSize String @default("M") + goalFallbackType String @default("quote") + goalDefaultSentence String @default("goal of the week") + goalFontFamily String? @default("Inter") + goalFontSize String? @default("0.9rem") + goalFontWeight String? @default("500") + headlineFont String @default("Inter") + headlineFontSize String? @default("1.25rem") + headlineFontWeight String? @default("900") + dateFontFamily String? @default("Inter") + dateFontSize String? @default("0.65rem") + dateFontWeight String? @default("400") + timeTaskFontFamily String? @default("Inter") + timeTaskFontSize String? @default("0.75rem") + timeTaskFontWeight String? @default("500") + bodyFont String @default("Inter") + taskFontFamily String? @default("Inter") + taskFontSize String? @default("0.9rem") + taskFontWeight String? @default("400") + eventFontFamily String? @default("Inter") + eventFontSize String? @default("0.85rem") + eventFontWeight String? @default("400") + fontWeight String @default("400") + weekdayColor String? @default("#888888") + dateColor String? @default("#888888") + taskColor String? @default("#333333") + todayHighlightColor String? @default("#f0fafa") + weekendColorSat String? @default("#666666") + weekendColorSun String? @default("#dc2626") + pastDayColor String? @default("#a6a6a7") + goalScope String @default("week") + dateLayout String @default("right") + dateAlignment String @default("center") + hourLabelFormat String @default("short") + showSubHourSlots Boolean @default(true) + allDayPosition String @default("below") + cwColor String? @default("#333333") + cwFontFamily String? @default("Inter") + cwFontSize String? @default("1.125rem") + cwFontWeight String? @default("700") + yearColor String? @default("#333333") + yearFontFamily String? @default("Inter") + yearFontSize String? @default("1.125rem") + yearFontWeight String? @default("700") + accounts Account[] + cachedCalendarEvents CachedCalendarEvent[] + calendarConnections CalendarConnection[] + sessions Session[] + somedayLists SomedayList[] + tasks Task[] + weeklyGoals WeeklyGoal[] } model Account { @@ -90,15 +99,14 @@ model Account { type String provider String providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text + refresh_token String? + access_token String? expires_at Int? token_type String? scope String? - id_token String? @db.Text + id_token String? session_state String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } @@ -120,97 +128,98 @@ model VerificationToken { } model Task { - id String @id @default(cuid()) - userId String - title String - description String? - markdownContent String? @db.Text - completed Boolean @default(false) - isRolling Boolean @default(false) - order Int @default(0) - dayOfWeek Int? // 0-6 for Sunday-Saturday (legacy/someday lists) - scheduledDate DateTime? // Actual date for the task - somedayListId String? - 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 - deletedAt DateTime? // Soft delete - null means active, set means trashed - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - somedayList SomedayList? @relation(fields: [somedayListId], references: [id], onDelete: SetNull) - - // External Integration - externalId String? - externalProvider String? // "google" | "apple" | "outlook" - externalListId String? - lastSyncedAt DateTime? + id String @id @default(cuid()) + userId String + title String + description String? + markdownContent String? + completed Boolean @default(false) + isRolling Boolean @default(false) + order Int @default(0) + dayOfWeek Int? + scheduledDate DateTime? + somedayListId String? + originalDate DateTime? + startTime String? + endTime String? + isRecurring Boolean @default(false) + recurrenceInterval Int? + recurrenceUnit String? + recurrenceTime String? + recurrenceEndDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + externalId String? + externalProvider String? + externalListId String? + lastSyncedAt DateTime? + deletedAt DateTime? + parentTaskId String? + parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade) + subTasks Task[] @relation("SubTasks") + somedayList SomedayList? @relation(fields: [somedayListId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, dayOfWeek]) @@index([userId, scheduledDate]) @@index([userId, somedayListId]) @@index([userId, externalId]) @@index([userId, deletedAt]) + @@index([parentTaskId]) } model SomedayList { - id String @id @default(cuid()) - userId String - title String - order Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - tasks Task[] + id String @id @default(cuid()) + userId String + title String + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + externalId String? + externalProvider String? + lastSyncedAt DateTime? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tasks Task[] @@index([userId]) } model CalendarConnection { - id String @id @default(cuid()) - userId String - provider String - accessToken String + id String @id @default(cuid()) + userId String + provider String + accessToken String refreshToken String? - expiresAt DateTime? - calendars Json? // Stores array of { id, title, isPrimary, selected } - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + expiresAt DateTime? + calendars Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt cachedEvents CachedCalendarEvent[] - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model CachedCalendarEvent { - id String @id @default(cuid()) - userId String - externalId String - connectionId String - provider String // "google" | "apple" | "outlook" - calendarId String - calendarTitle String - calendarColor String? - title String - description String? @db.Text - location String? - startDateTime DateTime? - startDate String? // YYYY-MM-DD for all-day events - endDateTime DateTime? - endDate String? // YYYY-MM-DD for all-day events - weekStart DateTime - syncedAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + externalId String + connectionId String + provider String + calendarId String + calendarTitle String + calendarColor String? + title String + description String? + location String? + startDateTime DateTime? + startDate String? + endDateTime DateTime? + endDate String? + weekStart DateTime + syncedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, externalId, provider]) @@index([userId, startDateTime]) @@ -229,4 +238,4 @@ model WeeklyGoal { @@unique([userId, weekStart]) @@index([userId]) -} \ No newline at end of file +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/src/app/api/someday-lists/external/route.ts b/src/app/api/someday-lists/external/route.ts new file mode 100644 index 0000000..01487c2 --- /dev/null +++ b/src/app/api/someday-lists/external/route.ts @@ -0,0 +1,99 @@ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/lib/auth"; +import { PrismaClient } from '@prisma/client'; +import { createGoogleClient, createGoogleTaskList } from '@/lib/google-tasks'; +import { createMsTodoList } from '@/lib/microsoft-todo'; +import { getOutlookAccessToken } from '@/lib/outlook-token'; +import { createAppleReminderList } from '@/lib/apple-reminders'; + +const prisma = new PrismaClient(); + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { title, provider } = body; + + if (!title) { + return NextResponse.json({ error: 'Title is required' }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + let externalId: string | undefined; + let externalProvider: string | undefined; + + if (provider === 'google') { + const account = await prisma.account.findFirst({ + where: { userId: user.id, provider: 'google' } + }); + if (!account || !account.access_token) { + return NextResponse.json({ error: 'Google account not connected' }, { status: 400 }); + } + const client = createGoogleClient(account.access_token, account.refresh_token || undefined); + const newList = await createGoogleTaskList(client, title); + externalId = newList.id; + externalProvider = 'google'; + } else if (provider === 'outlook' || provider === 'microsoft') { + const accessToken = await getOutlookAccessToken(user.id); + if (!accessToken) { + return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 }); + } + const newList = await createMsTodoList(accessToken, title); + externalId = newList.id; + externalProvider = 'outlook'; + } else if (provider === 'apple') { + const connection = await prisma.calendarConnection.findFirst({ + where: { userId: user.id, provider: 'apple' } + }); + if (!connection || !connection.accessToken) { + return NextResponse.json({ error: 'Apple account not connected' }, { status: 400 }); + } + const [email, password] = connection.accessToken.split(':'); + const newList = await createAppleReminderList(email, password, title); + externalId = newList.id; + externalProvider = 'apple'; + } + + // Create local SomedayList + console.log('Creating SomedayList with data:', { + userId: user.id, + title, + externalId: externalId ?? null, + externalProvider: externalProvider ?? null, + }); + + const somedayList = await prisma.somedayList.create({ + data: { + userId: user.id, + title, + order: 0, // In external route we can just default to 0 or calculate it + externalId: externalId ?? null, + externalProvider: externalProvider ?? null, + lastSyncedAt: externalId ? new Date() : null + } + }); + + console.log('Successfully created SomedayList:', somedayList.id); + return NextResponse.json({ success: true, somedayList }); + + } catch (error: any) { + console.error('Error creating external someday list:', error); + return NextResponse.json( + { error: error.message || 'Failed to create list' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tasks/import/route.ts b/src/app/api/tasks/import/route.ts index a174a81..eb5c024 100644 --- a/src/app/api/tasks/import/route.ts +++ b/src/app/api/tasks/import/route.ts @@ -4,7 +4,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks'; -import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; +import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted, fetchMsChecklistItems } from '@/lib/microsoft-todo'; import { getOutlookAccessToken } from '@/lib/outlook-token'; const prisma = new PrismaClient(); @@ -22,6 +22,7 @@ interface ImportedTask { dueDate: Date | null; status: string; sourceListTitle: string; + parentExternalId?: string; } export async function POST(req: NextRequest) { @@ -57,6 +58,7 @@ export async function POST(req: NextRequest) { } const importedTasks: ImportedTask[] = []; + let targetLists: SourceList[] = lists; if (provider === 'google') { const account = await prisma.account.findFirst({ @@ -69,7 +71,6 @@ export async function POST(req: NextRequest) { const client = createGoogleClient(account.access_token, account.refresh_token || undefined); - let targetLists = lists; if (targetLists.length === 0) { const googleLists = await fetchGoogleTaskLists(client); if (googleLists.length > 0) { @@ -78,18 +79,22 @@ export async function POST(req: NextRequest) { } for (const sourceList of targetLists) { - const googleTasks = await fetchGoogleTasks(client, sourceList.id); - importedTasks.push(...googleTasks.map(t => ({ - title: t.title, - description: t.notes || '', - externalId: t.id, - externalListId: sourceList.id, - dueDate: t.due ? new Date(t.due) : null, - status: t.status, - sourceListTitle: sourceList.title, - }))); + try { + const googleTasks = await fetchGoogleTasks(client, sourceList.id); + importedTasks.push(...googleTasks.map(t => ({ + title: t.title, + description: t.notes || '', + externalId: t.id, + externalListId: sourceList.id, + dueDate: t.due ? new Date(t.due) : null, + status: t.status, + sourceListTitle: sourceList.title, + parentExternalId: t.parent || undefined, + }))); + } catch (e) { + console.error(`Error fetching Google tasks for list ${sourceList.id}:`, e); + } } - } if (provider === 'outlook') { @@ -98,7 +103,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 }); } - let targetLists = lists; if (targetLists.length === 0) { const msTodoLists = await fetchMsTodoLists(accessToken); if (msTodoLists.length > 0) { @@ -108,21 +112,51 @@ export async function POST(req: NextRequest) { } for (const sourceList of targetLists) { - const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id); - importedTasks.push(...msTasks.map(t => ({ - title: t.title, - description: t.body?.content || '', - externalId: t.id, - externalListId: sourceList.id, - dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null, - status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted', - sourceListTitle: sourceList.title, - }))); + try { + const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id); + for (const t of msTasks) { + importedTasks.push({ + title: t.title, + description: t.body?.content || '', + externalId: t.id, + externalListId: sourceList.id, + dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null, + status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted', + sourceListTitle: sourceList.title, + }); + + // Fetch checklist items as sub-tasks + try { + const checklistItems = await fetchMsChecklistItems(accessToken, sourceList.id, t.id); + for (const item of checklistItems) { + importedTasks.push({ + title: item.displayName, + description: '', + externalId: item.id, + externalListId: sourceList.id, + dueDate: null, + status: item.isChecked ? 'completed' : 'notStarted', + sourceListTitle: sourceList.title, + parentExternalId: t.id, + }); + } + } catch (e) { + console.error(`Error fetching checklist items for task ${t.id}:`, e); + } + } + } catch (e) { + console.error(`Error fetching MS Todo tasks for list ${sourceList.id}:`, e); + } } } // Group tasks by source list title const tasksByList = new Map(); + // Pre-initialize with all target lists to ensure they are created even if empty + for (const tl of targetLists) { + tasksByList.set(tl.title, []); + } + for (const task of importedTasks) { const listTitle = task.sourceListTitle; if (!tasksByList.has(listTitle)) { @@ -137,6 +171,11 @@ export async function POST(req: NextRequest) { let updatedCount = 0; let listsCreated = 0; + // Track externalId -> local task ID for parent-child linking + const externalToLocalId = new Map(); + // Tasks that need parent linking after creation + const pendingParentLinks: { localId: string; parentExternalId: string }[] = []; + for (const [listTitle, tasks] of tasksByList) { let somedayList = await prisma.somedayList.findFirst({ where: { userId: user.id, title: listTitle } @@ -150,7 +189,15 @@ export async function POST(req: NextRequest) { console.log(`[IMPORT] Created SomedayList "${listTitle}" (${somedayList.id})`); } - for (const task of tasks) { + // First pass: create/update all tasks (parents first via sorting) + const sortedTasks = [...tasks].sort((a, b) => { + // Parent tasks (no parentExternalId) come first + if (!a.parentExternalId && b.parentExternalId) return -1; + if (a.parentExternalId && !b.parentExternalId) return 1; + return 0; + }); + + for (const task of sortedTasks) { const existingTask = await prisma.task.findFirst({ where: { userId: user.id, externalId: task.externalId, externalProvider: provider } }); @@ -163,9 +210,13 @@ export async function POST(req: NextRequest) { lastSyncedAt: new Date() } }); + externalToLocalId.set(task.externalId, existingTask.id); + if (task.parentExternalId) { + pendingParentLinks.push({ localId: existingTask.id, parentExternalId: task.parentExternalId }); + } updatedCount++; } else { - await prisma.task.create({ + const newTask = await prisma.task.create({ data: { userId: user.id, title: task.title, @@ -178,11 +229,26 @@ export async function POST(req: NextRequest) { lastSyncedAt: new Date() } }); + externalToLocalId.set(task.externalId, newTask.id); + if (task.parentExternalId) { + pendingParentLinks.push({ localId: newTask.id, parentExternalId: task.parentExternalId }); + } count++; } } } + // Second pass: link parent-child relationships + for (const { localId, parentExternalId } of pendingParentLinks) { + const parentLocalId = externalToLocalId.get(parentExternalId); + if (parentLocalId) { + await prisma.task.update({ + where: { id: localId }, + data: { parentTaskId: parentLocalId } + }); + } + } + console.log(`[IMPORT] Done! Created: ${count}, Updated: ${updatedCount}, Lists created: ${listsCreated}`); return NextResponse.json({ success: true, count, updatedCount, listsCreated }); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index b864f62..01e4862 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -132,13 +132,19 @@ export async function GET(request: NextRequest) { const start = searchParams.get('start'); const end = searchParams.get('end'); - // Fetch REAL tasks (exclude soft-deleted) + // Fetch REAL tasks (exclude soft-deleted), include sub-tasks const includeDeleted = searchParams.get('includeDeleted') === 'true'; const tasks = await prisma.task.findMany({ where: { userId, ...(includeDeleted ? {} : { deletedAt: null }), }, + include: { + subTasks: { + where: includeDeleted ? {} : { deletedAt: null }, + orderBy: { order: 'asc' }, + }, + }, orderBy: [ { dayOfWeek: 'asc' }, { order: 'asc' }, @@ -189,7 +195,7 @@ 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, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body; + const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId } = body; let { isRolling } = body; const { isRecurring } = body; @@ -225,7 +231,8 @@ export async function POST(request: NextRequest) { recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null, recurrenceUnit, recurrenceTime, - recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null + recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null, + parentTaskId: parentTaskId || null, }, }); @@ -258,7 +265,7 @@ export async function PATCH(request: NextRequest) { const body = await request.json(); const { id } = body; - const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore } = body; + const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId } = body; if (!id) { return NextResponse.json( @@ -341,7 +348,8 @@ export async function PATCH(request: NextRequest) { ...(recurrenceUnit !== undefined && { recurrenceUnit }), ...(recurrenceTime !== undefined && { recurrenceTime }), ...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }), - ...(restore === true && { deletedAt: null }) + ...(restore === true && { deletedAt: null }), + ...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }) }, }); diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index bc84354..c818bbc 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; -import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks'; +import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks'; import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; import { getOutlookAccessToken } from '@/lib/outlook-token'; @@ -33,14 +33,27 @@ export async function GET(req: NextRequest) { } }); + // Find all synced SomedayLists (for discovering new remote tasks) + const syncedLists = await prisma.somedayList.findMany({ + where: { + userId: user.id, + externalId: { not: null }, + externalProvider: { not: null }, + } + }); + const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google'); const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook'); let updated = 0; let deleted = 0; + let created = 0; // --- Google Tasks pull-sync --- - if (googleLocalTasks.length > 0) { + const googleSyncedLists = syncedLists.filter(l => l.externalProvider === 'google'); + const hasGoogleTasks = googleLocalTasks.length > 0 || googleSyncedLists.length > 0; + + if (hasGoogleTasks) { const account = await prisma.account.findFirst({ where: { userId: user.id, provider: 'google' } }); @@ -48,6 +61,21 @@ export async function GET(req: NextRequest) { if (account?.access_token) { const client = createGoogleClient(account.access_token, account.refresh_token || undefined); + // Build set of all Google list IDs to sync (from tasks + synced lists) + const googleListIds = new Set(); + const listIdToSomedayList = new Map(); + + for (const task of googleLocalTasks) { + if (task.externalListId) googleListIds.add(task.externalListId); + } + for (const sl of googleSyncedLists) { + if (sl.externalId) { + googleListIds.add(sl.externalId); + listIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title }); + } + } + + // Group existing local tasks by list const googleByList = new Map(); for (const task of googleLocalTasks) { if (!task.externalListId) continue; @@ -57,12 +85,23 @@ export async function GET(req: NextRequest) { googleByList.get(task.externalListId)!.push(task); } - for (const [listId, tasks] of googleByList) { + for (const listId of googleListIds) { + const localTasks = googleByList.get(listId) || []; try { const remoteTasks = await fetchGoogleTasksForSync(client, listId); const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); - for (const localTask of tasks) { + // Build externalId -> localId map for parent linking + const extToLocalMap = new Map(); + for (const t of localTasks) { + if (t.externalId) extToLocalMap.set(t.externalId, t.id); + } + + // Build set of existing external IDs for quick lookup + const existingExternalIds = new Set(localTasks.map(t => t.externalId)); + + // Update/delete existing local tasks + for (const localTask of localTasks) { const remote = remoteMap.get(localTask.externalId!); if (!remote) { @@ -93,6 +132,16 @@ export async function GET(req: NextRequest) { updateData.description = remote.notes || null; } + // Sync parent relationship from Google Tasks + if (remote.parent) { + const parentLocalId = extToLocalMap.get(remote.parent); + if (parentLocalId && localTask.parentTaskId !== parentLocalId) { + updateData.parentTaskId = parentLocalId; + } + } else if (localTask.parentTaskId && !remote.parent) { + updateData.parentTaskId = null; + } + if (Object.keys(updateData).length > 1) { await prisma.task.update({ where: { id: localTask.id }, @@ -106,6 +155,44 @@ export async function GET(req: NextRequest) { }); } } + + // Create new local tasks for remote tasks not yet in local DB + const somedayListInfo = listIdToSomedayList.get(listId); + if (somedayListInfo) { + // Sort: parents first, then children + const newRemoteTasks = remoteTasks + .filter(rt => !existingExternalIds.has(rt.id)) + .sort((a, b) => { + if (!a.parent && b.parent) return -1; + if (a.parent && !b.parent) return 1; + return 0; + }); + + for (const remote of newRemoteTasks) { + // Skip empty-title tasks + if (!remote.title || !remote.title.trim()) continue; + + const parentLocalId = remote.parent ? extToLocalMap.get(remote.parent) : undefined; + + const newTask = await prisma.task.create({ + data: { + userId: user.id, + title: remote.title, + description: remote.notes || null, + completed: remote.status === 'completed', + somedayListId: somedayListInfo.id, + externalId: remote.id, + externalProvider: 'google', + externalListId: listId, + parentTaskId: parentLocalId || null, + lastSyncedAt: new Date(), + } + }); + extToLocalMap.set(remote.id, newTask.id); + existingExternalIds.add(remote.id); + created++; + } + } } catch (listError) { console.error(`Error syncing Google list ${listId}:`, listError); } @@ -114,10 +201,28 @@ export async function GET(req: NextRequest) { } // --- Microsoft To-Do pull-sync --- - if (outlookLocalTasks.length > 0) { + const outlookSyncedLists = syncedLists.filter(l => l.externalProvider === 'outlook'); + const hasOutlookTasks = outlookLocalTasks.length > 0 || outlookSyncedLists.length > 0; + + if (hasOutlookTasks) { const outlookToken = await getOutlookAccessToken(user.id); if (outlookToken) { + // Build set of all Outlook list IDs to sync + const outlookListIds = new Set(); + const outlookListIdToSomedayList = new Map(); + + for (const task of outlookLocalTasks) { + if (task.externalListId) outlookListIds.add(task.externalListId); + } + for (const sl of outlookSyncedLists) { + if (sl.externalId) { + outlookListIds.add(sl.externalId); + outlookListIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title }); + } + } + + // Group existing local tasks by list const outlookByList = new Map(); for (const task of outlookLocalTasks) { if (!task.externalListId) continue; @@ -127,12 +232,15 @@ export async function GET(req: NextRequest) { outlookByList.get(task.externalListId)!.push(task); } - for (const [listId, tasks] of outlookByList) { + for (const listId of outlookListIds) { + const localTasks = outlookByList.get(listId) || []; try { const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); - for (const localTask of tasks) { + const existingExternalIds = new Set(localTasks.map(t => t.externalId)); + + for (const localTask of localTasks) { const remote = remoteMap.get(localTask.externalId!); if (!remote) { @@ -177,6 +285,32 @@ export async function GET(req: NextRequest) { }); } } + + // Create new local tasks for remote tasks not yet in local DB + const somedayListInfo = outlookListIdToSomedayList.get(listId); + if (somedayListInfo) { + const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id)); + + for (const remote of newRemoteTasks) { + if (!remote.title || !remote.title.trim()) continue; + + await prisma.task.create({ + data: { + userId: user.id, + title: remote.title, + description: remote.body?.content || null, + completed: isMsTodoTaskCompleted(remote.status), + somedayListId: somedayListInfo.id, + externalId: remote.id, + externalProvider: 'outlook', + externalListId: listId, + lastSyncedAt: new Date(), + } + }); + existingExternalIds.add(remote.id); + created++; + } + } } catch (listError) { console.error(`Error syncing Outlook list ${listId}:`, listError); } @@ -184,7 +318,7 @@ export async function GET(req: NextRequest) { } } - return NextResponse.json({ success: true, updated, deleted }); + return NextResponse.json({ success: true, updated, deleted, created }); } catch (error: unknown) { console.error('Pull sync error:', error); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index c0102da..08ee2e8 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -40,6 +40,7 @@ export async function GET(request: NextRequest) { goalFontSize: true, goalFontWeight: true, goalScope: true, + dateLayout: true, headlineFont: true, headlineFontSize: true, headlineFontWeight: true, @@ -64,6 +65,17 @@ export async function GET(request: NextRequest) { weekendColorSat: true, weekendColorSun: true, pastDayColor: true, + hourLabelFormat: true, + showSubHourSlots: true, + allDayPosition: true, + cwFontFamily: true, + cwFontSize: true, + cwFontWeight: true, + cwColor: true, + yearFontFamily: true, + yearFontSize: true, + yearFontWeight: true, + yearColor: true, createdAt: true } }); @@ -99,7 +111,11 @@ export async function PATCH(request: NextRequest) { fontWeight, weekendColorSat, weekendColorSun, weekdayColor, dateColor, taskColor, todayHighlightColor, pastDayColor, goalFallbackType, goalDefaultSentence, - goalFontFamily, goalFontSize, goalFontWeight, goalScope + goalFontFamily, goalFontSize, goalFontWeight, goalScope, + dateLayout, + hourLabelFormat, showSubHourSlots, allDayPosition, + cwFontFamily, cwFontSize, cwFontWeight, cwColor, + yearFontFamily, yearFontSize, yearFontWeight, yearColor } = body; const updateData: any = { @@ -154,6 +170,18 @@ export async function PATCH(request: NextRequest) { ...(goalFontSize !== undefined && { goalFontSize }), ...(goalFontWeight !== undefined && { goalFontWeight }), ...(goalScope !== undefined && { goalScope }), + ...(dateLayout !== undefined && { dateLayout }), + ...(hourLabelFormat !== undefined && { hourLabelFormat }), + ...(showSubHourSlots !== undefined && { showSubHourSlots }), + ...(allDayPosition !== undefined && { allDayPosition }), + ...(cwFontFamily !== undefined && { cwFontFamily }), + ...(cwFontSize !== undefined && { cwFontSize }), + ...(cwFontWeight !== undefined && { cwFontWeight }), + ...(cwColor !== undefined && { cwColor }), + ...(yearFontFamily !== undefined && { yearFontFamily }), + ...(yearFontSize !== undefined && { yearFontSize }), + ...(yearFontWeight !== undefined && { yearFontWeight }), + ...(yearColor !== undefined && { yearColor }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -210,12 +238,24 @@ export async function PATCH(request: NextRequest) { weekendColorSat: true, weekendColorSun: true, pastDayColor: true, + hourLabelFormat: true, + showSubHourSlots: true, + allDayPosition: true, goalFallbackType: true, goalDefaultSentence: true, goalFontFamily: true, goalFontSize: true, goalFontWeight: true, goalScope: true, + dateLayout: true, + cwFontFamily: true, + cwFontSize: true, + cwFontWeight: true, + cwColor: true, + yearFontFamily: true, + yearFontSize: true, + yearFontWeight: true, + yearColor: true, } }); diff --git a/src/app/globals.css b/src/app/globals.css index cdc1110..c708d6c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1701,28 +1701,12 @@ h3 { position: relative; } -/* Current time label in time column */ -.now-time-label { - position: absolute; - right: 2px; - transform: translateY(-50%); - font-size: 10px; - font-weight: 700; - color: #d50000; - background: var(--weekly-bg, #fff); - padding: 0 2px; - z-index: 12; - line-height: 1; - letter-spacing: -0.02em; - white-space: nowrap; - pointer-events: none; -} .time-slot-label { display: flex; - align-items: center; /* Changed from flex-start to center */ + align-items: flex-start; /* Aligns to top so translateY(-50%) centers on the line */ justify-content: flex-end; - padding: 0 0.5rem; /* Removed top padding */ + padding: 0 0.5rem; font-size: 0.65rem; color: var(--weekly-text-light); box-sizing: border-box; @@ -2023,7 +2007,7 @@ h3 { .now-line { position: absolute; left: 0; - right: 0; + right: 40px; height: 2px; background: #d50000; z-index: 10; @@ -2031,7 +2015,19 @@ h3 { } .now-line::before { - content: none; + content: attr(data-time); + position: absolute; + right: -40px; + padding-right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + font-weight: 700; + color: #d50000; + line-height: 1; + letter-spacing: -0.02em; + white-space: nowrap; + z-index: 12; } .now-line::after { @@ -2940,3 +2936,16 @@ h3 { max-width: 100vw; } } + + +/* Grid Task Block Resize Handle */ +.time-slot-task .task-resize-handle { + opacity: 0; + transition: opacity 0.2s ease; +} + +.time-slot-task:hover .task-resize-handle, +.time-slot-task .task-resize-handle.active { + opacity: 1; +} + diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx new file mode 100644 index 0000000..9a1401a --- /dev/null +++ b/src/components/GridTaskBlock.tsx @@ -0,0 +1,417 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Repeat, ChevronDown, ChevronRight, FileText, CheckCircle, Circle, Copy } from "lucide-react"; +import { Task } from "./WeeklyView"; + +interface GridTaskBlockProps { + task: Task; + date: Date; + activeDate: Date; + cellDuration: number; + darkMode: boolean; + isProtected: boolean; + editingTaskId: string | null; + setEditingTaskId: (id: string | null) => void; + updateTask: (id: string, title: string) => void; + updateTaskNotes: (id: string, notes: string) => void; + updateTaskDuration: (id: string, duration: number) => void; + toggleTask: (id: string) => void; + deleteTask: (id: string) => void; + toggleTaskRolling: (id: string) => void; + setSelectedTaskForNotes: (task: Task) => void; + setSelectedTaskForRecurrence: (task: Task) => void; + handleDragStart: (e: React.DragEvent, task: Task) => void; + handleDragEnd: (e: React.DragEvent) => void; + getSlotHeight: (duration: number) => number; + draggedTask: Task | null; + addSubTask: (parentId: string, title: string) => void; + toggleSubTask: (id: string) => void; + updateSubTask: (id: string, title: string) => void; + deleteSubTask: (id: string) => void; + onSetEditingTaskId?: (id: string | null) => void; + workingHoursStart: number; +} + +export function GridTaskBlock({ + task, + date, + activeDate, + cellDuration, + darkMode, + isProtected, + editingTaskId, + setEditingTaskId, + updateTask, + updateTaskNotes, + updateTaskDuration, + toggleTask, + deleteTask, + toggleTaskRolling, + setSelectedTaskForNotes, + setSelectedTaskForRecurrence, + handleDragStart, + handleDragEnd, + getSlotHeight, + draggedTask, + addSubTask, + toggleSubTask, + updateSubTask, + deleteSubTask, + onSetEditingTaskId, + workingHoursStart +}: GridTaskBlockProps) { + const [isNotesOpen, setIsNotesOpen] = useState(false); + const [notesValue, setNotesValue] = useState(task.markdownContent || ""); + const [isSubTasksOpen, setIsSubTasksOpen] = useState(false); + const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false); + const [newSubTaskTitle, setNewSubTaskTitle] = useState(""); + const notesRef = useRef(null); + const subTaskInputRef = useRef(null); + + // Resize State + const [isResizing, setIsResizing] = useState(false); + const [resizeHeight, setResizeHeight] = useState(null); + const resizeStartY = useRef(0); + const resizeStartHeight = useRef(0); + + // Focus notes when opened + useEffect(() => { + if (isNotesOpen && notesRef.current) { + notesRef.current.focus(); + } + }, [isNotesOpen]); + + const handleNotesBlur = () => { + if (notesValue !== task.markdownContent) { + updateTaskNotes(task.id, notesValue); + } + }; + + const insertMarkdown = (prefix: string, suffix: string = "") => { + if (!notesRef.current) return; + const start = notesRef.current.selectionStart; + const end = notesRef.current.selectionEnd; + const text = notesValue; + const before = text.substring(0, start); + const selection = text.substring(start, end); + const after = text.substring(end); + const newText = `${before}${prefix}${selection}${suffix}${after}`; + setNotesValue(newText); + setTimeout(() => { + if (notesRef.current) { + notesRef.current.focus(); + const newCursorPos = start + prefix.length + selection.length + suffix.length; + notesRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + }; + + if (!task.startTime) return null; + + const [startHour, startMinute] = task.startTime.split(":").map(Number); + const startMinutes = (startHour - workingHoursStart) * 60 + startMinute; + + // Calculate top offset + const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration; + const topOffset = startMinutes * pixelsPerMinute; + + // Calculate height + const duration = task.duration || 15; // default 15m if not set + const baseHeight = duration * pixelsPerMinute; + const currentHeight = isResizing && resizeHeight !== null ? resizeHeight : baseHeight; + + // Handlers for resizing + const onResizeStart = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsResizing(true); + resizeStartY.current = e.clientY; + resizeStartHeight.current = baseHeight; + document.body.style.cursor = "ns-resize"; + }; + + useEffect(() => { + const onResizeMove = (e: MouseEvent) => { + if (!isResizing) return; + const deltaY = e.clientY - resizeStartY.current; + let newHeight = resizeStartHeight.current + deltaY; + const minHeight = 15 * pixelsPerMinute; // min 15 mins + if (newHeight < minHeight) newHeight = minHeight; + setResizeHeight(newHeight); + }; + + const onResizeEnd = (e: MouseEvent) => { + if (!isResizing) return; + setIsResizing(false); + document.body.style.cursor = ""; + if (resizeHeight !== null) { + const newDurationMins = Math.round(resizeHeight / pixelsPerMinute / 15) * 15; + updateTaskDuration(task.id, newDurationMins); + } + setResizeHeight(null); + }; + + if (isResizing) { + window.addEventListener("mousemove", onResizeMove); + window.addEventListener("mouseup", onResizeEnd); + } + + return () => { + window.removeEventListener("mousemove", onResizeMove); + window.removeEventListener("mouseup", onResizeEnd); + }; + }, [isResizing, resizeHeight, pixelsPerMinute, task.id, updateTaskDuration]); + + + return ( +
handleDragStart(e, task)} + onDragEnd={handleDragEnd} + onClick={(e) => { + e.stopPropagation(); + if (editingTaskId !== task.id) toggleTask(task.id); + }} + > +
+ {editingTaskId === task.id ? ( +
{ + e.preventDefault(); + const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement; + updateTask(task.id, input.value); + }} + onClick={(e) => e.stopPropagation()} + style={{ width: "100%", paddingRight: "20px" }} + > + updateTask(task.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setEditingTaskId(null); + if (e.key === "Enter") e.currentTarget.blur(); + }} + className="weekly-task-text" + style={{ width: "100%", background: "transparent", border: "none", borderBottom: "1px solid var(--weekly-border)", outline: "none" }} + /> +
+ ) : ( + { + e.stopPropagation(); + setEditingTaskId(task.id); + }} + > + {task.externalProvider && ( + !task.externalId || + !task.lastSyncedAt || + new Date(task.updatedAt) > new Date(task.lastSyncedAt) + ) && ( + + + + + + )} + {task.subTasks && task.subTasks.length > 0 && ( + { e.stopPropagation(); setIsSubTasksOpen(!isSubTasksOpen); }} + > + + + + + )} + {task.markdownContent && ( + { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} + > + + + + + + + + )} + {task.title} + + )} + + {(currentHeight >= 30 || isNotesOpen || isSubTasksOpen) && ( +
+ + + + + {!task.completed && ( + + )} + +
+ )} +
+ + {/* Inline Expanders Container */} +
+ {isNotesOpen && ( +
e.stopPropagation()}> +
+ + + + Markdown +
+