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 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-03-29 09:34:09 +02:00
parent 51301bc497
commit d8c30cdb1f
4 changed files with 93 additions and 71 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -4974,12 +4974,11 @@ h3 {
} }
@media (max-width: 768px) { @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 { .time-grid-on .weekly-day-header {
position: sticky !important; position: relative !important;
top: 0 !important; top: auto !important;
z-index: 40 !important;
background-color: var(--weekly-bg, white);
} }
/* On mobile, time-column-header should NOT be sticky — it wastes vertical space */ /* On mobile, time-column-header should NOT be sticky — it wastes vertical space */
.time-grid-on .time-column-header { .time-grid-on .time-column-header {

View File

@ -1771,19 +1771,37 @@ export default function WeeklyView() {
const [customTabs, setCustomTabs] = useState<string[]>([]); const [customTabs, setCustomTabs] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { const email = session?.user?.email;
const saved = localStorage.getItem("weekly_active_someday_tab"); if (typeof window !== "undefined" && email) {
const saved = localStorage.getItem(`weekly_active_someday_tab_${email}`);
if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved); if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved);
try { try {
const savedTabs = localStorage.getItem("weekly_custom_tabs"); const savedTabs = localStorage.getItem(`weekly_custom_tabs_${email}`);
if (savedTabs) setCustomTabs(JSON.parse(savedTabs)); if (savedTabs) setCustomTabs(JSON.parse(savedTabs));
} catch { /* ignore */ } } 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[]) => { const saveCustomTabs = (tabs: string[]) => {
setCustomTabs(tabs); 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(() => { const somedayTabs = useMemo(() => {
@ -1795,7 +1813,8 @@ export default function WeeklyView() {
const setSomedayTab = (tab: string | null) => { const setSomedayTab = (tab: string | null) => {
setActiveSomedayTab(tab); 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) => { 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 // Optimistically add/update from API response, then force refresh cache
const data = await res.json(); const data = await res.json();
if (data.event) { const isRecurring = !!(eventData.recurrence);
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime) 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; const ev = data.event;
// Find calendar info from connections to fill in missing color/title // Find calendar info from connections to fill in missing color/title
const calInfo = connections.flatMap((c: any) => const calInfo = connections.flatMap((c: any) =>
@ -2700,6 +2721,23 @@ export default function WeeklyView() {
} }
return [...prev, frontendEvent]; 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 // Force refresh only the affected provider
const connId = getConnectionIdForCalendar(eventData.calendarId); const connId = getConnectionIdForCalendar(eventData.calendarId);
@ -3086,67 +3124,46 @@ export default function WeeklyView() {
return () => clearInterval(interval); 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(() => { useEffect(() => {
if (!isMobile || !gridRef.current) return; if (!isMobile || !showTimeGrid) return;
const headers = gridRef.current.querySelectorAll('.weekly-day-header'); const grid = gridRef.current;
if (!headers.length) return; if (!grid) return;
const observer = new IntersectionObserver( const updateStickyDay = () => {
(entries) => { const scrollTop = grid.scrollTop;
// Find the last header that is intersecting (at the top of viewport) setMobileStickyDayVisible(scrollTop > 40);
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;
}
}
});
// Also check which header is closest to top when scrolled past // Find which day column header is at the top of the scroll container
if (!topHeader) { const columns = grid.querySelectorAll('.weekly-day-column[data-date]');
headers.forEach(h => { let currentCol: Element | null = null;
const rect = h.getBoundingClientRect(); const gridTop = grid.getBoundingClientRect().top;
if (rect.top < 100 && rect.top > topY - 200) {
topY = rect.top; columns.forEach(col => {
topHeader = h; 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) { if (currentCol) {
const col = (topHeader as Element).closest('.weekly-day-column'); const dateStr = (currentCol as Element).getAttribute('data-date');
const dateStr = col?.getAttribute('data-date'); if (dateStr) {
if (dateStr) { const d = new Date(dateStr + 'T00:00:00');
const d = new Date(dateStr + 'T00:00:00'); const dayNames = language === 'de'
const dayNames = language === 'de' ? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
? ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const label = `${dayNames[d.getDay()]} ${d.getDate()}.${d.getMonth() + 1}.`;
const label = `${dayNames[d.getDay()]}, ${d.getDate()}.${d.getMonth() + 1}.`; setMobileStickyDay(label);
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(); grid.addEventListener('scroll', updateStickyDay, { passive: true });
return () => window.removeEventListener('scroll', handleScroll); updateStickyDay();
}, [isMobile]); 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 // Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date
const getGoalDateKey = useCallback( const getGoalDateKey = useCallback(

View File

@ -819,7 +819,9 @@ export const createCalendarEvent = async (
location: createdEvent.location, location: createdEvent.location,
source: 'google', source: 'google',
calendarId, 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; } as CalendarEvent;
} else if (connection.provider === 'outlook') { } else if (connection.provider === 'outlook') {
if (!event.title) throw new Error('Event title is required'); if (!event.title) throw new Error('Event title is required');
@ -861,6 +863,8 @@ export const createCalendarEvent = async (
source: 'outlook', source: 'outlook',
calendarId, calendarId,
calendarTitle: '', calendarTitle: '',
isRecurring: !!event.recurrence,
recurringEventId: event.recurrence ? createdEvent.id : undefined,
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'apple') { } else if (connection.provider === 'apple') {
const [email, appPassword] = connection.accessToken.split(':'); const [email, appPassword] = connection.accessToken.split(':');
@ -924,7 +928,9 @@ export const createCalendarEvent = async (
location: createdEvent.location, location: createdEvent.location,
source: 'apple', source: 'apple',
calendarId, calendarId,
calendarTitle: '', // Fetch if needed calendarTitle: '',
isRecurring: !!event.recurrence,
recurringEventId: event.recurrence ? createdEvent.id : undefined,
} as CalendarEvent; } as CalendarEvent;
} else if (connection.provider === 'synology') { } else if (connection.provider === 'synology') {
const [username, password] = connection.accessToken.split(':'); const [username, password] = connection.accessToken.split(':');