'use client'; import React, { useState, useEffect, useRef, useCallback, useMemo, DragEvent } from 'react'; import { useSession, signOut } from 'next-auth/react'; import CalendarEventModal from './CalendarEventModal'; import TaskRecurrenceModal from './RecurrenceModal'; import FocusModeOverlay from './FocusModeOverlay'; import { LayoutGrid, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, Settings, User, Clock, Menu, Target, Sun, Moon } from 'lucide-react'; // Types import UserMenu from './UserMenu'; import SearchModal from './SearchModal'; import SimpleDatePicker from './SimpleDatePicker'; import RecurringTasksManager from './RecurringTasksManager'; interface Task { id: string; title: string; markdownContent?: string; completed: boolean; dayOfWeek?: number | null; scheduledDate?: string | null; somedayListId?: string | null; order: number; startTime?: string | null; endTime?: string | null; userId: string; isRolling?: boolean; isRecurring?: boolean; recurrenceInterval?: number | null; recurrenceUnit?: string | null; recurrenceTime?: string | null; recurrenceEndDate?: Date | null; createdAt: Date; updatedAt: Date; } interface CalendarEvent { id: string; title: string; startTime: string; endTime: string; source: 'google' | 'apple'; calendarId?: string; calendarTitle?: string; calendarColor?: string; editable?: boolean; } interface SomedayList { id: string; title: string; tasks: Task[]; } // Time grid configuration options type CellDuration = 15 | 30 | 60 | 120; // Translations const translations: Record = { en: { settings: 'Settings', general: 'General', calendar: 'Calendar', account: 'Account', runningList: 'Running List (Auto-roll tasks to today)', protectEventTimes: 'Protect Event Times', showTimeGrid: 'Show Time Grid', timeSlotDuration: 'Time Slot Duration', viewStyle: 'View Style', gridView: 'Grid View', listView: 'List View', language: 'Language', dateFormat: 'Date Format', timeFormat: 'Time Format', saveChanges: 'Save Changes', connectedCalendars: 'Connected Calendars', connectMore: 'Connect More', connectGoogle: 'Connect Google Calendar', connectApple: 'Connect Apple Calendar', noCalendars: 'No calendars connected yet.', dataPrivacy: 'Data & Privacy', downloadData: 'Download My Data', deleteAccount: 'Delete Account', name: 'Name', email: 'Email', timezone: 'Timezone', changePassword: 'Change Password', newPassword: 'New Password', confirmPassword: 'Confirm Password', someday: 'SOMEDAY', lists: 'Lists', loading: 'Loading your tasks...', sycing: 'Syncing...', synced: 'Synced', localization: 'Localization', allDayEvents: 'ALL-DAY EVENTS', syncCalendar: 'Sync Calendar', toggleDarkMode: 'Toggle Dark Mode', signOut: 'Sign Out', startHour: 'Start of Day', endHour: 'End of Day', weekAbbr: 'W', mottoOfWeek: 'Motto of the Week', showSomeday: 'Show Someday Section', showAllDay: 'Show All-Day Section' }, de: { settings: 'Einstellungen', general: 'Allgemein', calendar: 'Kalender', account: 'Konto', runningList: 'Laufende Liste (Aufgaben automatisch auf heute verschieben)', protectEventTimes: 'Ereigniszeiten schützen', showTimeGrid: 'Zeitplan anzeigen', timeSlotDuration: 'Zeitfensterdauer', viewStyle: 'Ansichtsstil', gridView: 'Rasteransicht', listView: 'Listenansicht', language: 'Sprache', dateFormat: 'Datumsformat', timeFormat: 'Zeitformat', saveChanges: 'Änderungen speichern', connectedCalendars: 'Verbundene Kalender', connectMore: 'Mehr verbinden', connectGoogle: 'Google Kalender verbinden', connectApple: 'Apple Kalender verbinden', noCalendars: 'Keine Kalender verbunden.', dataPrivacy: 'Daten & Datenschutz', downloadData: 'Meine Daten herunterladen', deleteAccount: 'Konto löschen', name: 'Name', email: 'E-Mail', timezone: 'Zeitzone', changePassword: 'Passwort ändern', newPassword: 'Neues Passwort', confirmPassword: 'Passwort bestätigen', someday: 'IRGENDWANN', lists: 'Listen', loading: 'Lade Aufgaben...', syncing: 'Synchronisiere...', synced: 'Synchronisiert', localization: 'Lokalisierung', allDayEvents: 'GANZTÄGIGE EREIGNISSE', syncCalendar: 'Kalender synchronisieren', toggleDarkMode: 'Dunkelmodus umschalten', signOut: 'Abmelden', startHour: 'Tagesbeginn', endHour: 'Tagesende', weekAbbr: 'KW', mottoOfWeek: 'Motto der Woche', showSomeday: 'Irgendwann-Bereich anzeigen', showAllDay: 'Ganztägige Ereignisse anzeigen' } }; // Date utilities function getStartOfWeek(date: Date, startDay: number = 0): Date { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day + startDay; return new Date(d.setDate(diff)); } function formatDateHeader(date: Date, locale: string = 'en-US'): string { return date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }); } function getDayName(date: Date, locale: string = 'en-US'): string { return date.toLocaleDateString(locale, { weekday: 'long' }).toUpperCase(); } function isSameDay(d1: Date, d2: Date): boolean { return d1.toDateString() === d2.toDateString(); } function formatDateToISO(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function formatHour(hour: number): string { return `${hour}`; } function getTimeSlots(cellDuration: CellDuration, startHour: number, endHour: number): string[] { const slots: string[] = []; const slotsPerHour = 60 / cellDuration; for (let hour = startHour; hour < endHour; hour++) { for (let slot = 0; slot < slotsPerHour; slot++) { const minutes = slot * cellDuration; slots.push(`${hour.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`); } } return slots; } function getHourFromSlot(slot: string): number { return parseInt(slot.split(':')[0], 10); } function getWeekNumber(date: Date): number { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); } // Check if an event is an all-day event // Defined outside component to avoid stale closure issues in useCallbacks const isAllDayEvent = (event: CalendarEvent): boolean => { if (!event.startTime) return false; // Date-only format (YYYY-MM-DD) if (!event.startTime.includes('T')) return true; const start = new Date(event.startTime); const end = new Date(event.endTime); const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Check if strictly midnight to midnight in local time const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0; // Check if UTC midnight (common for API-converted date strings) const isUTCMidnight = start.getUTCHours() === 0 && start.getUTCMinutes() === 0; // If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day return durationHours >= 24 && (isLocalMidnight || isUTCMidnight); }; // Main Component export default function WeeklyView() { const { data: session } = useSession(); const [tasks, setTasks] = useState([]); const [connections, setConnections] = useState([]); // Lifted state const [rawCalendarEvents, setRawCalendarEvents] = useState([]); // Extend events with editable flag from connections const calendarEvents = useMemo(() => { return rawCalendarEvents.map(event => { let isEditable = false; if (event.calendarId) { for (const conn of connections) { if (conn.calendars && Array.isArray(conn.calendars)) { const cal = conn.calendars.find((c: any) => c.id === event.calendarId); if (cal && cal.editable) { isEditable = true; break; } } } } return { ...event, editable: isEditable }; }); }, [rawCalendarEvents, connections]); const [currentWeekStart, setCurrentWeekStart] = useState(getStartOfWeek(new Date())); const [viewDays, setViewDays] = useState(7); const [isLoading, setIsLoading] = useState(true); const [darkMode, setDarkMode] = useState(false); const [timeFormat, setTimeFormat] = useState('24h'); const [dateFormat, setDateFormat] = useState('yyyy-MM-dd'); const [somedayExpanded, setSomedayExpanded] = useState(true); const [draggingListId, setDraggingListId] = useState(null); const [isAllDayExpanded, setIsAllDayExpanded] = useState(true); const [somedayLists, setSomedayLists] = useState([]); const [editingTaskId, setEditingTaskId] = useState(null); const [showSettings, setShowSettings] = useState(false); const [showPreferences, setShowPreferences] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false); const [isAddingSomedayList, setIsAddingSomedayList] = useState(false); const [newSomedayListName, setNewSomedayListName] = useState(''); const [language, setLanguage] = useState('de'); const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle'); const [cellDuration, setCellDuration] = useState(60); const [draggedTask, setDraggedTask] = useState(null); const [showTimeGrid, setShowTimeGrid] = useState(true); const [slideDirection, setSlideDirection] = useState<'left' | 'right' | 'out-left' | 'out-right' | 'in-left' | 'in-right' | null>(null); const [activeSlot, setActiveSlot] = useState<{ day: number; slot: string } | null>(null); const [newSlotTask, setNewSlotTask] = useState(''); const [selectedTaskForNotes, setSelectedTaskForNotes] = useState(null); const [currentTime, setCurrentTime] = useState(new Date()); const [dropPreview, setDropPreview] = useState<{ day: number; slot: string } | null>(null); const [viewStyle, setViewStyle] = useState<'grid' | 'list'>('list'); const [protectEventTimes, setProtectEventTimes] = useState(true); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); const [startHour, setStartHour] = useState(8); const [endHour, setEndHour] = useState(22); const [showSomeday, setShowSomeday] = useState(true); const [showAllDay, setShowAllDay] = useState(true); const [motto, setMotto] = useState('Focus and Execute'); const [isEditingMotto, setIsEditingMotto] = useState(false); const [showNextTask, setShowNextTask] = useState(false); const [calendarEditMode, setCalendarEditMode] = useState(false); const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] = useState(null); const [showFocusMode, setShowFocusMode] = useState(false); // New UI State const [isSearchOpen, setIsSearchOpen] = useState(false); const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false); const [focusTimerDuration, setFocusTimerDuration] = useState(25); const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M'); // Calendar Event Modal State const [calendarEventModal, setCalendarEventModal] = useState<{ isOpen: boolean; event?: CalendarEvent; initialDate?: Date; initialStartTime?: string; }>({ isOpen: false }); // Dark Mode Persistence & Class Toggle const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); const savedDarkMode = localStorage.getItem('weekly-dark-mode'); if (savedDarkMode) { setDarkMode(JSON.parse(savedDarkMode)); } }, []); useEffect(() => { if (!mounted) return; localStorage.setItem('weekly-dark-mode', JSON.stringify(darkMode)); if (darkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [darkMode, mounted]); // Translation helper const t = translations[language] || translations['en']; // Refs for scroll synchronization const timeColumnRef = useRef(null); const dayColumnsRef = useRef([]); const isScrollSyncing = useRef(false); // Scroll sync handler const handleTimeColumnScroll = (e: React.UIEvent) => { if (isScrollSyncing.current) return; isScrollSyncing.current = true; const scrollTop = e.currentTarget.scrollTop; dayColumnsRef.current.forEach(col => { if (col) col.scrollTop = scrollTop; }); setTimeout(() => { isScrollSyncing.current = false; }, 10); }; const handleDayColumnScroll = (e: React.UIEvent, index: number) => { if (isScrollSyncing.current) return; isScrollSyncing.current = true; const scrollTop = e.currentTarget.scrollTop; if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollTop; dayColumnsRef.current.forEach((col, i) => { if (col && i !== index) col.scrollTop = scrollTop; }); setTimeout(() => { isScrollSyncing.current = false; }, 10); }; // Slot height based on cell duration const getSlotHeight = (duration: CellDuration) => { switch (duration) { case 15: return 25; case 30: return 35; case 60: return 50; case 120: return 80; default: return 50; } }; // Header height based on cell duration for alignment const getHeaderHeight = (duration: CellDuration) => { switch (duration) { case 15: return 65; case 30: return 55; case 60: return 40; case 120: return 40; default: return 40; } }; // Working hours range (configurable) const workingHoursStart = startHour; const workingHoursEnd = endHour; // Fetch calendar events const fetchCalendarEvents = useCallback(async () => { try { const response = await fetch('/api/calendar/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timeMin: currentWeekStart.toISOString(), timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), }), }); if (response.ok) { const data = await response.json(); if (data.events) { setRawCalendarEvents(data.events); } } } catch (error) { console.error('Error fetching calendar events:', error); } }, [currentWeekStart]); // Calendar Event Handlers const handleEventSave = async (eventData: any) => { try { const method = eventData.id ? 'PATCH' : 'POST'; const body = { ...eventData, eventId: eventData.id // For PATCH }; const res = await fetch('/api/calendar/events', { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to save event'); } // Refresh events await fetchCalendarEvents(); } catch (error) { console.error('Error saving event:', error); throw error; } }; const handleEventDelete = async (eventId: string, calendarId: string) => { try { const res = await fetch(`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`, { method: 'DELETE' }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to delete event'); } // Refresh events await fetchCalendarEvents(); } catch (error) { console.error('Error deleting event:', error); throw error; } }; const handleRecurrenceSave = async (taskId: string, recurrence: any) => { try { const res = await fetch('/api/tasks', { // Uses PATCH endpoint which handles ID in body method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, ...recurrence }) }); if (!res.ok) { throw new Error('Failed to update recurrence'); } const data = await res.json(); // Update local state setTasks(prev => prev.map(t => t.id === taskId ? data.task : t)); } catch (error) { console.error(error); alert('Failed to save recurrence settings'); } }; // Fetch tasks on mount useEffect(() => { if (session) { fetchTasks(); fetchConnections(); // Fetch connections fetchCalendarEvents(); } }, [session]); async function fetchConnections() { try { const response = await fetch('/api/calendar/connections'); if (response.ok) { const data = await response.json(); setConnections(data.connections || []); } } catch (error) { console.error('Error fetching connections:', error); } } // Refetch calendar events when week changes useEffect(() => { if (session) { fetchCalendarEvents(); } }, [currentWeekStart, session, fetchCalendarEvents]); // Update current time every minute for the "Now" line useEffect(() => { const interval = setInterval(() => { setCurrentTime(new Date()); }, 60000); // Update every minute return () => clearInterval(interval); }, []); const handleSettingsChanged = (newSettings: { showTimeGrid: boolean; cellDuration: CellDuration; viewStyle: 'grid' | 'list'; language: string; dateFormat: string; timeFormat: string; startHour: number; endHour: number; fontSize: 'S' | 'M' | 'L'; showNextTask: boolean; }) => { setShowTimeGrid(newSettings.showTimeGrid); setCellDuration(newSettings.cellDuration); setViewStyle(newSettings.viewStyle); setLanguage(newSettings.language); setDateFormat(newSettings.dateFormat); setTimeFormat(newSettings.timeFormat); setStartHour(newSettings.startHour); setEndHour(newSettings.endHour); setFontSize(newSettings.fontSize); setShowNextTask(newSettings.showNextTask); // Custom start/end hours might affect task placement if we filter strictly // But mainly we just re-render. Fetching tasks again isn't strictly necessary unless filtering changed on backend. // But let's do it to be safe if backend filtering relies on these. fetchTasks(); }; const fetchUserInfo = async () => { try { const res = await fetch('/api/user/profile'); if (res.ok) { const data = await res.json(); if (data.user) { setProtectEventTimes(data.user.protectEventTimes || false); setTimeFormat(data.user.timeFormat || '12h'); setDateFormat(data.user.dateFormat || 'MM/dd/yyyy'); setLanguage(data.user.language || 'en'); if (data.user.startHour !== undefined) setStartHour(data.user.startHour); if (data.user.endHour !== undefined) setEndHour(data.user.endHour); setShowNextTask(data.user.showNextTask || false); setCalendarEditMode(data.user.calendarEditMode || false); if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L'); } } } catch (e) { console.error(e); } }; useEffect(() => { fetchUserInfo(); }, []); async function fetchSomedayLists() { try { const response = await fetch('/api/someday-lists'); if (response.ok) { const data = await response.json(); // Map tasks is handled in fetchTasks or we can merge here if needed. // But fetchTasks fetches ALL tasks. // Optimally we fetch lists, then tasks, then merge. // For now, let's just set the lists structure. setSomedayLists(data.lists.map((l: any) => ({ id: l.id, title: l.title, tasks: l.tasks || [] // Tasks will be overwritten/populated by fetchTasks }))); return data.lists; } } catch (error) { console.error('Error fetching someday lists:', error); return []; } } async function fetchTasks() { try { const [tasksResponse, listsResponse] = await Promise.all([ fetch('/api/tasks'), fetch('/api/someday-lists') // Fetch lists in parallel ]); let fetchedLists: SomedayList[] = []; if (listsResponse.ok) { const listData = await listsResponse.json(); fetchedLists = listData.lists.map((l: any) => ({ id: l.id, title: l.title, tasks: [] })); } // If no lists exist, maybe create default 'Someday'? // TeuxDeux usually starts with one. // If DB is empty, maybe create one? // For now, if empty, we might show empty. if (fetchedLists.length === 0) { // Optionally create default list if none exist? // Let's stick to what's in DB. } if (tasksResponse.ok) { const data = await tasksResponse.json(); const fetchedTasks = data.tasks.map((t: any) => ({ ...t, createdAt: new Date(t.createdAt), updatedAt: new Date(t.updatedAt), })); const dayTasks = fetchedTasks.filter((t: Task) => t.dayOfWeek !== null && !t.somedayListId); const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId); setTasks(dayTasks); // Populate lists with tasks const populatedLists = fetchedLists.map(list => ({ ...list, tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id) })); // Fallback: If there are someday tasks with IDs that don't match any list (orphans), // or if we rely on the old "default" list for legacy data. // The old code had a default list. // Let's ensure we use the fetched lists. setSomedayLists(populatedLists); } } catch (error) { console.error('Error fetching data:', error); } finally { setIsLoading(false); } } // Get visible days based on current view setting const getVisibleDays = useCallback(() => { const days: Date[] = []; for (let i = 0; i < viewDays; i++) { days.push(new Date(currentWeekStart.getTime() + i * 24 * 60 * 60 * 1000)); } return days; }, [currentWeekStart, viewDays]); // Get tasks for a specific date const getTasksForDate = useCallback((date: Date): Task[] => { const dateStr = formatDateToISO(date); // Use local date formatting return tasks .filter(task => { if (!task.scheduledDate) return false; const taskDateStr = formatDateToISO(new Date(task.scheduledDate)); return taskDateStr === dateStr; }) .sort((a, b) => { // Sort by time if available if (a.startTime && b.startTime) { return a.startTime.localeCompare(b.startTime); } if (a.startTime) return -1; if (b.startTime) return 1; return a.order - b.order; }); }, [tasks]); // Get tasks for a specific time slot const getTasksForSlot = useCallback((date: Date, slot: string): Task[] => { const dateStr = formatDateToISO(date); return tasks.filter(task => { if (!task.scheduledDate) return false; const taskDateStr = formatDateToISO(new Date(task.scheduledDate)); return taskDateStr === dateStr && task.startTime === slot; }); }, [tasks]); // Get calendar events for a specific date const getEventsForDate = useCallback((date: Date): CalendarEvent[] => { return calendarEvents.filter(event => { // Skip all-day events (handled separately) if (isAllDayEvent(event)) return false; const eventDate = new Date(event.startTime); return isSameDay(eventDate, date); }); }, [calendarEvents]); // Get calendar events for a specific time slot const getEventsForSlot = useCallback((date: Date, slot: string): CalendarEvent[] => { return calendarEvents.filter(event => { // Skip all-day events (handled separately) const isAllDay = isAllDayEvent(event); if (isAllDay) return false; if (event.title.includes('Valentinstag')) { // Debug removed } const eventDate = new Date(event.startTime); if (!isSameDay(eventDate, date)) return false; // Extract hour:minute from event start time and compare with slot const eventHour = eventDate.getHours(); const eventMinute = eventDate.getMinutes(); // Match if event starts within this slot const [slotHour, slotMinute] = slot.split(':').map(Number); const slotStart = slotHour * 60 + slotMinute; const slotEnd = slotStart + cellDuration; const eventStart = eventHour * 60 + eventMinute; return eventStart >= slotStart && eventStart < slotEnd; }); }, [calendarEvents, cellDuration]); // Calculate event duration in pixels for proper height display const getEventDuration = (event: CalendarEvent): number => { if (isAllDayEvent(event)) return 0; // All-day events handled separately const start = new Date(event.startTime); const end = new Date(event.endTime); const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); // Calculate height based on duration and slot height const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration; return Math.max(durationMinutes * pixelsPerMinute, getSlotHeight(cellDuration)); }; // Get all-day events for a specific date const getAllDayEventsForDate = useCallback((date: Date): CalendarEvent[] => { return calendarEvents.filter(event => { if (!isAllDayEvent(event)) return false; // Parse date from startTime const eventStart = new Date(event.startTime); const eventEnd = event.endTime ? new Date(event.endTime) : new Date(eventStart); // Normalize dates to start of day for comparison const targetDate = new Date(date); targetDate.setHours(0, 0, 0, 0); const start = new Date(eventStart); start.setHours(0, 0, 0, 0); const end = new Date(eventEnd); end.setHours(0, 0, 0, 0); // If strictly dates, often end date is exclusive or same day? // Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day). // If start == end, it's 1 day (but usually GCal sends next day). // Let's assume inclusive start, exclusive end logic or "overlaps" logic. // Check if targetDate is >= start AND targetDate < end // Handle single day case where start == end or end is not provided if (!event.endTime || start.getTime() === end.getTime()) { return start.getTime() === targetDate.getTime(); } return targetDate.getTime() >= start.getTime() && targetDate.getTime() < end.getTime(); }); }, [calendarEvents]); // Get all all-day events for the visible week const getAllDayEventsForWeek = useCallback((): Map => { const eventsByDay = new Map(); const visibleDays = getVisibleDays(); visibleDays.forEach(date => { const dateKey = formatDateToISO(date); eventsByDay.set(dateKey, getAllDayEventsForDate(date)); }); return eventsByDay; }, [calendarEvents, currentWeekStart, viewDays]); // Check if a slot is protected by calendar events (only if slot starts within event time range) const isSlotProtected = useCallback((date: Date, slot: string): boolean => { if (!protectEventTimes) return false; const [slotHour, slotMinute] = slot.split(':').map(Number); const slotStart = slotHour * 60 + slotMinute; return calendarEvents.some(event => { if (isAllDayEvent(event)) return false; // Skip events that have been unlocked by the user if (unlockedEvents.has(event.id)) return false; const eventDate = new Date(event.startTime); if (!isSameDay(eventDate, date)) return false; const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes(); const eventEndDate = new Date(event.endTime); const eventEndMinutes = eventEndDate.getHours() * 60 + eventEndDate.getMinutes(); // Only protect if the slot start time falls within the event's actual duration // This ensures protection matches exactly what the event covers return slotStart >= eventStart && slotStart < eventEndMinutes; }); }, [protectEventTimes, calendarEvents, unlockedEvents]); // Navigation handlers with proper slide animation // Simplified: Immediate state update with slide-in animation to prevent blank flash const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => { if (typeof document !== 'undefined' && 'startViewTransition' in document) { const doc = document as any; doc.documentElement.dataset.transitionDirection = direction === 'left' ? 'next' : 'prev'; doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week doc.startViewTransition(() => { setCurrentWeekStart(newDate); setSlideDirection(null); }); } else { setCurrentWeekStart(newDate); } }; const goToPrevWeek = () => navigate(new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000), 'right', 'week'); const goToNextWeek = () => navigate(new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000), 'left', 'week'); const goToPrevDay = () => navigate(new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000), 'right', 'day'); const goToNextDay = () => navigate(new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000), 'left', 'day'); const goToToday = () => setCurrentWeekStart(getStartOfWeek(new Date())); // Task CRUD operations const addTask = async (date: Date, title: string, startTime?: string) => { if (!title.trim()) return; const scheduledDate = formatDateToISO(date); // Use local date formatting if (!session?.user) { // Local-only demo mode when not authenticated const tempId = `temp-${Date.now()}`; setTasks(prevTasks => [...prevTasks, { id: tempId, title: title.trim(), dayOfWeek: date.getDay(), scheduledDate, order: prevTasks.filter(t => t.scheduledDate === scheduledDate).length, completed: false, userId: 'temp', startTime, createdAt: new Date(), updatedAt: new Date(), }]); return; } try { const response = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title.trim(), dayOfWeek: date.getDay(), scheduledDate, order: 0, startTime }), }); if (response.ok) { const data = await response.json(); setTasks(prevTasks => [...prevTasks, { ...data.task, createdAt: new Date(data.task.createdAt), updatedAt: new Date(data.task.updatedAt), }]); } else { console.error('Failed to add task:', await response.text()); } } catch (error) { console.error('Error adding task:', error); } }; const toggleTask = async (taskId: string) => { const task = tasks.find(t => t.id === taskId); if (!task) return; const updatedCompleted = !task.completed; setTasks(tasks.map(t => t.id === taskId ? { ...t, completed: updatedCompleted, updatedAt: new Date() } : t )); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, completed: updatedCompleted }), }); } catch (error) { console.error('Error toggling task:', error); } }; const updateTask = async (taskId: string, newTitle: string) => { if (!newTitle.trim()) { await deleteTask(taskId); return; } setTasks(tasks.map(t => t.id === taskId ? { ...t, title: newTitle.trim(), updatedAt: new Date() } : t )); setEditingTaskId(null); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, title: newTitle.trim() }), }); } catch (error) { console.error('Error updating task:', error); } }; const updateTaskFields = async (taskId: string, fields: Partial) => { setTasks(tasks.map(t => t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t )); // Also update someday lists if the task is there setSomedayLists(lists => lists.map(list => ({ ...list, tasks: list.tasks.map(t => t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t) }))); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, ...fields }), }); } catch (error) { console.error('Error updating task fields:', error); } }; const updateTaskDuration = async (taskId: string, durationMinutes: number) => { const task = tasks.find(t => t.id === taskId); if (!task || !task.startTime) return; try { // Parse start time (HH:mm) const [startHour, startMinute] = task.startTime.split(':').map(Number); // Calculate end time const totalStartMinutes = startHour * 60 + startMinute; const totalEndMinutes = totalStartMinutes + durationMinutes; const endHour = Math.floor(totalEndMinutes / 60) % 24; // Wrap around 24h const endMinute = totalEndMinutes % 60; const endTimeStr = `${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}`; // Optimistic update setTasks(tasks.map(t => t.id === taskId ? { ...t, endTime: endTimeStr, updatedAt: new Date() } : t )); await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, endTime: endTimeStr }), }); } catch (error) { console.error('Error updating task duration:', error); } }; const updateTaskNotes = async (taskId: string, notes: string) => { setTasks(tasks.map(t => t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t )); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, markdownContent: notes }), }); } catch (error) { console.error('Error updating task notes:', error); } }; const toggleTaskRolling = async (taskId: string) => { const task = tasks.find(t => t.id === taskId); if (!task) return; const newRollingState = !task.isRolling; setTasks(tasks.map(t => t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t )); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, isRolling: newRollingState }), }); } catch (error) { console.error('Error updating task rolling state:', error); // Revert on error setTasks(tasks.map(t => t.id === taskId ? { ...t, isRolling: !newRollingState } : t )); } }; const moveTaskToSlot = async (taskId: string, dayOfWeek: number, startTime: string, scheduledDate?: Date) => { const newScheduledDate = scheduledDate ? formatDateToISO(scheduledDate) : undefined; setTasks(tasks.map(t => t.id === taskId ? { ...t, dayOfWeek, startTime, scheduledDate: newScheduledDate || t.scheduledDate, updatedAt: new Date() } : t )); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, dayOfWeek, startTime, scheduledDate: newScheduledDate }), }); } catch (error) { console.error('Error moving task:', error); } }; const deleteTask = async (taskId: string) => { setTasks(tasks.filter(t => t.id !== taskId)); setEditingTaskId(null); try { await fetch(`/api/tasks?id=${taskId}`, { method: 'DELETE' }); } catch (error) { console.error('Error deleting task:', error); } }; // Toggle rolling status const toggleRolling = async (taskId: string) => { const task = tasks.find(t => t.id === taskId); if (!task) return; const updatedIsRolling = !task.isRolling; setTasks(tasks.map(t => t.id === taskId ? { ...t, isRolling: updatedIsRolling, updatedAt: new Date() } : t )); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, isRolling: updatedIsRolling }), }); } catch (error) { console.error('Error toggling rolling status:', error); } }; // Roll task to tomorrow or next week const rollTask = async (taskId: string, rollType: 'tomorrow' | 'nextWeek') => { const task = tasks.find(t => t.id === taskId); if (!task || task.completed) return; // Get current task date const currentDate = task.scheduledDate ? new Date(task.scheduledDate) : new Date(); // Calculate new date const newDate = new Date(currentDate); if (rollType === 'tomorrow') { newDate.setDate(newDate.getDate() + 1); } else { newDate.setDate(newDate.getDate() + 7); } const newScheduledDate = formatDateToISO(newDate); // Preserve startTime — if the preferred slot is taken, find next free one let resolvedStartTime = task.startTime || undefined; if (resolvedStartTime) { const targetSlotTasks = tasks.filter(t => { if (t.id === taskId || !t.scheduledDate) return false; const tDate = formatDateToISO(new Date(t.scheduledDate)); return tDate === newScheduledDate && t.startTime === resolvedStartTime; }); if (targetSlotTasks.length > 0) { // Slot is taken — find next free slot const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd); const startIndex = allSlots.indexOf(resolvedStartTime); if (startIndex !== -1) { for (let i = startIndex + 1; i < allSlots.length; i++) { const candidate = allSlots[i]; const candidateTasks = tasks.filter(t => { if (t.id === taskId || !t.scheduledDate) return false; const tDate = formatDateToISO(new Date(t.scheduledDate)); return tDate === newScheduledDate && t.startTime === candidate; }); if (candidateTasks.length === 0) { resolvedStartTime = candidate; break; } } } } } setTasks(tasks.map(t => t.id === taskId ? { ...t, scheduledDate: newScheduledDate, dayOfWeek: newDate.getDay(), startTime: resolvedStartTime || t.startTime, updatedAt: new Date() } : t )); try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: taskId, scheduledDate: newScheduledDate, dayOfWeek: newDate.getDay(), startTime: resolvedStartTime }), }); } catch (error) { console.error('Error rolling task:', error); } }; // Drag and drop handlers const handleDragStart = (e: DragEvent, task: Task) => { setDraggedTask(task); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', task.id); } // Add drag-source class for styling if (e.currentTarget instanceof HTMLElement) { e.currentTarget.classList.add('drag-source'); } }; const handleDragOver = (e: DragEvent | React.DragEvent, dayOfWeek?: number, slot?: string) => { e.preventDefault(); if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; } // Update drop preview if we have day and slot info if (dayOfWeek !== undefined && slot) { setDropPreview({ day: dayOfWeek, slot }); } }; const handleDrop = async (e: DragEvent, dayOfWeek: number, slot?: string) => { e.preventDefault(); if (draggedTask) { const visibleDays = getVisibleDays(); const targetDateObj = visibleDays.find(d => d.getDay() === dayOfWeek) || new Date(); let targetSlot = slot; // If no slot provided (dropped on header/background), try to keep original time if (!targetSlot && draggedTask.startTime) { targetSlot = draggedTask.startTime; } // Collision detection / Find next free slot if (targetSlot) { const targetSlotTasks = getTasksForSlot(targetDateObj, targetSlot); if (targetSlotTasks.length > 0 && !targetSlotTasks.some(t => t.id === draggedTask.id)) { const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd); const startIndex = allSlots.indexOf(targetSlot); if (startIndex !== -1) { for (let i = startIndex + 1; i < allSlots.length; i++) { const nextSlot = allSlots[i]; const nextSlotTasks = getTasksForSlot(targetDateObj, nextSlot); if (nextSlotTasks.length === 0) { targetSlot = nextSlot; break; } } } } } // If the task was from a someday list, move it to the calendar if (draggedTask.somedayListId) { const newScheduledDate = formatDateToISO(targetDateObj); // Remove from someday list UI setSomedayLists(prev => prev.map(l => ({ ...l, tasks: l.tasks.filter(t => t.id !== draggedTask.id) }))); // Add to calendar tasks setTasks(prev => [...prev, { ...draggedTask, somedayListId: null, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || '' }]); // Persist try { await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: draggedTask.id, somedayListId: null, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || '' }), }); } catch (error) { console.error('Error moving task from someday to calendar:', error); } } else { moveTaskToSlot(draggedTask.id, dayOfWeek, targetSlot || '', targetDateObj); } setDraggedTask(null); } setDropPreview(null); }; const handleDragEnd = () => { setDraggedTask(null); setDropPreview(null); // Remove drag-source class from all elements document.querySelectorAll('.drag-source').forEach(el => el.classList.remove('drag-source')); }; const handleDragLeave = () => { setDropPreview(null); }; // Sync calendar const handleSync = async () => { setSyncStatus('syncing'); try { await fetchCalendarEvents(); setSyncStatus('synced'); // Reset status after 3 seconds setTimeout(() => setSyncStatus('idle'), 3000); } catch (error) { console.error('Error syncing calendar:', error); setSyncStatus('idle'); } }; // Start adding someday list UI const handleStartAddSomedayList = () => { setIsAddingSomedayList(true); // Focus will happen in render logic if possible or via ref, but let's render conditional input first }; const saveSomedayList = async () => { if (!newSomedayListName.trim()) { setIsAddingSomedayList(false); setNewSomedayListName(''); return; } try { const response = await fetch('/api/someday-lists', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newSomedayListName.trim() }), }); if (response.ok) { const data = await response.json(); setSomedayLists(prev => [...prev, { ...data.list, tasks: [] // Initially empty }]); setNewSomedayListName(''); setIsAddingSomedayList(false); } } catch (error) { console.error('Error adding someday list:', error); } }; // Get time slots to display // Get time slots to display const visibleSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd); if (isLoading) { return (
{translations[language]?.loading || translations['en'].loading}
); } return (
{/* View Transitions Style Block */}