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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-20 18:23:37 +01:00
parent 7f76774258
commit dd8d471a1d
10 changed files with 193 additions and 43 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "goalScope" TEXT NOT NULL DEFAULT 'week';

View File

@ -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")

View File

@ -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

View File

@ -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 {

View File

@ -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) {

View File

@ -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 });
}
}

View File

@ -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({

View File

@ -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,
}
});

View File

@ -178,6 +178,9 @@ const translations: Record<string, any> = {
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<string, any> = {
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() {
</button>
</div>
{/* Date Picker Toggle */}
<div className="relative">
<button
className={`p-1.5 hover:bg-gray-100 rounded-md transition-colors ${showDatePicker ? 'text-teal-600 bg-teal-50' : 'text-gray-500 hover:text-black'}`}
onClick={() => setShowDatePicker(!showDatePicker)}
title="Jump to date"
>
<Calendar size={18} />
</button>
{showDatePicker && (
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
setShowDatePicker(false);
}}
onClose={() => setShowDatePicker(false)}
language={language}
/>
)}
</div>
{/* Search */}
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
@ -3115,6 +3202,15 @@ export default function WeeklyView() {
startTime: null
}),
});
// Clear due date in external provider when moving to someday
if (taskToMove.externalId && taskToMove.externalProvider) {
fetch('/api/tasks/sync', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: taskToMove.id, scheduledDate: null }),
}).catch(e => console.error('Sync error:', e));
}
} catch (error) {
console.error('Error moving task to someday list:', error);
}
@ -4252,6 +4348,7 @@ function SettingsSidebar({
goalFontFamily?: string;
goalFontSize?: string;
goalFontWeight?: string;
goalScope?: 'week' | 'day';
}>({
name: '',
email: '',
@ -4363,6 +4460,7 @@ function SettingsSidebar({
goalFontFamily: data.user.goalFontFamily || 'Inter',
goalFontSize: data.user.goalFontSize || '0.9rem',
goalFontWeight: data.user.goalFontWeight || '500',
goalScope: data.user.goalScope || 'week',
weekendColorSat: data.user.weekendColorSat || '#666666',
weekendColorSun: data.user.weekendColorSun || '#dc2626',
weekdayColor: data.user.weekdayColor || '#888888',
@ -4636,7 +4734,8 @@ function SettingsSidebar({
protectEventTimes: profile.protectEventTimes || protectEventTimes,
focusTimerDuration: profile.focusTimerDuration || focusTimerDuration,
focusBreakDuration: profile.focusBreakDuration || focusBreakDuration,
pastDayColor: profile.pastDayColor
pastDayColor: profile.pastDayColor,
goalScope: profile.goalScope
} as any);
}
@ -5291,6 +5390,33 @@ function SettingsSidebar({
</div>
</div>
{/* Goal Scope Setting */}
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '16px', borderRadius: '8px', marginBottom: '1.5rem', border: '1px solid var(--weekly-border)' }}>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '12px' }}>{t.goalScope}</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setProfile({ ...profile, goalScope: 'week' })}
style={{
flex: 1, padding: '8px 12px', borderRadius: '6px',
border: profile.goalScope === 'week' || !profile.goalScope ? '2px solid var(--weekly-accent, #4A90D9)' : '1px solid #ddd',
background: profile.goalScope === 'week' || !profile.goalScope ? 'var(--weekly-accent-light, #e8f0fe)' : 'white',
fontWeight: profile.goalScope === 'week' || !profile.goalScope ? 600 : 400,
cursor: 'pointer', fontSize: '0.85rem'
}}
>{t.goalScopeWeek}</button>
<button
onClick={() => setProfile({ ...profile, goalScope: 'day' })}
style={{
flex: 1, padding: '8px 12px', borderRadius: '6px',
border: profile.goalScope === 'day' ? '2px solid var(--weekly-accent, #4A90D9)' : '1px solid #ddd',
background: profile.goalScope === 'day' ? 'var(--weekly-accent-light, #e8f0fe)' : 'white',
fontWeight: profile.goalScope === 'day' ? 600 : 400,
cursor: 'pointer', fontSize: '0.85rem'
}}
>{t.goalScopeDay}</button>
</div>
</div>
{/* Goal Fallback Settings */}
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '16px', borderRadius: '8px', marginBottom: '1.5rem', border: '1px solid var(--weekly-border)' }}>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '12px' }}>{t.goalOfWeek} Fallback</label>

View File

@ -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<GoogleTask> => {
export const updateGoogleTask = async (client: OAuth2Client, taskListId: string, taskId: string, updates: { title?: string; notes?: string; status?: 'needsAction' | 'completed'; due?: string | null }): Promise<GoogleTask> => {
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;