Mobile fixes: - Add viewport meta tag (was missing, causing broken mobile rendering) - Make header nav controls visible on mobile (opacity-0 group-hover was invisible on touch) - Add touch swipe gestures for day navigation on mobile - Fix viewDays responsive override after profile load Bug fixes: - Fix goal save (WeeklyView used POST but route only had GET/PUT; fix to use PUT, add POST alias) - Fix body key mismatch (goal → text) in goal save request Auth & identity (Task 1): - Add accountNumber (auto-increment) to User model for stable identity - Fix OAuth flows: pass user CUID in state instead of email - Google/Outlook callbacks now look up users by ID, not email - Non-Gmail users can now connect Google Calendar/Tasks CalDAV performance (Task 5): - Embed CalDAV object URL in Apple event IDs (caldav::<url>::<uid> format) - Delete/update now use O(1) direct URL access instead of O(n) full calendar scan - Optimistic UI removal on delete (no blocking force-refresh) - Legacy fallback for old-format IDs during transition Apple Calendar data (Task 2): - Pass URL, location, recurringEventId, isRecurring through full pipeline - Add url, recurringEventId, isRecurring to CachedCalendarEvent schema - Calendar cache now reads/writes all new fields Projects (Task 6): - New Project model (name, icon, color, description, order) - CRUD API at /api/tasks/projects - Tasks now support optional projectId with cascading nullify Quotes (Task 4): - Curated local quote database (50 quotes, DE+EN) with tag filtering - Preset quote source APIs (ZenQuotes, Quotable, Forismatic, Type.fit) Fonts (Task 7): - FontPicker component with searchable dropdown and live preview - /api/fonts endpoint (Google Fonts API proxy with 24h cache, fallback to 40 popular fonts) Quick Settings (Task 8): - QuickSettingsSidebar component (font size, spacing, show completed, start day, show lines) - New user preferences: showCompletedTasks, showLines, startDayOffset, quoteSourceUrls v1.9.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { authOptions } from "@/lib/auth";
|
|
import { PrismaClient, Task } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
// Helper to generate a deterministic virtual ID
|
|
const generateVirtualId = (originalId: string, dateStr: string) => {
|
|
return `virtual-${originalId}-${dateStr}`;
|
|
};
|
|
|
|
// Helper to project future tasks
|
|
const projectFutureTasks = (tasks: Task[], horizonDays = 90) => {
|
|
const projectedTasks: any[] = [];
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const horizonDate = new Date(today);
|
|
horizonDate.setDate(today.getDate() + horizonDays);
|
|
|
|
// Group tasks by "series signature" to find the latest one to project from
|
|
// Signature uses: title + recurrence settings + userId
|
|
const seriesGroups = new Map<string, Task[]>();
|
|
|
|
tasks.forEach(task => {
|
|
if (task.isRecurring && !task.completed && task.scheduledDate) {
|
|
const signature = `${task.userId}-${task.title}-${task.recurrenceInterval}-${task.recurrenceUnit}-${task.recurrenceTime}`;
|
|
if (!seriesGroups.has(signature)) {
|
|
seriesGroups.set(signature, []);
|
|
}
|
|
seriesGroups.get(signature)?.push(task);
|
|
}
|
|
});
|
|
|
|
// For each series, project from the LATEST scheduled task
|
|
seriesGroups.forEach((groupTasks) => {
|
|
// Sort descending by date
|
|
groupTasks.sort((a, b) => {
|
|
const da = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
|
const db = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
|
return db - da; // Latest first
|
|
});
|
|
|
|
const latestTask = groupTasks[0];
|
|
if (!latestTask.scheduledDate) return;
|
|
|
|
const baseDate = new Date(latestTask.scheduledDate);
|
|
// If base date is in future, start from there. If in past, start from today?
|
|
// Actually, simple projection: continue strictly from base date
|
|
|
|
const currentDate = new Date(baseDate);
|
|
const interval = latestTask.recurrenceInterval || 1;
|
|
const unit = latestTask.recurrenceUnit || 'weeks'; // 'days', 'weeks', 'months', 'years'
|
|
|
|
// Advance to next slot
|
|
// We only generate UP TO horizon.
|
|
// We avoid generating duplicates if a real task already exists at that date?
|
|
// We already grouped by series, and we are projecting from the *latest* one.
|
|
// So any future dates we generate *should* be new.
|
|
|
|
// Safety break
|
|
let iterations = 0;
|
|
while (currentDate < horizonDate && iterations < 100) {
|
|
iterations++;
|
|
|
|
// Advance date
|
|
if (unit === 'days') {
|
|
currentDate.setDate(currentDate.getDate() + interval);
|
|
} else if (unit === 'weeks') {
|
|
currentDate.setDate(currentDate.getDate() + (interval * 7));
|
|
} else if (unit === 'months') {
|
|
currentDate.setMonth(currentDate.getMonth() + interval);
|
|
} else if (unit === 'years') {
|
|
currentDate.setFullYear(currentDate.getFullYear() + interval);
|
|
} else {
|
|
// Default to weekly if unknown
|
|
currentDate.setDate(currentDate.getDate() + 7);
|
|
}
|
|
|
|
if (latestTask.recurrenceEndDate && currentDate > new Date(latestTask.recurrenceEndDate)) {
|
|
break;
|
|
}
|
|
|
|
if (currentDate <= today) {
|
|
// Skip past dates that weren't generated (logic gap? no, if it's in past and not in DB, maybe user deleted it? or we just missed it. Let's show it if it's > today, or maybe >= today if late?)
|
|
// If we project from *latest* task, and latest task is e.g. Yesterday.
|
|
// Next is Today. We should show it.
|
|
// If latest task is Today. Next is Tomorrow.
|
|
// So we just check if currentDate >= today?
|
|
// Actually, if we have "overdue" tasks in the list, users usually see them.
|
|
// We only care about *future* projections here usually.
|
|
// Although showing "missed" recurrence in the past as virtual tasks might be annoying to clean up.
|
|
// Let's stick to future-only projection (>= Today) for virtual tasks to be safe/clean.
|
|
if (currentDate < today) continue;
|
|
}
|
|
|
|
// Check if we already have a task for this date in the group (unlikely due to sorting, but possible if we have gaps)
|
|
// Actually, since we project from LATEST, we assume no *later* tasks exist.
|
|
|
|
const dateStr = currentDate.toISOString().split('T')[0];
|
|
|
|
projectedTasks.push({
|
|
...latestTask,
|
|
id: generateVirtualId(latestTask.id, dateStr),
|
|
scheduledDate: new Date(currentDate), // Clone
|
|
createdAt: new Date(), // Now
|
|
updatedAt: new Date(), // Now
|
|
isVirtual: true, // Flag for frontend if needed (not in Prisma type, but JS object accepts it)
|
|
originalTaskId: latestTask.id // Reference
|
|
});
|
|
}
|
|
});
|
|
|
|
return projectedTasks;
|
|
};
|
|
|
|
// GET - Fetch all tasks for authenticated user
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const userId = (session.user as any).id;
|
|
const { searchParams } = new URL(request.url);
|
|
const start = searchParams.get('start');
|
|
const end = searchParams.get('end');
|
|
|
|
// 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' },
|
|
},
|
|
project: {
|
|
select: { id: true, name: true, icon: true, color: true },
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ dayOfWeek: 'asc' },
|
|
{ order: 'asc' },
|
|
],
|
|
});
|
|
|
|
// Project VIRTUAL tasks
|
|
const virtualTasks = projectFutureTasks(tasks);
|
|
|
|
// Combine
|
|
const allTasks = [...tasks, ...virtualTasks];
|
|
|
|
// Optional: Filter by date range if provided (optimization)
|
|
// Front-end usually fetches all, but let's be ready
|
|
let filteredTasks = allTasks;
|
|
if (start && end) {
|
|
const startDate = new Date(start);
|
|
const endDate = new Date(end);
|
|
filteredTasks = allTasks.filter(t => {
|
|
if (!t.scheduledDate) return true; // keep undated?
|
|
const d = new Date(t.scheduledDate);
|
|
return d >= startDate && d <= endDate;
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({ tasks: filteredTasks });
|
|
} catch (error) {
|
|
console.error('Error fetching tasks:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch tasks' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// POST - Create new task
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const userId = (session.user as any).id;
|
|
const body = await request.json();
|
|
|
|
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, parentTaskId, projectId } = body;
|
|
let { isRolling } = body;
|
|
const { isRecurring } = body;
|
|
|
|
if (!title) {
|
|
return NextResponse.json(
|
|
{ error: 'Title is required' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// If isRolling is not specified, check user preference
|
|
if (isRolling === undefined) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: session.user.email },
|
|
select: { autoRolling: true }
|
|
});
|
|
isRolling = user?.autoRolling || false;
|
|
}
|
|
|
|
const task = await prisma.task.create({
|
|
data: {
|
|
title,
|
|
description,
|
|
dayOfWeek: dayOfWeek !== undefined ? parseInt(dayOfWeek) : null,
|
|
order: order !== undefined ? parseInt(order) : 0,
|
|
markdownContent,
|
|
somedayListId,
|
|
userId,
|
|
startTime: startTime || null,
|
|
scheduledDate: scheduledDate ? new Date(scheduledDate) : null,
|
|
isRolling: isRolling || false,
|
|
isRecurring: isRecurring || false,
|
|
recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null,
|
|
recurrenceUnit,
|
|
recurrenceTime,
|
|
recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null,
|
|
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
|
|
parentTaskId: parentTaskId || null,
|
|
...(projectId !== undefined && { projectId: projectId || null }),
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({ task });
|
|
} catch (error) {
|
|
console.error('Error creating task:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create task' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// MATCH virtual ID pattern: virtual-{originalId}-{dateStr}
|
|
const VIRTUAL_ID_REGEX = /^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/;
|
|
|
|
// PATCH - Update task
|
|
export async function PATCH(request: NextRequest) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const userId = (session.user as any).id;
|
|
const body = await request.json();
|
|
|
|
const { id } = body;
|
|
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore, parentTaskId, projectId } = body;
|
|
|
|
if (!id) {
|
|
return NextResponse.json(
|
|
{ error: 'Task ID is required' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Handle VIRTUAL TASK Materialization
|
|
const virtualMatch = id.match(VIRTUAL_ID_REGEX);
|
|
if (virtualMatch) {
|
|
const originalId = virtualMatch[1];
|
|
const dateStr = virtualMatch[2]; // YYYY-MM-DD
|
|
|
|
// 1. Fetch original task properties
|
|
const originalTask = await prisma.task.findUnique({
|
|
where: { id: originalId }
|
|
});
|
|
|
|
if (!originalTask || originalTask.userId !== userId) {
|
|
return NextResponse.json({ error: 'Original task not found' }, { status: 404 });
|
|
}
|
|
|
|
// 2. Create NEW task instance (Materialize)
|
|
const newTask = await prisma.task.create({
|
|
data: {
|
|
title: originalTask.title,
|
|
description: originalTask.description,
|
|
markdownContent: originalTask.markdownContent,
|
|
userId: userId,
|
|
scheduledDate: new Date(dateStr),
|
|
startTime: originalTask.recurrenceTime || originalTask.startTime,
|
|
// Inherit recurrence settings so IT projects further too?
|
|
// YES, the chain must continue.
|
|
isRecurring: true,
|
|
recurrenceInterval: originalTask.recurrenceInterval,
|
|
recurrenceUnit: originalTask.recurrenceUnit,
|
|
recurrenceTime: originalTask.recurrenceTime,
|
|
recurrenceEndDate: originalTask.recurrenceEndDate,
|
|
isRolling: originalTask.isRolling,
|
|
// Apply overrides from the patch body immediately
|
|
completed: completed !== undefined ? completed : false,
|
|
// If title changed in this patch, valid
|
|
...(title !== undefined && { title }),
|
|
// If rescheduled immediately
|
|
...(scheduledDate !== undefined && { scheduledDate: new Date(scheduledDate) }),
|
|
}
|
|
});
|
|
|
|
return NextResponse.json({ task: newTask });
|
|
}
|
|
|
|
// NORMAL UPDATE LOGIC for existing tasks
|
|
const existingTask = await prisma.task.findFirst({
|
|
where: { id, userId },
|
|
});
|
|
|
|
if (!existingTask) {
|
|
return NextResponse.json(
|
|
{ error: 'Task not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
const task = await prisma.task.update({
|
|
where: { id },
|
|
data: {
|
|
...(title !== undefined && { title }),
|
|
...(description !== undefined && { description }),
|
|
...(completed !== undefined && { completed }),
|
|
...(dayOfWeek !== undefined && { dayOfWeek: parseInt(dayOfWeek) }),
|
|
...(order !== undefined && { order: parseInt(order) }),
|
|
...(markdownContent !== undefined && { markdownContent }),
|
|
...(scheduledDate !== undefined && { scheduledDate: scheduledDate ? new Date(scheduledDate) : null }),
|
|
...(startTime !== undefined && { startTime }),
|
|
...(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 }),
|
|
...(restore === true && { deletedAt: null }),
|
|
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
|
|
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
|
|
...(projectId !== undefined && { projectId: projectId || null }),
|
|
},
|
|
});
|
|
|
|
// NOTE: We REMOVED the "create next task on completion" logic block here.
|
|
// Why? Because the projection system handles "next tasks" automatically.
|
|
// If we kept it, completing a task would create a duplicate materialized task for the next date,
|
|
// which would exist ALONGSIDE the one we projected.
|
|
// By removing it, we rely purely on the projection system (or manual materialization via interaction).
|
|
|
|
return NextResponse.json({ task });
|
|
} catch (error) {
|
|
console.error('Error updating task:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to update task' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// DELETE - Delete task
|
|
export async function DELETE(request: NextRequest) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const userId = (session.user as any).id;
|
|
const { searchParams } = new URL(request.url);
|
|
const id = searchParams.get('id');
|
|
|
|
if (!id) {
|
|
return NextResponse.json(
|
|
{ error: 'Task ID is required' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Handle VIRTUAL TASK Deletion
|
|
// We "delete" a virtual task by creating it as completed (so it doesn't show up as pending).
|
|
// Or we could create an explicit "exception" record, but for now completing it is the easiest way to "dismiss" it.
|
|
const virtualMatch = id.match(VIRTUAL_ID_REGEX);
|
|
if (virtualMatch) {
|
|
const originalId = virtualMatch[1];
|
|
const dateStr = virtualMatch[2];
|
|
|
|
const originalTask = await prisma.task.findUnique({ where: { id: originalId } });
|
|
if (!originalTask || originalTask.userId !== userId) {
|
|
return NextResponse.json({ error: 'Original task not found' }, { status: 404 });
|
|
}
|
|
|
|
// Materialize as COMPLETED to effectively "remove" it from the todo list
|
|
await prisma.task.create({
|
|
data: {
|
|
title: originalTask.title,
|
|
description: originalTask.description,
|
|
markdownContent: originalTask.markdownContent,
|
|
userId: userId,
|
|
scheduledDate: new Date(dateStr),
|
|
startTime: originalTask.recurrenceTime || originalTask.startTime,
|
|
isRecurring: true,
|
|
recurrenceInterval: originalTask.recurrenceInterval,
|
|
recurrenceUnit: originalTask.recurrenceUnit,
|
|
recurrenceTime: originalTask.recurrenceTime,
|
|
recurrenceEndDate: originalTask.recurrenceEndDate,
|
|
isRolling: originalTask.isRolling,
|
|
completed: true // Marked done so it doesn't appear pending
|
|
}
|
|
});
|
|
return NextResponse.json({ message: 'Virtual task dismissed' });
|
|
}
|
|
|
|
// Validate ownership before delete
|
|
const existingTask = await prisma.task.findFirst({
|
|
where: { id, userId },
|
|
});
|
|
|
|
if (!existingTask) {
|
|
return NextResponse.json(
|
|
{ error: 'Task not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Check if permanent delete is requested (for emptying trash)
|
|
const permanent = searchParams.get('permanent') === 'true';
|
|
|
|
if (permanent) {
|
|
await prisma.task.delete({
|
|
where: { id },
|
|
});
|
|
return NextResponse.json({ message: 'Task permanently deleted' });
|
|
}
|
|
|
|
// Soft delete - mark as deleted but keep in DB for recovery
|
|
await prisma.task.update({
|
|
where: { id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
return NextResponse.json({ message: 'Task moved to trash' });
|
|
} catch (error) {
|
|
console.error('Error deleting task:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to delete task' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
} |