From 8842123caf2ee81103ea5e9d22402f3d4087e669 Mon Sep 17 00:00:00 2001 From: mARTin Date: Tue, 24 Feb 2026 11:01:15 +0100 Subject: [PATCH] feat: improve calendar sync, event modal, and task list management - Fix All Day checkbox positioning in CalendarEventModal (own row) - Add provider name to calendar dropdown (Google/Apple/Outlook) - Optimistic UI updates after event save/delete (no reload needed) - Force-refresh calendar cache after event mutations - Reduce background sync interval from 5min to 2min - Support forceRefresh in background-sync API - Use shared Prisma singleton in tasks sync route - Add per-provider task list fetching and sync checkboxes - Add allDay support to event creation and editing v1.4.0 --- package.json | 2 +- src/app/api/calendar/background-sync/route.ts | 4 +- src/app/api/calendar/events/route.ts | 10 +- src/app/api/tasks/sync/route.ts | 5 +- src/app/globals.css | 192 +++++- src/components/CalendarEventModal.tsx | 33 +- src/components/GridTaskBlock.tsx | 18 +- src/components/WeeklyView.tsx | 573 +++++++++++------- src/lib/calendar-events.ts | 44 +- src/lib/microsoft-todo.ts | 18 +- src/lib/outlook-calendar.ts | 34 +- 11 files changed, 659 insertions(+), 274 deletions(-) diff --git a/package.json b/package.json index 64ac9d5..7e1f9b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.3.1", + "version": "1.4.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/calendar/background-sync/route.ts b/src/app/api/calendar/background-sync/route.ts index 7688abd..ee3e5e2 100644 --- a/src/app/api/calendar/background-sync/route.ts +++ b/src/app/api/calendar/background-sync/route.ts @@ -12,7 +12,7 @@ export async function POST(request: NextRequest) { } const body = await request.json().catch(() => ({})); - const { timeMin, timeMax } = body; + const { timeMin, timeMax, forceRefresh } = body; const user = await prisma.user.findUnique({ where: { email: session.user.email }, @@ -30,7 +30,7 @@ export async function POST(request: NextRequest) { const staleChecks = await Promise.all( user.calendarConnections.map(async conn => ({ conn, - stale: await isCacheStale(conn.id, tMin), + stale: forceRefresh || await isCacheStale(conn.id, tMin), })) ); diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts index bad2592..67c2c7f 100644 --- a/src/app/api/calendar/events/route.ts +++ b/src/app/api/calendar/events/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); - const { calendarId, title, description, start, end, location } = body; + const { calendarId, title, description, start, end, location, allDay } = body; console.log('[API] Creating event:', { calendarId, title, start, end }); @@ -59,7 +59,8 @@ export async function POST(request: NextRequest) { description, start, end, - location + location, + allDay: !!allDay }); // Update cache @@ -80,7 +81,7 @@ export async function PATCH(request: NextRequest) { if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const body = await request.json(); - const { calendarId, eventId, title, description, start, end, location } = body; + const { calendarId, eventId, title, description, start, end, location, allDay } = body; console.log('[API] Updating event:', { calendarId, eventId, title }); @@ -100,7 +101,8 @@ export async function PATCH(request: NextRequest) { description, start, end, - location + location, + allDay: allDay !== undefined ? !!allDay : undefined }); // Update cache diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index 8d49e11..807f535 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -1,13 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; -import { PrismaClient } from '@prisma/client'; +import { prisma } from '@/lib/prisma'; import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks'; import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; import { getOutlookAccessToken } from '@/lib/outlook-token'; -const prisma = new PrismaClient(); - // GET - Pull changes from Google Tasks into local DB export async function GET(req: NextRequest) { try { @@ -478,6 +476,7 @@ export async function POST(req: NextRequest) { const created = await createMsTodoTask(outlookToken, listExternalId, { title: task.title, body: task.description || undefined, + dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined, }); const updatedTask = await prisma.task.update({ diff --git a/src/app/globals.css b/src/app/globals.css index 4845170..3d2f782 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -800,6 +800,80 @@ h3 { outline: none; } +/* Floating Notes Popup */ +.weekly-notes-popup { + position: absolute; + left: 0; + right: 0; + top: 100%; + min-width: 250px; + background: var(--weekly-bg); + border: 1px solid var(--weekly-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + padding: 8px; + cursor: default; +} + +/* Arrow default (pointing up, when popup is below) */ +.weekly-notes-popup::before { + content: ""; + position: absolute; + left: 20px; + top: -8px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid var(--weekly-border); +} + +.weekly-notes-popup::after { + content: ""; + position: absolute; + left: 20px; + top: -7px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid var(--weekly-bg); +} + +/* Arrow when popup is above (pointing down) */ +.weekly-notes-popup.on-top::before { + top: auto !important; + bottom: -8px !important; + border-bottom: none !important; + border-top: 8px solid var(--weekly-border) !important; +} + +.weekly-notes-popup.on-top::after { + top: auto !important; + bottom: -7px !important; + border-bottom: none !important; + border-top: 8px solid var(--weekly-bg) !important; +} + +.weekly-container.dark-mode .weekly-notes-popup { + background: #2a2a2a; + border-color: #444; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.weekly-container.dark-mode .weekly-notes-popup::before { + border-bottom-color: #444; +} + +.weekly-container.dark-mode .weekly-notes-popup::after { + border-bottom-color: #2a2a2a; +} + +.weekly-container.dark-mode .weekly-notes-popup.on-top::before { + border-top-color: #444 !important; +} + +.weekly-container.dark-mode .weekly-notes-popup.on-top::after { + border-top-color: #2a2a2a !important; +} + .weekly-notes-editor-inline:focus { border-color: var(--weekly-teal); background: #fff; @@ -1753,6 +1827,7 @@ h3 { .time-slots-container { flex: 1; overflow-y: auto; + overflow-x: visible; } .time-slot { @@ -2709,18 +2784,129 @@ h3 { .weekly-settings-sidebar .weekly-settings-header { padding: 24px; - border-bottom: 1px solid #eee; display: flex; - align-items: center; justify-content: space-between; + align-items: center; + border-bottom: 1px solid #eee; } +/* Notes Sidebar */ +.weekly-notes-sidebar { + position: fixed; + top: 0; + right: 0; + width: 500px; + max-width: 95vw; + height: 100vh; + background: white; + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 2005; + overflow: hidden; + border-left: 1px solid #eee; +} + +.weekly-notes-sidebar.open { + transform: translateX(0); +} + +.dark-mode .weekly-notes-sidebar { + background: #111; + border-left: 1px solid #333; + color: white; +} + +.weekly-notes-sidebar-header { + padding: 20px 24px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #eee; +} + +.dark-mode .weekly-notes-sidebar-header { + border-bottom-color: #333; +} + +.weekly-notes-sidebar-title { + margin: 0; + font-size: 1.25rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: -0.02em; +} + +.weekly-notes-sidebar-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #888; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + transition: all 0.2s; +} + +.weekly-notes-sidebar-close:hover { + background: #f5f5f5; + color: #333; +} + +.dark-mode .weekly-notes-sidebar-close:hover { + background: #222; + color: white; +} + +.weekly-notes-sidebar-content { + flex: 1; + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.weekly-notes-sidebar .weekly-notes-editor { + flex: 1; + min-height: 300px; + border: 1px solid #eee; + padding: 16px; + font-size: 1rem; + line-height: 1.6; + border-radius: 8px; + background: #fafafa; + margin-top: 16px; +} + +.dark-mode .weekly-notes-sidebar .weekly-notes-editor { + background: #1a1a1a; + border-color: #333; + color: #eee; +} + +.weekly-notes-sidebar .notes-toolbar { + display: flex; + gap: 8px; + padding: 8px; + background: #f5f5f5; + border-radius: 6px; + margin-bottom: 4px; +} + +.dark-mode .weekly-notes-sidebar .notes-toolbar { + background: #222; +} .settings-tab-btn:hover { opacity: 0.8 !important; background: rgba(0, 0, 0, 0.04) !important; border-radius: 6px 6px 0 0; } - .dark-mode .settings-tab-btn:hover { background: rgba(255, 255, 255, 0.08) !important; } diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index eabfeff..00e31f2 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -21,7 +21,12 @@ export default function CalendarEventModal({ }: CalendarEventModalProps) { // Flatten calendars from connections to get selectable options const availableCalendars = connections - .flatMap(conn => conn.calendars || []) + .flatMap(conn => (conn.calendars || []).map((cal: any) => ({ + ...cal, + providerName: conn.provider === 'google' ? 'Google Calendar' : + conn.provider === 'apple' ? 'Apple Calendar' : + 'Outlook Calendar' + }))) .filter((cal: any) => cal.editable); // Only editable calendars const [title, setTitle] = useState(event?.title || ''); @@ -59,6 +64,7 @@ export default function CalendarEventModal({ const [startDate, setStartDate] = useState(getInitialStart()); const [endDate, setEndDate] = useState(getInitialEnd()); + const [allDay, setAllDay] = useState(!!event?.allDay); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(''); @@ -85,6 +91,7 @@ export default function CalendarEventModal({ description, location, calendarId, + allDay, start: { dateTime: startDate.toISOString() }, end: { dateTime: endDate.toISOString() } }); @@ -170,18 +177,32 @@ export default function CalendarEventModal({ > {availableCalendars.length === 0 && } {availableCalendars.map((cal: any) => ( - + ))} + {/* All Day Toggle */} +
+ +
+ {/* Date/Time */}
handleStartDateChange(e.target.value)} style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} /> @@ -189,8 +210,8 @@ export default function CalendarEventModal({
setEndDate(new Date(e.target.value))} style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} /> diff --git a/src/components/GridTaskBlock.tsx b/src/components/GridTaskBlock.tsx index 9a1c3bf..5f8f4aa 100644 --- a/src/components/GridTaskBlock.tsx +++ b/src/components/GridTaskBlock.tsx @@ -171,7 +171,7 @@ export function GridTaskBlock({ left: 0, right: 0, minHeight: `${Math.max(currentHeight, 20)}px`, - height: isNotesOpen || isSubTasksOpen ? "auto" : `${currentHeight}px`, + height: isSubTasksOpen ? "auto" : `${currentHeight}px`, zIndex: isResizing || isNotesOpen || isSubTasksOpen ? 10 : 5, background: (isNotesOpen || isSubTasksOpen || isResizing) ? (darkMode ? "#2a2a2a" : "#ffffff") : "transparent", border: (isNotesOpen || isSubTasksOpen || isResizing) ? `1px solid ${darkMode ? "#404040" : "#e0e0e0"}` : "none", @@ -342,7 +342,19 @@ export function GridTaskBlock({ {/* Inline Expanders Container */}
{isNotesOpen && ( -
e.stopPropagation()}> +
180 ? "on-top" : ""}`} + onClick={(e) => e.stopPropagation()} + style={{ + top: topOffset > 180 ? "auto" : "100%", + bottom: topOffset > 180 ? "100%" : "auto", + marginTop: topOffset > 180 ? "0" : "10px", + marginBottom: topOffset > 180 ? "10px" : "0", + left: "-10px", + right: "-10px", + width: "auto", + }} + >
@@ -356,7 +368,7 @@ export function GridTaskBlock({ onChange={(e) => setNotesValue(e.target.value)} onBlur={handleNotesBlur} placeholder="Add notes..." - style={{ minHeight: "60px", padding: "4px" }} + style={{ minHeight: "120px", padding: "4px" }} />
)} diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index 2b7c509..1ca1b2e 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -108,6 +108,8 @@ interface SomedayList { title: string; tasks: Task[]; externalProvider?: string | null; + externalId?: string | null; + externalListId?: string | null; } // Time grid configuration options @@ -236,6 +238,8 @@ const translations: Record = { simpleView: "Einfach", calendarView: "Kalender", listView: "Liste", + notes: "Notizen", + notesSidebar: "Notizen-Seitenleiste", language: "Sprache", dateFormat: "Datumsformat", timeFormat: "Zeitformat", @@ -529,6 +533,12 @@ export default function WeeklyView() { { id: string; title: string }[] >([]); const [isFetchingLists, setIsFetchingLists] = useState(false); + const [availableTaskLists, setAvailableTaskLists] = useState<{ + [key in "google" | "apple" | "outlook"]?: { id: string; title: string }[]; + }>({}); + const [isFetchingProviderLists, setIsFetchingProviderLists] = useState< + Record + >({}); const [isVisible, setIsVisible] = useState(false); const [profile, setProfile] = useState<{ name: string; @@ -921,8 +931,20 @@ export default function WeeklyView() { throw new Error(err.error || "Failed to save event"); } - // Refresh events - await fetchCalendarEvents(); + // Optimistically add/update from API response, then force refresh cache + const data = await res.json(); + if (data.event) { + setRawCalendarEvents(prev => { + if (eventData.id) { + // Update existing + return prev.map(e => e.id === eventData.id ? data.event : e); + } + // Add new + return [...prev, data.event]; + }); + } + // Also force-refresh from provider to ensure full sync + fetchCalendarEvents(true); } catch (error: any) { console.error("Error saving event:", error); if (error.name === "AbortError") { @@ -948,8 +970,9 @@ export default function WeeklyView() { throw new Error(err.error || "Failed to delete event"); } - // Refresh events - await fetchCalendarEvents(); + // Optimistically remove, then force refresh + setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId)); + fetchCalendarEvents(true); } catch (error) { console.error("Error deleting event:", error); throw error; @@ -1057,7 +1080,7 @@ export default function WeeklyView() { return () => clearInterval(interval); }, [session]); - // Periodic background calendar cache refresh (every 5 minutes) + // Periodic background calendar cache refresh (every 2 minutes) useEffect(() => { if (!session) return; const interval = setInterval( @@ -1074,12 +1097,13 @@ export default function WeeklyView() { timeMax: new Date( now.getTime() + 14 * 24 * 60 * 60 * 1000, ).toISOString(), + forceRefresh: true, }), }); if (res.ok) { const data = await res.json(); - if (data.queued > 0) { - // Stale connections are being refreshed; re-fetch events after delay + if (data.queued > 0 || data.refreshed > 0) { + // Cache was refreshed; re-fetch events after delay setTimeout(() => fetchCalendarEvents(), 8000); } } @@ -1087,7 +1111,7 @@ export default function WeeklyView() { // Silent fail for background sync } }, - 5 * 60 * 1000, + 2 * 60 * 1000, ); return () => clearInterval(interval); }, [session, fetchCalendarEvents]); @@ -1946,6 +1970,75 @@ export default function WeeklyView() { } }; + const fetchAvailableTaskLists = useCallback( + async (provider: "google" | "apple" | "outlook") => { + setIsFetchingProviderLists((prev) => ({ + ...prev, + [provider]: true, + })); + try { + const res = await fetch(`/api/tasks/lists?provider=${provider}`); + if (res.ok) { + const data = await res.json(); + setAvailableTaskLists((prev) => ({ + ...prev, + [provider]: data.lists || [], + })); + } + } catch (error) { + console.error(`Failed to fetch lists for ${provider}`, error); + } finally { + setIsFetchingProviderLists((prev) => ({ + ...prev, + [provider]: false, + })); + } + }, + [], + ); + + + const handleToggleTaskList = async ( + provider: "google" | "apple" | "outlook", + list: { id: string; title: string }, + ) => { + const existing = somedayLists.find( + (l) => l.externalId === list.id && l.externalProvider === provider, + ); + + if (existing) { + // Unsync/Remove + if ( + !confirm( + `Are you sure you want to stop syncing the list "${list.title}"? This will move its tasks to the trash.`, + ) + ) { + return; + } + try { + const res = await fetch(`/api/someday-lists?id=${existing.id}`, { + method: "DELETE", + }); + if (res.ok) { + setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id)); + setImportStatusMsg({ + type: "success", + text: `Stopped syncing "${list.title}".`, + }); + } + } catch (error) { + console.error("Failed to delete list", error); + setImportStatusMsg({ + type: "error", + text: "Failed to stop syncing list.", + }); + } + } else { + // Sync/Import + await doImport(provider, [list]); + } + }; + // Core import logic — accepts provider directly so it works both from modal and sidebar const doImport = async ( provider: "google" | "apple" | "outlook", @@ -5350,6 +5443,11 @@ export default function WeeklyView() { allDayPosition={allDayPosition} setAllDayPosition={setAllDayPosition} saveSetting={saveSetting} + availableTaskLists={availableTaskLists} + isFetchingProviderLists={isFetchingProviderLists} + somedayLists={somedayLists} + handleToggleTaskList={handleToggleTaskList} + fetchAvailableTaskLists={fetchAvailableTaskLists} /> )} @@ -5362,166 +5460,11 @@ export default function WeeklyView() { )} {selectedTaskForNotes && ( -
setSelectedTaskForNotes(null)} - > -
e.stopPropagation()} - > -

Notes: {selectedTaskForNotes.title}

- {/* Toolbar for Modal */} -
- - - - - -
- -