diff --git a/package.json b/package.json index 0579888..eff1cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.11.2", + "version": "1.12.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/goal/route.ts b/src/app/api/goal/route.ts index bf48682..6f67efd 100644 --- a/src/app/api/goal/route.ts +++ b/src/app/api/goal/route.ts @@ -47,7 +47,7 @@ export async function GET(req: Request) { }, }); - if (goal && goal.text && goal.text !== 'your goal of this week' && goal.text !== user?.goalDefaultSentence) { + if (goal && goal.text) { return NextResponse.json({ goal: goal.text }); } diff --git a/src/app/api/user/export-data/route.ts b/src/app/api/user/export-data/route.ts new file mode 100644 index 0000000..b2bf32b --- /dev/null +++ b/src/app/api/user/export-data/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/lib/auth"; +import { prisma } from '@/lib/prisma'; + +export async function GET() { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + try { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user) { + return new NextResponse('User not found', { status: 404 }); + } + + // Fetch someday lists + const somedayLists = await prisma.somedayList.findMany({ + where: { userId: user.id }, + orderBy: { order: 'asc' }, + select: { + id: true, + title: true, + order: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Fetch projects + const projects = await prisma.project.findMany({ + where: { userId: user.id }, + orderBy: { order: 'asc' }, + select: { + id: true, + name: true, + icon: true, + color: true, + description: true, + order: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Fetch all non-deleted tasks (top-level and subtasks) + const allTasks = await prisma.task.findMany({ + where: { + userId: user.id, + deletedAt: null, + }, + orderBy: { order: 'asc' }, + select: { + id: true, + title: true, + description: true, + markdownContent: true, + completed: true, + isRolling: true, + order: true, + dayOfWeek: true, + scheduledDate: true, + somedayListId: true, + originalDate: true, + startTime: true, + endTime: true, + isRecurring: true, + recurrenceInterval: true, + recurrenceUnit: true, + recurrenceTime: true, + recurrenceEndDate: true, + createdAt: true, + updatedAt: true, + parentTaskId: true, + somedaySlotIndex: true, + projectId: true, + }, + }); + + // Build a tree: nest subtasks under their parents + const taskMap = new Map(); + const topLevelTasks: any[] = []; + + for (const task of allTasks) { + taskMap.set(task.id, { ...task, subTasks: [] }); + } + + for (const task of allTasks) { + const taskWithSubs = taskMap.get(task.id)!; + if (task.parentTaskId && taskMap.has(task.parentTaskId)) { + taskMap.get(task.parentTaskId)!.subTasks.push(taskWithSubs); + } else { + topLevelTasks.push(taskWithSubs); + } + } + + const exportData = { + exportVersion: 1, + exportDate: new Date().toISOString(), + somedayLists, + projects, + tasks: topLevelTasks, + }; + + const jsonContent = JSON.stringify(exportData, null, 2); + + return new NextResponse(jsonContent, { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="weekly_todo_backup_${new Date().toISOString().split('T')[0]}.json"`, + }, + }); + } catch (error) { + console.error('Export data error:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/src/app/api/user/import-data/route.ts b/src/app/api/user/import-data/route.ts new file mode 100644 index 0000000..cfa103d --- /dev/null +++ b/src/app/api/user/import-data/route.ts @@ -0,0 +1,215 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from "@/lib/auth"; +import { prisma } from '@/lib/prisma'; + +interface ImportTask { + id?: string; + title: string; + description?: string | null; + markdownContent?: string | null; + completed?: boolean; + isRolling?: boolean; + order?: number; + dayOfWeek?: number | null; + scheduledDate?: string | null; + somedayListId?: string | null; + originalDate?: string | null; + startTime?: string | null; + endTime?: string | null; + isRecurring?: boolean; + recurrenceInterval?: number | null; + recurrenceUnit?: string | null; + recurrenceTime?: string | null; + recurrenceEndDate?: string | null; + createdAt?: string; + updatedAt?: string; + parentTaskId?: string | null; + somedaySlotIndex?: number | null; + projectId?: string | null; + subTasks?: ImportTask[]; +} + +interface ImportData { + exportVersion: number; + somedayLists?: Array<{ + id?: string; + title: string; + order?: number; + createdAt?: string; + updatedAt?: string; + }>; + projects?: Array<{ + id?: string; + name: string; + icon?: string | null; + color?: string | null; + description?: string | null; + order?: number; + createdAt?: string; + updatedAt?: string; + }>; + tasks?: ImportTask[]; +} + +export async function POST(request: Request) { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const { searchParams } = new URL(request.url); + const mode = searchParams.get('mode') || 'merge'; + + const body: ImportData = await request.json(); + + // Validate structure + if (!body.exportVersion || typeof body.exportVersion !== 'number') { + return NextResponse.json({ error: 'Invalid export file: missing exportVersion' }, { status: 400 }); + } + + const somedayLists = body.somedayLists || []; + const projects = body.projects || []; + const tasks = body.tasks || []; + + // ID mapping: old export ID -> new DB ID + const listIdMap = new Map(); + const projectIdMap = new Map(); + const taskIdMap = new Map(); + + let importedLists = 0; + let importedProjects = 0; + let importedTasks = 0; + + // In replace mode, delete all existing data first + if (mode === 'replace') { + await prisma.task.deleteMany({ where: { userId: user.id } }); + await prisma.somedayList.deleteMany({ where: { userId: user.id } }); + await prisma.project.deleteMany({ where: { userId: user.id } }); + } + + // 1. Import someday lists + for (const list of somedayLists) { + const created = await prisma.somedayList.create({ + data: { + userId: user.id, + title: list.title, + order: list.order ?? 0, + }, + }); + if (list.id) { + listIdMap.set(list.id, created.id); + } + importedLists++; + } + + // 2. Import projects + for (const project of projects) { + const created = await prisma.project.create({ + data: { + userId: user.id, + name: project.name, + icon: project.icon ?? null, + color: project.color ?? null, + description: project.description ?? null, + order: project.order ?? 0, + }, + }); + if (project.id) { + projectIdMap.set(project.id, created.id); + } + importedProjects++; + } + + // 3. Import tasks (two-pass: parents first, then link subtasks) + // Flatten all tasks with their subtasks + const flatTasks: Array<{ task: ImportTask; originalParentId: string | null }> = []; + + function flattenTasks(taskList: ImportTask[], parentId: string | null) { + for (const task of taskList) { + flatTasks.push({ task, originalParentId: parentId }); + if (task.subTasks && task.subTasks.length > 0) { + flattenTasks(task.subTasks, task.id || null); + } + } + } + + flattenTasks(tasks, null); + + // Pass 1: Create all tasks without parentTaskId + for (const { task } of flatTasks) { + const resolvedListId = task.somedayListId ? listIdMap.get(task.somedayListId) : null; + const resolvedProjectId = task.projectId ? projectIdMap.get(task.projectId) : null; + + const created = await prisma.task.create({ + data: { + userId: user.id, + title: task.title, + description: task.description ?? null, + markdownContent: task.markdownContent ?? null, + completed: task.completed ?? false, + isRolling: task.isRolling ?? false, + order: task.order ?? 0, + dayOfWeek: task.dayOfWeek ?? null, + scheduledDate: task.scheduledDate ? new Date(task.scheduledDate) : null, + somedayListId: resolvedListId ?? null, + originalDate: task.originalDate ? new Date(task.originalDate) : null, + startTime: task.startTime ?? null, + endTime: task.endTime ?? null, + isRecurring: task.isRecurring ?? false, + recurrenceInterval: task.recurrenceInterval ?? null, + recurrenceUnit: task.recurrenceUnit ?? null, + recurrenceTime: task.recurrenceTime ?? null, + recurrenceEndDate: task.recurrenceEndDate ? new Date(task.recurrenceEndDate) : null, + somedaySlotIndex: task.somedaySlotIndex ?? null, + projectId: resolvedProjectId ?? null, + }, + }); + + if (task.id) { + taskIdMap.set(task.id, created.id); + } + importedTasks++; + } + + // Pass 2: Link subtasks to their parents + for (const { task, originalParentId } of flatTasks) { + if (originalParentId && task.id) { + const newTaskId = taskIdMap.get(task.id); + const newParentId = taskIdMap.get(originalParentId); + if (newTaskId && newParentId) { + await prisma.task.update({ + where: { id: newTaskId }, + data: { parentTaskId: newParentId }, + }); + } + } + } + + return NextResponse.json({ + success: true, + mode, + imported: { + somedayLists: importedLists, + projects: importedProjects, + tasks: importedTasks, + }, + }); + } catch (error) { + console.error('Import data error:', error); + const message = error instanceof SyntaxError + ? 'Invalid JSON file' + : 'Import failed. Please check the file format.'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index da76de1..2ae492c 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -12,6 +12,7 @@ export async function GET(request: NextRequest) { const user = await (prisma.user as any).findUnique({ where: { email: session.user.email }, select: { + id: true, name: true, email: true, timezone: true, diff --git a/src/app/globals.css b/src/app/globals.css index 352c6fa..a0636e1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2848,8 +2848,7 @@ h3 { position: fixed; top: 0; right: 0; - width: 500px; - max-width: 95vw; + max-width: 90vw; height: 100vh; background: white; box-shadow: -10px 0 30px rgba(0, 0, 0, 0.1); diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index 823da85..09edcde 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -23,6 +23,7 @@ export default function CalendarEventModal({ const availableCalendars = connections .flatMap(conn => (conn.calendars || []).map((cal: any) => ({ ...cal, + provider: conn.provider, providerName: conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : 'Outlook Calendar' @@ -257,16 +258,31 @@ export default function CalendarEventModal({ {/* URL */} -
- - setUrl(e.target.value)} - placeholder="https://..." - style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} - /> -
+ {(() => { + const selectedCal = availableCalendars.find((c: any) => c.id === calendarId); + const isUrlDisabled = selectedCal && (selectedCal.provider === 'google' || selectedCal.provider === 'outlook'); + return ( +
+ + setUrl(e.target.value)} + placeholder={isUrlDisabled ? 'Not supported by this calendar provider' : 'https://...'} + disabled={!!isUrlDisabled} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ddd', + borderRadius: '4px', + opacity: isUrlDisabled ? 0.5 : 1, + background: isUrlDisabled ? '#f5f5f5' : undefined, + cursor: isUrlDisabled ? 'not-allowed' : undefined, + }} + /> +
+ ); + })()} {/* Description */}
diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index c07a66a..f645173 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -258,6 +258,20 @@ const translations: Record = { alignmentCenter: "Center", alignmentRight: "Right", alignmentTight: "Tight", + backupRestore: "Backup & Restore", + backupRestoreDesc: "Export all your tasks, anyday lists, and projects as a JSON file. You can edit the file and import it back.", + exportAllData: "Export All Data (JSON)", + importData: "Import Data", + importMode: "Import Mode", + importModeMerge: "Merge", + importModeMergeDesc: "Add imported data alongside existing tasks", + importModeReplace: "Replace", + importModeReplaceDesc: "Delete all existing data and replace with imported data", + importReplaceWarning: "Warning: This will permanently delete all your current tasks, lists, and projects!", + importSelectFile: "Select JSON file...", + importButton: "Import", + importing: "Importing...", + exporting: "Exporting...", }, de: { settings: "Einstellungen", @@ -323,6 +337,20 @@ const translations: Record = { alignmentCenter: "Mitte", alignmentRight: "Rechts", alignmentTight: "Eng", + backupRestore: "Sicherung & Wiederherstellung", + backupRestoreDesc: "Exportieren Sie alle Aufgaben, Irgendwann-Listen und Projekte als JSON-Datei. Sie können die Datei bearbeiten und wieder importieren.", + exportAllData: "Alle Daten exportieren (JSON)", + importData: "Daten importieren", + importMode: "Import-Modus", + importModeMerge: "Zusammenführen", + importModeMergeDesc: "Importierte Daten neben bestehenden Aufgaben hinzufügen", + importModeReplace: "Ersetzen", + importModeReplaceDesc: "Alle bestehenden Daten löschen und durch importierte ersetzen", + importReplaceWarning: "Warnung: Dies löscht dauerhaft alle Ihre aktuellen Aufgaben, Listen und Projekte!", + importSelectFile: "JSON-Datei auswählen...", + importButton: "Importieren", + importing: "Importiere...", + exporting: "Exportiere...", }, }; @@ -401,6 +429,7 @@ function getHourFromSlot(slot: string): number { } function getWeekNumber(date: Date): number { + // ISO 8601 week number: weeks start on Monday const d = new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), ); @@ -410,6 +439,25 @@ function getWeekNumber(date: Date): number { return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); } +// Find the Monday within a visible week range to get the correct CW +function getMondayOfVisibleWeek(days: Date[]): Date { + // Look for Monday in visible days + for (const day of days) { + if (day.getDay() === 1) return day; + } + // If no Monday visible (e.g. 5-day view starting Wed), find nearest Monday + if (days.length > 0) { + const first = days[0]; + const dayOfWeek = first.getDay(); + // Go forward to Monday + const daysUntilMon = (1 - dayOfWeek + 7) % 7; + if (daysUntilMon <= 6) { + return new Date(first.getTime() + daysUntilMon * 86400000); + } + } + return days[Math.floor(days.length / 2)] || new Date(); +} + // Check if an event is an all-day event // Defined outside component to avoid stale closure issues in useCallbacks const isAllDayEvent = (event: CalendarEvent): boolean => { @@ -1068,8 +1116,9 @@ export default function WeeklyView() { return [...prev, frontendEvent]; }); } - // Also force-refresh from provider to ensure full sync - fetchCalendarEvents(true); + // Delay the force-refresh to give the provider time to propagate + // This prevents overwriting the optimistic update with stale data + setTimeout(() => fetchCalendarEvents(true), 3000); } catch (error: any) { console.error("Error saving event:", error); if (error.name === "AbortError") { @@ -3752,7 +3801,7 @@ export default function WeeklyView() { ) : syncError ? ( ) : null} - KW{getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")}/{(() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })().getFullYear()} + KW{getWeekNumber(getMondayOfVisibleWeek(getVisibleDays())).toString().padStart(2, "0")}/{getMondayOfVisibleWeek(getVisibleDays()).getFullYear()}
{/* Right: Settings + Overflow */} @@ -3963,7 +4012,7 @@ export default function WeeklyView() { color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode), filter: "brightness(var(--weekly-header-brightness, 1))" }}> - KW {getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")} + KW {getWeekNumber(getMondayOfVisibleWeek(getVisibleDays())).toString().padStart(2, "0")} | {task.title} + {/* Subtask indicator - toggles subtask list */} {task.subTasks && task.subTasks.length > 0 && !isSubTask && ( )} - {task.markdownContent && ( - - + {/* Note indicator - toggles inline notes */} + {task.markdownContent && task.markdownContent.trim().length > 0 && ( + + )} @@ -6937,6 +7027,29 @@ interface NotesSidebarProps { function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) { const [isVisible, setIsVisible] = useState(false); const textareaRef = useRef(null); + const [sidebarWidth, setSidebarWidth] = useState(500); + const isResizing = useRef(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing.current) return; + const newWidth = window.innerWidth - e.clientX; + setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9))); + }; + const handleMouseUp = () => { + if (isResizing.current) { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); useEffect(() => { const timer = setTimeout(() => setIsVisible(true), 10); @@ -6985,7 +7098,26 @@ function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) { onClick={handleClose} style={{ zIndex: 1999 }} /> -
+
+ {/* Resize handle */} +
{ + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }} + style={{ + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: '6px', + cursor: 'col-resize', + zIndex: 10, + }} + title="Drag to resize" + />

Notes: {task.title}

+ {profile.id && ( +
+ + (e.target as HTMLInputElement).select()} + className="weekly-input" + style={{ + width: "100%", + padding: "8px", + border: "1px solid #eee", + borderRadius: "4px", + background: "#f5f5f5", + color: "#555", + fontSize: "0.85rem", + fontFamily: "monospace", + cursor: "text", + }} + /> + + {profile.language === "de" + ? "Ihre eindeutige Konto-Kennung" + : "Your unique account identifier"} + +
+ )}
+ {/* Backup & Restore Section */} +
+

+ {t.backupRestore} +

+

+ {t.backupRestoreDesc} +

+ + {/* Export All Data */} + + + {/* Import Section */} +
+ + + {/* Import Mode Toggle */} +
+ +
+ + +
+
+ + {importMode === "replace" && ( +
+ {t.importReplaceWarning} +
+ )} + + {/* File Input */} + { + setImportFile(e.target.files?.[0] || null); + setImportMsg(""); + }} + className="weekly-input" + style={{ + width: "100%", + padding: "6px", + border: "1px solid var(--weekly-settings-input-border)", + borderRadius: "4px", + background: "var(--weekly-settings-input-bg)", + color: "var(--weekly-settings-text)", + marginBottom: "10px", + fontSize: "0.85rem", + }} + /> + + + + {importMsg && ( +

+ {importMsg} +

+ )} +
+
+