"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 dynamic from "next/dynamic"; const IconPicker = dynamic(() => import("./IconPicker"), { ssr: false }); import { allIcons } from "./iconRegistry"; import MdiIcon from "@mdi/react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons"; import { faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar, faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop, faGlobe, faHouse, faBuilding, faChartBar, faChartLine, faWrench, faBolt, faGamepad, faPen, faBook, faGraduationCap, faFlask, faMicroscope, faDumbbell, faUtensils, faPlane, faLeaf, faHeart, faCartShopping, faCoins, faGift, faCamera, faFilm, faBroom, faPaw, faEarthAmericas, faLock, faCheck, faCode, faCube, faUsers, faCar, faMountain, faUmbrella, faClock, faTag, IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import FocusModeOverlay from "./FocusModeOverlay"; import { LayoutGrid, LayoutPanelLeft, LayoutPanelTop, Calendar, ChevronDown, 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, FolderPlus, ListPlus, Circle, X, Cable, Link, Globe, Tag, Kanban, CalendarDays, ListTodo, Filter, Pencil, FileText, ArrowLeftRight, CalendarClock, CornerUpRight, Archive, Bot, Printer, Star, LogOut, Briefcase, SlidersHorizontal, } from "lucide-react"; const stripHtml = (html: string) => html.replace(/<[^>]*>/g, '').trim(); // Types import UserMenu from "./UserMenu"; import SearchModal from "./SearchModal"; import SimpleDatePicker from "./SimpleDatePicker"; import RecurringTasksManager from "./RecurringTasksManager"; import WeekPrintModal from "./WeekPrintModal"; export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; } import { ImportListModal } from "./ImportListModal"; import OnboardingWizard from "./OnboardingWizard"; import CalendarSyncRulesPanel from "./CalendarSyncRulesPanel"; import { getRandomLocalQuote } from "@/lib/quotes"; import { AVAILABLE_FONTS, isCustomFont } from "../lib/fontConstants"; import { translations } from "../lib/weeklyViewTranslations"; import { TIMEZONE_OPTIONS as TIMEZONE_OPTIONS_LOCAL } from "../lib/timezones"; import { WeatherDisplayKey, WEATHER_DISPLAY_DEFAULTS } from "../lib/weeklyViewConstants"; const SettingsSidebar = dynamic(() => import("./SettingsSidebar"), { ssr: false }); const RichTextEditor = dynamic(() => import("./RichTextEditor"), { ssr: false }); const PriorityView = dynamic(() => import("./PriorityView"), { ssr: false }); // Cookie helpers for per-device settings // Keys here bypass the DB and save/load from cookie only — each device keeps its own value const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour", "fontSize", "showSubHourSlots"]; // Per-view toggle keys that are also device-specific (sidebar eye toggles) const DEVICE_VIEW_SETTINGS_KEYS = ["showSomeday", "showAllDayEvents", "showTaskCheckboxes", "showProjectIcons", "showPriorityIcons", "weatherEnabled"] as const; type DeviceViewSettingKey = typeof DEVICE_VIEW_SETTINGS_KEYS[number]; // Settings that save to DB (cross-device default) AND to cookie (device override wins on load) const DEVICE_ALSO_COOKIE_KEYS = ["viewStyle", "showTimeGrid", "startDayOffset"]; const VIEW_SETTINGS_PROFILE_KEYS = ["menuPosition", "showHeaderControls"]; 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`; } function readDeviceViewCookie(): Record> { const raw = getCookie("device_view_settings"); if (!raw) return {}; try { return JSON.parse(raw); } catch { return {}; } } function writeDeviceViewCookie(settings: Record>) { setCookie("device_view_settings", JSON.stringify(settings)); } export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban" | "priority"; export interface KanbanStage { id: string; name: string; color: string; } 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; kanbanStage?: string | null; url?: string | null; urgency?: boolean | null; importance?: boolean | null; priority?: string | null; delegatedTo?: string | null; delegationNote?: string | null; } interface CalendarEvent { id: string; title: string; startTime: string; endTime: string; source: "google" | "apple" | "outlook" | "synology" | "notion"; calendarId?: string; calendarTitle?: string; calendarColor?: string; editable?: boolean; recurringEventId?: string; isRecurring?: boolean; description?: string; location?: string; url?: string; } export interface SomedayList { id: string; title: string; tasks: Task[]; tab?: string | null; color?: string | null; icon?: string | null; externalProvider?: string | null; externalId?: string | null; externalListId?: string | null; } // Time grid configuration options export type CellDuration = 15 | 20 | 30 | 60; 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); }; // 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=")}&subset=latin,latin-ext&display=swap`; if (!link) { link = document.createElement("link"); link.id = linkId; link.rel = "stylesheet"; document.head.appendChild(link); } link.href = href; }, [fonts]); }; // 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", format?: string, customNames?: string, weekStartDay: number = 0, dayCase: string = "capitalize"): string { let name: string; if (format === "custom" && customNames) { // Split by comma or semicolon to allow spaces in names const names = customNames.split(/[,;]+/).map(s => s.trim()).filter(Boolean); if (names.length === 7) { // Adjust index based on weekStartDay (0=Sun, 1=Mon) const index = (date.getDay() - weekStartDay + 7) % 7; name = names[index]; } else { name = date.toLocaleDateString(locale, { weekday: "long" }); } } else { const weekdayOption = format === "narrow" ? "narrow" : (format === "short" ? "short" : "long"); try { name = date.toLocaleDateString(locale, { weekday: weekdayOption }); } catch (e) { name = date.toLocaleDateString("en-US", { weekday: weekdayOption }); } } if (dayCase === "uppercase") return name.toUpperCase(); if (dayCase === "normal") return name.toLowerCase(); // capitalize: first letter uppercase, rest lowercase return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); } 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}`; } /** * Parses a date string from a calendar event. * If strictly a date (YYYY-MM-DD), it's parsed as local mid-night. * If an ISO string with time, it's parsed regularly. */ function parseCalendarDate(dateStr: string): Date { if (!dateStr) return new Date(); // If it's date-only (YYYY-MM-DD), parse as local midnight if (!dateStr.includes("T")) { const parts = dateStr.split("-").map(Number); if (parts.length === 3) { return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0); } } // If it's an ISO string but we want local midnight (e.g. from cache or older backend) // we still parse it. The fix in the backend should reduce this. return new Date(dateStr); } function formatHour(hour: number, minutes: number = 0, format: "short" | "full" = "short", timeFormat: string = "24h"): string { if (timeFormat === "12h") { const h = hour % 12 || 12; const ampm = hour >= 12 ? "PM" : "AM"; const m = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; return format === "full" || minutes > 0 ? `${h}:${minutes.toString().padStart(2, "0")} ${ampm}` : `${h}${m} ${ampm}`; } // 24h format if (format === "short" && minutes === 0) { return `${hour}`; } return `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } function getTimeSlots( cellDuration: CellDuration, startHour: number, endHour: number, ): string[] { const slots: string[] = []; const startMins = startHour * 60; const endMins = endHour * 60; for (let mins = startMins; mins < endMins; mins += cellDuration) { const h = Math.floor(mins / 60); const m = mins % 60; slots.push(`${h.toString().padStart(2, "0")}:${m.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); } // Format a custom header string using tokens function formatCustomHeader(format: string, days: Date[], language: string, t: any, refDateOverride?: Date): string { if (!format) return ""; // Choose the reference date: if today is within the visible days, use today. // Otherwise, use the standard CW reference date (start of week). const today = new Date(); const isTodayInWeek = days.some(d => d.getDate() === today.getDate() && d.getMonth() === today.getMonth() && d.getFullYear() === today.getFullYear() ); const refDate = refDateOverride ?? (isTodayInWeek ? today : getCWReferenceDate(days)); // Define token mappings const tokens: Record = { "YYYY": refDate.getFullYear().toString(), "WW": getWeekNumber(refDate).toString().padStart(2, '0'), "MMMM": refDate.toLocaleDateString(language, { month: 'long' }), "MMM": refDate.toLocaleDateString(language, { month: 'short' }), "MM": (refDate.getMonth() + 1).toString().padStart(2, '0'), "M": (refDate.getMonth() + 1).toString(), "DDDD": refDate.toLocaleDateString(language, { weekday: 'long' }), "DDD": refDate.toLocaleDateString(language, { weekday: 'short' }), "DD": refDate.getDate().toString().padStart(2, '0'), "D": refDate.getDate().toString(), "[TODAY]": today.toLocaleDateString(language, { day: '2-digit', month: '2-digit', year: 'numeric' }) }; // Single-pass replacement using regex to avoid nested replacements (e.g. M in MMMM) // Standalone 'W' removed to allow literal 'W' (like in 'KW') const regex = /\[TODAY\]|YYYY|WW|MMMM|MMM|MM|M|DDDD|DDD|DD|D/g; return format.replace(regex, (match) => tokens[match] || match); } // Get the "selected" day for current_day header. // Priority: explicit selection → today if visible → first visible day function getSelectedDay(days: Date[], explicitSelection?: Date | null): Date { if (explicitSelection) return explicitSelection; const today = new Date(); return days.some(d => d.getDate() === today.getDate() && d.getMonth() === today.getMonth() && d.getFullYear() === today.getFullYear() ) ? today : days[0]; } // 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 = parseCalendarDate(event.startTime); const end = parseCalendarDate(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( [], ); // Weather data: { "2026-03-17T08:00": { temp: 5, code: 2, wind: 12, ... }, ... } type WeatherHour = { temp: number; code: number; feelsLike?: number; wind?: number; gusts?: number; precipProb?: number; precip?: number; humidity?: number; uv?: number }; const [weatherData, setWeatherData] = useState>({}); // Extend events with editable flag from connections, deduplicate by id // Also deduplicate recurring series masters vs expanded instances: // When a recurring event is created, the master is cached. Then the sync // returns expanded instances with different IDs but the same recurringEventId. // We keep instances and discard masters that overlap with them. // Build calendarId → editable map once per connections change (O(connections × calendars)) const calendarEditabilityMap = useMemo(() => { const map = new Map(); for (const conn of connections) { if (conn.calendars && Array.isArray(conn.calendars)) { for (const cal of conn.calendars as any[]) { if (cal.id && !map.has(cal.id)) { map.set(cal.id, !!cal.editable); } } } } return map; }, [connections]); const calendarEvents = useMemo(() => { const seen = new Set(); const seenSlot = new Set(); // Collect recurring event IDs that have expanded instances const seriesWithInstances = new Set(); for (const event of rawCalendarEvents) { if (event.recurringEventId && event.id !== event.recurringEventId) { seriesWithInstances.add(event.recurringEventId); } } return rawCalendarEvents.filter((event) => { if (seen.has(event.id)) return false; seen.add(event.id); // Skip series master if expanded instances exist for this series if (seriesWithInstances.has(event.id)) return false; // Deduplicate by title+startTime+calendarId (catches optimistic add + cache read) const slotKey = `${event.title}|${event.startTime}|${event.calendarId}`; if (seenSlot.has(slotKey)) return false; seenSlot.add(slotKey); return true; }).map((event) => ({ ...event, editable: event.calendarId ? (calendarEditabilityMap.get(event.calendarId) ?? false) : false, })); }, [rawCalendarEvents, calendarEditabilityMap]); 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 orientation / width useEffect(() => { const getResponsiveViewDays = (width: number, height: number): number => { if (width <= 768) { // Mobile: portrait → 1 day, landscape → 3 days return height > width ? 1 : 3; } if (width <= 1024) return Math.min(savedViewDaysRef.current, 5); return savedViewDaysRef.current; }; const handleResize = () => { const responsiveDays = getResponsiveViewDays(window.innerWidth, window.innerHeight); 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(() => { const c = getCookie("setting_showSubHourSlots"); return c !== null ? c === "true" : true; }); const [allDayPosition, setAllDayPosition] = useState<"above" | "below">("below"); const [somedayExpanded, setSomedayExpanded] = useState(true); const [isAllDayExpanded, setIsAllDayExpanded] = useState(true); const [somedayHeight, setSomedayHeight] = useState(() => { if (typeof document !== 'undefined') { const c = document.cookie.match(/somedayHeight=(\d+)/); return c ? parseInt(c[1]) : null; } return null; }); const [allDayHeight, setAllDayHeight] = useState(() => { if (typeof document !== 'undefined') { const c = document.cookie.match(/allDayHeight=(\d+)/); return c ? parseInt(c[1]) : null; } return null; }); const [priorityViewHeight, setPriorityViewHeight] = useState(() => { if (typeof document !== 'undefined') { const c = document.cookie.match(/priorityViewHeight=(\d+)/); return c ? parseInt(c[1]) : 340; } return 340; }); const priorityViewRef = useRef(null); const resizingRef = useRef<{ target: 'someday' | 'allday' | 'priority'; startY: number; startHeight: number; handleOnTop: boolean } | null>(null); const startResize = useCallback((e: React.MouseEvent | React.TouchEvent, target: 'someday' | 'allday' | 'priority', handleOnTop = false) => { e.preventDefault(); e.stopPropagation(); const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; const section = target === 'someday' ? somedaySectionRef.current : target === 'priority' ? priorityViewRef.current : (document.querySelector('.all-day-events-section') as HTMLElement); if (!section) return; resizingRef.current = { target, startY: clientY, startHeight: section.getBoundingClientRect().height, handleOnTop }; const onMove = (ev: MouseEvent | TouchEvent) => { if (!resizingRef.current) return; const y = 'touches' in ev ? ev.touches[0].clientY : ev.clientY; const rawDelta = y - resizingRef.current.startY; // Top handle: dragging up = increase height (invert delta); bottom handle: normal const delta = resizingRef.current.handleOnTop ? -rawDelta : rawDelta; if (resizingRef.current.target === 'priority') { setPriorityViewHeight(Math.max(80, Math.min(800, resizingRef.current.startHeight + delta))); } else { const newHeight = Math.max(40, Math.min(600, resizingRef.current.startHeight + delta)); if (resizingRef.current.target === 'someday') setSomedayHeight(newHeight); else setAllDayHeight(newHeight); } }; const onEnd = () => { if (resizingRef.current) { const cookieKey = resizingRef.current.target === 'someday' ? 'somedayHeight' : resizingRef.current.target === 'priority' ? 'priorityViewHeight' : 'allDayHeight'; const section2 = resizingRef.current.target === 'someday' ? somedaySectionRef.current : resizingRef.current.target === 'priority' ? priorityViewRef.current : (document.querySelector('.all-day-events-section') as HTMLElement); if (section2) { const h = Math.round(section2.getBoundingClientRect().height); document.cookie = `${cookieKey}=${h};path=/;max-age=31536000`; } } resizingRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onEnd); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onEnd); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onEnd); window.addEventListener('touchmove', onMove); window.addEventListener('touchend', onEnd); }, []); 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 [listToDelete, setListToDelete] = useState(null); const [menuOpenListId, setMenuOpenListId] = useState(null); // Punkt 7+8: per-list color/icon edit popover, and per-tab settings popover const [editingListVisualsId, setEditingListVisualsId] = useState(null); const [editingListVisualsRect, setEditingListVisualsRect] = useState(null); const [editingTabVisualsName, setEditingTabVisualsName] = useState(null); const [editingTabVisualsRect, setEditingTabVisualsRect] = useState(null); const [activeSomedayTab, setActiveSomedayTab] = useState(null); const [editingTabName, setEditingTabName] = useState(null); const [renamingTabValue, setRenamingTabValue] = useState(""); const [newTabForListId, setNewTabForListId] = useState(null); const [newTabNameValue, setNewTabNameValue] = useState(""); const [creatingNewTab, setCreatingNewTab] = useState(false); const [creatingNewTabName, setCreatingNewTabName] = useState(""); const [dragOverTab, setDragOverTab] = useState(null); const [customTabs, setCustomTabs] = useState([]); useEffect(() => { const email = session?.user?.email; if (typeof window !== "undefined" && email) { const saved = localStorage.getItem(`weekly_active_someday_tab_${email}`); if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved); try { const savedTabs = localStorage.getItem(`weekly_custom_tabs_${email}`); if (savedTabs) setCustomTabs(JSON.parse(savedTabs)); } catch { /* ignore */ } // Migrate old non-namespaced keys (one-time cleanup) if (localStorage.getItem("weekly_custom_tabs") && !localStorage.getItem(`weekly_custom_tabs_${email}_migrated`)) { const oldTabs = localStorage.getItem("weekly_custom_tabs"); const oldActive = localStorage.getItem("weekly_active_someday_tab"); if (oldTabs && !localStorage.getItem(`weekly_custom_tabs_${email}`)) { localStorage.setItem(`weekly_custom_tabs_${email}`, oldTabs); try { setCustomTabs(JSON.parse(oldTabs)); } catch { /* ignore */ } } if (oldActive && !localStorage.getItem(`weekly_active_someday_tab_${email}`)) { localStorage.setItem(`weekly_active_someday_tab_${email}`, oldActive); setActiveSomedayTab(oldActive === "__all__" ? null : oldActive); } localStorage.removeItem("weekly_custom_tabs"); localStorage.removeItem("weekly_active_someday_tab"); localStorage.setItem(`weekly_custom_tabs_${email}_migrated`, "1"); } } }, [session?.user?.email]); const saveCustomTabs = (tabs: string[]) => { setCustomTabs(tabs); const email = session?.user?.email; if (email) localStorage.setItem(`weekly_custom_tabs_${email}`, JSON.stringify(tabs)); // Also persist to DB so tabs survive on other devices and reconnects const updated = { ...(viewSettingsRef.current as any), somedayCustomTabs: tabs }; viewSettingsRef.current = updated; setViewSettings(updated); fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ viewSettings: updated }), }).catch(e => console.error("[tabs] Failed to save custom tabs to DB:", e)); }; const somedayTabs = useMemo(() => { const tabs = new Set(); somedayLists.forEach(l => { if (l.tab) tabs.add(l.tab); }); customTabs.forEach(t => tabs.add(t)); return Array.from(tabs).sort(); }, [somedayLists, customTabs]); const setSomedayTab = (tab: string | null) => { setActiveSomedayTab(tab); const email = session?.user?.email; if (email) localStorage.setItem(`weekly_active_someday_tab_${email}`, tab ?? "__all__"); }; const assignListToTab = async (listId: string, tab: string | null) => { setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, tab } : l)); // Persist title→tab preference so it survives reconnects const list = somedayLists.find(l => l.id === listId); if (list) { const prefs: Record = { ...((viewSettingsRef.current as any).somedayTabPrefs || {}) }; if (tab) { prefs[list.title] = tab; } else { delete prefs[list.title]; } const updated = { ...(viewSettingsRef.current as any), somedayTabPrefs: prefs }; viewSettingsRef.current = updated; setViewSettings(updated); fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ viewSettings: updated }), }).catch(e => console.error("[tabs] Failed to save tab pref:", e)); } try { await fetch("/api/someday-lists", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: listId, tab }), }); } catch (e) { console.error("Failed to update list tab:", e); } }; const renameTab = async (oldName: string, newName: string) => { if (!newName.trim() || newName === oldName) return; const trimmed = newName.trim(); const listsToUpdate = somedayLists.filter(l => l.tab === oldName); setSomedayLists(prev => prev.map(l => l.tab === oldName ? { ...l, tab: trimmed } : l)); if (customTabs.includes(oldName)) { saveCustomTabs(customTabs.map(t => t === oldName ? trimmed : t)); } if (activeSomedayTab === oldName) setSomedayTab(trimmed); for (const list of listsToUpdate) { try { await fetch("/api/someday-lists", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: list.id, tab: trimmed }), }); } catch (e) { console.error("Failed to rename tab for list:", e); } } }; // Update a list's color or icon, and persist to the API. const updateListVisuals = async (listId: string, updates: { color?: string | null; icon?: string | null }) => { setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, ...updates } : l)); try { await fetch("/api/someday-lists", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: listId, ...updates }), }); } catch (e) { console.error("Failed to update list visuals:", e); } }; // Tab visuals are not first-class entities — store color/icon in user.viewSettings JSON. const getTabVisuals = (tabName: string): { color?: string; icon?: string } => { const cfg = (viewSettingsRef.current as any).somedayTabConfig || {}; return cfg[tabName] || {}; }; const updateTabVisuals = (tabName: string, updates: { color?: string | null; icon?: string | null }) => { const cfg = { ...((viewSettingsRef.current as any).somedayTabConfig || {}) }; const existing = cfg[tabName] || {}; const next: any = { ...existing, ...updates }; if (!next.color) delete next.color; if (!next.icon) delete next.icon; if (Object.keys(next).length === 0) delete cfg[tabName]; else cfg[tabName] = next; const updated = { ...(viewSettingsRef.current as any), somedayTabConfig: cfg }; viewSettingsRef.current = updated; setViewSettings(updated); fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ viewSettings: updated }), }).catch(e => console.error("[tabs] Failed to save tab visuals:", e)); }; const dissolveTab = async (tabName: string) => { const listsToUpdate = somedayLists.filter(l => l.tab === tabName); setSomedayLists(prev => prev.map(l => l.tab === tabName ? { ...l, tab: null } : l)); if (customTabs.includes(tabName)) { saveCustomTabs(customTabs.filter(t => t !== tabName)); } if (activeSomedayTab === tabName) setSomedayTab(null); for (const list of listsToUpdate) { try { await fetch("/api/someday-lists", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: list.id, tab: null }), }); } catch (e) { console.error("Failed to dissolve tab for list:", e); } } }; const filteredSomedayLists = useMemo(() => { if (activeSomedayTab === null) return somedayLists; return somedayLists.filter(l => (l.tab || null) === activeSomedayTab); }, [somedayLists, activeSomedayTab]); 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 [isPortrait, setIsPortrait] = useState(false); const [isCompactHeight, setIsCompactHeight] = useState(false); // Tracks the last day column the user interacted with (for "selected day" header display) const [selectedDay, setSelectedDay] = useState(null); const [showMobileFabSheet, setShowMobileFabSheet] = useState(false); const [showMobileRail, setShowMobileRail] = useState(false); const [showMobileFabMenu, setShowMobileFabMenu] = useState(false); const [mobileStickyDay, setMobileStickyDay] = useState(null); const [mobileStickyDayVisible, setMobileStickyDayVisible] = useState(false); const [showHeaderMore, setShowHeaderMore] = useState(false); const [fabTaskTitle, setFabTaskTitle] = useState(""); const fabTextareaRef = useRef(null); // Moved state definitions to the top const [showSettings, setShowSettings] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [activeTab, setActiveTab] = useState< "calendar" | "general" | "account" | "styling" | "motivation" | "about" >("general"); const [exportStartDate, setExportStartDate] = useState(""); const [exportEndDate, setExportEndDate] = useState(""); const [showWeekPrintModal, setShowWeekPrintModal] = useState(false); 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 [unsyncConfirm, setUnsyncConfirm] = useState<{ provider: "google" | "apple" | "outlook" | "synology"; list: { id: string; title: 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: 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", dateVerticalAlign: "middle", allDayPosition: "above", weekStartDay: 1, 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: "", quoteSourceUrls: [], quoteLanguages: ["en", "de"], weatherEnabled: false, weatherLat: null, weatherLon: null, weatherLocation: "", showCalendarProviderIcon: false, menuPosition: "left", showHeaderControls: true, }); 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 [announceMsg, setAnnounceMsg] = useState(''); const announceTimeout = useRef | null>(null); const announce = (msg: string) => { if (announceTimeout.current) clearTimeout(announceTimeout.current); setAnnounceMsg(msg); announceTimeout.current = setTimeout(() => setAnnounceMsg(''), 3000); }; const [cellDuration, setCellDuration] = useState(30); const [draggedTask, setDraggedTask] = useState(null); const [showTimeGrid, setShowTimeGrid] = useState(() => { const c = getCookie("setting_showTimeGrid"); return c !== null ? c === "true" : 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(() => { const c = getCookie("setting_viewStyle"); return (c as ViewStyle) || "simple"; }); // Per-view settings: overrides that apply only to a specific view type PerViewOverrides = { hourLabelFormat?: "short" | "full"; showSubHourSlots?: boolean; weatherEnabled?: boolean; weatherDisplay?: WeatherDisplayKey[]; showTaskCheckboxes?: boolean; showProjectIcons?: boolean; showPriorityIcons?: boolean; showSomeday?: boolean; showAllDayEvents?: boolean; allDayPosition?: "above" | "below"; showCompletedTasks?: boolean; cellDuration?: number; startHour?: number; endHour?: number; }; const PER_VIEW_KEYS = ["hourLabelFormat", "showSubHourSlots", "weatherEnabled", "weatherDisplay", "showTaskCheckboxes", "showProjectIcons", "showPriorityIcons", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const; const [viewSettings, setViewSettings] = useState>({}); const viewSettingsRef = useRef>({}); viewSettingsRef.current = viewSettings; // Device-local per-view overrides (cookie-based, not synced to DB) const [deviceViewSettings, setDeviceViewSettings] = useState>>(() => readDeviceViewCookie()); const deviceViewSettingsRef = useRef>>({}); deviceViewSettingsRef.current = deviceViewSettings; const getEffective = (key: K, globalVal: PerViewOverrides[K]): PerViewOverrides[K] => { // Device-specific settings take priority (cookie-based, not synced across devices) if ((DEVICE_VIEW_SETTINGS_KEYS as readonly string[]).includes(key)) { const dvs = deviceViewSettingsRef.current[profile.viewStyle]; if (dvs && dvs[key] !== undefined) return dvs[key] as PerViewOverrides[K]; } const vs = viewSettingsRef.current[profile.viewStyle]; if (vs && vs[key] !== undefined) return vs[key] as PerViewOverrides[K]; return globalVal; }; const isPerView = (key: keyof PerViewOverrides): boolean => { const vs = viewSettingsRef.current[profile.viewStyle]; return !!(vs && vs[key] !== undefined); }; const saveViewSetting = async (key: K, value: PerViewOverrides[K], perView: boolean) => { // Device-specific keys: save to cookie only, not DB if ((DEVICE_VIEW_SETTINGS_KEYS as readonly string[]).includes(key) && perView) { const dvs = { ...deviceViewSettingsRef.current }; dvs[profile.viewStyle] = { ...(dvs[profile.viewStyle] || {}), [key]: value }; deviceViewSettingsRef.current = dvs; setDeviceViewSettings(dvs); writeDeviceViewCookie(dvs); return; } const updated = { ...viewSettingsRef.current }; if (perView) { updated[profile.viewStyle] = { ...(updated[profile.viewStyle] || {}), [key]: value }; } else { // Remove per-view overrides for this key from ALL views and set globally for (const v of Object.keys(updated)) { if (updated[v] && updated[v][key] !== undefined) { const { [key]: _, ...rest } = updated[v] as any; updated[v] = rest; } } } viewSettingsRef.current = updated; setViewSettings(updated); // Save to DB try { await fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ viewSettings: updated }), }); } catch (e) { console.error("Failed to save view settings:", e); } }; const togglePerView = async (key: keyof PerViewOverrides, globalVal: any) => { if (isPerView(key)) { // Remove per-view override (revert to global) const updated = { ...viewSettingsRef.current }; if (updated[profile.viewStyle]) { const { [key]: _, ...rest } = updated[profile.viewStyle] as any; updated[profile.viewStyle] = rest; } viewSettingsRef.current = updated; setViewSettings(updated); try { await fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ viewSettings: updated }), }); } catch (e) { console.error("Failed to save view settings:", e); } } else { // Set per-view override to current global value saveViewSetting(key, globalVal, true); } }; // State declarations needed before effective per-view values const [showSomeday, setShowSomeday] = useState(true); const [showAllDay, setShowAllDay] = useState(true); // Effective per-view values (override if set for current view, else global) const effectiveHourLabelFormat = getEffective("hourLabelFormat", profile.hourLabelFormat ?? "short"); const effectiveShowSubHourSlots = getEffective("showSubHourSlots", profile.showSubHourSlots ?? true); const effectiveWeatherEnabled = getEffective("weatherEnabled", profile.weatherEnabled); const effectiveWeatherDisplay = (getEffective("weatherDisplay", WEATHER_DISPLAY_DEFAULTS) || WEATHER_DISPLAY_DEFAULTS) as WeatherDisplayKey[]; const effectiveShowTaskCheckboxes = getEffective("showTaskCheckboxes", profile.showTaskCheckboxes); const effectiveShowProjectIcons = getEffective("showProjectIcons", profile.showProjectIcons); const effectiveShowPriorityIcons = getEffective("showPriorityIcons", profile.showPriorityIcons !== false) as boolean; const effectivePriorityStyle = (profile.priorityStyle || "eisenhower") as string; const effectiveShowSomeday = getEffective("showSomeday", profile.showSomeday ?? true); const effectiveShowAllDay = getEffective("showAllDayEvents", profile.showAllDayEvents ?? true); const effectiveAllDayPosition = getEffective("allDayPosition", profile.allDayPosition ?? "above") || "above"; const effectiveShowCompletedTasks = getEffective("showCompletedTasks", profile.showCompletedTasks !== false); const effectiveCellDuration = getEffective("cellDuration", cellDuration) as CellDuration; const effectiveStartHour = getEffective("startHour", profile.startHour ?? 8) ?? 8; const effectiveEndHour = getEffective("endHour", profile.endHour ?? 18) ?? 18; const defaultKanbanStages: KanbanStage[] = [ { id: "backlog", name: "Backlog", color: "#94a3b8" }, { id: "todo", name: "To Do", color: "#3b82f6" }, { id: "in-progress", name: "In Progress", color: "#f59e0b" }, { id: "review", name: "Review", color: "#8b5cf6" }, { id: "done", name: "Done", color: "#22c55e" }, ]; const [kanbanStages, setKanbanStages] = useState(defaultKanbanStages); const saveKanbanStages = async (stages: KanbanStage[]) => { setKanbanStages(stages); try { await fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kanbanStages: JSON.stringify(stages) }), }); } catch (e) { console.error("Failed to save kanban stages:", e); } }; // Kanban filters const [kanbanFilterProject, setKanbanFilterProject] = useState(""); const [kanbanFilterList, setKanbanFilterList] = useState(""); const [kanbanFilterWeek, setKanbanFilterWeek] = useState(""); const [kanbanSearch, setKanbanSearch] = useState(""); const [kanbanDeleteStageId, setKanbanDeleteStageId] = useState(null); const [kanbanAddingStageId, setKanbanAddingStageId] = useState(null); const [kanbanNewTaskTitle, setKanbanNewTaskTitle] = useState(""); const [kanbanDetailTask, setKanbanDetailTask] = useState(null); const [kanbanExpandedCards, setKanbanExpandedCards] = useState>(new Set()); 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 [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); const [weekdayFormat, setWeekdayFormat] = useState<"long" | "short" | "narrow" | "custom">("long"); const [weekdayCase, setWeekdayCase] = useState<"normal" | "capitalize" | "uppercase">("capitalize"); const [customWeekdayNames, setCustomWeekdayNames] = useState(""); // New UI State const [isSearchOpen, setIsSearchOpen] = useState(false); const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false); const datePickerBtnRef = useRef(null); const [showQuickSettings, setShowQuickSettings] = useState(false); const [leftRailExpanded, setLeftRailExpanded] = useState(false); const [flyoutSection, setFlyoutSection] = useState(null); const [showRailUserMenu, setShowRailUserMenu] = useState(false); const [flyoutY, setFlyoutY] = useState(0); const flyoutTimerRef = useRef(null); const [showProjectsSidebar, setShowProjectsSidebar] = useState(false); const [activeProjectFilter, setActiveProjectFilter] = useState(null); const [showIntroHints, setShowIntroHints] = useState(true); const [focusTimerDuration, setFocusTimerDuration] = useState(25); const [fontSize, setFontSize] = useState<"S" | "M" | "L">(() => { const c = getCookie("setting_fontSize"); return (c as "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; initialEndTime?: string; }>({ isOpen: false }); // Slot drag-to-create calendar event state (mouse + touch) const slotDragJustEndedRef = useRef(false); const slotDragRef = useRef<{ active: boolean; date: Date; startSlot: string; currentSlot: string; startY: number; } | null>(null); const [slotDragSelection, setSlotDragSelection] = useState<{ dateStr: string; startSlot: string; endSlot: string; } | null>(null); // Touch long-press state for mobile drag-to-create const touchLongPressRef = useRef<{ timerId: ReturnType; startX: number; startY: number; date: Date; slot: string; activated: boolean; } | null>(null); // Calendar event resize/drag state const [eventDragState, setEventDragState] = useState<{ eventId: string; mode: 'move' | 'resize-top' | 'resize-bottom'; startY: number; startX: number; originalStartTime: string; originalEndTime: string; currentStartTime: string; currentEndTime: string; calendarId?: string; source?: string; hasMoved?: boolean; } | null>(null); // Pending recurring event edit after drag/resize — asks "this" or "all" const [pendingRecurringDrag, setPendingRecurringDrag] = useState<{ eventId: string; calendarId: string; recurringEventId?: string; startTime: string; endTime: string; originalStartTime: string; originalEndTime: string; } | null>(null); const [recurringDragEditMode, setRecurringDragEditMode] = useState<'this' | 'future' | 'all'>('this'); // 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 and orientation useEffect(() => { const check = () => { setIsMobile(window.innerWidth <= 768); setIsPortrait(window.innerHeight > window.innerWidth); setIsCompactHeight(window.innerHeight <= 720); }; check(); window.addEventListener("resize", check); return () => window.removeEventListener("resize", check); }, []); const profileLoadedRef = useRef(false); const autoSaveTimerRef = useRef(null); const dragJustEndedRef = useRef(false); const fetchProfile = async () => { try { const res = await fetch("/api/user/profile"); if (res.ok) { const data = await res.json(); if (data && data.user) { const viewPrefs = (data.user.viewSettings || {}) as any; const profileData = { ...data.user, menuPosition: viewPrefs.menuPosition ?? data.user.menuPosition ?? "left", showHeaderControls: viewPrefs.showHeaderControls ?? data.user.showHeaderControls ?? true, }; setProfile(profileData); // Sync individual states to profile data if (profileData.viewStyle) setViewStyle(profileData.viewStyle); if (profileData.viewDays) { savedViewDaysRef.current = profileData.viewDays; const w = window.innerWidth, h = window.innerHeight; if (w <= 768) setViewDays(h > w ? 1 : 3); else if (w <= 1024) setViewDays(Math.min(profileData.viewDays, 5)); else setViewDays(profileData.viewDays); } if (profileData.showTimeGrid !== undefined) setShowTimeGrid(profileData.showTimeGrid); if (profileData.showSomeday !== undefined) setShowSomeday(profileData.showSomeday); if (profileData.showAllDayEvents !== undefined) setShowAllDay(profileData.showAllDayEvents); if (profileData.showSchedule !== undefined) setShowSchedule(profileData.showSchedule); if (profileData.cellDuration) setCellDuration(profileData.cellDuration); if (profileData.language) setLanguage(profileData.language); if (profileData.dateFormat) setDateFormat(profileData.dateFormat); if (profileData.timeFormat) setTimeFormat(profileData.timeFormat); if (profileData.startHour !== undefined) setStartHour(profileData.startHour); if (profileData.endHour !== undefined) setEndHour(profileData.endHour); if (profileData.fontSize) setFontSize(profileData.fontSize); if (profileData.showNextTask !== undefined) setShowNextTask(profileData.showNextTask); if (profileData.protectEventTimes !== undefined) setProtectEventTimes(profileData.protectEventTimes); if (profileData.headlineFont) setHeadlineFont(profileData.headlineFont); if (profileData.headlineFontSize) setHeadlineFontSize(profileData.headlineFontSize); if (profileData.headlineFontWeight) setHeadlineFontWeight(profileData.headlineFontWeight); if (profileData.dateFontFamily) setDateFontFamily(profileData.dateFontFamily); if (profileData.dateFontSize) setDateFontSize(profileData.dateFontSize); if (profileData.dateFontWeight) setDateFontWeight(profileData.dateFontWeight); if (profileData.timeTaskFontFamily) setTimeTaskFontFamily(profileData.timeTaskFontFamily); if (profileData.timeTaskFontSize) setTimeTaskFontSize(profileData.timeTaskFontSize); if (profileData.timeTaskFontWeight) setTimeTaskFontWeight(profileData.timeTaskFontWeight); if (profileData.bodyFont) setBodyFont(profileData.bodyFont); if (profileData.taskFontFamily) setTaskFontFamily(profileData.taskFontFamily); if (profileData.taskFontSize) setTaskFontSize(profileData.taskFontSize); if (profileData.taskFontWeight) setTaskFontWeight(profileData.taskFontWeight); if (profileData.fontWeight) setFontWeight(profileData.fontWeight); if (profileData.weekendColorSat) setWeekendColorSat(profileData.weekendColorSat); if (profileData.weekendColorSun) setWeekendColorSun(profileData.weekendColorSun); if (profileData.hourLabelFormat) setHourLabelFormat(profileData.hourLabelFormat); if (profileData.showSubHourSlots !== undefined) setShowSubHourSlots(profileData.showSubHourSlots); if (profileData.allDayPosition) setAllDayPosition(profileData.allDayPosition); if (profileData.viewSettings) { setViewSettings(profileData.viewSettings); // Load customTabs from DB (cross-device, survives reconnects) const dbTabs = (profileData.viewSettings as any).somedayCustomTabs; if (Array.isArray(dbTabs) && dbTabs.length > 0) { setCustomTabs(prev => { const merged = new Set([...dbTabs, ...prev]); return Array.from(merged); }); } } // Re-apply per-device cookie overrides — these always win over DB values const _cFS = getCookie("setting_fontSize"); if (_cFS) setFontSize(_cFS as "S" | "M" | "L"); const _cSH = getCookie("setting_showSubHourSlots"); if (_cSH !== null) setShowSubHourSlots(_cSH === "true"); const _cCD = getCookie("setting_cellDuration"); if (_cCD) setCellDuration(Number(_cCD) as CellDuration); const _cStart = getCookie("setting_startHour"); if (_cStart) setStartHour(Number(_cStart)); const _cEnd = getCookie("setting_endHour"); if (_cEnd) setEndHour(Number(_cEnd)); const _cVS = getCookie("setting_viewStyle"); if (_cVS) { setViewStyle(_cVS as ViewStyle); setProfile((p: any) => ({ ...p, viewStyle: _cVS })); } const _cTG = getCookie("setting_showTimeGrid"); if (_cTG !== null) { const v = _cTG === "true"; setShowTimeGrid(v); setProfile((p: any) => ({ ...p, showTimeGrid: v })); } const _cSDO = getCookie("setting_startDayOffset"); if (_cSDO !== null) setProfile((p: any) => ({ ...p, startDayOffset: Number(_cSDO) })); // Show onboarding wizard for new users if (profileData.hasCompletedOnboarding === false) { setShowOnboarding(true); } } } } catch (err) { console.error("Failed to fetch profile:", err); } finally { profileLoadedRef.current = true; setIsLoading(false); } }; useEffect(() => { fetchProfile(); }, []); useEffect(() => { if (!profileLoadedRef.current) return; if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = setTimeout(async () => { try { await fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(profile), }); console.log("[SETTINGS] Auto-saved profile"); } catch (err) { console.error("[SETTINGS] Auto-save failed:", err); } }, 800); return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); }; }, [profile]); // 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(profile.weekStartDay ?? 1)); // 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)); }, [profile.weekStartDay, mounted]); // Translation helper const t = translations[profile.language] || translations["en"]; // Refs for scroll const dayColumnsRef = useRef([]); const isScrollSyncing = useRef(false); const isInitialScrollDone = useRef(false); const intendedScrollTop = useRef(null); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(65); const dayHeaderRef = useRef(null); const somedayGridRef = useRef(null); useEffect(() => { const updateHeight = () => { if (dayHeaderRef.current) { const height = dayHeaderRef.current.offsetHeight; // Allow some tolerance to avoid infinite loops across various browsers if (height > 0 && Math.abs(height - measuredHeaderHeight) > 1) { setMeasuredHeaderHeight(height); } } }; // Initial measurement updateHeight(); // Measurement after a short delay for layout stabilization const timer = setTimeout(updateHeight, 800); // Also track window resize window.addEventListener('resize', updateHeight); // ResizeObserver for more robust tracking of layout shifts let resizeObserver: ResizeObserver | null = null; if (typeof window !== 'undefined' && 'ResizeObserver' in window && dayHeaderRef.current) { resizeObserver = new ResizeObserver(updateHeight); resizeObserver.observe(dayHeaderRef.current); } return () => { clearTimeout(timer); window.removeEventListener('resize', updateHeight); if (resizeObserver) resizeObserver.disconnect(); }; }, [dayHeaderRef.current, profile.cellDuration, viewDays, isMobile, profile.mobileDateLayout, profile.dateLayout, profile.dateAlignment, profile.dayHeaderGap, profile.headlineFontSize, profile.headlineFontWeight, profile.dateFontSize, profile.dateVerticalAlign, profile.headerDisplay, profile.weekdayFormat, profile.viewStyle]); const somedaySectionRef = useRef(null); // Unified scroll sync handlers // handleTimeColumnScroll no longer needed — single scroll container via time-grid-wrapper const handleGridScroll = (_e: React.UIEvent) => { // Single scroll container — no sync needed // Mobile sticky day is handled by the native scroll listener in the useEffect }; const jumpToHour = (hour: number) => { const slotsPerHour = 60 / effectiveCellDuration; const slotHeight = getSlotHeight(effectiveCellDuration); const scrollOffset = hour * slotsPerHour * slotHeight; console.log(`[SCROLL] Jumping to hour ${hour} (offset ${scrollOffset}px)`); // Clear old stabilization isInitialScrollDone.current = true; isScrollSyncing.current = true; intendedScrollTop.current = scrollOffset; const perform = () => { if (gridRef.current) gridRef.current.scrollTop = scrollOffset; }; // Repeated enforcement perform(); requestAnimationFrame(perform); setTimeout(perform, 50); setTimeout(perform, 100); setTimeout(perform, 250); setTimeout(() => { isScrollSyncing.current = false; }, 500); }; // Slot and Header height based on cell duration // WMO weather code → emoji icon const getWeatherIcon = (code: number): string => { if (code === 0) return "☀️"; if (code <= 3) return "⛅"; if (code >= 45 && code <= 48) return "🌫️"; if (code >= 51 && code <= 55) return "🌦️"; if (code >= 56 && code <= 57) return "🌧️"; if (code >= 61 && code <= 65) return "🌧️"; if (code >= 66 && code <= 67) return "🌨️"; if (code >= 71 && code <= 77) return "❄️"; if (code >= 80 && code <= 82) return "🌧️"; if (code >= 85 && code <= 86) return "❄️"; if (code >= 95) return "⛈️"; return "☁️"; }; const getSlotHeight = (duration: number) => { switch (duration) { case 15: return 25; case 20: return 30; case 30: return 35; case 60: return 50; default: return 50; } }; const getHeaderHeight = (duration: number) => { if (measuredHeaderHeight > 0) return measuredHeaderHeight; 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 = profile.startHour ?? 8; const workingHoursEnd = profile.endHour ?? 18; // Fetch calendar events // Find connectionId for a given calendarId const getConnectionIdForCalendar = useCallback((calId?: string) => { if (!calId) return undefined; const conn = connections.find((c: any) => (c.calendars || []).some((cal: any) => cal.id === calId) ); return conn?.id; }, [connections]); const fetchCalendarEvents = useCallback(async (forceRefresh = false, connectionId?: string) => { 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, ...(connectionId ? { connectionId } : {}), }), }); if (response.ok) { const text = await response.text(); try { const data = JSON.parse(text); if (data.events) { setRawCalendarEvents(data.events); } // If stale connections were refreshing in background, re-fetch after they finish if (data.staleConnectionCount > 0 && !forceRefresh) { setTimeout(() => { 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(), }), }).then(r => r.json()).then(d => { if (d.events) setRawCalendarEvents(d.events); }).catch(() => {}); }, 5000); // 5s delay for background refresh to finish } } 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]); // Weather fetch const fetchWeather = useCallback(async () => { if (!effectiveWeatherEnabled) return; if (!profile.weatherLat || !profile.weatherLon) return; try { const start = new Date(currentWeekStart.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const end = new Date(currentWeekStart.getTime() + 8 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const res = await fetch(`/api/weather?start=${start}&end=${end}`); if (res.ok) { const data = await res.json(); if (data.hourly) setWeatherData(data.hourly); } } catch (e) { console.error("Weather fetch failed:", e); } }, [currentWeekStart, effectiveWeatherEnabled, profile.weatherLat, profile.weatherLon]); useEffect(() => { if (effectiveWeatherEnabled) fetchWeather(); }, [fetchWeather, effectiveWeatherEnabled]); // 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(); const isRecurring = !!(eventData.recurrence); if (data.event && !isRecurring) { // For non-recurring events: optimistic update before sync // For recurring events: skip — sync will fetch all expanded instances const ev = data.event; // Find calendar info from connections to fill in missing color/title const calInfo = connections.flatMap((c: any) => (c.calendars || []).map((cal: any) => ({ ...cal, provider: c.provider })) ).find((c: any) => c.id === (ev.calendarId || eventData.calendarId)); const frontendEvent: CalendarEvent = { id: ev.id, title: ev.title, startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '', endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '', source: ev.source || calInfo?.provider || 'google', calendarId: ev.calendarId || eventData.calendarId, calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '', calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6', }; setRawCalendarEvents(prev => { if (eventData.id) { return prev.map(e => e.id === eventData.id ? frontendEvent : e); } return [...prev, frontendEvent]; }); } else if (data.event && eventData.id) { // Recurring update: keep optimistic update for the edited instance only const ev = data.event; const calInfo = connections.flatMap((c: any) => (c.calendars || []).map((cal: any) => ({ ...cal, provider: c.provider })) ).find((c: any) => c.id === (ev.calendarId || eventData.calendarId)); const frontendEvent: CalendarEvent = { id: ev.id, title: ev.title, startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '', endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '', source: ev.source || calInfo?.provider || 'google', calendarId: ev.calendarId || eventData.calendarId, calendarTitle: ev.calendarTitle || calInfo?.summary || calInfo?.title || '', calendarColor: ev.backgroundColor || ev.calendarColor || calInfo?.backgroundColor || calInfo?.color || '#3b82f6', }; setRawCalendarEvents(prev => prev.map(e => e.id === eventData.id ? frontendEvent : e)); } // Force refresh only the affected provider const connId = getConnectionIdForCalendar(eventData.calendarId); await fetchCalendarEvents(true, connId); } 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, deleteMode?: string) => { try { const params = new URLSearchParams({ calendarId, eventId }); if (deleteMode) params.set('deleteMode', deleteMode); const res = await fetch( `/api/calendar/events?${params.toString()}`, { method: "DELETE", }, ); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to delete event"); } // Optimistically remove affected events if (deleteMode === 'this') { setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId)); } else if (deleteMode === 'past') { // Remove this and past instances of the same recurring series const targetEvent = calendarEvents.find(e => e.id === eventId); if (targetEvent) { const targetTime = new Date(targetEvent.startTime).getTime(); const seriesId = targetEvent.recurringEventId || eventId; setRawCalendarEvents(prev => prev.filter(e => { if (e.recurringEventId !== seriesId && e.id !== seriesId) return true; return new Date(e.startTime).getTime() > targetTime; })); } else { setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId)); } } else if (deleteMode === 'future') { // Remove this and future instances of the same recurring series const targetEvent = calendarEvents.find(e => e.id === eventId); if (targetEvent) { const targetTime = new Date(targetEvent.startTime).getTime(); const seriesId = targetEvent.recurringEventId || eventId; setRawCalendarEvents(prev => prev.filter(e => { if (e.recurringEventId !== seriesId && e.id !== seriesId) return true; return new Date(e.startTime).getTime() < targetTime; })); } else { setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId)); } } else { // 'all' — remove all instances of the series const targetEvent = calendarEvents.find(e => e.id === eventId); const seriesId = targetEvent?.recurringEventId || eventId; setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId && e.recurringEventId !== seriesId && e.id !== seriesId )); } // Force refresh only the affected provider const connId = getConnectionIdForCalendar(calendarId); await fetchCalendarEvents(true, connId); } 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 ? [profile.quoteSourceUrl] : []; // Strategy: try sources until one works for (const url of urls) { try { const res = await fetch(url); if (!res.ok) continue; const contentType = res.headers.get("content-type") || ""; if (!contentType.includes("application/json")) continue; // Ensure proper UTF-8 decoding for quotes with special characters const rawText = await res.text(); const data = JSON.parse(rawText); 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 in user-selected languages const quoteLangs = profile.quoteLanguages && profile.quoteLanguages.length > 0 ? profile.quoteLanguages : [profile.language || "en"]; const randomLang = quoteLangs[Math.floor(Math.random() * quoteLangs.length)]; const localQuote = getRandomLocalQuote(randomLang); if (localQuote) { setMotivationalQuote(`${localQuote.text} — ${localQuote.author}`); } else { setMotivationalQuote(randomLang === "de" ? "Bleib fokussiert und produktiv." : "Stay focused and productive."); } }, [profile.goalFallbackType, profile.quoteSourceUrl, profile.quoteSourceUrls, profile.language, profile.quoteLanguages]); // Fetch tasks on mount useEffect(() => { if (session) { fetchTasks(); fetchConnections(); fetchCalendarEvents(); fetchMotivationalQuote(); } }, [session]); // Removed fetchMotivationalQuote from deps to avoid re-runs // 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(); } }, []); // Pull-sync from external task providers. // // Strategy: trigger on user-visible moments (tab gains focus, page becomes // visible, app mounts) plus a slow safety-net interval. The pull uses the // Microsoft Graph delta endpoint where possible, so each call is a few hundred // bytes when nothing changed — cheap to run on every focus event. // // Throttle: at most one pull every 5 seconds to absorb rapid focus toggles. useEffect(() => { if (!session) return; let lastSyncAt = 0; const MIN_INTERVAL_MS = 5_000; const runPullSync = async (reason: string) => { const now = Date.now(); if (now - lastSyncAt < MIN_INTERVAL_MS) return; lastSyncAt = now; 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] (${reason}) Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`, ); fetchTasks(); } } } catch (e) { console.error("[SYNC] Task sync error:", e); } }; // Eager pull on mount runPullSync('mount'); // Pull when the tab becomes visible or the window regains focus — // this is when the user actually expects fresh data. const onVisible = () => { if (document.visibilityState === 'visible') runPullSync('visibility'); }; const onFocus = () => runPullSync('focus'); document.addEventListener('visibilitychange', onVisible); window.addEventListener('focus', onFocus); // Slow safety-net interval (covers the case of the tab being open & focused // for a long time while changes happen on another device). const interval = setInterval(() => runPullSync('interval'), 15 * 60 * 1000); return () => { document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onFocus); clearInterval(interval); }; }, [session]); // SSE real-time sync: listen for server-pushed task/list changes useEffect(() => { if (!session) return; let eventSource: EventSource | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; const connect = () => { eventSource = new EventSource("/api/events/stream"); eventSource.addEventListener("connected", () => { console.log("[SSE] Connected for real-time sync"); }); eventSource.addEventListener("tasks-changed", () => { console.log("[SSE] Tasks changed remotely, refetching..."); fetchTasks(); }); eventSource.addEventListener("list-changed", () => { console.log("[SSE] Lists changed remotely, refetching..."); fetchTasks(); }); eventSource.onerror = () => { console.log("[SSE] Connection lost, reconnecting in 5s..."); eventSource?.close(); reconnectTimeout = setTimeout(connect, 5000); }; }; connect(); return () => { eventSource?.close(); if (reconnectTimeout) clearTimeout(reconnectTimeout); }; }, [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(), }), }); if (res.ok) { const data = await res.json(); if (data.queued > 0) { // Stale connections are refreshing in background; re-read cache after delay setTimeout(() => fetchCalendarEvents(), 10000); } } } catch (e) { console.error("[SYNC] Calendar sync error:", e); } }, 15 * 60 * 1000, // 15 min — avoid iCloud rate limiting ); return () => clearInterval(interval); }, [session, fetchCalendarEvents]); // Sync when tab regains focus (catches external changes in other apps) // Throttled: at most once per 5 minutes to avoid iCloud rate limiting const lastFocusSyncRef = useRef(0); useEffect(() => { if (!session) return; const handleVisibility = () => { if (!document.hidden) { const now = Date.now(); if (now - lastFocusSyncRef.current < 5 * 60 * 1000) return; // throttle lastFocusSyncRef.current = now; fetchCalendarEvents(); fetchTasks(); } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, [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]); // Disable browser scroll restoration so Safari doesn't fight our initial scroll position useEffect(() => { if (typeof window !== 'undefined' && window.history.scrollRestoration) { window.history.scrollRestoration = 'manual'; } }, []); // Scroll to preferred start hour (initial load + when user changes startHour) useEffect(() => { if (!isLoading) { const slotsPerHour = 60 / cellDuration; const slotHeight = getSlotHeight(cellDuration); const scrollOffset = workingHoursStart * slotsPerHour * slotHeight; // Single scroll — no interval, no enforcement loop. // We set scrollRestoration='manual' so the browser won't override this. const delay = isInitialScrollDone.current ? 50 : 300; const timer = setTimeout(() => { if (gridRef.current) gridRef.current.scrollTop = scrollOffset; intendedScrollTop.current = scrollOffset; isInitialScrollDone.current = true; }, delay); return () => clearTimeout(timer); } }, [isLoading, workingHoursStart, cellDuration]); // Update current time every 30 seconds for the "Now" line and clock useEffect(() => { const interval = setInterval(() => { setCurrentTime(new Date()); }, 30000); return () => clearInterval(interval); }, []); // Intro hint: briefly reveal hidden header controls and bounce-scroll the main grid on first load useEffect(() => { const headerTimer = setTimeout(() => setShowIntroHints(false), 2200); const scrollTimer = setTimeout(() => { const grid = gridRef.current; if (!grid || grid.scrollHeight <= grid.clientHeight + 20) return; const baseTop = grid.scrollTop; const peek = Math.min(80, grid.scrollHeight - grid.clientHeight - baseTop); if (peek <= 10) return; const prevBehavior = grid.style.scrollBehavior; grid.style.scrollBehavior = "smooth"; grid.scrollTo({ top: baseTop + peek }); const back = setTimeout(() => { grid.scrollTo({ top: baseTop }); setTimeout(() => { grid.style.scrollBehavior = prevBehavior; }, 600); }, 700); return () => { clearTimeout(back); grid.style.scrollBehavior = prevBehavior; }; }, 900); return () => { clearTimeout(headerTimer); clearTimeout(scrollTimer); }; }, []); // Mobile: show a sticky day bar by reading scroll position on the actual grid scroll container useEffect(() => { if (!isMobile || !profile.showTimeGrid) return; const grid = gridRef.current; if (!grid) return; const updateStickyDay = () => { const scrollTop = grid.scrollTop; setMobileStickyDayVisible(scrollTop > 40); // Find which day column header is at the top of the scroll container const columns = grid.querySelectorAll('.weekly-day-column[data-date]'); let currentCol: Element | null = null; const gridTop = grid.getBoundingClientRect().top; columns.forEach(col => { const rect = col.getBoundingClientRect(); // Column whose top is at or above the grid's top edge if (rect.top <= gridTop + 60) { currentCol = col; } }); if (currentCol) { const dateStr = (currentCol as Element).getAttribute('data-date'); if (dateStr) { const d = new Date(dateStr + 'T00:00:00'); const dayNames = profile.language === 'de' ? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const label = `${dayNames[d.getDay()]} ${d.getDate()}.${d.getMonth() + 1}.`; setMobileStickyDay(label); } } }; grid.addEventListener('scroll', updateStickyDay, { passive: true }); updateStickyDay(); return () => grid.removeEventListener('scroll', updateStickyDay); }, [isMobile, profile.showTimeGrid, currentWeekStart, viewDays, profile.language]); // 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) => { if (VIEW_SETTINGS_PROFILE_KEYS.includes(key)) { const updated = { ...(viewSettingsRef.current as any), [key]: value }; viewSettingsRef.current = updated; setViewSettings(updated as any); setProfile((p: any) => ({ ...p, [key]: value })); try { await fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ viewSettings: updated }), }); } catch (err) { console.error(`Failed to save setting ${key}:`, err); } return; } // 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)); setProfile((p: any) => ({ ...p, [key]: value })); // still update profile so rendering reacts immediately return; // Don't write to DB — that would overwrite other devices } // Also persist to device cookie so this device remembers its own preference on reload if (DEVICE_ALSO_COOKIE_KEYS.includes(key)) { setCookie(`setting_${key}`, String(value)); } // Keep profile object in sync so the debounced auto-save never sends stale values setProfile((p: any) => ({ ...p, [key]: value })); 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); if (newSettings.weekdayFormat) setWeekdayFormat(newSettings.weekdayFormat); if (newSettings.weekdayCase) setWeekdayCase(newSettings.weekdayCase); if (newSettings.customWeekdayNames !== undefined) setCustomWeekdayNames(newSettings.customWeekdayNames); 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.kanbanStages) { try { const parsed = JSON.parse(data.user.kanbanStages); if (Array.isArray(parsed) && parsed.length > 0) setKanbanStages(parsed); } catch { /* use defaults */ } } if (data.user.viewDays !== undefined) { savedViewDaysRef.current = data.user.viewDays; const w = window.innerWidth, h = window.innerHeight; if (w <= 768) setViewDays(h > w ? 1 : 3); else if (w <= 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 w = window.innerWidth, h = window.innerHeight; if (w <= 768) setViewDays(h > w ? 1 : 3); else if (w <= 1024) setViewDays(Math.min(v, 5)); else setViewDays(v); } if (data.user.weekdayFormat) { setProfile((prev: any) => ({ ...prev, weekdayFormat: data.user.weekdayFormat })); } if (data.user.customWeekdayNames) { setProfile((prev: any) => ({ ...prev, customWeekdayNames: data.user.customWeekdayNames })); } 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"); // Cookie wins over DB for per-device settings const cookieFontSize2 = getCookie("setting_fontSize"); if (cookieFontSize2) setFontSize(cookieFontSize2 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.weekdayFormat) setWeekdayFormat(data.user.weekdayFormat as any); if (data.user.weekdayCase) setWeekdayCase(data.user.weekdayCase as any); if (data.user.customWeekdayNames) setCustomWeekdayNames(data.user.customWeekdayNames); if (data.user.hourLabelFormat) setHourLabelFormat(data.user.hourLabelFormat as "short" | "full"); if (data.user.showSubHourSlots !== undefined) setShowSubHourSlots(data.user.showSubHourSlots); // Cookie wins over DB for showSubHourSlots (per-device) const _cSH2 = getCookie("setting_showSubHourSlots"); if (_cSH2 !== null) setShowSubHourSlots(_cSH2 === "true"); if (data.user.allDayPosition) setAllDayPosition(data.user.allDayPosition as "above" | "below"); if (data.user.viewSettings) setViewSettings(data.user.viewSettings); 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: any) => ({ ...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) — only for multi-day views // On single-day view (phones), always start on today const effectiveViewDays = (() => { const width = window.innerWidth; if (width <= 480) return 1; if (width <= 768) return 3; if (width <= 1024) return Math.min(data.user.viewDays || 7, 5); return data.user.viewDays || 7; })(); if (data.user.startDayOffset && data.user.startDayOffset !== 0 && effectiveViewDays > 1) { 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); // Cookie overrides for settings that are also device-local const _cVS2 = getCookie("setting_viewStyle"); if (_cVS2) { setViewStyle(_cVS2 as ViewStyle); setProfile((p: any) => ({ ...p, viewStyle: _cVS2, showTimeGrid: _cVS2 === "simple" || _cVS2 === "calendar" })); } const _cTG2 = getCookie("setting_showTimeGrid"); if (_cTG2 !== null) setShowTimeGrid(_cTG2 === "true"); const _cSDO2 = getCookie("setting_startDayOffset"); if (_cSDO2 !== null) { const offset = Number(_cSDO2); setProfile((p: any) => ({ ...p, startDayOffset: offset })); if (offset !== 0) { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + offset); setCurrentWeekStart(d); } } } } } 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, tab: l.tab || null, color: l.color || null, icon: l.icon || null, tasks: l.tasks || [], 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(); const updatedProjects = data.projects || []; setProjects(updatedProjects); // Update project references on tasks so color changes take effect immediately const projectMap = new Map( updatedProjects.map((p: any) => [p.id, p]) ); setTasks(prev => prev.map(t => { if (t.projectId && projectMap.has(t.projectId)) { return { ...t, project: projectMap.get(t.projectId) || null }; } return t; })); setSomedayLists(prev => prev.map(list => ({ ...list, tasks: list.tasks.map(t => { if (t.projectId && projectMap.has(t.projectId)) { return { ...t, project: projectMap.get(t.projectId) || null }; } return t; }), }))); } } 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, tab: l.tab || null, color: l.color || null, icon: l.icon || null, 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); } } 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; // Hide completed tasks if setting is off if (!effectiveShowCompletedTasks && task.completed) return false; if (activeProjectFilter && task.projectId !== activeProjectFilter) 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, effectiveShowCompletedTasks, activeProjectFilter], ); // 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; // Hide completed tasks if setting is off if (!effectiveShowCompletedTasks && task.completed) return false; if (activeProjectFilter && task.projectId !== activeProjectFilter) 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 + effectiveCellDuration; return taskStart >= slotStart && taskStart < slotEnd; }); }, [tasks, effectiveCellDuration, effectiveShowCompletedTasks, activeProjectFilter], ); // 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; // During drag, use the drag state's current start time for slot assignment const isDraggedEvent = eventDragState?.eventId === event.id && eventDragState?.hasMoved && eventDragState?.mode === 'move'; const startTime = isDraggedEvent ? eventDragState!.currentStartTime : event.startTime; const eventDate = new Date(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 + effectiveCellDuration; const eventStart = eventHour * 60 + eventMinute; return eventStart >= slotStart && eventStart < slotEnd; }); }, [calendarEvents, effectiveCellDuration, eventDragState], ); // Precompute overlap layout: { [eventId]: { column, totalColumns } } const eventOverlapLayout = useMemo(() => { const layout: Record = {}; // Group events by day const dayMap = new Map(); for (const event of calendarEvents) { if (isAllDayEvent(event)) continue; const d = new Date(event.startTime); const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; if (!dayMap.has(key)) dayMap.set(key, []); dayMap.get(key)!.push(event); } for (const events of dayMap.values()) { // Sort by start time, then by duration (longer first) events.sort((a, b) => { const diff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime(); if (diff !== 0) return diff; return (new Date(b.endTime).getTime() - new Date(b.startTime).getTime()) - (new Date(a.endTime).getTime() - new Date(a.startTime).getTime()); }); // Build overlap groups using a greedy column assignment const columns: { end: number; eventId: string }[][] = []; for (const event of events) { const start = new Date(event.startTime).getTime(); const end = new Date(event.endTime).getTime(); // Find first column where this event doesn't overlap let placed = false; for (let col = 0; col < columns.length; col++) { const lastInCol = columns[col][columns[col].length - 1]; if (lastInCol.end <= start) { columns[col].push({ end, eventId: event.id }); placed = true; break; } } if (!placed) { columns.push([{ end, eventId: event.id }]); } } // Now find the max columns each event actually shares with // For each event, find all events that overlap it and determine the group width for (const event of events) { const start = new Date(event.startTime).getTime(); const end = new Date(event.endTime).getTime(); // Count how many columns have events overlapping this time range let overlappingCols = 0; for (const col of columns) { for (const item of col) { const itemStart = calendarEvents.find(e => e.id === item.eventId); if (itemStart) { const iStart = new Date(itemStart.startTime).getTime(); const iEnd = new Date(itemStart.endTime).getTime(); if (iStart < end && iEnd > start) { overlappingCols++; break; } } } } // Find which column this event is in let eventCol = 0; for (let col = 0; col < columns.length; col++) { if (columns[col].some(item => item.eventId === event.id)) { eventCol = col; break; } } layout[event.id] = { column: eventCol, totalColumns: Math.max(overlappingCols, 1) }; } } return layout; }, [calendarEvents]); // 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); // Guard against NaN or negative durations (missing/invalid end time) if (!isFinite(durationMinutes) || durationMinutes <= 0) { return getSlotHeight(effectiveCellDuration); // Default to one slot height } // Calculate height based on duration and slot height const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration; return Math.max( durationMinutes * pixelsPerMinute, getSlotHeight(effectiveCellDuration), ); }; // Snap minutes to nearest 5-minute increment const snapMinutes = (mins: number) => Math.round(mins / 5) * 5; // Handle calendar event drag/resize const handleEventDragStart = (e: React.MouseEvent, event: CalendarEvent, mode: 'move' | 'resize-top' | 'resize-bottom') => { if (!event.editable) return; e.preventDefault(); e.stopPropagation(); setEventDragState({ eventId: event.id, mode, startY: e.clientY, startX: e.clientX, originalStartTime: event.startTime, originalEndTime: event.endTime, currentStartTime: event.startTime, currentEndTime: event.endTime, calendarId: event.calendarId, source: event.source, }); }; useEffect(() => { if (!eventDragState) return; const pixelsPerMinute = getSlotHeight(effectiveCellDuration) / effectiveCellDuration; const handleMouseMove = (e: MouseEvent) => { const deltaY = e.clientY - eventDragState.startY; const deltaX = e.clientX - eventDragState.startX; // Require minimum 3px movement to start actual drag if (!eventDragState.hasMoved && Math.abs(deltaY) < 3 && Math.abs(deltaX) < 3) return; if (!eventDragState.hasMoved) { setEventDragState(prev => prev ? { ...prev, hasMoved: true } : null); } const deltaMinutes = snapMinutes(deltaY / pixelsPerMinute); const origStart = new Date(eventDragState.originalStartTime); const origEnd = new Date(eventDragState.originalEndTime); if (eventDragState.mode === 'move') { // Detect day column under cursor for cross-day drag let dayOffset = 0; const dayColumns = document.querySelectorAll('.weekly-day-column'); if (dayColumns.length > 0) { const origDate = `${origStart.getFullYear()}-${String(origStart.getMonth() + 1).padStart(2, '0')}-${String(origStart.getDate()).padStart(2, '0')}`; let origColIndex = -1; let hoverColIndex = -1; dayColumns.forEach((col, i) => { const rect = col.getBoundingClientRect(); const colDate = col.getAttribute('data-date'); if (colDate === origDate) origColIndex = i; if (e.clientX >= rect.left && e.clientX <= rect.right) hoverColIndex = i; }); if (origColIndex >= 0 && hoverColIndex >= 0) { dayOffset = hoverColIndex - origColIndex; } } const newStart = new Date(origStart.getTime() + deltaMinutes * 60000 + dayOffset * 86400000); const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000 + dayOffset * 86400000); setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString(), currentEndTime: newEnd.toISOString() } : null); } else if (eventDragState.mode === 'resize-bottom') { const newEnd = new Date(origEnd.getTime() + deltaMinutes * 60000); if (newEnd.getTime() > origStart.getTime() + 5 * 60000) { setEventDragState(prev => prev ? { ...prev, currentEndTime: newEnd.toISOString() } : null); } } else if (eventDragState.mode === 'resize-top') { const newStart = new Date(origStart.getTime() + deltaMinutes * 60000); if (newStart.getTime() < origEnd.getTime() - 5 * 60000) { setEventDragState(prev => prev ? { ...prev, currentStartTime: newStart.toISOString() } : null); } } }; const handleMouseUp = async () => { if (!eventDragState) return; const startChanged = eventDragState.currentStartTime !== eventDragState.originalStartTime; const endChanged = eventDragState.currentEndTime !== eventDragState.originalEndTime; // Set drag-ended ref immediately (before async) to prevent click from opening popup if (eventDragState.hasMoved) { dragJustEndedRef.current = true; setTimeout(() => { dragJustEndedRef.current = false; }, 300); } if (eventDragState.hasMoved && (startChanged || endChanged)) { // Optimistically update the UI setRawCalendarEvents(prev => prev.map(ev => ev.id === eventDragState.eventId ? { ...ev, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime } : ev )); // Check if this is a recurring event — if so, ask before saving const draggedEvent = calendarEvents.find(e => e.id === eventDragState.eventId); if (draggedEvent?.isRecurring) { setRecurringDragEditMode('this'); setPendingRecurringDrag({ eventId: eventDragState.eventId, calendarId: eventDragState.calendarId || '', recurringEventId: draggedEvent.recurringEventId, startTime: eventDragState.currentStartTime, endTime: eventDragState.currentEndTime, originalStartTime: eventDragState.originalStartTime, originalEndTime: eventDragState.originalEndTime, }); } else { // Non-recurring: save immediately try { const res = await fetch("/api/calendar/events", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ calendarId: eventDragState.calendarId, eventId: eventDragState.eventId, start: { dateTime: eventDragState.currentStartTime }, end: { dateTime: eventDragState.currentEndTime }, }), }); if (!res.ok) { setRawCalendarEvents(prev => prev.map(ev => ev.id === eventDragState.eventId ? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime } : ev )); } } catch { setRawCalendarEvents(prev => prev.map(ev => ev.id === eventDragState.eventId ? { ...ev, startTime: eventDragState.originalStartTime, endTime: eventDragState.originalEndTime } : ev )); } } } setEventDragState(null); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [eventDragState, effectiveCellDuration, calendarEvents]); // Slot drag-to-create: mousemove + mouseup on document (always active, ref-gated) const effectiveCellDurationRef = useRef(effectiveCellDuration); effectiveCellDurationRef.current = effectiveCellDuration; useEffect(() => { const handleMouseMove = (e: MouseEvent) => { const drag = slotDragRef.current; if (!drag) return; // Require minimum movement to distinguish from click if (!drag.active && Math.abs(e.clientY - drag.startY) < 5) return; if (!drag.active) { drag.active = true; document.body.style.userSelect = 'none'; } // Find which slot the mouse is over const el = document.elementFromPoint(e.clientX, e.clientY); const slotEl = el?.closest('[data-slot]') as HTMLElement | null; if (slotEl) { const slot = slotEl.getAttribute('data-slot'); if (slot) { drag.currentSlot = slot; const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`; // Determine visual range (start <= end) const startSlot = drag.startSlot <= slot ? drag.startSlot : slot; const endSlot = drag.startSlot <= slot ? slot : drag.startSlot; setSlotDragSelection({ dateStr, startSlot, endSlot }); } } }; const handleMouseUp = () => { const drag = slotDragRef.current; slotDragRef.current = null; if (!drag || !drag.active) { setSlotDragSelection(null); document.body.style.userSelect = ''; return; } setSlotDragSelection(null); document.body.style.userSelect = ''; // Suppress the click event that follows mouseup slotDragJustEndedRef.current = true; setTimeout(() => { slotDragJustEndedRef.current = false; }, 300); // Calculate start and end times const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot; const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot; // End time = endSlot + cellDuration const [eh, em] = endSlot.split(':').map(Number); const endMinutes = eh * 60 + em + effectiveCellDurationRef.current; const endH = Math.floor(endMinutes / 60); const endM = endMinutes % 60; const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`; setCalendarEventModal({ isOpen: true, initialDate: drag.date, initialStartTime: startSlot, initialEndTime: endTime, }); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // Touch long-press drag-to-create: listeners are attached ONCE at mount (not per-slot-touch). // This avoids any DOM manipulation inside onTouchStart on slot divs, which can // confuse iOS Safari's scroll-intent detection and block vertical scrolling. // The slot onTouchStart only updates touchLongPressRef (pure ref, no DOM side effects). const longPressDragActiveRef = useRef(false); // true when non-passive drag listener is attached const upgradeToDragListeners = useCallback(() => { if (longPressDragActiveRef.current) return; longPressDragActiveRef.current = true; // Non-passive listener added only when drag is actually active (500ms hold) const handleDragMove = (e: TouchEvent) => { if (!longPressDragActiveRef.current) return; e.preventDefault(); const touch = e.touches[0]; const el = document.elementFromPoint(touch.clientX, touch.clientY); const slotEl = el?.closest('[data-slot]') as HTMLElement | null; if (slotEl) { const slot = slotEl.getAttribute('data-slot'); if (slot) { const drag = slotDragRef.current; if (drag) { drag.currentSlot = slot; const dateStr = `${drag.date.getFullYear()}-${String(drag.date.getMonth() + 1).padStart(2, '0')}-${String(drag.date.getDate()).padStart(2, '0')}`; const startSlot = drag.startSlot <= slot ? drag.startSlot : slot; const endSlot = drag.startSlot <= slot ? slot : drag.startSlot; setSlotDragSelection({ dateStr, startSlot, endSlot }); } } } }; document.addEventListener('touchmove', handleDragMove, { passive: false }); // Store for cleanup (upgradeToDragListeners as any)._handler = handleDragMove; }, []); const cancelDragListeners = useCallback(() => { if (!longPressDragActiveRef.current) return; longPressDragActiveRef.current = false; const handler = (upgradeToDragListeners as any)._handler; if (handler) { document.removeEventListener('touchmove', handler); (upgradeToDragListeners as any)._handler = null; } }, [upgradeToDragListeners]); // All touch listeners for the time-grid are attached ONCE at mount as native listeners. // CRITICAL: do NOT use React onTouchStart on slot divs — React registers those as // non-passive at the app root, causing iOS Safari to wait for JS before committing // a scroll gesture, which blocks vertical scrolling on day columns entirely. useEffect(() => { // TOUCHSTART — passive, on document. Reads data attributes from slot element. // Slot divs must have data-slot, data-slot-blocked, and their column must have data-date. const handleTouchStart = (e: TouchEvent) => { const target = e.target as Element; const slotEl = target.closest('[data-slot]') as HTMLElement | null; if (!slotEl) return; if (slotEl.dataset.slotBlocked) return; if (target.closest('.calendar-event-block, .grid-task-block, .task-input-slot')) return; const touch = e.touches[0]; const colEl = slotEl.closest('[data-date]') as HTMLElement | null; if (!colEl?.dataset.date) return; const slotDate = new Date(colEl.dataset.date); const slotName = slotEl.dataset.slot!; if (touchLongPressRef.current) { clearTimeout(touchLongPressRef.current.timerId); } const timerId = setTimeout(() => { if (navigator.vibrate) navigator.vibrate(50); slotDragRef.current = { active: true, date: slotDate, startSlot: slotName, currentSlot: slotName, startY: touch.clientY, }; if (touchLongPressRef.current) { touchLongPressRef.current.activated = true; } upgradeToDragListeners(); const ds = `${slotDate.getFullYear()}-${String(slotDate.getMonth() + 1).padStart(2, '0')}-${String(slotDate.getDate()).padStart(2, '0')}`; setSlotDragSelection({ dateStr: ds, startSlot: slotName, endSlot: slotName }); }, 500); touchLongPressRef.current = { timerId, startX: touch.clientX, startY: touch.clientY, date: slotDate, slot: slotName, activated: false, }; }; // TOUCHMOVE — passive, cancels long-press if finger moves (user is scrolling) const handlePassiveTouchMove = (e: TouchEvent) => { const lp = touchLongPressRef.current; if (!lp || lp.activated) return; const touch = e.touches[0]; if (Math.abs(touch.clientX - lp.startX) > 10 || Math.abs(touch.clientY - lp.startY) > 10) { clearTimeout(lp.timerId); touchLongPressRef.current = null; } }; // TOUCHEND — passive, completes drag or cancels const handleTouchEnd = () => { const lp = touchLongPressRef.current; if (lp) { clearTimeout(lp.timerId); touchLongPressRef.current = null; } cancelDragListeners(); const drag = slotDragRef.current; slotDragRef.current = null; if (!drag || !drag.active) { setSlotDragSelection(null); return; } setSlotDragSelection(null); slotDragJustEndedRef.current = true; setTimeout(() => { slotDragJustEndedRef.current = false; }, 300); const startSlot = drag.startSlot <= drag.currentSlot ? drag.startSlot : drag.currentSlot; const endSlot = drag.startSlot <= drag.currentSlot ? drag.currentSlot : drag.startSlot; const [eh, em] = endSlot.split(':').map(Number); const endMinutes = eh * 60 + em + effectiveCellDurationRef.current; const endH = Math.floor(endMinutes / 60); const endM = endMinutes % 60; const endTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`; setCalendarEventModal({ isOpen: true, initialDate: drag.date, initialStartTime: startSlot, initialEndTime: endTime, }); }; document.addEventListener('touchstart', handleTouchStart, { passive: true }); document.addEventListener('touchmove', handlePassiveTouchMove, { passive: true }); document.addEventListener('touchend', handleTouchEnd, { passive: true }); document.addEventListener('touchcancel', handleTouchEnd, { passive: true }); return () => { document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchmove', handlePassiveTouchMove); document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('touchcancel', handleTouchEnd); }; }, [cancelDragListeners, upgradeToDragListeners]); // eslint-disable-line react-hooks/exhaustive-deps const attachTouchListeners = useCallback(() => { /* no-op — all listeners are permanent */ }, []); // Handle recurring event drag confirm (this/all) const handleRecurringDragConfirm = async (editMode: 'this' | 'future' | 'all') => { if (!pendingRecurringDrag) return; const { eventId, calendarId, recurringEventId, startTime, endTime, originalStartTime, originalEndTime } = pendingRecurringDrag; try { const res = await fetch("/api/calendar/events", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ calendarId, eventId, start: { dateTime: startTime }, end: { dateTime: endTime }, editMode, recurringEventId, }), }); if (!res.ok) { // Revert setRawCalendarEvents(prev => prev.map(ev => ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev )); } } catch { setRawCalendarEvents(prev => prev.map(ev => ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev )); } setPendingRecurringDrag(null); }; const handleRecurringDragCancel = () => { if (!pendingRecurringDrag) return; // Revert to original times const { eventId, originalStartTime, originalEndTime } = pendingRecurringDrag; setRawCalendarEvents(prev => prev.map(ev => ev.id === eventId ? { ...ev, startTime: originalStartTime, endTime: originalEndTime } : ev )); setPendingRecurringDrag(null); }; // 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 safely using our local-time helper const start = parseCalendarDate(event.startTime); const end = event.endTime ? parseCalendarDate(event.endTime) : new Date(start); // Normalize dates to start of day for comparison const targetDate = new Date(date); targetDate.setHours(0, 0, 0, 0); start.setHours(0, 0, 0, 0); end.setHours(0, 0, 0, 0); // Handle single day case where start == end if (start.getTime() === end.getTime()) { return start.getTime() === targetDate.getTime(); } // Standard range comparison (inclusive start, exclusive end) 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; const now = new Date(); const todayStr = formatDateToISO(now); const today = new Date(todayStr); // Roll tasks that are explicitly marked as rolling (per-task flag), // OR all incomplete overdue tasks if global autoRolling is enabled const overdue = currentTasks.filter( (t) => !t.completed && (t.isRolling || autoRolling) && 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 const taskDuration = task.duration || effectiveCellDuration; // Collision detection — checks task duration overlap, not just exact slot match const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => { const dateStr = formatDateToISO(date); const [h, m] = slot.split(":").map(Number); const slotStart = h * 60 + m; const slotEnd = slotStart + taskDuration; // Check other tasks (duration-aware overlap) const taskConflict = tasksToCheck.some((t) => { if (t.id === task.id || !t.startTime || t.completed) return false; if (t.parentTaskId) return false; const tDateStr = t.scheduledDate ? (typeof t.scheduledDate === "string" ? t.scheduledDate.substring(0, 10) : formatDateToISO(new Date(t.scheduledDate))) : null; if (tDateStr !== dateStr) return false; const [th, tm] = t.startTime.split(":").map(Number); const tStart = th * 60 + tm; const tEnd = tStart + (t.duration || effectiveCellDuration); return slotStart < tEnd && slotEnd > tStart; }); if (taskConflict) return true; // Check calendar events const slotStartDate = new Date(date); slotStartDate.setHours(h, m, 0, 0); const slotEndDate = new Date(slotStartDate); slotEndDate.setMinutes(slotEndDate.getMinutes() + taskDuration); return dailyEvents.some((event) => { const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); return slotStartDate < eventEnd && slotEndDate > 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 += effectiveCellDuration; if (m >= 60) { h += 1; m = 0; } if (h >= effectiveEndHour) 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, effectiveCellDuration, effectiveEndHour, getEventsForDate], ); // Run rolling after profile is loaded and tasks are available const rollingRanRef = useRef(false); useEffect(() => { if (rollingRanRef.current) return; if (tasks.length === 0) return; // Run if global autoRolling is on, OR if any task has per-task isRolling enabled const hasRollingTasks = tasks.some((t) => t.isRolling && !t.completed); if (!profile.autoRolling && !hasRollingTasks) return; rollingRanRef.current = true; rollOverdueTasks(tasks); }, [profile.autoRolling, tasks, rollOverdueTasks]); // 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 + effectiveCellDuration; 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, effectiveCellDuration], ); // 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"); setSelectedDay(null); // reset so header shows the new week's today/first day }; 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); // Only apply startDayOffset for multi-day views; on single-day view, go directly to today if (viewDays > 1) { 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; let touchStartedInSomeday = false; const handleTouchStart = (e: TouchEvent) => { touchStartX = e.changedTouches[0].screenX; touchStartY = e.changedTouches[0].screenY; // Check if touch started inside the someday area (which has its own horizontal scroll) touchStartedInSomeday = !!(e.target as HTMLElement)?.closest?.('.weekly-someday'); }; const handleTouchEnd = (e: TouchEvent) => { if (touchStartedInSomeday) return; // Don't hijack someday horizontal scrolling 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) { // Show inline confirmation instead of browser confirm() setUnsyncConfirm({ provider, list }); return; } else { // Sync/Import await doImport(provider, [list]); } }; const confirmUnsync = async () => { if (!unsyncConfirm) return; const { provider, list } = unsyncConfirm; const existing = somedayLists.find( (l) => l.externalId === list.id && l.externalProvider === provider, ); if (!existing) { setUnsyncConfirm(null); return; } try { // tasksOnly=true: soft-disconnect — removes tasks but keeps list record with its tab assignment // so when the user reconnects, the tab is restored automatically without re-dragging const res = await fetch(`/api/someday-lists?id=${existing.id}&tasksOnly=true`, { method: "DELETE", }); if (res.ok) { // Remove from local state (clean UI); DB record is kept for tab restoration on reconnect setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id)); setImportStatusMsg({ type: "success", text: `Stopped syncing "${list.title}".`, }); } } catch (error) { console.error("Failed to unsync list", error); setImportStatusMsg({ type: "error", text: "Failed to stop syncing list.", }); } setUnsyncConfirm(null); }; const handleSyncAll = async ( provider: "google" | "outlook" | "synology", lists: { id: string; title: string }[], syncOn: boolean, ) => { if (syncOn) { const unsynced = lists.filter( (list) => !somedayLists.some( (sl) => sl.externalId === list.id && sl.externalProvider === provider, ), ); if (unsynced.length > 0) await doImport(provider, unsynced); } else { // Unsync all synced lists const synced = lists.filter( (list) => somedayLists.some( (sl) => sl.externalId === list.id && sl.externalProvider === provider, ), ); for (const list of synced) { const existing = somedayLists.find( (l) => l.externalId === list.id && l.externalProvider === provider, ); if (!existing) continue; try { // tasksOnly=true: soft-disconnect — keeps list record with tab for reconnect restoration const res = await fetch(`/api/someday-lists?id=${existing.id}&tasksOnly=true`, { method: "DELETE", }); if (res.ok) { setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id)); } } catch (error) { console.error("Failed to unsync list", error); } } setImportStatusMsg({ type: "success", text: `Stopped syncing ${synced.length} list(s).`, }); } }; // 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(); announce(`Task "${title.trim()}" added`); 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); } }; // Create a task in kanban view — auto-creates a someday list if needed const addKanbanTask = async (title: string, stageId: string | null) => { if (!title.trim() || !session?.user) return; saveSnapshot(); try { // Determine the target someday list name const activeProject = kanbanFilterProject ? projects.find(p => p.id === kanbanFilterProject) : null; const listName = activeProject ? activeProject.name : "Kanban"; // Find existing someday list with that name let targetList = somedayLists.find(sl => sl.title === listName); // Create the list if it doesn't exist if (!targetList) { const listRes = await fetch("/api/someday-lists", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: listName }), }); if (listRes.ok) { const listData = await listRes.json(); targetList = { ...listData.list, tasks: [] }; setSomedayLists(prev => [...prev, targetList!]); } else { console.error("Failed to create someday list:", await listRes.text()); return; } } // Create the task const response = await fetch("/api/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title.trim(), somedayListId: targetList!.id, kanbanStage: stageId, projectId: activeProject?.id || undefined, order: 0, }), }); if (response.ok) { const data = await response.json(); const newTask = { ...data.task, createdAt: new Date(data.task.createdAt), updatedAt: new Date(data.task.updatedAt), }; setSomedayLists(prev => prev.map(sl => sl.id === targetList!.id ? { ...sl, tasks: [...sl.tasks, newTask] } : sl ) ); } else { console.error("Failed to add kanban task:", await response.text()); } } catch (error) { console.error("Error adding kanban task:", error); } setKanbanAddingStageId(null); setKanbanNewTaskTitle(""); }; // 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; announce(updatedCompleted ? `"${task.title}" marked complete` : `"${task.title}" marked incomplete`); 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((prev) => prev.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 { const res = await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, ...fields }), }); if (!res.ok) { const errData = await res.json().catch(() => ({})); console.error("Failed to update task fields:", res.status, errData); } } 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 updateTaskUrl = async (taskId: string, url: string) => { const task = findTaskAnywhere(taskId); const isSomeday = !!task?.somedayListId; const normalised = url.trim() ? (url.trim().startsWith("http") ? url.trim() : `https://${url.trim()}`) : ""; if (isSomeday) { setSomedayLists(prev => prev.map(l => ({ ...l, tasks: l.tasks.map(t => t.id === taskId ? { ...t, url: normalised || null, updatedAt: new Date() } : t), }))); } else { setTasks(prev => prev.map(t => t.id === taskId ? { ...t, url: normalised || null, updatedAt: new Date() } : t)); } try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, url: normalised || null }), }); } catch (error) { console.error("Error updating task url:", 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); if (taskToDelete) announce(`"${taskToDelete.title}" deleted`); 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( effectiveCellDuration, 0, 24, ); 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( effectiveCellDuration, 0, 24, ); 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); // Inherit provider from someday list if task doesn't have one const sourceList = somedayLists.find(l => l.id === draggedTask.somedayListId); const taskProvider = draggedTask.externalProvider || sourceList?.externalProvider || null; // 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 || "", externalProvider: taskProvider, }, ]); // 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 || "", ...(taskProvider && { externalProvider: taskProvider }), }), }); // 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, }), }); // If the target list is synced to an external provider and // the task doesn't already exist at that provider/list, create it there const targetList = somedayLists.find((l) => l.id === listId); const needsSync = targetList?.externalId && targetList?.externalProvider && ( !draggedTask.externalId || draggedTask.externalProvider !== targetList.externalProvider || draggedTask.externalListId !== targetList.externalId ); if (needsSync) { try { const syncRes = await fetch("/api/tasks/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId: draggedTask.id }), }); if (syncRes.ok) { const syncData = await syncRes.json(); // Update local state with external IDs if (syncData.task) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === draggedTask.id ? { ...t, externalId: syncData.task.externalId, externalProvider: syncData.task.externalProvider, externalListId: syncData.task.externalListId, } : t ), })) ); } } } catch (syncError) { console.error("Failed to sync task to external provider:", syncError); } } } 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"); announce("Sync complete"); setTimeout(() => setSyncStatus("idle"), 3000); } catch (error) { console.error("Error syncing:", error); setSyncStatus("idle"); announce("Sync failed"); 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 const visibleSlots = getTimeSlots( effectiveCellDuration, 0, 24, ); const fontSizeScale = profile.fontSize === "S" ? 0.85 : profile.fontSize === "L" ? 1.15 : 1; const mobileScale = isMobile ? (profile.mobileFontScale || 1.0) : 1.0; const fontVal = (v: string | undefined) => v && v !== "__custom__" ? v : ""; const scaleRem = (base: string) => { const num = parseFloat(base); return `${(num * fontSizeScale * mobileScale).toFixed(3)}rem`; }; const activeTheme = (darkMode ? profile.darkTheme : profile.lightTheme) as Record | null; const useLeftRail = !isMobile && profile.menuPosition !== "top"; const showHeaderControls = profile.showHeaderControls !== false; const compactRail = useLeftRail && isCompactHeight; const collapsedRailWidth = compactRail ? 38 : 44; const expandedRailWidth = compactRail ? 220 : 240; const containerStyle = { ...(activeTheme ? { "--weekly-bg": activeTheme.background, "--weekly-text": activeTheme.foreground, "--weekly-text-light": activeTheme.color8 || activeTheme.color7, "--weekly-border": activeTheme.color0, "--weekly-teal": activeTheme.color4 || activeTheme.color6, "--weekly-settings-item-bg": activeTheme.color0, "--weekly-item-hover": activeTheme.color0, } : {}), "--weekly-font-headline": fontVal(profile.headlineFont) ? `"${fontVal(profile.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": (() => { // If time task font is explicitly customized (not default "Inter"), use it // Otherwise inherit from task font const ttf = fontVal(profile.timeTaskFontFamily); const tf = fontVal(profile.taskFontFamily); const isDefault = !ttf || ttf === "Inter"; if (!isDefault) return `"${ttf}", sans-serif`; if (tf) return `"${tf}", sans-serif`; return "var(--weekly-font)"; })(), "--weekly-time-task-size": scaleRem( // If time task size is the old default 0.75rem, use task size instead profile.timeTaskFontSize && profile.timeTaskFontSize !== "0.75rem" ? profile.timeTaskFontSize : profile.taskFontSize || "0.9rem" ), "--weekly-time-task-weight": // If time task weight is the old default 500, use task weight instead profile.timeTaskFontWeight && profile.timeTaskFontWeight !== "500" ? profile.timeTaskFontWeight : profile.taskFontWeight || "400", "--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) ? `"${fontVal(profile.eventFontFamily)}", sans-serif` : "var(--weekly-font)", "--weekly-event-size": scaleRem(profile.eventFontSize || "0.85rem"), "--weekly-event-weight": profile.eventFontWeight || "400", "--font-weight-body": profile.fontWeight || "400", "--weekly-weekend-sat": activeTheme?.color3 || (darkMode ? invertColor(profile.weekendColorSat || "#666666") : profile.weekendColorSat || "#666666"), "--weekly-weekend-sun": activeTheme?.color1 || (darkMode ? invertColor(profile.weekendColorSun || "#dc2626") : profile.weekendColorSun || "#dc2626"), "--weekly-weekday-color": activeTheme?.foreground || (darkMode ? invertColor(profile.weekdayColor || "#888888") : profile.weekdayColor || "#888888"), "--weekly-date-color": activeTheme?.color8 || (darkMode ? invertColor(profile.dateColor || "#888888") : profile.dateColor || "#888888"), "--weekly-task-color": activeTheme?.color7 || (darkMode ? invertColor(profile.taskColor || "#333333") : profile.taskColor || "#333333"), "--weekly-today-highlight": activeTheme?.color0 || (darkMode ? invertColor(profile.todayHighlightColor || "#f0fafa") : profile.todayHighlightColor || "#f0fafa"), "--weekly-past-color": activeTheme?.color8 || (darkMode ? invertColor(profile.pastDayColor || "#a6a6a7") : profile.pastDayColor || "#a6a6a7"), ...(useLeftRail ? { paddingLeft: leftRailExpanded ? `${expandedRailWidth}px` : `${collapsedRailWidth}px`, transition: "padding-left 0.2s ease" } : {}), } as React.CSSProperties; if (isLoading) { return (
{translations[profile.language]?.loading || translations["en"].loading}
); } const activeDateLayout = isMobile ? (profile.mobileDateLayout || "below") : (profile.dateLayout || "right"); // Quick settings sidebar button styles const qsBtnStyle = (dm: boolean): React.CSSProperties => ({ padding: "6px", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", background: dm ? "#1f2937" : "#e5e7eb", color: dm ? "#9ca3af" : "#6b7280", }); const qsActionStyle = (dm: boolean): React.CSSProperties => ({ display: "flex", alignItems: "center", gap: "8px", padding: "7px 8px", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", background: "none", color: dm ? "#d1d5db" : "#333", textAlign: "left" as const, }); const railIconBtnStyle: React.CSSProperties = { width: compactRail ? "30px" : "36px", height: compactRail ? "30px" : "36px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", borderRadius: compactRail ? "7px" : "8px", color: darkMode ? "#9ca3af" : "#6b7280", flexShrink: 0, }; const flyoutPanelStyle: React.CSSProperties = { position: "fixed", left: `${collapsedRailWidth + 4}px`, top: `${Math.min(flyoutY, (typeof window !== "undefined" ? window.innerHeight : 800) - 260)}px`, background: darkMode ? "#1a1a2e" : "var(--paper)", border: "1px solid var(--line)", borderRadius: "10px", padding: "12px 14px", zIndex: 200, boxShadow: "2px 4px 16px rgba(0,0,0,0.10)", minWidth: "200px", display: "flex", flexDirection: "column", gap: "10px", }; const flyoutLabelStyle: React.CSSProperties = { fontSize: "0.68rem", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" as const, color: darkMode ? "#6b7280" : "var(--ink-3)", marginBottom: "2px", }; const openFlyout = (section: string, e: React.MouseEvent) => { if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setFlyoutY(rect.top); setFlyoutSection(section); }; const closeFlyoutDelayed = () => { flyoutTimerRef.current = setTimeout(() => setFlyoutSection(null), 180); }; const keepFlyoutOpen = () => { if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current); }; const railSep = (
); // All-Day Events Section (reusable for above/below positioning) const allDaySection = (() => { if (!effectiveShowAllDay) return null; const allDayEvents = calendarEvents.filter((event) => isAllDayEvent(event), ); if (allDayEvents.length === 0) return null; const handleOnTop = effectiveAllDayPosition === "below"; const resizeHandle = isAllDayExpanded ? (
startResize(e, 'allday', handleOnTop)} onTouchStart={(e) => startResize(e, 'allday', handleOnTop)} >
) : null; return ( <> {effectiveAllDayPosition === "below" && resizeHandle}
{profile.showTimeGrid && (
setIsAllDayExpanded(!isAllDayExpanded)} style={{ width: "55px", flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", cursor: "pointer", borderRight: "1px solid var(--weekly-border)", padding: "4px 4px 2px", gap: "0px", marginLeft: "-1px", position: "relative", }} title={isAllDayExpanded ? "Collapse" : "Expand"} > all day {(() => { const visibleDays = getVisibleDays(); const seen = new Set(); visibleDays.forEach(d => getAllDayEventsForDate(d).forEach(e => seen.add(e.id))); return seen.size; })()}
)} {/* In list mode (no time grid), skip the label column so events align with day columns */} {isAllDayExpanded && (
{getVisibleDays().map((date) => { const dayEvents = getAllDayEventsForDate(date); return (
{dayEvents.length > 0 ? ( dayEvents.map((event) => (
{ e.stopPropagation(); if (event.editable) { setCalendarEventModal({ isOpen: true, event: event, }); } }} style={{ backgroundColor: event.calendarColor || "#3b82f6", color: "white", borderLeft: "none", padding: "2px 4px", borderRadius: "3px", fontSize: "0.75rem", marginBottom: "2px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", display: "flex", alignItems: "center", gap: "4px", cursor: event.editable ? "pointer" : "default", transition: "filter 0.1s ease", }} onMouseEnter={(e) => { if (event.editable) e.currentTarget.style.filter = "brightness(0.9)"; }} onMouseLeave={(e) => { if (event.editable) e.currentTarget.style.filter = "none"; }} > 📅 {event.title}
)) ) : (
)}
); })}
)}
{effectiveAllDayPosition === "above" && resizeHandle} ); })(); return (
{ const hd = isMobile ? (isPortrait ? (profile.mobilePortraitHeaderDisplay || "current_day") : (profile.mobileLandscapeHeaderDisplay || profile.headerDisplay || "kw")) : (profile.headerDisplay || "kw"); return (viewDays === 1 && hd === "current_day") ? " header-current-day-single" : ""; })()}`} style={containerStyle} > {/* Visually hidden live region for screen reader announcements */}
{announceMsg}
{/* Mobile sticky day indicator */} {isMobile && mobileStickyDay && mobileStickyDayVisible && (
{mobileStickyDay}
)} {/* Mobile Quick Settings — full overlay triggered from mobile header */} {isMobile && showQuickSettings && ( <>
setShowQuickSettings(false)} style={{ position: "fixed", inset: 0, zIndex: 999 }} />
{profile.language === "de" ? "Einstellungen" : "Preferences"}
{profile.language === "de" ? "Ansicht" : "View"}
{[{ key: "simple", icon: }, { key: "calendar", icon: }, { key: "list", icon: }, { key: "kanban", icon: }, { key: "priority", icon: }].map((v) => ())}
{profile.language === "de" ? "Spalten" : "Days"}
{[1, 2, 3, 5, 7].map((num) => ())}
{profile.showTimeGrid && (
{profile.language === "de" ? "Zeitfenster" : "Slot"}
{(([15, 20, 30, 60] as CellDuration[])).map((d) => ())}
)} {profile.showTimeGrid && (
:15/:30/:45
)}
{profile.language === "de" ? "Textgröße" : "Text size"}
{(["S", "M", "L"] as const).map((size) => ())}
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (
{label}
))}
{profile.language === "de" ? "Starten mit" : "Start on"}
{profile.language === "de" ? "Anzeige" : "Display"}
)} {/* Desktop Top-mode overlay — opens like the mobile overlay, triggered by header button */} {!isMobile && !useLeftRail && showQuickSettings && ( <>
setShowQuickSettings(false)} style={{ position: "fixed", inset: 0, zIndex: 999 }} />
{profile.language === "de" ? "Einstellungen" : "Preferences"}
{/* Menu position toggle */}
{profile.language === "de" ? "Menüposition" : "Menu position"}
{profile.language === "de" ? "Ansicht" : "View"}
{[{ key: "simple", icon: }, { key: "calendar", icon: }, { key: "list", icon: }, { key: "kanban", icon: }, { key: "priority", icon: }].map((v) => ())}
{profile.language === "de" ? "Spalten" : "Days"}
{[1, 2, 3, 5, 7].map((num) => ())}
{profile.showTimeGrid && (
{profile.language === "de" ? "Zeitfenster" : "Slot"}
{(([15, 20, 30, 60] as CellDuration[])).map((d) => ())}
)} {profile.showTimeGrid && (
:15/:30/:45
)}
{profile.language === "de" ? "Textgröße" : "Text size"}
{(["S", "M", "L"] as const).map((size) => ())}
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (
{label}
))}
{profile.language === "de" ? "Starten mit" : "Start on"}
{profile.language === "de" ? "Anzeige" : "Display"}
)} {/* Desktop Left Rail — persistent, collapses to icon strip */} {useLeftRail && ( <> {/* The rail panel */}
{leftRailExpanded ? ( // ── EXPANDED MODE ──
{profile.language === "de" ? "Einstellungen" : "Preferences"}
{/* Menu position toggle */}
{profile.language === "de" ? "Menüposition" : "Menu position"}
{/* Navigation */}
{/* Quick Actions */}
{/* View Style */}
{profile.language === "de" ? "Ansicht" : "View"}
{[{ key: "simple", icon: }, { key: "calendar", icon: }, { key: "list", icon: }, { key: "kanban", icon: }, { key: "priority", icon: }].map((v) => ())}
{/* Columns / Days */}
{profile.language === "de" ? "Spalten" : "Days"}
{[1, 2, 3, 5, 7].map((num) => ())}
{/* Slot Duration */} {profile.showTimeGrid && (
{profile.language === "de" ? "Zeitfenster" : "Slot"}
{(([15, 20, 30, 60] as CellDuration[])).map((d) => ())}
)} {profile.showTimeGrid && (
:15/:30/:45
)} {/* Text size */}
{profile.language === "de" ? "Textgröße" : "Text size"}
{(["S", "M", "L"] as const).map((size) => ())}
{/* Visibility toggles */} {[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (
{label}
))} {/* Start on */}
{profile.language === "de" ? "Starten mit" : "Start on"}
{/* Display */}
{profile.language === "de" ? "Anzeige" : "Display"}
{/* Undo / Redo / Refresh */}
) : ( // ── COLLAPSED MODE — icon strip ──
{/* Expand toggle */} {railSep} {/* Navigation flyout */} {railSep} {/* View flyout */} {/* Columns flyout */} {/* Slot flyout */} {profile.showTimeGrid && ()} {railSep} {/* Quick Actions */} {railSep} {/* Visibility + Appearance merged flyout */} {railSep} {/* History flyout (undo / redo) */} {/* Refresh + Print */}
{railSep} {/* Settings (cogwheel) + User menu at bottom */}
{showRailUserMenu && ( <>
setShowRailUserMenu(false)} />

{profile.language === "de" ? "Angemeldet als" : "Signed in as"}

{session?.user?.email || "User"}

)}
)}
{/* Flyout panels — rendered outside the rail so they can overflow */} {!leftRailExpanded && flyoutSection && (
{flyoutSection === "nav" && ( <>
{profile.language === "de" ? "Navigation" : "Navigation"}
)} {flyoutSection === "history" && ( <>
{profile.language === "de" ? "Verlauf" : "History"}
)} {flyoutSection === "view" && ( <>
{profile.language === "de" ? "Ansicht" : "View"}
{[{ key: "simple", icon: , label: profile.language === "de" ? "Einfach" : "Simple" }, { key: "calendar", icon: , label: profile.language === "de" ? "Kalender" : "Calendar" }, { key: "list", icon: , label: profile.language === "de" ? "Liste" : "List" }, { key: "kanban", icon: , label: "Kanban" }, { key: "priority", icon: , label: profile.language === "de" ? "Priorität" : "Priority" }].map((v) => ())}
)} {flyoutSection === "columns" && ( <>
{profile.language === "de" ? "Spalten" : "Days"}
{[1, 2, 3, 5, 7].map((num) => ())}
)} {flyoutSection === "slot" && ( <>
{profile.language === "de" ? "Zeitfenster" : "Slot Duration"}
{(([15, 20, 30, 60] as CellDuration[])).map((d) => ())}
:15/:30/:45
)} {flyoutSection === "vis-display" && ( <>
{profile.language === "de" ? "Sichtbarkeit" : "Visibility"}
{[{ label: profile.language === "de" ? "Irgendwann" : "Someday", value: effectiveShowSomeday, toggle: () => saveViewSetting("showSomeday", !effectiveShowSomeday, true) }, { label: profile.language === "de" ? "Ganztägig" : "All-day", value: effectiveShowAllDay, toggle: () => saveViewSetting("showAllDayEvents", !effectiveShowAllDay, true) }, { label: profile.language === "de" ? "Checkboxen" : "Checkboxes", value: effectiveShowTaskCheckboxes, toggle: () => saveViewSetting("showTaskCheckboxes", !effectiveShowTaskCheckboxes, true) }, { label: profile.language === "de" ? "Projekt-Icons" : "Project Icons", value: effectiveShowProjectIcons, toggle: () => saveViewSetting("showProjectIcons", !effectiveShowProjectIcons, true) }, { label: profile.language === "de" ? "Prioritäts-Icons" : "Priority Icons", value: effectiveShowPriorityIcons, toggle: () => saveViewSetting("showPriorityIcons", !effectiveShowPriorityIcons, true) }, ...((profile.weatherEnabled || profile.weatherLat || profile.weatherLon) ? [{ label: profile.language === "de" ? "Wetter" : "Weather", value: effectiveWeatherEnabled, toggle: () => { const v = !effectiveWeatherEnabled; setProfile((p: any) => ({ ...p, weatherEnabled: v })); saveViewSetting("weatherEnabled", v, true); } }] : []) ].map(({ label, value, toggle }) => (
{label}
))}
{profile.language === "de" ? "Darstellung" : "Appearance"}
{profile.language === "de" ? "Textgröße" : "Text size"}
{(["S", "M", "L"] as const).map((size) => ())}
{profile.language === "de" ? "Starten mit" : "Start on"}
{profile.language === "de" ? "Anzeige" : "Display"}
)}
)} )} {/* Projects Sidebar */} {showProjectsSidebar && ( setShowProjectsSidebar(false)} tasks={[...tasks, ...somedayLists.flatMap(l => l.tasks)]} activeProjectFilter={activeProjectFilter} onSetProjectFilter={setActiveProjectFilter} /> )} {/* View Transitions Style Block */}