"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 { GridTaskBlock } from "./GridTaskBlock"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons"; import { faServer } from "@fortawesome/free-solid-svg-icons"; import FocusModeOverlay from "./FocusModeOverlay"; import { LayoutGrid, Calendar, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, Settings, User, Clock, Menu, Target, Sun, Moon, Repeat, GripVertical, Play, Zap, Plus, RefreshCcw, Layout, Palette, Sparkles, Info, Trash2, Undo2, Redo2, AlertCircle, MoreVertical, Check, Eye, EyeOff, PanelLeftClose, PanelLeftOpen, Type, FolderOpen, Circle, X, Cable, Link, } from "lucide-react"; // Types import UserMenu from "./UserMenu"; import SearchModal from "./SearchModal"; import SimpleDatePicker from "./SimpleDatePicker"; import RecurringTasksManager from "./RecurringTasksManager"; export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; } import { ImportListModal } from "./ImportListModal"; import { getRandomLocalQuote } from "@/lib/quotes"; // Cookie helpers for per-device settings const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour"]; function getCookie(name: string): string | null { if (typeof document === "undefined") return null; const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null; } function setCookie(name: string, value: string, days: number = 365) { if (typeof document === "undefined") return; const expires = new Date(Date.now() + days * 864e5).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; } export type ViewStyle = "simple" | "calendar" | "list" | "grid"; export interface Task { id: string; title: string; completed: boolean; dayOfWeek?: number | null; scheduledDate?: string | null; markdownContent?: string | null; createdAt?: Date; updatedAt: Date; order: number; completedAt?: Date | null; externalId?: string | null; externalProvider?: string | null; lastSyncedAt?: Date | null; syncStatus?: string | null; subTasks?: Task[]; parentId?: string | null; isRolling?: boolean; isRecurring?: boolean; somedayListId?: string | null; somedaySlotIndex?: number | null; repeatPattern?: string | null; repeatEndDate?: string | null; repeatStartDate?: string | null; originalRecurringId?: string | null; baseRecurringTask?: Task | null; recurringExceptions?: RecurringTaskException[]; startTime?: string | null; duration?: number | null; parentTaskId?: string | null; userId: string; recurrenceInterval?: number | null; recurrenceUnit?: string | null; recurrenceTime?: string | null; recurrenceEndDate?: Date | null; recurrenceDays?: number[] | null; externalListId?: string | null; projectId?: string | null; project?: { id: string; name: string; icon?: string | null; color?: string | null } | null; } interface CalendarEvent { id: string; title: string; startTime: string; endTime: string; source: "google" | "apple" | "outlook"; calendarId?: string; calendarTitle?: string; calendarColor?: string; editable?: boolean; } interface SomedayList { id: string; title: string; tasks: Task[]; externalProvider?: string | null; externalId?: string | null; externalListId?: string | null; } // Time grid configuration options type CellDuration = 15 | 30 | 60 | 120; const DEFAULT_SOMEDAY_SLOT_COUNT = 5; const getSomedaySlotCount = (tasks: Task[]) => { const maxIdx = tasks.reduce((max, t) => { if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) { return Math.max(max, t.somedaySlotIndex); } return max; }, -1); // Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total. // "add 5 rows and then when 4 are taken add another row" // Let's ensure there's always at least one empty slot at the bottom. return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2); }; // 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" }, { name: "Custom Google Font...", value: "__custom__" }, ]; // Check if a font value is a custom (non-preset) font const isCustomFont = (value: string): boolean => !!value && value !== "__custom__" && !AVAILABLE_FONTS.slice(0, -1).some((f) => f.value === value); 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", connectSynology: "Connect Synology", 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", showTaskCheckboxes: "Show Checkboxes on Tasks", showSomeday: "Show Someday Section", showAllDay: "Show All-Day Section", allDayPosition: "All-Day Events Position", allDayAbove: "Above", allDayBelow: "Below", newPasswordDesc: "Leave blank to keep current password.", dateAlignment: "Date Alignment", alignmentLeft: "Left", alignmentCenter: "Center", alignmentRight: "Right", alignmentTight: "Tight", backupRestore: "Backup & Restore", backupRestoreDesc: "Export all your tasks, anyday lists, and projects as a JSON file. You can edit the file and import it back.", exportAllData: "Export All Data (JSON)", importData: "Import Data", importMode: "Import Mode", importModeMerge: "Merge", importModeMergeDesc: "Add imported data alongside existing tasks", importModeReplace: "Replace", importModeReplaceDesc: "Delete all existing data and replace with imported data", importReplaceWarning: "Warning: This will permanently delete all your current tasks, lists, and projects!", importSelectFile: "Select JSON file...", importButton: "Import", importing: "Importing...", exporting: "Exporting...", projects: "Projects", projectsDesc: "Organize tasks with color-coded projects", addProject: "Add Project", projectName: "Name", projectColor: "Color", noProjects: "No projects yet", assignProject: "Assign project", removeProject: "Remove project", }, 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", notes: "Notizen", notesSidebar: "Notizen-Seitenleiste", 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", goalOfWeek: "Ziel der Woche", goalScope: "Ziel-Zeitraum", goalScopeWeek: "Pro Woche", goalScopeDay: "Pro Tag", goalFallback: "Ziel-Fallback-Typ", defaultGoal: "Benutzerdefiniertes Standardziel", showTaskCheckboxes: "Checkboxen bei Aufgaben anzeigen", showSomeday: "Irgendwann-Bereich anzeigen", showAllDay: "Ganztägige Ereignisse anzeigen", allDayPosition: "Position ganztägiger Ereignisse", allDayAbove: "Oben", allDayBelow: "Unten", newPasswordDesc: "Leer lassen, um das aktuelle Passwort zu behalten.", dateAlignment: "Datums-Ausrichtung", alignmentLeft: "Links", alignmentCenter: "Mitte", alignmentRight: "Rechts", alignmentTight: "Eng", backupRestore: "Sicherung & Wiederherstellung", backupRestoreDesc: "Exportieren Sie alle Aufgaben, Irgendwann-Listen und Projekte als JSON-Datei. Sie können die Datei bearbeiten und wieder importieren.", exportAllData: "Alle Daten exportieren (JSON)", importData: "Daten importieren", importMode: "Import-Modus", importModeMerge: "Zusammenführen", importModeMergeDesc: "Importierte Daten neben bestehenden Aufgaben hinzufügen", importModeReplace: "Ersetzen", importModeReplaceDesc: "Alle bestehenden Daten löschen und durch importierte ersetzen", importReplaceWarning: "Warnung: Dies löscht dauerhaft alle Ihre aktuellen Aufgaben, Listen und Projekte!", importSelectFile: "JSON-Datei auswählen...", importButton: "Importieren", importing: "Importiere...", exporting: "Exportiere...", projects: "Projekte", projectsDesc: "Aufgaben mit farbcodierten Projekten organisieren", addProject: "Projekt hinzufügen", projectName: "Name", projectColor: "Farbe", noProjects: "Noch keine Projekte", assignProject: "Projekt zuweisen", removeProject: "Projekt entfernen", }, }; // 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, format: "short" | "full" = "short", timeFormat: string = "24h"): string { if (timeFormat === "12h") { const h = hour % 12 || 12; const ampm = hour >= 12 ? "PM" : "AM"; return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`; } return format === "full" ? `${hour}:00` : `${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 { // ISO 8601 week number: weeks start on Monday 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); } // Get the Monday of the ISO week that the first visible day belongs to. // This ensures CW changes when the first visible day crosses into a new ISO week. function getCWReferenceDate(days: Date[]): Date { if (days.length === 0) return new Date(); const first = days[0]; const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat // Calculate distance back to Monday (ISO week start) // Sunday (0) → go back 6 days to previous Monday // Monday (1) → 0, Tuesday (2) → 1, etc. const distToMonday = dow === 0 ? 6 : dow - 1; return new Date(first.getTime() - distToMonday * 86400000); } // 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; } } // Helper to lighten color for dark mode function adjustColorForDarkMode(hex: string, isDarkMode: boolean): string { if (!isDarkMode || !hex || !hex.startsWith("#")) return hex; // Simple hex to RGB let r = parseInt(hex.slice(1, 3), 16); let g = parseInt(hex.slice(3, 5), 16); let b = parseInt(hex.slice(5, 7), 16); // Calculate brightness (0-255) const brightness = (r * 299 + g * 587 + b * 114) / 1000; // If it's too dark for dark mode, lighten it if (brightness < 120) { r = Math.min(255, r + 100); g = Math.min(255, g + 100); b = Math.min(255, b + 100); return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; } 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); return d; }); const [viewDays, setViewDays] = useState(7); const savedViewDaysRef = useRef(7); // Track user's saved preference for restoring on resize const [isLoading, setIsLoading] = useState(true); // Responsive: auto-adjust viewDays based on screen width useEffect(() => { const getResponsiveViewDays = (width: number): number => { if (width <= 480) return 1; if (width <= 768) return 3; if (width <= 1024) return Math.min(savedViewDaysRef.current, 5); return savedViewDaysRef.current; }; const handleResize = () => { const responsiveDays = getResponsiveViewDays(window.innerWidth); setViewDays(responsiveDays); }; // Set initial value handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // savedViewDaysRef is a ref, so no dependency needed const [isSyncing, setIsSyncing] = useState(false); const [isFetchingCalendar, setIsFetchingCalendar] = useState(false); const [syncError, setSyncError] = useState(null); const syncCountRef = useRef(0); const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []); const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []); const [darkMode, setDarkMode] = useState(false); const [timeFormat, setTimeFormat] = useState("24h"); const [dateFormat, setDateFormat] = useState("yyyy-MM-dd"); const [hourLabelFormat, setHourLabelFormat] = useState<"short" | "full">("short"); const [showSubHourSlots, setShowSubHourSlots] = useState(true); const [allDayPosition, setAllDayPosition] = useState<"above" | "below">("below"); const [somedayExpanded, setSomedayExpanded] = useState(true); const [isAllDayExpanded, setIsAllDayExpanded] = useState(true); const [somedayLists, setSomedayLists] = useState([]); const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]); const [editingTaskId, setEditingTaskId] = useState(null); const [draggingListId, setDraggingListId] = useState(null); const [dropTargetListIndex, setDropTargetListIndex] = useState(null); const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null); const isDragFromHandle = useRef(false); // Undo/Redo state const undoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]); const redoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]); const [undoCount, setUndoCount] = useState(0); const [redoCount, setRedoCount] = useState(0); const skipSnapshotRef = useRef(false); // Mobile detection const [isMobile, setIsMobile] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMobileFabSheet, setShowMobileFabSheet] = useState(false); const [fabTaskTitle, setFabTaskTitle] = useState(""); const mobileMenuRef = useRef(null); const fabTextareaRef = useRef(null); // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); const [activeTab, setActiveTab] = useState< "calendar" | "general" | "account" | "styling" | "motivation" | "about" >("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" | "outlook" | "synology" | null >(null); const [importLists, setImportLists] = useState< { id: string; title: string }[] >([]); const [isFetchingLists, setIsFetchingLists] = useState(false); const [availableTaskLists, setAvailableTaskLists] = useState<{ [key in "google" | "apple" | "outlook" | "synology"]?: { id: string; title: string }[]; }>({}); const [isFetchingProviderLists, setIsFetchingProviderLists] = useState< Record >({}); 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"; quoteSourceUrl?: string; goalDefaultSentence?: string; goalFontFamily?: string; goalFontSize?: string; goalFontWeight?: string; goalScope?: "week" | "day"; dateLayout?: "above" | "below" | "left" | "right" | "hidden"; mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden"; dateAlignment?: "left" | "center" | "right" | "tight"; hourLabelFormat?: "short" | "full"; showSubHourSlots?: boolean; allDayPosition?: "above" | "below"; cwFontFamily?: string; cwFontSize?: string; cwFontWeight?: string; cwColor?: string; yearFontFamily?: string; yearFontSize?: string; yearFontWeight?: string; yearColor?: string; dayHeaderGap?: string; showTaskCheckboxes?: boolean; quoteSourceUrls?: string[]; startDayOffset?: number; }>({ name: session?.user?.name || "", email: session?.user?.email || "", timezone: "UTC", language: "de", dateFormat: "yyyy-MM-dd", timeFormat: "24h", startHour: 8, endHour: 18, autoRolling: false, protectEventTimes: false, showTimeGrid: true, cellDuration: 30, viewStyle: "simple", fontSize: "M", showNextTask: false, showSomeday: true, showAllDayEvents: true, showSchedule: true, hourLabelFormat: "short", showSubHourSlots: true, dayHeaderGap: "0.75em", allDayPosition: "above", focusTimerDuration: 25, focusBreakDuration: 5, headlineFont: "Oswald", headlineFontSize: "1.5rem", headlineFontWeight: "900", weekdayColor: "#0ea5e9", 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", goalFallbackType: "quote", goalFontFamily: "Lato", goalFontSize: "1rem", goalFontWeight: "500", goalScope: "week", dateLayout: "right", mobileDateLayout: "below", dateAlignment: "center", weekendColorSat: "#ffc107", weekendColorSun: "#dc2626", pastDayColor: "#a6a6a7", cwFontFamily: "Oswald", cwFontSize: "1.5rem", cwFontWeight: "700", yearFontFamily: "Oswald", yearFontSize: "1.5rem", yearFontWeight: "700", quoteSourceUrl: "https://recite.vercel.app/api/random", quoteSourceUrls: ["https://recite.vercel.app/api/random"], }); const [motivationalQuote, setMotivationalQuote] = useState(""); const [showSummary, setShowSummary] = useState(false); const [isAddingSomedayList, setIsAddingSomedayList] = useState(false); const [newSomedayListName, setNewSomedayListName] = useState(""); const [selectedSomedayProvider, setSelectedSomedayProvider] = useState< string | null >(null); const [language, setLanguage] = useState("de"); const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">( "idle", ); const [cellDuration, setCellDuration] = useState(30); 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; listId?: string; slotIdx?: number; } | null>(null); const [viewStyle, setViewStyle] = useState("simple"); const [protectEventTimes, setProtectEventTimes] = useState(false); const [unlockedEvents, setUnlockedEvents] = useState>(new Set()); const [startHour, setStartHour] = useState(8); const [endHour, setEndHour] = useState(18); 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 [showQuickSettings, setShowQuickSettings] = 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 // Collect custom font names from profile settings const customFonts = useMemo(() => { const fontProps = [ profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily, profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily, profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont, ]; return fontProps.filter((f): f is string => !!f && isCustomFont(f)); }, [profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily, profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily, profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont]); // Load ALL available fonts + any custom fonts at the top level useGoogleFonts([ ...AVAILABLE_FONTS.filter((f) => f.value !== "__custom__").map((f) => f.value), ...customFonts, ]); // 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); const [recurringDeleteModal, setRecurringDeleteModal] = useState<{ isOpen: boolean; taskId: string | null; }>({ isOpen: false, taskId: null }); 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)); } }, []); // Mobile detection — track viewport width useEffect(() => { const check = () => setIsMobile(window.innerWidth <= 768); check(); window.addEventListener("resize", check); return () => window.removeEventListener("resize", check); }, []); // Close mobile menu on outside click useEffect(() => { if (!showMobileMenu) return; const handler = (e: MouseEvent) => { if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) { setShowMobileMenu(false); } }; document.addEventListener("mousedown", handler); document.addEventListener("touchstart", handler as EventListener); return () => { document.removeEventListener("mousedown", handler); document.removeEventListener("touchstart", handler as EventListener); }; }, [showMobileMenu]); // Auto-focus FAB bottom sheet textarea useEffect(() => { if (showMobileFabSheet && fabTextareaRef.current) { setTimeout(() => fabTextareaRef.current?.focus(), 100); } }, [showMobileFabSheet]); 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); const dayHeaderRef = useRef(null); const somedayGridRef = useRef(null); const somedaySectionRef = useRef(null); // 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: number) => { 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 50; case 120: return 50; default: return 50; } }; // Working hours range (configurable) const workingHoursStart = startHour; const workingHoursEnd = endHour; // Fetch calendar events const fetchCalendarEvents = useCallback(async (forceRefresh = false) => { startSync(); setIsFetchingCalendar(true); try { const response = await fetch("/api/calendar/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timeMin: new Date( currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000, ).toISOString(), timeMax: new Date( currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000, ).toISOString(), forceRefresh, }), }); 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 { setIsFetchingCalendar(false); endSync(); } }, [currentWeekStart, startSync, endSync]); // 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"); } // Optimistically add/update from API response, then force refresh cache const data = await res.json(); if (data.event) { // Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime) const ev = data.event; const frontendEvent: CalendarEvent = { id: ev.id, title: ev.title, startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '', endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '', source: ev.source, calendarId: ev.calendarId, calendarTitle: ev.calendarTitle, calendarColor: ev.backgroundColor || ev.calendarColor, }; setRawCalendarEvents(prev => { if (eventData.id) { return prev.map(e => e.id === eventData.id ? frontendEvent : e); } return [...prev, frontendEvent]; }); } // Delay the force-refresh to give the provider time to propagate // This prevents overwriting the optimistic update with stale data setTimeout(() => fetchCalendarEvents(true), 3000); } 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"); } // Optimistically remove, then force refresh setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId)); fetchCalendarEvents(true); } 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 and REFRESH all tasks to show virtual instances setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t))); await fetchTasks(); } catch (error) { console.error(error); alert("Failed to save recurrence settings"); } }; const fetchMotivationalQuote = useCallback(async () => { if (profile.goalFallbackType !== "quote") return; const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0 ? profile.quoteSourceUrls : [profile.quoteSourceUrl || "https://recite.vercel.app/api/random"]; // Strategy: try sources until one works for (const url of urls) { try { const res = await fetch(url); if (!res.ok) continue; const data = await res.json(); let quoteText = ""; if (Array.isArray(data) && data.length > 0) { const item = data[0]; quoteText = item.quote || item.text || item.content || (typeof item === 'string' ? item : ""); if (item.author) quoteText += ` - ${item.author}`; } else if (data && typeof data === 'object') { quoteText = data.quote || data.text || data.content || ""; if (data.author) quoteText += ` - ${data.author}`; } else if (typeof data === 'string') { quoteText = data; } if (quoteText) { setMotivationalQuote(quoteText); return; // Success! } } catch (error) { console.error(`Error fetching quote from ${url}:`, error); } } // Final fallback: use local curated quotes const lang = profile.language === "de" ? "de" : "en"; const localQuote = getRandomLocalQuote(lang); if (localQuote) { setMotivationalQuote(`${localQuote.text} — ${localQuote.author}`); } else { setMotivationalQuote(lang === "de" ? "Bleib fokussiert und produktiv." : "Stay focused and productive."); } }, [profile.goalFallbackType, profile.quoteSourceUrl, profile.quoteSourceUrls, profile.language]); // Fetch tasks on mount useEffect(() => { if (session) { fetchTasks(); fetchConnections(); fetchCalendarEvents(); fetchMotivationalQuote(); } }, [session, fetchMotivationalQuote]); // Auto-open settings to calendar tab after OAuth redirect useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('openSettings') === 'calendars') { setShowSettings(true); setActiveTab('calendar'); // Clean up URL const url = new URL(window.location.href); url.searchParams.delete('openSettings'); url.searchParams.delete('calendar'); window.history.replaceState({}, '', url.pathname); // Refresh connections to pick up the new one fetchConnections(); } }, []); // Periodic pull-sync from Google Tasks (every 2 minutes) useEffect(() => { if (!session) return; const interval = setInterval( async () => { try { const res = await fetch("/api/tasks/sync"); if (res.ok) { const data = await res.json(); if (data.updated > 0 || data.deleted > 0 || data.created > 0) { console.log( `[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`, ); fetchTasks(); // Reload to reflect changes } } } catch (e) { console.error("[SYNC] Task sync error:", e); setSyncError("Task sync failed"); setTimeout(() => setSyncError(null), 10000); } }, 2 * 60 * 1000, ); return () => clearInterval(interval); }, [session]); // Periodic background calendar cache refresh (every 2 minutes) useEffect(() => { if (!session) return; const interval = setInterval( async () => { try { const now = new Date(); const res = await fetch("/api/calendar/background-sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timeMin: new Date( now.getTime() - 7 * 24 * 60 * 60 * 1000, ).toISOString(), timeMax: new Date( now.getTime() + 14 * 24 * 60 * 60 * 1000, ).toISOString(), forceRefresh: true, }), }); if (res.ok) { const data = await res.json(); if (data.queued > 0 || data.refreshed > 0) { // Cache was refreshed; re-fetch events after delay setTimeout(() => fetchCalendarEvents(), 8000); } } } catch (e) { console.error("[SYNC] Calendar sync error:", e); setSyncError("Calendar sync failed"); setTimeout(() => setSyncError(null), 10000); } }, 2 * 60 * 1000, ); return () => clearInterval(interval); }, [session, fetchCalendarEvents]); 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 30 seconds for the "Now" line and clock useEffect(() => { const interval = setInterval(() => { setCurrentTime(new Date()); }, 30000); 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 { const res = await fetch("/api/goal", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ weekStart: goalDateKey, text: newGoal, }), }); if (!res.ok) { console.error("Goal save failed:", res.status); } } catch (error) { console.error("Error saving goal:", error); } }; // Horizontal scroll: convert vertical wheel to horizontal in someday area // Callback ref ensures handler is attached as soon as element mounts const somedayWheelCleanup = useRef<(() => void) | null>(null); const somedaySectionRefCb = useCallback((node: HTMLElement | null) => { // Cleanup previous if (somedayWheelCleanup.current) { somedayWheelCleanup.current(); somedayWheelCleanup.current = null; } somedaySectionRef.current = node; if (!node) return; const handler = (e: WheelEvent) => { const grid = somedayGridRef.current; if (!grid) return; // Let native horizontal scroll (trackpad) pass through if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; if (e.deltaY === 0) return; // Only convert if grid has horizontal overflow if (grid.scrollWidth <= grid.clientWidth + 1) return; // Check boundaries - allow page scroll when at edges const atLeft = grid.scrollLeft <= 0; const atRight = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 1; if (e.deltaY < 0 && atLeft) return; if (e.deltaY > 0 && atRight) return; e.preventDefault(); grid.scrollLeft += e.deltaY; }; node.addEventListener("wheel", handler, { passive: false }); somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler); }, []); const saveSetting = async (key: string, value: any) => { // Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value if (DEVICE_SETTINGS_KEYS.includes(key)) { setCookie(`setting_${key}`, String(value)); return; // Don't write to DB — that would overwrite other devices } 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) { savedViewDaysRef.current = data.user.viewDays; const width = window.innerWidth; if (width <= 480) setViewDays(1); else if (width <= 768) setViewDays(3); else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5)); else setViewDays(data.user.viewDays); } if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration); // Cookie overrides for per-device settings (always apply, even if DB has no value) const cookieViewDays = getCookie("setting_viewDays"); if (cookieViewDays) { const v = Number(cookieViewDays); savedViewDaysRef.current = v; const width = window.innerWidth; if (width <= 480) setViewDays(1); else if (width <= 768) setViewDays(3); else if (width <= 1024) setViewDays(Math.min(v, 5)); else setViewDays(v); } const cookieCellDuration = getCookie("setting_cellDuration"); if (cookieCellDuration) setCellDuration(Number(cookieCellDuration) as CellDuration); const cookieStartHour = getCookie("setting_startHour"); if (cookieStartHour) setStartHour(Number(cookieStartHour)); const cookieEndHour = getCookie("setting_endHour"); if (cookieEndHour) setEndHour(Number(cookieEndHour)); 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.hourLabelFormat) setHourLabelFormat(data.user.hourLabelFormat as "short" | "full"); if (data.user.showSubHourSlots !== undefined) setShowSubHourSlots(data.user.showSubHourSlots); if (data.user.allDayPosition) setAllDayPosition(data.user.allDayPosition as "above" | "below"); 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", })); // Apply start day offset (e.g. -1 for yesterday) if (data.user.startDayOffset && data.user.startDayOffset !== 0) { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + data.user.startDayOffset); setCurrentWeekStart(d); } 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 externalId: l.externalId || null, externalProvider: l.externalProvider || null, })), ); return data.lists; } } catch (error) { console.error("Error fetching someday lists:", error); return []; } } async function fetchProjects() { try { const res = await fetch("/api/projects"); if (res.ok) { const data = await res.json(); setProjects(data.projects || []); } } catch (error) { console.error("Error fetching projects:", error); } } async function fetchTasks() { startSync(); try { const [tasksResponse, listsResponse] = await Promise.all([ fetch("/api/tasks"), fetch("/api/someday-lists"), // Fetch lists in parallel ]); // Also fetch projects in background fetchProjects(); let fetchedLists: SomedayList[] = []; if (listsResponse.ok) { const listData = await listsResponse.json(); fetchedLists = listData.lists.map((l: any) => ({ id: l.id, title: l.title, tasks: [], externalId: l.externalId || null, externalProvider: l.externalProvider || null, })); } // 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), recurrenceDays: t.recurrenceDays ? (typeof t.recurrenceDays === 'string' ? JSON.parse(t.recurrenceDays) : t.recurrenceDays) : null, })); // Calendar tasks: anything NOT in a someday list (includes tasks with scheduledDate OR dayOfWeek) const dayTasks = fetchedTasks.filter((t: Task) => !t.somedayListId); const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId); setTasks(dayTasks); // Populate lists with tasks const listIds = new Set(fetchedLists.map((l: SomedayList) => l.id)); const orphanedSomedayTasks = somedayTasks.filter( (t: Task) => !listIds.has(t.somedayListId || ""), ); const populatedLists = fetchedLists.map((list) => ({ ...list, tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id && !t.parentTaskId), })); // Rescue orphaned someday tasks: if their list was deleted, move them to calendar if (orphanedSomedayTasks.length > 0) { console.warn( `[RESCUE] Found ${orphanedSomedayTasks.length} orphaned someday tasks, recovering to calendar`, ); const rescuedTasks = orphanedSomedayTasks.map((t: Task) => ({ ...t, somedayListId: null, scheduledDate: t.scheduledDate || new Date().toISOString(), })); setTasks((prev) => [...prev, ...rescuedTasks]); // Persist the rescue to DB for (const t of orphanedSomedayTasks) { fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: t.id, somedayListId: null, scheduledDate: new Date().toISOString(), }), }).catch((e) => console.error("Failed to rescue orphaned task:", e), ); } } setSomedayLists(populatedLists); // Roll overdue tasks if (dayTasks.length > 0) { rollOverdueTasks(dayTasks); } } } catch (error) { console.error("Error fetching data:", error); } finally { setIsLoading(false); endSync(); } } // 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; // Exclude sub-tasks from top-level list (they render inside their parent) if (task.parentTaskId) 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; // Exclude sub-tasks from top-level list if (task.parentTaskId) 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)); if (taskDateStr !== dateStr || !task.startTime) return false; // Extract hour:minute from task start time and compare with slot const [taskHour, taskMinute] = task.startTime.split(":").map(Number); const taskStart = taskHour * 60 + taskMinute; const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; const slotEnd = slotStart + cellDuration; return taskStart >= slotStart && taskStart < slotEnd; }); }, [tasks, cellDuration], ); // 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< string, CalendarEvent[] > => { 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.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], ); // Check if a slot is occupied by any task (to prevent stacking) const isSlotOccupiedByTask = useCallback( (date: Date, slot: string, excludeTaskId?: string): boolean => { const dateStr = formatDateToISO(date); const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; const slotEnd = slotStart + cellDuration; return tasks.some(task => { if (!task.scheduledDate || !task.startTime) return false; if (excludeTaskId && task.id === excludeTaskId) return false; // Exclude sub-tasks if (task.parentTaskId) return false; const taskDateStr = typeof task.scheduledDate === "string" ? task.scheduledDate.substring(0, 10) : formatDateToISO(new Date(task.scheduledDate)); if (taskDateStr !== dateStr) return false; const [taskHour, taskMinute] = task.startTime.split(":").map(Number); const taskStart = taskHour * 60 + taskMinute; const taskDuration = task.duration || 15; const taskEnd = taskStart + taskDuration; // Skip completed tasks (they don't render in the grid) if (task.completed) return false; // Overlap condition: task starts before slot ends AND task ends after slot starts return taskStart < slotEnd && taskEnd > slotStart; }); }, [tasks, cellDuration], ); // Navigation handlers with CSS class-based slide animation (works in all browsers) const gridRef = useRef(null); const allSlideClasses = ["slide-animate-next", "slide-animate-prev", "slide-animate-week-next", "slide-animate-week-prev"]; const navigate = ( newDate: Date, direction: "left" | "right", type: "day" | "week", ) => { const grid = gridRef.current; if (grid) { // Remove any existing animation class grid.classList.remove(...allSlideClasses); // Trigger reflow to restart animation if same direction void grid.offsetWidth; // Pick class: week uses longer animation const prefix = type === "week" ? "slide-animate-week-" : "slide-animate-"; grid.classList.add(prefix + (direction === "left" ? "next" : "prev")); // Clean up after animation const cleanup = () => { grid.classList.remove(...allSlideClasses); grid.removeEventListener("animationend", cleanup); }; grid.addEventListener("animationend", cleanup, { once: true }); } setCurrentWeekStart(newDate); setSlideDirection(direction === "left" ? "next" : "prev"); }; 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() + (profile?.startDayOffset || 0)); setCurrentWeekStart(d); }; // Touch swipe navigation for mobile useEffect(() => { let touchStartX = 0; let touchStartY = 0; let touchEndX = 0; let touchEndY = 0; const handleTouchStart = (e: TouchEvent) => { touchStartX = e.changedTouches[0].screenX; touchStartY = e.changedTouches[0].screenY; }; const handleTouchEnd = (e: TouchEvent) => { touchEndX = e.changedTouches[0].screenX; touchEndY = e.changedTouches[0].screenY; const diffX = touchEndX - touchStartX; const diffY = touchEndY - touchStartY; // Only trigger if horizontal swipe is dominant and > 80px if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) { if (diffX > 0) { // Swipe right → go to previous day goToPrevDay(); } else { // Swipe left → go to next day goToNextDay(); } } }; const container = document.querySelector('.weekly-container') as HTMLElement | null; if (container) { container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true }); container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true }); } return () => { if (container) { container.removeEventListener('touchstart', handleTouchStart as EventListener); container.removeEventListener('touchend', handleTouchEnd as EventListener); } }; }, [currentWeekStart]); // Re-attach when week changes so closures are fresh const executeImport = async (provider: "google" | "apple" | "outlook" | "synology") => { 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); } }; const fetchAvailableTaskLists = useCallback( async (provider: "google" | "apple" | "outlook" | "synology") => { setIsFetchingProviderLists((prev) => ({ ...prev, [provider]: true, })); try { const res = await fetch(`/api/tasks/lists?provider=${provider}`); if (res.ok) { const data = await res.json(); setAvailableTaskLists((prev) => ({ ...prev, [provider]: data.lists || [], })); } } catch (error) { console.error(`Failed to fetch lists for ${provider}`, error); } finally { setIsFetchingProviderLists((prev) => ({ ...prev, [provider]: false, })); } }, [], ); const handleToggleTaskList = async ( provider: "google" | "apple" | "outlook" | "synology", list: { id: string; title: string }, ) => { const existing = somedayLists.find( (l) => l.externalId === list.id && l.externalProvider === provider, ); if (existing) { // Unsync/Remove if ( !confirm( `Are you sure you want to stop syncing the list "${list.title}"? This will move its tasks to the trash.`, ) ) { return; } try { const res = await fetch(`/api/someday-lists?id=${existing.id}`, { method: "DELETE", }); if (res.ok) { setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id)); setImportStatusMsg({ type: "success", text: `Stopped syncing "${list.title}".`, }); } } catch (error) { console.error("Failed to delete list", error); setImportStatusMsg({ type: "error", text: "Failed to stop syncing list.", }); } } else { // Sync/Import await doImport(provider, [list]); } }; // Core import logic — accepts provider directly so it works both from modal and sidebar const doImport = async ( provider: "google" | "apple" | "outlook" | "synology", 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: `Synced ${data.count} new tasks across ${data.listsCreated || 1} list(s).`, }); // Trigger immediate pull-sync to get latest state try { await fetch("/api/tasks/sync"); } catch (e) { // Non-critical, auto-sync will catch up } await fetchTasks(); } else { setImportStatusMsg({ type: "error", text: data.error || "Sync failed.", }); } } catch (error) { console.error("Import error:", error); setImportStatusMsg({ type: "error", text: "An error occurred during sync.", }); } 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); }; // Undo/Redo helpers const saveSnapshot = useCallback(() => { if (skipSnapshotRef.current) return; undoStackRef.current = [ ...undoStackRef.current.slice(-29), // keep last 30 snapshots { tasks: JSON.parse(JSON.stringify(tasks)), somedayLists: JSON.parse(JSON.stringify(somedayLists)), }, ]; redoStackRef.current = []; setUndoCount(undoStackRef.current.length); setRedoCount(0); }, [tasks, somedayLists]); const handleUndo = useCallback(() => { if (undoStackRef.current.length === 0) return; const snapshot = undoStackRef.current.pop()!; redoStackRef.current.push({ tasks: JSON.parse(JSON.stringify(tasks)), somedayLists: JSON.parse(JSON.stringify(somedayLists)), }); skipSnapshotRef.current = true; setTasks(snapshot.tasks); setSomedayLists(snapshot.somedayLists); skipSnapshotRef.current = false; setUndoCount(undoStackRef.current.length); setRedoCount(redoStackRef.current.length); }, [tasks, somedayLists]); const handleRedo = useCallback(() => { if (redoStackRef.current.length === 0) return; const snapshot = redoStackRef.current.pop()!; undoStackRef.current.push({ tasks: JSON.parse(JSON.stringify(tasks)), somedayLists: JSON.parse(JSON.stringify(somedayLists)), }); skipSnapshotRef.current = true; setTasks(snapshot.tasks); setSomedayLists(snapshot.somedayLists); skipSnapshotRef.current = false; setUndoCount(undoStackRef.current.length); setRedoCount(redoStackRef.current.length); }, [tasks, somedayLists]); // Keyboard shortcuts for undo/redo useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) { e.preventDefault(); handleUndo(); } if ((e.ctrlKey || e.metaKey) && (e.key === "y" || (e.key === "z" && e.shiftKey))) { e.preventDefault(); handleRedo(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleUndo, handleRedo]); // Task CRUD operations const addTask = async (date: Date, title: string, startTime?: string) => { if (!title.trim()) return; saveSnapshot(); 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); } }; // Helper to find a task in both calendar tasks and someday lists const findTaskAnywhere = (taskId: string): Task | undefined => { const calTask = tasks.find((t) => t.id === taskId); if (calTask) return calTask; for (const list of somedayLists) { const found = list.tasks.find((t) => t.id === taskId); if (found) return found; } return undefined; }; const toggleTask = async (taskId: string) => { saveSnapshot(); const task = findTaskAnywhere(taskId); if (!task) return; const updatedCompleted = !task.completed; const isSomeday = !!task.somedayListId; if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, completed: updatedCompleted, updatedAt: new Date() } : t, ), })), ); } else { 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 }), }); if (task.externalId && task.externalProvider) { 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) => { saveSnapshot(); if (!newTitle.trim()) { await deleteTask(taskId); return; } const task = findTaskAnywhere(taskId); const isSomeday = !!task?.somedayListId; if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, title: newTitle.trim(), updatedAt: new Date() } : t, ), })), ); } else { 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() }), }); 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); } }; // Sub-task CRUD operations const addSubTask = async (parentId: string, title: string) => { if (!title.trim() || !session?.user) return; // Find parent task to inherit scheduling const parentTask = findTaskAnywhere(parentId); try { const response = await fetch("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title.trim(), parentTaskId: parentId, scheduledDate: parentTask?.scheduledDate || null, dayOfWeek: parentTask?.dayOfWeek ?? null, order: (parentTask?.subTasks?.length || 0), }), }); if (response.ok) { const data = await response.json(); const newSubTask = { ...data.task, createdAt: new Date(data.task.createdAt), updatedAt: new Date(data.task.updatedAt), }; // Update local state: add sub-task to parent setTasks((prev) => prev.map((t) => t.id === parentId ? { ...t, subTasks: [...(t.subTasks || []), newSubTask] } : t, ), ); setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === parentId ? { ...t, subTasks: [...(t.subTasks || []), newSubTask] } : t, ), })), ); } } catch (error) { console.error("Error adding sub-task:", error); } }; const toggleSubTask = async (subTaskId: string) => { // Find the sub-task in any parent let foundSubTask: Task | undefined; for (const task of tasks) { foundSubTask = task.subTasks?.find((st) => st.id === subTaskId); if (foundSubTask) break; } if (!foundSubTask) { for (const list of somedayLists) { for (const task of list.tasks) { foundSubTask = task.subTasks?.find((st) => st.id === subTaskId); if (foundSubTask) break; } if (foundSubTask) break; } } if (!foundSubTask) return; const newCompleted = !foundSubTask.completed; // Optimistic update const updateSubTasks = (taskList: Task[]) => taskList.map((t) => ({ ...t, subTasks: t.subTasks?.map((st) => st.id === subTaskId ? { ...st, completed: newCompleted } : st, ), })); setTasks((prev) => updateSubTasks(prev)); setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })), ); try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: subTaskId, completed: newCompleted }), }); } catch (error) { console.error("Error toggling sub-task:", error); } }; const deleteSubTask = async (subTaskId: string) => { // Optimistic update: remove from parent's subTasks const removeSubTask = (taskList: Task[]) => taskList.map((t) => ({ ...t, subTasks: t.subTasks?.filter((st) => st.id !== subTaskId), })); setTasks((prev) => removeSubTask(prev)); setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: removeSubTask(l.tasks) })), ); try { await fetch(`/api/tasks?id=${subTaskId}`, { method: "DELETE" }); } catch (error) { console.error("Error deleting sub-task:", error); } }; const updateSubTask = async (subTaskId: string, newTitle: string) => { if (!newTitle.trim()) { await deleteSubTask(subTaskId); return; } const updateSubTasks = (taskList: Task[]) => taskList.map((t) => ({ ...t, subTasks: t.subTasks?.map((st) => st.id === subTaskId ? { ...st, title: newTitle.trim() } : st, ), })); setTasks((prev) => updateSubTasks(prev)); setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })), ); try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: subTaskId, title: newTitle.trim() }), }); } catch (error) { console.error("Error updating sub-task:", 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) => { const task = findTaskAnywhere(taskId); const isSomeday = !!task?.somedayListId; if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t, ), })), ); } else { 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 }), }); 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) => { saveSnapshot(); const task = findTaskAnywhere(taskId); if (!task) return; const newRollingState = !task.isRolling; const isSomeday = !!task.somedayListId; if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t, ), })), ); } else { 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); if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, isRolling: !newRollingState } : t, ), })), ); } else { 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, somedayListId: null, somedaySlotIndex: null, 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, somedayListId: null, somedaySlotIndex: null, }), }); // 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 assignProject = async (taskId: string, projectId: string | null) => { const proj = projectId ? projects.find((p) => p.id === projectId) || null : null; // Optimistic update const updateTask = (t: Task) => t.id === taskId ? { ...t, projectId: projectId, project: proj } : t; setTasks((prev) => prev.map(updateTask)); setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map(updateTask) })) ); try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, projectId }), }); } catch (e) { console.error("Failed to assign project:", e); } }; const deleteTask = async (taskId: string) => { saveSnapshot(); const taskToDelete = findTaskAnywhere(taskId); const isSomeday = !!taskToDelete?.somedayListId; 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) { setRecurringDeleteModal({ isOpen: true, taskId }); return; } // NORMAL DELETE (Single instance) if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.filter((t) => t.id !== taskId), })), ); } else { setTasks((prev) => prev.filter((t) => t.id !== taskId)); } setEditingTaskId(null); try { 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); } }; const handleConfirmDeleteSeries = async (taskId: string) => { let originalId = taskId; if (taskId.startsWith("virtual-")) { const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/); if (match) originalId = match[1]; } const taskToDelete = findTaskAnywhere(originalId); setTasks((prev) => prev.filter((t) => { if (taskToDelete && t.title === taskToDelete.title && t.recurrenceInterval === taskToDelete.recurrenceInterval && t.recurrenceUnit === taskToDelete.recurrenceUnit) { return false; } if (t.id === originalId) return false; if (t.id.startsWith(`virtual-${originalId}-`)) return false; if (t.id === taskId) return false; return true; }), ); setEditingTaskId(null); setRecurringDeleteModal({ isOpen: false, taskId: null }); try { const origTask = findTaskAnywhere(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)); } await fetch(`/api/tasks?id=${originalId}`, { method: "DELETE" }); } catch (error) { console.error("Error deleting series:", error); } }; const handleConfirmDeleteOccurrence = async (taskId: string) => { setTasks((prev) => prev.filter((t) => t.id !== taskId)); setEditingTaskId(null); setRecurringDeleteModal({ isOpen: false, taskId: null }); try { const taskToDelete = findTaskAnywhere(taskId); 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 instance:", 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, ) => { // Reject someday list drags on day slots if (draggingListId) { e.preventDefault(); if (e.dataTransfer) { e.dataTransfer.dropEffect = "none"; } return; } 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) { if (isSlotOccupiedByTask(targetDateObj, targetSlot, 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]; if (!isSlotOccupiedByTask(targetDateObj, nextSlot, draggedTask.id) && !isSlotProtected(targetDateObj, nextSlot)) { targetSlot = nextSlot; break; } } } } } // If the task is a subtask, promote it to a standalone task if (draggedTask.parentTaskId) { const newScheduledDate = formatDateToISO(targetDateObj); // Remove subtask from parent in UI setTasks((prev) => prev.map((t) => t.id === draggedTask.parentTaskId ? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) } : t ) ); // Add as standalone task in UI setTasks((prev) => [ ...prev, { ...draggedTask, parentTaskId: null, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || "", } as Task, ]); // Persist try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: draggedTask.id, parentTaskId: null, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || "", }), }); } catch (error) { console.error("Error promoting subtask:", error); } setDraggedTask(null); setDropPreview(null); return; } // 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, somedaySlotIndex: 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, somedaySlotIndex: 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); }; const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => { e.preventDefault(); setDropPreview({ listId, slotIdx }); }; const handleSomedayDrop = async (e: React.DragEvent, listId: string, slotIndex: number) => { e.preventDefault(); if (draggedTask) { // If subtask, remove from parent first if (draggedTask.parentTaskId) { setTasks((prev) => prev.map((t) => t.id === draggedTask.parentTaskId ? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) } : t ) ); } // Update local state for someday lists setSomedayLists((prev) => prev.map((l) => { // Remove the task from its current position in all lists const filteredTasks = l.tasks.filter((t) => t.id !== draggedTask.id); if (l.id === listId) { const movedTask = { ...draggedTask, parentTaskId: null, somedayListId: listId, somedaySlotIndex: slotIndex, scheduledDate: null as any, dayOfWeek: null as any, startTime: null as any, }; return { ...l, tasks: [...filteredTasks, movedTask], }; } return { ...l, tasks: filteredTasks }; }), ); // If it was a calendar task (not someday, not subtask), remove from calendar tasks if (!draggedTask.somedayListId && !draggedTask.parentTaskId) { setTasks((prev) => prev.filter((t) => t.id !== draggedTask.id)); } // Persist the change try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: draggedTask.id, parentTaskId: null, somedayListId: listId, somedaySlotIndex: slotIndex, scheduledDate: null, dayOfWeek: null, startTime: null, }), }); } catch (error) { console.error("Error moving task to someday slot:", error); } setDraggedTask(null); setDropPreview(null); } }; // Sync calendar const handleSync = async () => { setSyncStatus("syncing"); try { // Pull changes from Google Tasks, then force-refresh calendar cache await fetch("/api/tasks/sync").catch((e) => console.error("Task pull sync error:", e), ); // Force live refresh from providers (bypass staleness check) const syncRes = await fetch("/api/calendar/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timeMin: new Date( currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000, ).toISOString(), timeMax: new Date( currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000, ).toISOString(), forceRefresh: true, }), }); if (syncRes.ok) { const data = await syncRes.json(); if (data.events) setRawCalendarEvents(data.events); } await fetchTasks(); // Re-fetch after background refresh completes if (true) { setTimeout(() => fetchCalendarEvents(), 8000); } setSyncStatus("synced"); setTimeout(() => setSyncStatus("idle"), 3000); } catch (error) { console.error("Error syncing:", error); setSyncStatus("idle"); setSyncError("Sync failed"); setTimeout(() => setSyncError(null), 10000); } }; // 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(""); setSelectedSomedayProvider(null); return; } try { const url = selectedSomedayProvider ? "/api/someday-lists/external" : "/api/someday-lists"; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newSomedayListName.trim(), provider: selectedSomedayProvider, }), }); if (response.ok) { const data = await response.json(); setSomedayLists((prev) => [ ...prev, { ...(data.somedayList || data.list), tasks: [], // Initially empty }, ]); setNewSomedayListName(""); setSelectedSomedayProvider(null); setIsAddingSomedayList(false); } else { const error = await response.json(); alert(error.error || "Failed to create list"); } } catch (error) { console.error("Error adding someday list:", error); alert("An error occurred while creating the list"); } }; // Get time slots to display // Get time slots to display const visibleSlots = getTimeSlots( cellDuration, workingHoursStart, workingHoursEnd, ); const fontSizeScale = fontSize === "S" ? 0.85 : fontSize === "L" ? 1.15 : 1; const fontVal = (v: string | undefined) => v && v !== "__custom__" ? v : ""; const scaleRem = (base: string) => { const num = parseFloat(base); return `${(num * fontSizeScale).toFixed(3)}rem`; }; const containerStyle = { "--weekly-font-headline": fontVal(profile.headlineFont) || headlineFont ? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif` : "var(--font-headline)", "--weekly-headline-size": scaleRem(profile.headlineFontSize || "1.25rem"), "--weekly-headline-weight": profile.headlineFontWeight || "900", "--weekly-date-font": fontVal(profile.dateFontFamily) ? `"${fontVal(profile.dateFontFamily)}", sans-serif` : "var(--weekly-font-headline)", "--weekly-date-size": scaleRem(profile.dateFontSize || "0.65rem"), "--weekly-date-weight": profile.dateFontWeight || "400", "--weekly-time-task-font": fontVal(profile.timeTaskFontFamily) ? `"${fontVal(profile.timeTaskFontFamily)}", sans-serif` : "var(--weekly-font)", "--weekly-time-task-size": scaleRem(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": fontVal(profile.taskFontFamily) ? `"${fontVal(profile.taskFontFamily)}", sans-serif` : "var(--weekly-font)", "--weekly-task-size": scaleRem(profile.taskFontSize || "0.9rem"), "--weekly-task-weight": profile.taskFontWeight || "400", "--weekly-event-font": fontVal(profile.eventFontFamily) || eventFontFamily ? `"${fontVal(profile.eventFontFamily) || eventFontFamily}", sans-serif` : "var(--weekly-font)", "--weekly-event-size": scaleRem(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}
); } const activeDateLayout = isMobile ? (profile.mobileDateLayout || "below") : (profile.dateLayout || "right"); // All-Day Events Section (reusable for above/below positioning) const allDaySection = (() => { if (!showAllDay) return null; const allDayEvents = calendarEvents.filter((event) => isAllDayEvent(event), ); if (allDayEvents.length === 0) return null; return (
{showTimeGrid && (
setIsAllDayExpanded(!isAllDayExpanded)} style={{ width: "50px", flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "pointer", borderRight: "1px solid var(--weekly-border)", padding: "2px 4px", gap: "0px", position: "relative", }} title={isAllDayExpanded ? "Collapse" : "Expand"} > all day {allDayEvents.length}
)} {!showTimeGrid && (
setIsAllDayExpanded(!isAllDayExpanded)} style={{ display: "flex", alignItems: "center", cursor: "pointer", padding: "2px 8px", gap: "6px", }} title={isAllDayExpanded ? "Collapse" : "Expand"} > all day {allDayEvents.length}
)} {isAllDayExpanded && (
{getVisibleDays().map((date) => { const dayEvents = getAllDayEventsForDate(date); return (
{dayEvents.length > 0 ? ( dayEvents.map((event) => (
📅 {event.title}
)) ) : (
)}
); })}
)}
); })(); return (
{/* Quick Settings Sidebar (TeuxDeux-style) */} {showQuickSettings && ( <>
setShowQuickSettings(false)} style={{ position: "fixed", inset: 0, zIndex: 999 }} />
{language === "de" ? "Einstellungen" : "Preferences"}
{/* Columns */}
{language === "de" ? "Spalten" : "Columns"}
{[1, 3, 5, 7].map((num) => ( ))}
{/* Text size */}
{language === "de" ? "Textgröße" : "Text size"}
{(["S", "M", "L"] as const).map((size) => ( ))}
{/* Someday section */}
{language === "de" ? "Irgendwann" : "Someday"}
{/* Schedule / Time Grid */}
{language === "de" ? "Zeitplan" : "Schedule"}
{/* All-day events */}
{language === "de" ? "Ganztägig" : "All-day"}
{/* Checkboxes */}
{language === "de" ? "Checkboxen" : "Checkboxes"}
{/* Start on */}
{language === "de" ? "Starten mit" : "Start on"}
{/* Display mode */}
{language === "de" ? "Anzeige" : "Display"}
{/* Spacer */}
{/* Close button at bottom */}
)} {/* Quick Settings toggle button (bottom-left) */} {!showQuickSettings && ( )} {/* View Transitions Style Block */}