From dd8d471a1dc873bf96609c936f599755f9c3d1e3 Mon Sep 17 00:00:00 2001 From: mARTin Date: Fri, 20 Feb 2026 18:23:37 +0100 Subject: [PATCH] fix: Switch Apple Reminders to CalDAV, restore date picker, add goal scope - Replace broken CloudKit/pyicloud Apple Reminders integration with CalDAV-based approach (getAppleReminderLists, fetchTasks from apple-calendar.ts) in tasks/lists, tasks/import, and tasks/sync routes - Restore calendar date picker icon in header for jump-to-date navigation - Add goalScope field to User model with migration - Force-dynamic Google OAuth routes to fix redirect issues - Update Google Tasks client and sync logic Co-Authored-By: Claude Opus 4.6 --- .../20260220_add_goal_scope/migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/calendar/google/oauth/route.ts | 2 + src/app/api/calendar/google/start/route.ts | 2 + src/app/api/tasks/import/route.ts | 30 ++-- src/app/api/tasks/lists/route.ts | 23 +-- src/app/api/tasks/sync/route.ts | 32 +++-- src/app/api/user/profile/route.ts | 5 +- src/components/WeeklyView.tsx | 136 +++++++++++++++++- src/lib/google-tasks.ts | 3 +- 10 files changed, 193 insertions(+), 43 deletions(-) create mode 100644 prisma/migrations/20260220_add_goal_scope/migration.sql diff --git a/prisma/migrations/20260220_add_goal_scope/migration.sql b/prisma/migrations/20260220_add_goal_scope/migration.sql new file mode 100644 index 0000000..afaaa3e --- /dev/null +++ b/prisma/migrations/20260220_add_goal_scope/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "goalScope" TEXT NOT NULL DEFAULT 'week'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7c57470..682ad7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { 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") diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index dc10ab6..f5143d0 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -4,6 +4,8 @@ import { authOptions } from "@/lib/auth"; import { google } from 'googleapis'; import { PrismaClient } from '@prisma/client'; +export const dynamic = 'force-dynamic'; + const prisma = new PrismaClient(); // Google Calendar OAuth callback endpoint diff --git a/src/app/api/calendar/google/start/route.ts b/src/app/api/calendar/google/start/route.ts index e422c2c..2fb8554 100644 --- a/src/app/api/calendar/google/start/route.ts +++ b/src/app/api/calendar/google/start/route.ts @@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { google } from 'googleapis'; +export const dynamic = 'force-dynamic'; + // Initiate Google Calendar OAuth flow export async function GET(request: NextRequest) { try { diff --git a/src/app/api/tasks/import/route.ts b/src/app/api/tasks/import/route.ts index a4adce9..2fa9b96 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 { fetchReminders as fetchAppleReminders } from '@/lib/apple-reminders'; +import { fetchTasks as fetchAppleTasks } from '@/lib/apple-calendar'; const prisma = new PrismaClient(); @@ -90,12 +90,17 @@ export async function POST(req: NextRequest) { } } else if (provider === 'apple') { - const connection = await prisma.calendarConnection.findFirst({ + // Prefer 'apple' (CalDAV) connection, fall back to 'apple-reminders' + const appleConn = await prisma.calendarConnection.findFirst({ + where: { userId: user.id, provider: 'apple' } + }); + const remindersConn = await prisma.calendarConnection.findFirst({ where: { userId: user.id, provider: 'apple-reminders' } }); + const connection = appleConn || remindersConn; if (!connection) { - return NextResponse.json({ error: 'Apple Reminders not connected' }, { status: 400 }); + return NextResponse.json({ error: 'Apple Calendar not connected. Please connect Apple Calendar in Settings first.' }, { status: 400 }); } const colonIdx = connection.accessToken.indexOf(':'); @@ -106,16 +111,17 @@ export async function POST(req: NextRequest) { for (const sourceList of lists) { try { - console.log(`[IMPORT] Fetching reminders from list: ${sourceList.title} (guid: ${sourceList.id})`); - const reminders = await fetchAppleReminders(email, password, sourceList.id); - console.log(`[IMPORT] Fetched ${reminders.length} reminders from "${sourceList.title}"`); - importedTasks.push(...reminders.map(r => ({ - title: r.title, - description: r.description || '', - externalId: r.guid, + // sourceList.id is a CalDAV URL (from getAppleReminderLists) + console.log(`[IMPORT] Fetching reminders from list: ${sourceList.title} (url: ${sourceList.id})`); + const tasks = await fetchAppleTasks(email, password, sourceList.id); + console.log(`[IMPORT] Fetched ${tasks.length} tasks from "${sourceList.title}"`); + importedTasks.push(...tasks.map(t => ({ + title: t.title, + description: t.description || '', + externalId: t.id, externalListId: sourceList.id, - dueDate: r.dueDate || null, - status: r.isCompleted ? 'completed' : 'NEEDS-ACTION', + dueDate: t.endDate ? new Date(t.endDate) : null, + status: 'NEEDS-ACTION', // fetchTasks already filters out completed sourceListTitle: sourceList.title, }))); } catch (e) { diff --git a/src/app/api/tasks/lists/route.ts b/src/app/api/tasks/lists/route.ts index fc3f805..a1651f9 100644 --- a/src/app/api/tasks/lists/route.ts +++ b/src/app/api/tasks/lists/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks'; -import { fetchReminderLists } from '@/lib/apple-reminders'; +import { getAppleReminderLists } from '@/lib/apple-calendar'; const prisma = new PrismaClient(); @@ -44,13 +44,18 @@ export async function GET(req: NextRequest) { return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) }); } else if (provider === 'apple') { - // Look for the apple-reminders connection - const connection = await prisma.calendarConnection.findFirst({ + // Try CalDAV connections: prefer 'apple' (CalDAV with app-specific password), + // fall back to 'apple-reminders' credentials + const appleConn = await prisma.calendarConnection.findFirst({ + where: { userId: user.id, provider: 'apple' } + }); + const remindersConn = await prisma.calendarConnection.findFirst({ where: { userId: user.id, provider: 'apple-reminders' } }); + const connection = appleConn || remindersConn; if (!connection) { - return NextResponse.json({ error: 'Apple Reminders not connected. Please connect Apple Reminders in Settings.' }, { status: 400 }); + return NextResponse.json({ error: 'Apple Calendar not connected. Please connect Apple Calendar in Settings first (requires app-specific password).' }, { status: 400 }); } const colonIdx = connection.accessToken.indexOf(':'); @@ -58,13 +63,13 @@ export async function GET(req: NextRequest) { const password = connection.accessToken.slice(colonIdx + 1); try { - const reminderLists = await fetchReminderLists(email, password); - return NextResponse.json({ lists: reminderLists.map(l => ({ id: l.guid, title: l.title })) }); + const reminderLists = await getAppleReminderLists(email, password); + return NextResponse.json({ lists: reminderLists.map(l => ({ id: l.id, title: l.title })) }); } catch (error: any) { - console.error('[TASKS/LISTS] Failed to fetch reminder lists:', error.message); + console.error('[TASKS/LISTS] Failed to fetch reminder lists via CalDAV:', error.message); return NextResponse.json({ - error: error.message || 'Failed to fetch Apple Reminder lists.', - needsReconnect: error.message?.includes('2FA') || error.message?.includes('expired') || error.message?.includes('reconnect') + error: error.message || 'Failed to fetch Apple Reminder lists via CalDAV.', + needsReconnect: error.message?.includes('auth') || error.message?.includes('credentials') || error.message?.includes('401') }, { status: 401 }); } } diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index 4426616..6fef529 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -15,7 +15,7 @@ export async function PATCH(req: NextRequest) { } const body = await req.json(); - const { taskId, completed, title, action } = body; + const { taskId, completed, title, action, scheduledDate, notes } = body; if (!taskId) { return NextResponse.json({ error: 'Task ID required' }, { status: 400 }); @@ -47,25 +47,26 @@ export async function PATCH(req: NextRequest) { if (action === 'delete') { await deleteGoogleTask(client, task.externalListId, task.externalId); - } else if (title !== undefined && completed !== undefined) { - await updateGoogleTask(client, task.externalListId, task.externalId, { - title, - status: completed ? 'completed' : 'needsAction', - }); - } else if (title !== undefined) { - await updateGoogleTask(client, task.externalListId, task.externalId, { title }); - } else if (completed !== undefined) { - await updateGoogleTaskStatus( - client, - task.externalListId, - task.externalId, - completed ? 'completed' : 'needsAction' - ); + } else { + const updates: { title?: string; notes?: string; status?: 'needsAction' | 'completed'; due?: string | null } = {}; + if (title !== undefined) updates.title = title; + if (notes !== undefined) updates.notes = notes; + if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction'; + if (scheduledDate !== undefined) { + // Google Tasks expects RFC 3339 date (YYYY-MM-DDT00:00:00.000Z) + updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null; + } + if (Object.keys(updates).length > 0) { + await updateGoogleTask(client, task.externalListId, task.externalId, updates); + } } } } else if (task.externalProvider === 'apple' && task.externalListId) { + // Prefer 'apple' (CalDAV) connection, fall back to 'apple-reminders' const connection = await prisma.calendarConnection.findFirst({ + where: { userId: task.userId, provider: 'apple' } + }) || await prisma.calendarConnection.findFirst({ where: { userId: task.userId, provider: 'apple-reminders' } }); @@ -99,6 +100,7 @@ export async function PATCH(req: NextRequest) { const updateData: any = {}; if (completed !== undefined) updateData.completed = completed; if (title !== undefined) updateData.title = title; + if (scheduledDate !== undefined) updateData.scheduledDate = scheduledDate ? new Date(scheduledDate) : null; if (Object.keys(updateData).length > 0) { const updatedTask = await prisma.task.update({ diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 1df9f43..c0102da 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -39,6 +39,7 @@ export async function GET(request: NextRequest) { goalFontFamily: true, goalFontSize: true, goalFontWeight: true, + goalScope: true, headlineFont: true, headlineFontSize: true, headlineFontWeight: true, @@ -98,7 +99,7 @@ export async function PATCH(request: NextRequest) { fontWeight, weekendColorSat, weekendColorSun, weekdayColor, dateColor, taskColor, todayHighlightColor, pastDayColor, goalFallbackType, goalDefaultSentence, - goalFontFamily, goalFontSize, goalFontWeight + goalFontFamily, goalFontSize, goalFontWeight, goalScope } = body; const updateData: any = { @@ -152,6 +153,7 @@ export async function PATCH(request: NextRequest) { ...(goalFontFamily !== undefined && { goalFontFamily }), ...(goalFontSize !== undefined && { goalFontSize }), ...(goalFontWeight !== undefined && { goalFontWeight }), + ...(goalScope !== undefined && { goalScope }), }; if (password && password.trim() !== "") { updateData.passwordHash = await bcrypt.hash(password, 10); @@ -213,6 +215,7 @@ export async function PATCH(request: NextRequest) { goalFontFamily: true, goalFontSize: true, goalFontWeight: true, + goalScope: true, } }); diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 5fe31d1..506fc72 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -178,6 +178,9 @@ const translations: Record = { endHour: 'End of Day', weekAbbr: 'W', goalOfWeek: 'Goal of the Week', + goalScope: 'Goal Scope', + goalScopeWeek: 'Per Week', + goalScopeDay: 'Per Day', goalFallback: 'Goal Fallback Type', defaultGoal: 'Custom Default Goal', showSomeday: 'Show Someday Section', @@ -230,6 +233,9 @@ const translations: Record = { endHour: 'Tagesende', weekAbbr: 'KW', goalOfWeek: 'Ziel der Woche', + goalScope: 'Ziel-Zeitraum', + goalScopeWeek: 'Pro Woche', + goalScopeDay: 'Pro Tag', goalFallback: 'Ziel-Fallback-Typ', defaultGoal: 'Benutzerdefiniertes Standardziel', showSomeday: 'Irgendwann-Bereich anzeigen', @@ -458,6 +464,7 @@ export default function WeeklyView() { goalFontFamily?: string; goalFontSize?: string; goalFontWeight?: string; + goalScope?: 'week' | 'day'; }>({ name: session?.user?.name || '', email: session?.user?.email || '', @@ -539,6 +546,7 @@ export default function WeeklyView() { // New UI State const [isSearchOpen, setIsSearchOpen] = useState(false); const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false); + const [showDatePicker, setShowDatePicker] = useState(false); const [focusTimerDuration, setFocusTimerDuration] = useState(25); const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M'); @@ -848,11 +856,30 @@ export default function WeeklyView() { return () => clearInterval(interval); }, []); - // Fetch goal for current week + // Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date + const getGoalDateKey = useCallback((date: Date): string => { + const scope = profile.goalScope || 'week'; + if (scope === 'day') { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d.toISOString(); + } + // Normalize to Monday of the week containing this date + const d = new Date(date); + d.setHours(0, 0, 0, 0); + const day = d.getDay(); // 0=Sun, 1=Mon, ... + const diff = day === 0 ? -6 : 1 - day; // Monday offset + d.setDate(d.getDate() + diff); + return d.toISOString(); + }, [profile.goalScope]); + + const goalDateKey = useMemo(() => getGoalDateKey(currentWeekStart), [currentWeekStart, getGoalDateKey]); + + // Fetch goal for current week/day useEffect(() => { const fetchGoal = async () => { try { - const res = await fetch(`/api/goal?weekStart=${currentWeekStart.toISOString()}`); + const res = await fetch(`/api/goal?weekStart=${goalDateKey}`); if (res.ok) { const data = await res.json(); setGoal(data.goal); @@ -862,7 +889,7 @@ export default function WeeklyView() { } }; fetchGoal(); - }, [currentWeekStart]); + }, [goalDateKey]); const saveGoal = async (newGoal: string) => { setGoal(newGoal); @@ -871,7 +898,7 @@ export default function WeeklyView() { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - weekStart: currentWeekStart.toISOString(), + weekStart: goalDateKey, text: newGoal, }), }); @@ -1660,6 +1687,16 @@ export default function WeeklyView() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, markdownContent: notes }), }); + + // Sync notes to external provider + const task = tasks.find(t => t.id === taskId); + if (task?.externalId && task?.externalProvider) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId, notes }), + }).catch(e => console.error('Sync error:', e)); + } } catch (error) { console.error('Error updating task notes:', error); } @@ -1692,6 +1729,7 @@ export default function WeeklyView() { const moveTaskToSlot = async (taskId: string, dayOfWeek: number, startTime: string, scheduledDate?: Date) => { const newScheduledDate = scheduledDate ? formatDateToISO(scheduledDate) : undefined; + const task = tasks.find(t => t.id === taskId); setTasks(tasks.map(t => t.id === taskId ? { ...t, dayOfWeek, startTime, scheduledDate: newScheduledDate || t.scheduledDate, updatedAt: new Date() } @@ -1704,6 +1742,15 @@ export default function WeeklyView() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, dayOfWeek, startTime, scheduledDate: newScheduledDate }), }); + + // Sync due date change to external provider + if (task?.externalId && task?.externalProvider && newScheduledDate) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }), + }).catch(e => console.error('Sync error:', e)); + } } catch (error) { console.error('Error moving task:', error); } @@ -1870,6 +1917,15 @@ export default function WeeklyView() { startTime: resolvedStartTime }), }); + + // Sync due date change to external provider + if (task.externalId && task.externalProvider) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }), + }).catch(e => console.error('Sync error:', e)); + } } catch (error) { console.error('Error rolling task:', error); } @@ -1954,6 +2010,15 @@ export default function WeeklyView() { startTime: targetSlot || '' }), }); + + // Sync due date to external provider when moving from someday to calendar + if (draggedTask.externalId && draggedTask.externalProvider) { + fetch('/api/tasks/sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId: draggedTask.id, scheduledDate: newScheduledDate }), + }).catch(e => console.error('Sync error:', e)); + } } catch (error) { console.error('Error moving task from someday to calendar:', error); } @@ -2333,6 +2398,28 @@ export default function WeeklyView() { + {/* Date Picker Toggle */} +
+ + {showDatePicker && ( + { + setCurrentWeekStart(getStartOfWeek(date)); + setShowDatePicker(false); + }} + onClose={() => setShowDatePicker(false)} + language={language} + /> + )} +
+ {/* Search */} + + + + {/* Goal Fallback Settings */}
diff --git a/src/lib/google-tasks.ts b/src/lib/google-tasks.ts index b8a0d01..104de2d 100644 --- a/src/lib/google-tasks.ts +++ b/src/lib/google-tasks.ts @@ -79,12 +79,13 @@ export const fetchGoogleTasks = async (client: OAuth2Client, taskListId: string) /** * Update a Google Task status */ -export const updateGoogleTask = async (client: OAuth2Client, taskListId: string, taskId: string, updates: { title?: string; notes?: string; status?: 'needsAction' | 'completed' }): Promise => { +export const updateGoogleTask = async (client: OAuth2Client, taskListId: string, taskId: string, updates: { title?: string; notes?: string; status?: 'needsAction' | 'completed'; due?: string | null }): Promise => { const service = google.tasks({ version: 'v1', auth: client }); try { const requestBody: any = {}; if (updates.title !== undefined) requestBody.title = updates.title; if (updates.notes !== undefined) requestBody.notes = updates.notes; + if (updates.due !== undefined) requestBody.due = updates.due; if (updates.status !== undefined) { requestBody.status = updates.status; requestBody.completed = updates.status === 'completed' ? new Date().toISOString() : null;