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:
parent
51301bc497
commit
d8c30cdb1f
@ -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": {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(':');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user