'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, Repeat, GripVertical, Play, Zap, Plus } from 'lucide-react'; // Types import UserMenu from './UserMenu'; import SearchModal from './SearchModal'; import SimpleDatePicker from './SimpleDatePicker'; import RecurringTasksManager from './RecurringTasksManager'; import { ImportListModal } from './ImportListModal'; export type ViewStyle = 'simple' | 'calendar' | 'list'; 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; externalId?: string | null; externalProvider?: string | null; externalListId?: string | null; lastSyncedAt?: Date | null; } 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; // Font options const AVAILABLE_FONTS = [ { name: 'Default (Inter)', value: 'Inter' }, { name: 'Roboto', value: 'Roboto' }, { name: 'Open Sans', value: 'Open Sans' }, { name: 'Lato', value: 'Lato' }, { name: 'Montserrat', value: 'Montserrat' }, { name: 'Oswald', value: 'Oswald' }, { name: 'Raleway', value: 'Raleway' }, { name: 'Playfair Display', value: 'Playfair Display' }, { name: 'Merriweather', value: 'Merriweather' }, { name: 'Nunito', value: 'Nunito' }, { name: 'Dancing Script', value: 'Dancing Script' }, { name: 'Pacifico', value: 'Pacifico' }, ]; const FONT_WEIGHTS = [ { name: 'Light', value: '300' }, { name: 'Normal', value: '400' }, { name: 'Medium', value: '500' }, { name: 'Bold', value: '700' }, ]; // Helper to load Google Fonts const useGoogleFonts = (fonts: string[]) => { useEffect(() => { if (typeof window === 'undefined') return; const fontsToLoad = fonts.filter(f => f && f !== 'Inter'); if (fontsToLoad.length === 0) return; const linkId = 'google-fonts-link'; let link = document.getElementById(linkId) as HTMLLinkElement; const fontQuery = fontsToLoad.map(f => f.replace(' ', '+')).join('|'); const href = `https://fonts.googleapis.com/css2?family=${fontsToLoad.map(f => `${f.replace(' ', '+')}:wght@300;400;500;700`).join('&family=')}&display=swap`; if (!link) { link = document.createElement('link'); link.id = linkId; link.rel = 'stylesheet'; document.head.appendChild(link); } link.href = href; }, [fonts]); }; // Translations const translations: Record = { en: { settings: 'Settings', general: 'General', calendar: 'Connections', 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', simpleView: 'Simple', calendarView: 'Calendar', listView: 'List', 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', connectAppleReminders: 'Connect Apple Reminders', 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', goalOfWeek: 'Goal of the Week', goalScope: 'Goal Scope', goalScopeWeek: 'Per Week', goalScopeDay: 'Per Day', goalFallback: 'Goal Fallback Type', defaultGoal: 'Custom Default Goal', showSomeday: 'Show Someday Section', showAllDay: 'Show All-Day Section', newPasswordDesc: 'Leave blank to keep current password.' }, de: { settings: 'Einstellungen', general: 'Allgemein', calendar: 'Verbindungen', account: 'Konto', runningList: 'Laufende Liste (Aufgaben automatisch auf heute verschieben)', protectEventTimes: 'Ereigniszeiten schützen', showTimeGrid: 'Zeitplan anzeigen', timeSlotDuration: 'Zeitfensterdauer', viewStyle: 'Ansichtsstil', simpleView: 'Einfach', calendarView: 'Kalender', listView: 'Liste', language: 'Sprache', dateFormat: 'Datumsformat', timeFormat: 'Zeitformat', saveChanges: 'Änderungen speichern', connectedCalendars: 'Verbundene Kalender', connectMore: 'Mehr verbinden', connectGoogle: 'Google Kalender verbinden', connectApple: 'Apple Kalender verbinden', connectAppleReminders: 'Apple Reminders 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', goalOfWeek: 'Ziel der Woche', goalScope: 'Ziel-Zeitraum', goalScopeWeek: 'Pro Woche', goalScopeDay: 'Pro Tag', goalFallback: 'Ziel-Fallback-Typ', defaultGoal: 'Benutzerdefiniertes Standardziel', showSomeday: 'Irgendwann-Bereich anzeigen', showAllDay: 'Ganztägige Ereignisse anzeigen', newPasswordDesc: 'Leer lassen, um das aktuelle Passwort zu behalten.' } }; // Date utilities function getStartOfWeek(date: Date, startDay: number = 0): Date { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct. // Wait, let's re-verify: // Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday). // Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct. // Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday). // Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct. // What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct. // Better logic: // const day = d.getDay(); // const diff = (day < startDay ? 7 : 0) + day - startDay; // d.setDate(d.getDate() - diff); // // Let's stick to a robust one: const currentDay = d.getDay(); const distance = (currentDay - startDay + 7) % 7; d.setDate(d.getDate() - distance); return d; } function formatDateHeader(date: Date, locale: string = 'en-US'): string { return date.toLocaleDateString(locale, { day: 'numeric', month: 'short' }); // e.g. 12. Feb. } 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); }; // Helper to invert colors for dark mode function invertColor(hex: string): string { if (!hex) return hex; let color = hex.startsWith('#') ? hex.slice(1) : hex; if (color.length === 3) { color = color.split('').map(c => c + c).join(''); } if (color.length !== 6) return hex; try { const r = (255 - parseInt(color.slice(0, 2), 16)).toString(16).padStart(2, '0'); const g = (255 - parseInt(color.slice(2, 4), 16)).toString(16).padStart(2, '0'); const b = (255 - parseInt(color.slice(4, 6), 16)).toString(16).padStart(2, '0'); return `#${r}${g}${b}`; } catch (e) { return hex; } } // 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(() => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); return d; }); const [viewDays, setViewDays] = useState(7); const [isLoading, setIsLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [darkMode, setDarkMode] = useState(false); const [timeFormat, setTimeFormat] = useState('24h'); const [dateFormat, setDateFormat] = useState('yyyy-MM-dd'); const [somedayExpanded, setSomedayExpanded] = useState(true); const [isAllDayExpanded, setIsAllDayExpanded] = useState(true); const [somedayLists, setSomedayLists] = useState([]); const [editingTaskId, setEditingTaskId] = useState(null); const [draggingListId, setDraggingListId] = useState(null); // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general'); const [exportStartDate, setExportStartDate] = useState(''); const [exportEndDate, setExportEndDate] = useState(''); const [passwords, setPasswords] = useState({ new: '', confirm: '' }); const [accountMsg, setAccountMsg] = useState(''); const [importingTasksState, setImportingTasksState] = useState(false); const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null); const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [importProvider, setImportProvider] = useState<'google' | 'apple' | null>(null); const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]); const [isFetchingLists, setIsFetchingLists] = useState(false); const [isVisible, setIsVisible] = useState(false); const [profile, setProfile] = useState<{ name: string; email: string; timezone: string; autoRolling?: boolean; protectEventTimes?: boolean; language?: string; dateFormat?: string; timeFormat?: string; startHour?: number; endHour?: number; focusTimerDuration?: number; focusBreakDuration?: number; showTimeGrid?: boolean; cellDuration?: number; viewStyle?: string; fontSize?: 'S' | 'M' | 'L'; showNextTask?: boolean; showSomeday?: boolean; showAllDayEvents?: boolean; showSchedule?: boolean; headlineFont?: string; headlineFontSize?: string; headlineFontWeight?: string; dateFontFamily?: string; dateFontSize?: string; dateFontWeight?: string; timeTaskFontFamily?: string; timeTaskFontSize?: string; timeTaskFontWeight?: string; bodyFont?: string; taskFontFamily?: string; taskFontSize?: string; taskFontWeight?: string; eventFontFamily?: string; eventFontSize?: string; eventFontWeight?: string; fontWeight?: string; weekendColorSat?: string; weekendColorSun?: string; weekdayColor?: string; dateColor?: string; taskColor?: string; todayHighlightColor?: string; pastDayColor?: string; goalFallbackType?: 'quote' | 'next_todo' | 'default'; goalDefaultSentence?: string; goalFontFamily?: string; goalFontSize?: string; goalFontWeight?: string; goalScope?: 'week' | 'day'; }>({ name: session?.user?.name || '', email: session?.user?.email || '', timezone: 'UTC', language: 'de', dateFormat: 'yyyy-MM-dd', timeFormat: '24h', startHour: 8, endHour: 22, autoRolling: true, protectEventTimes: true, showTimeGrid: true, cellDuration: 60, viewStyle: 'list', fontSize: 'M', showNextTask: false, showSomeday: true, showAllDayEvents: true, showSchedule: true, headlineFont: 'Inter', headlineFontSize: '1.25rem', headlineFontWeight: '900', dateFontFamily: 'Inter', dateFontSize: '0.65rem', dateFontWeight: '400', timeTaskFontFamily: 'Inter', timeTaskFontSize: '0.75rem', timeTaskFontWeight: '500', bodyFont: 'Inter', taskFontFamily: 'Inter', taskFontSize: '0.9rem', taskFontWeight: '400', eventFontFamily: 'Inter', eventFontSize: '0.85rem', eventFontWeight: '400', fontWeight: '400', goalFontFamily: 'Inter', goalFontSize: '0.9rem', goalFontWeight: '500', weekendColorSat: '#666666', weekendColorSun: '#dc2626', focusTimerDuration: 25, focusBreakDuration: 5, pastDayColor: '#a6a6a7' }); const [showSummary, setShowSummary] = 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<'next' | 'prev' | 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('simple'); const [protectEventTimes, setProtectEventTimes] = useState(true); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); const [startHour, setStartHour] = useState(8); const [endHour, setEndHour] = useState(22); const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday const [showSomeday, setShowSomeday] = useState(true); const [showAllDay, setShowAllDay] = useState(true); const [goal, setGoal] = useState('your goal of this week'); const [isEditingGoal, setIsEditingGoal] = useState(false); const [showNextTask, setShowNextTask] = useState(false); const [calendarEditMode, setCalendarEditMode] = useState(false); const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] = useState(null); const [showFocusMode, setShowFocusMode] = useState(false); const [showSchedule, setShowSchedule] = useState(true); const [focusBreakDuration, setFocusBreakDuration] = useState(5); // New UI State const [isSearchOpen, setIsSearchOpen] = useState(false); const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false); const [focusTimerDuration, setFocusTimerDuration] = useState(25); const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M'); const [headlineFont, setHeadlineFont] = useState('Inter'); const [headlineFontSize, setHeadlineFontSize] = useState('1.25rem'); const [headlineFontWeight, setHeadlineFontWeight] = useState('900'); const [dateFontFamily, setDateFontFamily] = useState('Inter'); const [dateFontSize, setDateFontSize] = useState('0.65rem'); const [dateFontWeight, setDateFontWeight] = useState('400'); const [timeTaskFontFamily, setTimeTaskFontFamily] = useState('Inter'); const [timeTaskFontSize, setTimeTaskFontSize] = useState('0.75rem'); const [timeTaskFontWeight, setTimeTaskFontWeight] = useState('500'); const [bodyFont, setBodyFont] = useState('Inter'); const [taskFontFamily, setTaskFontFamily] = useState('Inter'); const [taskFontSize, setTaskFontSize] = useState('0.9rem'); const [taskFontWeight, setTaskFontWeight] = useState('400'); const [eventFontFamily, setEventFontFamily] = useState('Inter'); const [eventFontSize, setEventFontSize] = useState('0.85rem'); const [eventFontWeight, setEventFontWeight] = useState('400'); const [fontWeight, setFontWeight] = useState('400'); const [weekendColorSat, setWeekendColorSat] = useState('#666666'); const [weekendColorSun, setWeekendColorSun] = useState('#dc2626'); // Load fonts // Dynamic font loading is handled by the main useGoogleFonts hook call below // Load ALL available fonts at the top level to ensure they are available // regardless of whether the settings modal is open or closed, and for // real-time preview usage. useGoogleFonts([ ...AVAILABLE_FONTS.map(f => f.value), 'Dancing Script', 'Pacifico' ]); // Dynamic font loading is handled by useGoogleFonts hook call above // 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)); } const savedWeekStart = localStorage.getItem('weekly-week-start'); if (savedWeekStart) { setWeekStartDay(Number(savedWeekStart)); } }, []); 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]); useEffect(() => { if (!mounted) return; localStorage.setItem('weekly-week-start', String(weekStartDay)); // REMOVED: Re-align current week start when start day changes // This was forcing the view to snap to Monday, breaking the "Yesterday as first column" setting. // setCurrentWeekStart(prev => getStartOfWeek(prev, weekStartDay)); }, [weekStartDay, 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 () => { setIsSyncing(true); 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 text = await response.text(); try { const data = JSON.parse(text); if (data.events) { setRawCalendarEvents(data.events); } } catch (e) { console.error('Failed to parse calendar sync response:', text.substring(0, 100)); } } } catch (error) { console.error('Error fetching calendar events:', error); } finally { setIsSyncing(false); } }, [currentWeekStart]); // Calendar Event Handlers const handleEventSave = async (eventData: any) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout 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), signal: controller.signal }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to save event'); } // Refresh events await fetchCalendarEvents(); } catch (error: any) { console.error('Error saving event:', error); if (error.name === 'AbortError') { throw new Error('Request timed out. Please try again.'); } throw error; } finally { clearTimeout(timeoutId); } }; 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 { setIsLoading(true); 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); } finally { setIsLoading(false); } } const handleRemoveConnection = async (connectionId: string) => { console.log('Disconnecting connection:', connectionId); const res = await fetch(`/api/calendar/connections?id=${connectionId}`, { method: 'DELETE' }); if (res.ok) { // Update state immediately setConnections(prev => prev.filter(c => c.id !== connectionId)); // Refresh connections to be sure fetchConnections(); // Optionally refresh events too as they might be gone fetchCalendarEvents(); } else { const err = await res.json(); console.error('Failed to disconnect calendar', err); throw new Error(err.error || 'Unknown 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); }, []); // Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date const getGoalDateKey = useCallback((date: Date): string => { const scope = profile.goalScope || 'week'; if (scope === 'day') { const d = new Date(date); d.setHours(0, 0, 0, 0); return d.toISOString(); } // Normalize to Monday of the week containing this date const d = new Date(date); d.setHours(0, 0, 0, 0); const day = d.getDay(); // 0=Sun, 1=Mon, ... const diff = day === 0 ? -6 : 1 - day; // Monday offset d.setDate(d.getDate() + diff); return d.toISOString(); }, [profile.goalScope]); const goalDateKey = useMemo(() => getGoalDateKey(currentWeekStart), [currentWeekStart, getGoalDateKey]); // Fetch goal for current week/day useEffect(() => { const fetchGoal = async () => { try { const res = await fetch(`/api/goal?weekStart=${goalDateKey}`); if (res.ok) { const data = await res.json(); setGoal(data.goal); } } catch (err) { console.error('Failed to fetch goal:', err); } }; fetchGoal(); }, [goalDateKey]); const saveGoal = async (newGoal: string) => { setGoal(newGoal); try { await fetch('/api/goal', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weekStart: goalDateKey, text: newGoal, }), }); } catch (err) { console.error('Failed to save goal:', err); } }; const handleSomedayWheel = (e: React.WheelEvent) => { if (e.currentTarget) { e.currentTarget.scrollLeft += e.deltaY; } }; const saveSetting = async (key: string, value: any) => { try { await fetch('/api/user/profile', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [key]: value }) }); } catch (err) { console.error(`Failed to save setting ${key}:`, err); } }; const handleSettingsChanged = (newSettings: any) => { 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); setShowSomeday(newSettings.showSomeday); setShowAllDay(newSettings.showAllDayEvents); setShowSchedule(newSettings.showSchedule); setHeadlineFont(newSettings.headlineFont); setHeadlineFontSize(newSettings.headlineFontSize); setHeadlineFontWeight(newSettings.headlineFontWeight); setDateFontFamily(newSettings.dateFontFamily); setDateFontSize(newSettings.dateFontSize); setDateFontWeight(newSettings.dateFontWeight); setTimeTaskFontFamily(newSettings.timeTaskFontFamily); setTimeTaskFontSize(newSettings.timeTaskFontSize); setTimeTaskFontWeight(newSettings.timeTaskFontWeight); setBodyFont(newSettings.bodyFont); setTaskFontFamily(newSettings.taskFontFamily); setTaskFontSize(newSettings.taskFontSize); setTaskFontWeight(newSettings.taskFontWeight); if (newSettings.eventFontFamily) setEventFontFamily(newSettings.eventFontFamily); if (newSettings.eventFontSize) setEventFontSize(newSettings.eventFontSize); if (newSettings.eventFontWeight) setEventFontWeight(newSettings.eventFontWeight); if (newSettings.fontWeight) setFontWeight(newSettings.fontWeight); if (newSettings.weekendColorSat) setWeekendColorSat(newSettings.weekendColorSat); if (newSettings.weekendColorSun) setWeekendColorSun(newSettings.weekendColorSun); setProfile((prev: any) => ({ ...prev, ...newSettings, weekdayColor: newSettings.weekdayColor || prev.weekdayColor, dateColor: newSettings.dateColor || prev.dateColor, taskColor: newSettings.taskColor || prev.taskColor, todayHighlightColor: newSettings.todayHighlightColor || prev.todayHighlightColor, eventFontFamily: newSettings.eventFontFamily || prev.eventFontFamily, eventFontSize: newSettings.eventFontSize || prev.eventFontSize, eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight })); // Custom start/end hours might affect task placement if we filter strictly 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); if (data.user.viewStyle !== undefined) { setViewStyle(data.user.viewStyle as ViewStyle); setShowTimeGrid(data.user.showTimeGrid ?? true); } if (data.user.viewDays !== undefined) setViewDays(data.user.viewDays); if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration); setShowNextTask(data.user.showNextTask || false); setCalendarEditMode(data.user.calendarEditMode || false); if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L'); if (data.user.showSomeday !== undefined) setShowSomeday(data.user.showSomeday); if (data.user.showAllDayEvents !== undefined) setShowAllDay(data.user.showAllDayEvents); if (data.user.showSchedule !== undefined) setShowSchedule(data.user.showSchedule); if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont); if (data.user.headlineFontSize) setHeadlineFontSize(data.user.headlineFontSize); if (data.user.headlineFontWeight) setHeadlineFontWeight(data.user.headlineFontWeight); if (data.user.dateFontFamily) setDateFontFamily(data.user.dateFontFamily); if (data.user.dateFontSize) setDateFontSize(data.user.dateFontSize); if (data.user.dateFontWeight) setDateFontWeight(data.user.dateFontWeight); if (data.user.timeTaskFontFamily) setTimeTaskFontFamily(data.user.timeTaskFontFamily); if (data.user.timeTaskFontSize) setTimeTaskFontSize(data.user.timeTaskFontSize); if (data.user.timeTaskFontWeight) setTimeTaskFontWeight(data.user.timeTaskFontWeight); if (data.user.bodyFont) setBodyFont(data.user.bodyFont); if (data.user.taskFontFamily) setTaskFontFamily(data.user.taskFontFamily); if (data.user.taskFontSize) setTaskFontSize(data.user.taskFontSize); if (data.user.taskFontWeight) setTaskFontWeight(data.user.taskFontWeight); if (data.user.eventFontFamily) setEventFontFamily(data.user.eventFontFamily); if (data.user.eventFontSize) setEventFontSize(data.user.eventFontSize); if (data.user.eventFontWeight) setEventFontWeight(data.user.eventFontWeight); if (data.user.fontWeight) setFontWeight(data.user.fontWeight); if (data.user.weekendColorSat) setWeekendColorSat(data.user.weekendColorSat); if (data.user.weekendColorSun) setWeekendColorSun(data.user.weekendColorSun); setProfile(prev => ({ ...prev, ...data.user, name: data.user.name || prev.name, email: data.user.email || prev.email, weekdayColor: data.user.weekdayColor || '#888888', dateColor: data.user.dateColor || '#888888', taskColor: data.user.taskColor || '#333333', todayHighlightColor: data.user.todayHighlightColor || '#f0fafa', })); if (data.user.focusTimerDuration) setFocusTimerDuration(data.user.focusTimerDuration); if (data.user.focusBreakDuration) setFocusBreakDuration(data.user.focusBreakDuration); if (data.user.showTimeGrid !== undefined) setShowTimeGrid(data.user.showTimeGrid); if (data.user.cellDuration) setCellDuration(data.user.cellDuration as CellDuration); if (data.user.viewStyle) setViewStyle(data.user.viewStyle as ViewStyle); } } } 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); // Roll overdue tasks if (dayTasks.length > 0) { rollOverdueTasks(dayTasks); } } } 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; // Use string comparison to avoid timezone shifts const taskDateStr = typeof task.scheduledDate === 'string' ? task.scheduledDate.substring(0, 10) : 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; // Use string comparison to avoid timezone shifts const taskDateStr = typeof task.scheduledDate === 'string' ? task.scheduledDate.substring(0, 10) : 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]); const rollOverdueTasks = useCallback(async (currentTasks: Task[]) => { const autoRolling = profile.autoRolling ?? false; if (!autoRolling) return; const now = new Date(); const todayStr = formatDateToISO(now); const today = new Date(todayStr); const overdue = currentTasks.filter(t => !t.completed && t.isRolling && t.scheduledDate && formatDateToISO(new Date(t.scheduledDate)) < todayStr ); if (overdue.length === 0) return; console.log(`[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`); const updatedTasks = [...currentTasks]; let hasChanges = false; const dailyEvents = getEventsForDate(today); for (const task of overdue) { const targetSlot = task.startTime || '09:00'; // Default to 9am if no time // Collision detection const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => { // Check other tasks in the updated list const taskConflict = tasksToCheck.find(t => t.id !== task.id && t.scheduledDate && formatDateToISO(new Date(t.scheduledDate)) === formatDateToISO(date) && t.startTime === slot ); if (taskConflict) return true; // Check calendar events const [h, m] = slot.split(':').map(Number); const slotStart = new Date(date); slotStart.setHours(h, m, 0, 0); const slotEnd = new Date(slotStart); slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration); return dailyEvents.some(event => { const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); return slotStart < eventEnd && slotEnd > eventStart; }); }; const findFreeSlot = (date: Date, preferred: string, tasksToCheck: Task[]) => { let current = preferred; let [h, m] = current.split(':').map(Number); while (isBlocked(date, current, tasksToCheck)) { m += cellDuration; if (m >= 60) { h += 1; m = 0; } if (h >= endHour) break; current = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; } return current; }; const nextSlot = findFreeSlot(today, targetSlot, updatedTasks); // Update in DB try { const res = await fetch('/api/tasks', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: task.id, scheduledDate: todayStr, startTime: nextSlot }) }); if (res.ok) { const data = await res.json(); const taskIndex = updatedTasks.findIndex(t => t.id === task.id); if (taskIndex !== -1) { updatedTasks[taskIndex] = { ...data.task, createdAt: new Date(data.task.createdAt), updatedAt: new Date(data.task.updatedAt) }; hasChanges = true; } } } catch (err) { console.error(`Failed to roll task ${task.id}:`, err); } } if (hasChanges) { setTasks(updatedTasks.filter(t => t.dayOfWeek !== null && !t.somedayListId)); } }, [profile.autoRolling, cellDuration, endHour, getEventsForDate]); // 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.slideDirection = direction === 'left' ? 'next' : 'prev'; doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week doc.startViewTransition(() => { setCurrentWeekStart(newDate); // setSlideDirection state is not strictly needed for global transition but keeping it clean setSlideDirection(direction === 'left' ? 'next' : 'prev'); }); } 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 = () => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d); }; const executeImport = async (provider: 'google' | 'apple') => { setImportProvider(provider); setIsImportModalOpen(true); setIsFetchingLists(true); setImportLists([]); setImportStatusMsg(null); try { const res = await fetch(`/api/tasks/lists?provider=${provider}`); if (res.ok) { const data = await res.json(); setImportLists(data.lists || []); } else { const errData = await res.json(); console.error('Failed to fetch lists', errData); setIsImportModalOpen(false); setImportStatusMsg({ type: 'error', text: errData.error || 'Failed to fetch task lists.' }); } } catch (e) { console.error('Error fetching lists:', e); setIsImportModalOpen(false); setImportStatusMsg({ type: 'error', text: 'Error fetching task lists.' }); } finally { setIsFetchingLists(false); } }; // Core import logic — accepts provider directly so it works both from modal and sidebar const doImport = async (provider: 'google' | 'apple', selectedLists: { id: string, title: string }[]) => { setImportingTasksState(true); setImportStatusMsg(null); try { const response = await fetch('/api/tasks/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, sourceLists: selectedLists }) }); const data = await response.json(); if (response.ok) { setImportStatusMsg({ type: 'success', text: `Imported ${data.count} new tasks into ${data.listsCreated || 1} list(s).` }); await fetchTasks(); } else { setImportStatusMsg({ type: 'error', text: data.error || 'Import failed.' }); } } catch (error) { console.error('Import error:', error); setImportStatusMsg({ type: 'error', text: 'An error occurred during import.' }); } finally { setImportingTasksState(false); } }; // Called from the Google Tasks modal const handleConfirmImport = async (selectedLists: { id: string, title: string }[]) => { if (!importProvider) return; setIsImportModalOpen(false); await doImport(importProvider, selectedLists); setImportProvider(null); }; // 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 }), }); // Trigger external sync if applicable if (task.externalId && task.externalProvider) { // Don't await this strictly or handle error silently? // Better to fire and forget for UI responsiveness, or await if we want to ensure consistency? // Let's await but catch separately fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: taskId, completed: updatedCompleted }), }).catch(e => console.error('Sync error:', e)); } } 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() }), }); // Sync title change to external provider if applicable const task = tasks.find(t => t.id === taskId); if (task?.externalId && task?.externalProvider) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId, title: newTitle.trim() }), }).catch(e => console.error('Sync error:', e)); } } 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 }), }); // Sync notes to external provider const task = tasks.find(t => t.id === taskId); if (task?.externalId && task?.externalProvider) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId, notes }), }).catch(e => console.error('Sync error:', e)); } } 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; const task = tasks.find(t => t.id === taskId); 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 }), }); // Sync due date change to external provider if (task?.externalId && task?.externalProvider && newScheduledDate) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }), }).catch(e => console.error('Sync error:', e)); } } catch (error) { console.error('Error moving task:', error); } }; const deleteTask = async (taskId: string) => { const taskToDelete = tasks.find(t => t.id === taskId); const isVirtual = taskId.startsWith('virtual-'); let originalId = taskId; if (isVirtual) { const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/); if (match) { originalId = match[1]; } } // Check if it's a series (virtual or real recurring) const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring); if (isSeries) { // Confirm deletion type const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE."); if (deleteSeries) { // DELETE SERIES // Remove all tasks related to this series from the UI immediately setTasks(prev => prev.filter(t => { // Check if t is the original task if (t.id === originalId) return false; // Check if t is a virtual task of this series if (t.id.startsWith(`virtual-${originalId}-`)) return false; // Check if t is the specific task being clicked (if logic above didn't catch it) if (t.id === taskId) return false; return true; })); setEditingTaskId(null); try { // Sync delete to external provider if applicable const origTask = tasks.find(t => t.id === originalId); if (origTask?.externalId && origTask?.externalProvider) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: originalId, action: 'delete' }), }).catch(e => console.error('Sync delete error:', e)); } // Deleting the original ID stops the series await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' }); } catch (error) { console.error('Error deleting series:', error); } return; } } // NORMAL DELETE (Single instance) setTasks(prev => prev.filter(t => t.id !== taskId)); setEditingTaskId(null); try { // Sync delete to external provider if applicable if (taskToDelete?.externalId && taskToDelete?.externalProvider) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId, action: 'delete' }), }).catch(e => console.error('Sync delete error:', e)); } 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 }), }); // Sync due date change to external provider if (task.externalId && task.externalProvider) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }), }).catch(e => console.error('Sync error:', e)); } } 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 || '' }), }); // Sync due date to external provider when moving from someday to calendar if (draggedTask.externalId && draggedTask.externalProvider) { fetch('/api/tasks/sync', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: draggedTask.id, scheduledDate: newScheduledDate }), }).catch(e => console.error('Sync error:', e)); } } 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); const containerStyle = { '--weekly-font-headline': (profile.headlineFont || headlineFont) ? `"${profile.headlineFont || headlineFont}", sans-serif` : 'var(--font-headline)', '--weekly-headline-size': profile.headlineFontSize || '1.25rem', '--weekly-headline-weight': profile.headlineFontWeight || '900', '--weekly-date-font': (profile.dateFontFamily) ? `"${profile.dateFontFamily}", sans-serif` : 'var(--weekly-font-headline)', '--weekly-date-size': profile.dateFontSize || '0.65rem', '--weekly-date-weight': profile.dateFontWeight || '400', '--weekly-time-task-font': (profile.timeTaskFontFamily) ? `"${profile.timeTaskFontFamily}", sans-serif` : 'var(--weekly-font)', '--weekly-time-task-size': profile.timeTaskFontSize || '0.75rem', '--weekly-time-task-weight': profile.timeTaskFontWeight || '500', '--weekly-font': 'var(--font-body)', /* Force default body font as requested */ '--weekly-task-font': (profile.taskFontFamily) ? `"${profile.taskFontFamily}", sans-serif` : 'var(--weekly-font)', '--weekly-task-size': profile.taskFontSize || '0.9rem', '--weekly-task-weight': profile.taskFontWeight || '400', '--weekly-event-font': (profile.eventFontFamily || eventFontFamily) ? `"${profile.eventFontFamily || eventFontFamily}", sans-serif` : 'var(--weekly-font)', '--weekly-event-size': profile.eventFontSize || eventFontSize || '0.85rem', '--weekly-event-weight': profile.eventFontWeight || eventFontWeight || '400', '--font-weight-body': profile.fontWeight || fontWeight || '400', '--weekly-weekend-sat': darkMode ? invertColor(profile.weekendColorSat || '#666666') : (profile.weekendColorSat || '#666666'), '--weekly-weekend-sun': darkMode ? invertColor(profile.weekendColorSun || '#dc2626') : (profile.weekendColorSun || '#dc2626'), '--weekly-weekday-color': darkMode ? invertColor(profile.weekdayColor || '#888888') : (profile.weekdayColor || '#888888'), '--weekly-date-color': darkMode ? invertColor(profile.dateColor || '#888888') : (profile.dateColor || '#888888'), '--weekly-task-color': darkMode ? invertColor(profile.taskColor || '#333333') : (profile.taskColor || '#333333'), '--weekly-today-highlight': darkMode ? invertColor(profile.todayHighlightColor || '#f0fafa') : (profile.todayHighlightColor || '#f0fafa'), '--weekly-past-color': darkMode ? invertColor(profile.pastDayColor || '#a6a6a7') : (profile.pastDayColor || '#a6a6a7'), } as React.CSSProperties; if (isLoading) { return (
{translations[language]?.loading || translations['en'].loading}
); } return (
{/* View Transitions Style Block */}