From d8c30cdb1f0df7d93f7840bf5efb615598363e22 Mon Sep 17 00:00:00 2001 From: mARTin Date: Sun, 29 Mar 2026 09:34:09 +0200 Subject: [PATCH] fix: double event on recurring create + mobile sticky day bar - Skip optimistic UI update for new recurring events (causes duplicate when force-sync fetches expanded instances with different IDs) - Mobile sticky day bar: listen on gridRef scroll instead of window (time-grid scrolls inside weekly-days-grid, not window) - Remove broken position:sticky from mobile day headers (CSS sticky doesn't work inside CSS grid scroll containers); use fixed overlay only - Also propagate isRecurring flag in create response for Google/Outlook v1.75.8 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/app/globals.css | 9 +-- src/components/WeeklyView.tsx | 141 +++++++++++++++++++--------------- src/lib/calendar-events.ts | 12 ++- 4 files changed, 93 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 3457e22..97d07f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.75.7", + "version": "1.75.8", "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/globals.css b/src/app/globals.css index ae2eacd..aa070de 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4974,12 +4974,11 @@ h3 { } @media (max-width: 768px) { - /* Sticky day header: top: 0 relative to the scroll container (time-grid-wrapper) */ + /* Day headers are not sticky on mobile — CSS sticky doesn't work in a CSS grid scroll container. + The mobile-sticky-day-bar overlay (fixed position) handles this instead. */ .time-grid-on .weekly-day-header { - position: sticky !important; - top: 0 !important; - z-index: 40 !important; - background-color: var(--weekly-bg, white); + position: relative !important; + top: auto !important; } /* On mobile, time-column-header should NOT be sticky — it wastes vertical space */ .time-grid-on .time-column-header { diff --git a/src/components/WeeklyView.tsx b/src/components/WeeklyView.tsx index d44067c..4d65a73 100644 --- a/src/components/WeeklyView.tsx +++ b/src/components/WeeklyView.tsx @@ -1771,19 +1771,37 @@ export default function WeeklyView() { const [customTabs, setCustomTabs] = useState([]); useEffect(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("weekly_active_someday_tab"); + const email = session?.user?.email; + if (typeof window !== "undefined" && email) { + const saved = localStorage.getItem(`weekly_active_someday_tab_${email}`); if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved); try { - const savedTabs = localStorage.getItem("weekly_custom_tabs"); + const savedTabs = localStorage.getItem(`weekly_custom_tabs_${email}`); if (savedTabs) setCustomTabs(JSON.parse(savedTabs)); } catch { /* ignore */ } + // Migrate old non-namespaced keys (one-time cleanup) + if (localStorage.getItem("weekly_custom_tabs") && !localStorage.getItem(`weekly_custom_tabs_${email}_migrated`)) { + const oldTabs = localStorage.getItem("weekly_custom_tabs"); + const oldActive = localStorage.getItem("weekly_active_someday_tab"); + if (oldTabs && !localStorage.getItem(`weekly_custom_tabs_${email}`)) { + localStorage.setItem(`weekly_custom_tabs_${email}`, oldTabs); + try { setCustomTabs(JSON.parse(oldTabs)); } catch { /* ignore */ } + } + if (oldActive && !localStorage.getItem(`weekly_active_someday_tab_${email}`)) { + localStorage.setItem(`weekly_active_someday_tab_${email}`, oldActive); + setActiveSomedayTab(oldActive === "__all__" ? null : oldActive); + } + localStorage.removeItem("weekly_custom_tabs"); + localStorage.removeItem("weekly_active_someday_tab"); + localStorage.setItem(`weekly_custom_tabs_${email}_migrated`, "1"); + } } - }, []); + }, [session?.user?.email]); const saveCustomTabs = (tabs: string[]) => { setCustomTabs(tabs); - localStorage.setItem("weekly_custom_tabs", JSON.stringify(tabs)); + const email = session?.user?.email; + if (email) localStorage.setItem(`weekly_custom_tabs_${email}`, JSON.stringify(tabs)); }; const somedayTabs = useMemo(() => { @@ -1795,7 +1813,8 @@ export default function WeeklyView() { const setSomedayTab = (tab: string | null) => { setActiveSomedayTab(tab); - localStorage.setItem("weekly_active_someday_tab", tab ?? "__all__"); + const email = session?.user?.email; + if (email) localStorage.setItem(`weekly_active_someday_tab_${email}`, tab ?? "__all__"); }; const assignListToTab = async (listId: string, tab: string | null) => { @@ -2677,8 +2696,10 @@ export default function WeeklyView() { // Optimistically add/update from API response, then force refresh cache const data = await res.json(); - if (data.event) { - // Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime) + const isRecurring = !!(eventData.recurrence); + if (data.event && !isRecurring) { + // For non-recurring events: optimistic update before sync + // For recurring events: skip — sync will fetch all expanded instances const ev = data.event; // Find calendar info from connections to fill in missing color/title const calInfo = connections.flatMap((c: any) => @@ -2700,6 +2721,23 @@ export default function WeeklyView() { } return [...prev, frontendEvent]; }); + } else if (data.event && eventData.id) { + // Recurring update: keep optimistic update for the edited instance only + const ev = data.event; + const calInfo = connections.flatMap((c: any) => + (c.calendars || []).map((cal: any) => ({ ...cal, provider: c.provider })) + ).find((c: any) => c.id === (ev.calendarId || eventData.calendarId)); + const frontendEvent: CalendarEvent = { + id: ev.id, + title: ev.title, + startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '', + endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '', + source: ev.source || calInfo?.provider || 'google', + calendarId: ev.calendarId || eventData.calendarId, + calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '', + calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6', + }; + setRawCalendarEvents(prev => prev.map(e => e.id === eventData.id ? frontendEvent : e)); } // Force refresh only the affected provider const connId = getConnectionIdForCalendar(eventData.calendarId); @@ -3086,67 +3124,46 @@ export default function WeeklyView() { return () => clearInterval(interval); }, []); - // Mobile: track which day column is at the top of the viewport via IntersectionObserver + // Mobile: show a sticky day bar by reading scroll position on the actual grid scroll container useEffect(() => { - if (!isMobile || !gridRef.current) return; - const headers = gridRef.current.querySelectorAll('.weekly-day-header'); - if (!headers.length) return; + if (!isMobile || !showTimeGrid) return; + const grid = gridRef.current; + if (!grid) return; - const observer = new IntersectionObserver( - (entries) => { - // Find the last header that is intersecting (at the top of viewport) - let topHeader: Element | null = null; - let topY = Infinity; - entries.forEach(entry => { - if (entry.isIntersecting || entry.boundingClientRect.top < 100) { - if (entry.boundingClientRect.top < topY) { - topY = entry.boundingClientRect.top; - topHeader = entry.target; - } - } - }); + const updateStickyDay = () => { + const scrollTop = grid.scrollTop; + setMobileStickyDayVisible(scrollTop > 40); - // Also check which header is closest to top when scrolled past - if (!topHeader) { - headers.forEach(h => { - const rect = h.getBoundingClientRect(); - if (rect.top < 100 && rect.top > topY - 200) { - topY = rect.top; - topHeader = h; - } - }); + // Find which day column header is at the top of the scroll container + const columns = grid.querySelectorAll('.weekly-day-column[data-date]'); + let currentCol: Element | null = null; + const gridTop = grid.getBoundingClientRect().top; + + columns.forEach(col => { + const rect = col.getBoundingClientRect(); + // Column whose top is at or above the grid's top edge + if (rect.top <= gridTop + 60) { + currentCol = col; } + }); - if (topHeader) { - const col = (topHeader as Element).closest('.weekly-day-column'); - const dateStr = col?.getAttribute('data-date'); - if (dateStr) { - const d = new Date(dateStr + 'T00:00:00'); - const dayNames = language === 'de' - ? ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] - : ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const label = `${dayNames[d.getDay()]}, ${d.getDate()}.${d.getMonth() + 1}.`; - setMobileStickyDay(label); - } + if (currentCol) { + const dateStr = (currentCol as Element).getAttribute('data-date'); + if (dateStr) { + const d = new Date(dateStr + 'T00:00:00'); + const dayNames = language === 'de' + ? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] + : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const label = `${dayNames[d.getDay()]} ${d.getDate()}.${d.getMonth() + 1}.`; + setMobileStickyDay(label); } - }, - { threshold: [0, 0.1, 0.5, 1], rootMargin: '-48px 0px 0px 0px' } - ); - - headers.forEach(h => observer.observe(h)); - return () => observer.disconnect(); - }, [isMobile, currentWeekStart, viewDays, language]); - - // Show/hide sticky day bar based on scroll position - useEffect(() => { - if (!isMobile) return; - const handleScroll = () => { - setMobileStickyDayVisible(window.scrollY > 80); + } }; - window.addEventListener('scroll', handleScroll, { passive: true }); - handleScroll(); - return () => window.removeEventListener('scroll', handleScroll); - }, [isMobile]); + + grid.addEventListener('scroll', updateStickyDay, { passive: true }); + updateStickyDay(); + return () => grid.removeEventListener('scroll', updateStickyDay); + }, [isMobile, showTimeGrid, currentWeekStart, viewDays, language]); // Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date const getGoalDateKey = useCallback( diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index 5e2fee1..9a52700 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -819,7 +819,9 @@ export const createCalendarEvent = async ( location: createdEvent.location, source: 'google', calendarId, - calendarTitle: '', // We don't have this here, simpler to leave empty or fetch + calendarTitle: '', + isRecurring: !!event.recurrence, + recurringEventId: event.recurrence ? createdEvent.id : undefined, } as CalendarEvent; } else if (connection.provider === 'outlook') { if (!event.title) throw new Error('Event title is required'); @@ -861,6 +863,8 @@ export const createCalendarEvent = async ( source: 'outlook', calendarId, calendarTitle: '', + isRecurring: !!event.recurrence, + recurringEventId: event.recurrence ? createdEvent.id : undefined, } as CalendarEvent; } else if (connection.provider === 'apple') { const [email, appPassword] = connection.accessToken.split(':'); @@ -919,12 +923,14 @@ export const createCalendarEvent = async ( id: createdEvent.id, title: createdEvent.title, description: createdEvent.description, - start: { dateTime: createdEvent.startDate }, + start: { dateTime: createdEvent.startDate }, end: { dateTime: createdEvent.endDate }, location: createdEvent.location, source: 'apple', calendarId, - calendarTitle: '', // Fetch if needed + calendarTitle: '', + isRecurring: !!event.recurrence, + recurringEventId: event.recurrence ? createdEvent.id : undefined, } as CalendarEvent; } else if (connection.provider === 'synology') { const [username, password] = connection.accessToken.split(':');