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",
"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": {

View File

@ -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 {

View File

@ -1771,19 +1771,37 @@ export default function WeeklyView() {
const [customTabs, setCustomTabs] = useState<string[]>([]);
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(

View File

@ -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(':');