"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 IconPicker, { allIcons } from "./IconPicker"; 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, 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, } from "lucide-react"; // Types import UserMenu from "./UserMenu"; import SearchModal from "./SearchModal"; import SimpleDatePicker from "./SimpleDatePicker"; import RecurringTasksManager from "./RecurringTasksManager"; export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; } import { ImportListModal } from "./ImportListModal"; import OnboardingWizard from "./OnboardingWizard"; import { getRandomLocalQuote } from "@/lib/quotes"; // Cookie helpers for per-device settings const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour"]; function getCookie(name: string): string | null { if (typeof document === "undefined") return null; const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null; } function setCookie(name: string, value: string, days: number = 365) { if (typeof document === "undefined") return; const expires = new Date(Date.now() + days * 864e5).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; } export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban"; 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; } 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; } interface SomedayList { id: string; title: string; tasks: Task[]; tab?: string | null; externalProvider?: string | null; externalId?: string | null; externalListId?: string | null; } // Time grid configuration options type CellDuration = 15 | 20 | 30 | 60; type WeatherDisplayKey = "icon" | "temp" | "feelsLike" | "wind" | "gusts" | "precipProb" | "precip" | "humidity" | "uv"; const WEATHER_DISPLAY_DEFAULTS: WeatherDisplayKey[] = ["icon", "temp"]; const DEFAULT_SOMEDAY_SLOT_COUNT = 5; const getSomedaySlotCount = (tasks: Task[]) => { const maxIdx = tasks.reduce((max, t) => { if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) { return Math.max(max, t.somedaySlotIndex); } return max; }, -1); // Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total. // "add 5 rows and then when 4 are taken add another row" // Let's ensure there's always at least one empty slot at the bottom. return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2); }; // Font options const AVAILABLE_FONTS = [ { name: "Default (Inter)", value: "Inter" }, { name: "Roboto", value: "Roboto" }, { name: "Open Sans", value: "Open Sans" }, { name: "Lato", value: "Lato" }, { name: "Montserrat", value: "Montserrat" }, { name: "Oswald", value: "Oswald" }, { name: "Raleway", value: "Raleway" }, { name: "Playfair Display", value: "Playfair Display" }, { name: "Merriweather", value: "Merriweather" }, { name: "Nunito", value: "Nunito" }, { name: "Dancing Script", value: "Dancing Script" }, { name: "Pacifico", value: "Pacifico" }, { name: "Custom Google Font...", value: "__custom__" }, ]; // Check if a font value is a custom (non-preset) font const isCustomFont = (value: string): boolean => !!value && value !== "__custom__" && !AVAILABLE_FONTS.slice(0, -1).some((f) => f.value === value); const FONT_WEIGHTS = [ { name: "Light", value: "300" }, { name: "Normal", value: "400" }, { name: "Medium", value: "500" }, { name: "Bold", value: "700" }, ]; // Helper to load Google Fonts const useGoogleFonts = (fonts: string[]) => { useEffect(() => { if (typeof window === "undefined") return; const fontsToLoad = fonts.filter((f) => f && f !== "Inter"); if (fontsToLoad.length === 0) return; const linkId = "google-fonts-link"; let link = document.getElementById(linkId) as HTMLLinkElement; const fontQuery = fontsToLoad.map((f) => f.replace(" ", "+")).join("|"); const href = `https://fonts.googleapis.com/css2?family=${fontsToLoad.map((f) => `${f.replace(" ", "+")}:wght@300;400;500;700`).join("&family=")}&display=swap`; if (!link) { link = document.createElement("link"); link.id = linkId; link.rel = "stylesheet"; document.head.appendChild(link); } link.href = href; }, [fonts]); }; // Translations const translations: Record = { en: { settings: "Settings", general: "General", calendar: "Connections", localisation: "Localisation", account: "Account", runningList: "Running List (Auto-roll tasks to today)", protectEventTimes: "Protect Event Times", showTimeGrid: "Show Time Grid", timeSlotDuration: "Time Slot Duration", viewStyle: "View Style", simpleView: "Simple", calendarView: "Calendar", listView: "List", weekView: "Week", kanbanView: "Kanban", filterByProject: "All Projects", filterByList: "All Lists", filterByWeek: "All Weeks", kanbanStages: "Kanban Stages", kanbanStagesDesc: "Define the stages for your Kanban board. Drag tasks between columns to change their stage.", addStage: "Add stage", stageName: "Stage name", noStage: "No stage", headerDisplay: "Header Display", headerDisplayKW: "Calendar Week (KW)", headerDisplayMonth: "Month Name - March", headerDisplayMonthYear: "Month & Year - March | 2026", headerDisplayDate: "Full Date - 13.03.2026", headerDisplayCustom: "Custom - Friday - 13. March", headerDisplayNone: "None", headerCustomFormatLabel: "Format string (e.g. DD.MM.YYYY)", language: "Language", dateFormat: "Date Format", timeFormat: "Time Format", saveChanges: "Save Changes", connectedCalendars: "Connected Calendars", connectMore: "Connect More", connectGoogle: "Connect Google Calendar", connectApple: "Connect Apple Calendar", appleRemindersNote: "Apple Reminders are not supported. Since iOS 13 / macOS Catalina, Apple no longer provides a CalDAV or public API for Reminders. Only calendar events can be synced.", connectSynology: "Connect Synology", connectNotion: "Connect Notion", noCalendars: "No calendars connected yet.", dataPrivacy: "Data & Privacy", downloadData: "Download My Data", deleteAccount: "Delete Account", name: "Name", email: "Email", timezone: "Timezone", changePassword: "Change Password", newPassword: "New Password", confirmPassword: "Confirm Password", someday: "SOMEDAY", lists: "Lists", newList: "New list", allTabs: "All", newTab: "New tab", newTabName: "New tab name:", assignTab: "Assign to tab", noTab: "No tab", renameTab: "Double-click to rename", dissolveTab: "Remove tab (keep lists)", loading: "Loading your tasks...", sycing: "Syncing...", synced: "Synced", localization: "Localization", allDayEvents: "ALL-DAY EVENTS", syncCalendar: "Sync Calendar", toggleDarkMode: "Toggle Dark Mode", signOut: "Sign Out", startHour: "Start of Day", endHour: "End of Day", weekAbbr: "W", goalOfWeek: "Goal of the Week", goalScope: "Goal Scope", goalScopeWeek: "Per Week", goalScopeDay: "Per Day", goalFallback: "Goal Fallback Type", defaultGoal: "Custom Default Goal", showTaskCheckboxes: "Show Checkboxes on Tasks", showProjectIcons: "Show Icons for Projects", showSomeday: "Show Someday Section", showAllDay: "Show All-Day Section", allDayPosition: "All-Day Events Position", allDayAbove: "Above", allDayBelow: "Below", newPasswordDesc: "Leave blank to keep current password.", dateAlignment: "Date Alignment", dateVerticalAlign: "Date Vertical Alignment", alignTop: "Top", alignMiddle: "Middle", alignBottom: "Bottom", dateLayout: "Date Layout", alignmentLeft: "Left", alignmentCenter: "Center", alignmentRight: "Right", alignmentTight: "Tight", backupRestore: "Backup & Restore", backupRestoreDesc: "Export all your tasks, anyday lists, and projects as a JSON file. You can edit the file and import it back.", exportAllData: "Export All Data (JSON)", importData: "Import Data", importMode: "Import Mode", importModeMerge: "Merge", importModeMergeDesc: "Add imported data alongside existing tasks", importModeReplace: "Replace", importModeReplaceDesc: "Delete all existing data and replace with imported data", importReplaceWarning: "Warning: This will permanently delete all your current tasks, lists, and projects!", importSelectFile: "Select JSON file...", importButton: "Import", importing: "Importing...", exporting: "Exporting...", projects: "Projects", projectsDesc: "Organize tasks with color-coded projects", addProject: "Add Project", projectName: "Name", projectColor: "Color", noProjects: "No projects yet", assignProject: "Assign project", removeProject: "Remove project", weekdayFormat: "Weekday Format", weekdayFormatFull: "Full Name (Monday)", weekdayFormatShort: "Short (Mon)", weekdayFormatNarrow: "Narrow (M)", weekdayFormatCustom: "Custom", customWeekdayNamesMon: "Mo; Tu; We; Th; Fr; Sa; Su", customWeekdayNamesSun: "Su; Mo; Tu; We; Th; Fr; Sa", weekdayCase: "Weekday Case", weekdayCaseNormal: "Normal (monday)", weekdayCaseCapitalize: "Capitalize (Monday)", weekdayCaseUppercase: "Uppercase (MONDAY)", styling: "Styling", motivation: "Motivation", about: "About", setupAssistant: "Run Setup Assistant", weekStartLabel: "Start week on", startViewLabel: "Start view on", monday: "Monday", sunday: "Sunday", today: "Today", yesterday: "Yesterday", accountId: "Account ID", accountIdDesc: "Your unique account identifier", accountNumberLabel: "Account Number", accountNumberDesc: "Your account number for identification when changing email", connectOutlook: "Connect Outlook", syncTasks: "Sync Tasks", syncTasksDesc: "Sync tasks with Google Tasks or Microsoft To-Do.", unsyncConfirmMsg: "Stop syncing \"{title}\"? Its tasks will be moved to trash.", unsyncConfirm: "Stop syncing", unsyncCancel: "Cancel", syncAll: "Sync all", unsyncAll: "Unsync all", fetchingLists: "(fetching lists...)", listHeader: "List", syncHeader: "Sync", noTaskListsFound: "No task lists found.", connectProviderAbove: "Connect a provider above to sync task lists.", noCalendarsFound: "No calendars found or permission denied.", noCalendarsApple: "No calendars loaded. Please disconnect and reconnect Apple Calendar.", noCalendarsSynology: "No calendars loaded. Please disconnect and reconnect Synology.", selectionAfterConnect: "Selection available after connect.", sharedCalendar: "Shared calendar", primaryCalendar: "(Primary)", fontCustomization: "Font Customization", dateLayoutRight: "Date Right of Weekday", dateLayoutLeft: "Date Left of Weekday", dateLayoutAbove: "Date Above Weekday", dateLayoutBelow: "Date Below Weekday", dateLayoutHidden: "Date Hidden", dateLayoutMobile: "Date Layout (Mobile)", dayWeekdayGap: "Day / Weekday Gap", weekdayFont: "Weekday Font", dateFont: "Date Font", taskFont: "Task Font", eventFont: "Event Font", goalFont: "Goal / Quote Font", cwFont: "Calendar Week Font", yearFont: "Year Font", fontPlaceholder: "e.g. Poppins, Bebas Neue...", fontSizePlaceholder: "Font size (e.g. 1.25rem)", weightLight: "Light", weightNormal: "Normal", weightMedium: "Medium", weightSemi: "Semi", weightBold: "Bold", weightBlack: "Black", hourLabelFormat: "Hour Label Format", hourLabelShort: "Short (8, 9, 10)", hourLabelFull: "Full (8:00, 9:00, 10:00)", showSubhourLabels: "Show Sub-hour Labels (:15, :30, :45)", showScheduleCalendar: "Show Schedule / Calendar", showDoThisNow: 'Show "Do This Now" instead of Motto', focusTimer: "Focus Timer (min)", focusBreak: "Focus Break (min)", goalScopeTitle: "Goal Time Period", goalFallbackTitle: "Goal Fallback", motivationalQuote: "Motivational Quote / Holiday Hint", nextTodo: "Next To-Do", defaultText: "Default Text", apiDataSources: "API Data Sources (URLs)", addSource: "Add Source", urlFormatHelp: "URL returning JSON quotes", quoteLanguages: "Quote Languages", quoteLanguagesDesc: "Choose which languages your quotes appear in. At least one must be selected.", quoteFallbackDesc: "If no external source responds, curated local quotes in your language are used as fallback.", defaultGoalPlaceholder: "Enter your goal here...", saturdayColor: "Saturday", sundayColor: "Sunday", todayHighlight: "Today Highlight", pastDayColor: "Past Day Color", deleteProjectConfirm: "Delete project", importConfirmReplace: "This will delete ALL existing tasks, lists, and projects. Continue?", importSuccess: "Import complete", importInvalidJson: "Invalid JSON file", }, de: { settings: "Einstellungen", general: "Allgemein", calendar: "Verbindungen", localisation: "Lokalisierung", account: "Konto", runningList: "Laufende Liste (Aufgaben automatisch auf heute verschieben)", protectEventTimes: "Ereigniszeiten schützen", showTimeGrid: "Zeitplan anzeigen", timeSlotDuration: "Zeitfensterdauer", viewStyle: "Ansichtsstil", simpleView: "Einfach", calendarView: "Kalender", kanbanView: "Kanban", weekView: "Woche", filterByProject: "Alle Projekte", filterByList: "Alle Listen", filterByWeek: "Alle Wochen", kanbanStages: "Kanban-Phasen", kanbanStagesDesc: "Definiere die Phasen für dein Kanban-Board. Ziehe Aufgaben zwischen Spalten, um ihre Phase zu ändern.", addStage: "Phase hinzufügen", stageName: "Phasenname", noStage: "Keine Phase", headerDisplay: "Kopfzeile", headerDisplayKW: "Kalenderwoche (KW)", headerDisplayMonth: "Monatsname - März", headerDisplayMonthYear: "Monat & Jahr - März | 2026", headerDisplayDate: "Vollständiges Datum - 13.03.2026", headerDisplayCustom: "Benutzerdefiniert - Freitag - 13. März", headerDisplayNone: "Nichts", headerCustomFormatLabel: "Format (z.B. DD.MM.YYYY)", listView: "Liste", notes: "Notizen", notesSidebar: "Notizen-Seitenleiste", language: "Sprache", dateFormat: "Datumsformat", timeFormat: "Zeitformat", saveChanges: "Änderungen speichern", connectedCalendars: "Verbundene Kalender", connectMore: "Mehr verbinden", connectGoogle: "Google Kalender verbinden", connectApple: "Apple Kalender verbinden", appleRemindersNote: "Apple Erinnerungen werden nicht unterstützt. Seit iOS 13 / macOS Catalina bietet Apple keine CalDAV- oder öffentliche API mehr für Erinnerungen an. Nur Kalender-Ereignisse können synchronisiert werden.", connectNotion: "Notion verbinden", noCalendars: "Keine Kalender verbunden.", dataPrivacy: "Daten & Datenschutz", downloadData: "Meine Daten herunterladen", deleteAccount: "Konto löschen", name: "Name", email: "E-Mail", timezone: "Zeitzone", changePassword: "Passwort ändern", newPassword: "Neues Passwort", confirmPassword: "Passwort bestätigen", someday: "IRGENDWANN", lists: "Listen", newList: "Neue Liste", allTabs: "Alle", newTab: "Neuer Tab", newTabName: "Neuer Tab-Name:", assignTab: "Tab zuweisen", noTab: "Kein Tab", renameTab: "Doppelklick zum Umbenennen", dissolveTab: "Tab entfernen (Listen behalten)", loading: "Lade Aufgaben...", syncing: "Synchronisiere...", synced: "Synchronisiert", localization: "Lokalisierung", allDayEvents: "GANZTÄGIGE EREIGNISSE", syncCalendar: "Kalender synchronisieren", toggleDarkMode: "Dunkelmodus umschalten", signOut: "Abmelden", startHour: "Tagesbeginn", endHour: "Tagesende", weekAbbr: "KW", goalOfWeek: "Ziel der Woche", goalScope: "Ziel-Zeitraum", goalScopeWeek: "Pro Woche", goalScopeDay: "Pro Tag", goalFallback: "Ziel-Fallback-Typ", defaultGoal: "Benutzerdefiniertes Standardziel", showTaskCheckboxes: "Checkboxen bei Aufgaben anzeigen", showProjectIcons: "Icons für Projekte anzeigen", showSomeday: "Irgendwann-Bereich anzeigen", showAllDay: "Ganztägige Ereignisse anzeigen", allDayPosition: "Position ganztägiger Ereignisse", allDayAbove: "Oben", allDayBelow: "Unten", newPasswordDesc: "Leer lassen, um das aktuelle Passwort zu behalten.", dateAlignment: "Datums-Ausrichtung", dateVerticalAlign: "Datums-Vertikalausrichtung", alignTop: "Oben", alignMiddle: "Mitte", alignBottom: "Unten", dateLayout: "Datumslayout", alignmentLeft: "Links", alignmentCenter: "Mitte", alignmentRight: "Rechts", alignmentTight: "Eng", backupRestore: "Sicherung & Wiederherstellung", backupRestoreDesc: "Exportieren Sie alle Aufgaben, Irgendwann-Listen und Projekte als JSON-Datei. Sie können die Datei bearbeiten und wieder importieren.", exportAllData: "Alle Daten exportieren (JSON)", importData: "Daten importieren", importMode: "Import-Modus", importModeMerge: "Zusammenführen", importModeMergeDesc: "Importierte Daten neben bestehenden Aufgaben hinzufügen", importModeReplace: "Ersetzen", importModeReplaceDesc: "Alle bestehenden Daten löschen und durch importierte ersetzen", importReplaceWarning: "Warnung: Dies löscht dauerhaft alle Ihre aktuellen Aufgaben, Listen und Projekte!", importSelectFile: "JSON-Datei auswählen...", importButton: "Importieren", importing: "Importiere...", exporting: "Exportiere...", projects: "Projekte", projectsDesc: "Aufgaben mit farbcodierten Projekten organisieren", addProject: "Projekt hinzufügen", projectName: "Name", projectColor: "Farbe", noProjects: "Noch keine Projekte", assignProject: "Projekt zuweisen", removeProject: "Projekt entfernen", weekdayFormat: "Wochentag-Format", weekdayFormatFull: "Vollständiger Name (Montag)", weekdayFormatShort: "Kurz (Mo)", weekdayFormatNarrow: "Schmal (M)", weekdayFormatCustom: "Benutzerdefiniert", customWeekdayNamesMon: "Mo; Di; Mi; Do; Fr; Sa; So", customWeekdayNamesSun: "So; Mo; Di; Mi; Do; Fr; Sa", weekdayCase: "Groß-/Kleinschreibung", weekdayCaseNormal: "Klein (montag)", weekdayCaseCapitalize: "Großbuchstabe (Montag)", weekdayCaseUppercase: "Großbuchstaben (MONTAG)", styling: "Design", motivation: "Motivation", about: "Über", setupAssistant: "Einrichtungsassistent starten", weekStartLabel: "Woche beginnt am", startViewLabel: "Ansicht beginnt mit", monday: "Montag", sunday: "Sonntag", today: "Heute", yesterday: "Gestern", accountId: "Konto-ID", accountIdDesc: "Ihre eindeutige Konto-Kennung", accountNumberLabel: "Kontonummer", accountNumberDesc: "Ihre Kontonummer zur Identifikation", connectOutlook: "Outlook verbinden", syncTasks: "Aufgaben synchronisieren", syncTasksDesc: "Aufgaben mit Google Tasks oder Microsoft To-Do synchronisieren.", unsyncConfirmMsg: "Synchronisierung von \"{title}\" beenden? Die Aufgaben werden in den Papierkorb verschoben.", unsyncConfirm: "Sync beenden", unsyncCancel: "Abbrechen", syncAll: "Alle synchronisieren", unsyncAll: "Alle trennen", fetchingLists: "(Listen werden geladen...)", listHeader: "Liste", syncHeader: "Sync", noTaskListsFound: "Keine Aufgabenlisten gefunden.", connectProviderAbove: "Verbinden Sie einen Anbieter oben, um Aufgabenlisten zu synchronisieren.", noCalendarsFound: "Keine Kalender gefunden oder Zugriff verweigert.", noCalendarsApple: "Keine Kalender geladen. Bitte trennen und erneut verbinden.", noCalendarsSynology: "Keine Kalender geladen. Bitte trennen und erneut verbinden.", selectionAfterConnect: "Auswahl nach Verbindung verfügbar.", sharedCalendar: "Geteilter Kalender", primaryCalendar: "(Primär)", fontCustomization: "Schriftart-Anpassung", dateLayoutRight: "Datum rechts vom Wochentag", dateLayoutLeft: "Datum links vom Wochentag", dateLayoutAbove: "Datum über Wochentag", dateLayoutBelow: "Datum unter Wochentag", dateLayoutHidden: "Datum ausgeblendet", dateLayoutMobile: "Datum-Layout (Mobil)", dayWeekdayGap: "Tag / Wochentag Abstand", weekdayFont: "Wochentag-Schrift", dateFont: "Datum-Schrift", taskFont: "Aufgaben-Schrift", eventFont: "Termin-Schrift", goalFont: "Ziel / Zitat-Schrift", cwFont: "Kalenderwoche-Schrift", yearFont: "Jahr-Schrift", fontPlaceholder: "z.B. Poppins, Bebas Neue...", fontSizePlaceholder: "Schriftgröße (z.B. 1.25rem)", weightLight: "Leicht", weightNormal: "Normal", weightMedium: "Mittel", weightSemi: "Halb-fett", weightBold: "Fett", weightBlack: "Schwarz", hourLabelFormat: "Stundenformat", hourLabelShort: "Kurz (8, 9, 10)", hourLabelFull: "Voll (8:00, 9:00, 10:00)", showSubhourLabels: "Viertelstunden anzeigen (:15, :30, :45)", showScheduleCalendar: "Zeitplan / Kalender anzeigen", showDoThisNow: '"Jetzt erledigen" statt Motto anzeigen', focusTimer: "Fokus-Timer (Min)", focusBreak: "Fokus-Pause (Min)", goalScopeTitle: "Ziel-Zeitraum", goalFallbackTitle: "Ziel-Fallback", motivationalQuote: "Motivationszitat / Feiertags-Hinweis", nextTodo: "Nächstes To-Do", defaultText: "Standardtext", apiDataSources: "API-Datenquellen (URLs)", addSource: "Quelle hinzufügen", urlFormatHelp: "URL die JSON-Zitate liefert", quoteLanguages: "Zitatsprachen", quoteLanguagesDesc: "Wählen Sie die Sprachen für Ihre Zitate. Mindestens eine muss ausgewählt sein.", quoteFallbackDesc: "Wenn keine externe Quelle antwortet, werden lokale kuratierte Zitate in Ihrer Sprache verwendet.", defaultGoalPlaceholder: "Ihr Ziel hier eingeben...", saturdayColor: "Samstag", sundayColor: "Sonntag", todayHighlight: "Heute-Hervorhebung", pastDayColor: "Vergangene Tage", deleteProjectConfirm: "Projekt löschen", importConfirmReplace: "Dies löscht ALLE bestehenden Aufgaben, Listen und Projekte. Fortfahren?", importSuccess: "Import abgeschlossen", importInvalidJson: "Ungültige JSON-Datei", }, fr: { settings: "Paramètres", general: "Général", calendar: "Connexions", localisation: "Localisation", account: "Compte", runningList: "Liste continue (reporter les tâches à aujourd'hui)", protectEventTimes: "Protéger les horaires des événements", showTimeGrid: "Afficher la grille horaire", timeSlotDuration: "Durée des créneaux horaires", viewStyle: "Style d'affichage", simpleView: "Simple", calendarView: "Calendrier", listView: "Liste", weekView: "Semaine", kanbanView: "Kanban", filterByProject: "Tous les projets", filterByList: "Toutes les listes", filterByWeek: "Toutes les semaines", kanbanStages: "Étapes Kanban", kanbanStagesDesc: "Définissez les étapes de votre tableau Kanban. Glissez les tâches entre les colonnes pour changer leur étape.", addStage: "Ajouter une étape", stageName: "Nom de l'étape", noStage: "Aucune étape", headerDisplay: "Affichage en-tête", headerDisplayKW: "Semaine calendaire (KW)", headerDisplayMonth: "Nom du mois - Mars", headerDisplayMonthYear: "Mois & Année - Mars | 2026", headerDisplayDate: "Date complète - 13.03.2026", headerDisplayCustom: "Personnalisé - Vendredi - 13 Mars", headerDisplayNone: "Aucun", headerCustomFormatLabel: "Format (ex: DD.MM.YYYY)", language: "Langue", dateFormat: "Format de date", timeFormat: "Format d'heure", saveChanges: "Enregistrer", connectedCalendars: "Calendriers connectés", connectMore: "En connecter d'autres", connectGoogle: "Connecter Google Agenda", connectApple: "Connecter le calendrier Apple", appleRemindersNote: "Les rappels Apple ne sont pas pris en charge. Depuis iOS 13 / macOS Catalina, Apple ne fournit plus de CalDAV ni d'API publique pour les rappels. Seuls les événements de calendrier peuvent être synchronisés.", connectSynology: "Connecter Synology", connectNotion: "Connecter Notion", noCalendars: "Aucun calendrier connecté.", dataPrivacy: "Données et confidentialité", downloadData: "Télécharger mes données", deleteAccount: "Supprimer le compte", name: "Nom", email: "E-mail", timezone: "Fuseau horaire", changePassword: "Changer le mot de passe", newPassword: "Nouveau mot de passe", confirmPassword: "Confirmer le mot de passe", someday: "UN JOUR", lists: "Listes", newList: "Nouvelle liste", allTabs: "Tous", newTab: "Nouvel onglet", newTabName: "Nom du nouvel onglet :", assignTab: "Assigner à un onglet", noTab: "Aucun onglet", renameTab: "Double-cliquez pour renommer", dissolveTab: "Supprimer l'onglet (garder les listes)", loading: "Chargement de vos tâches…", sycing: "Synchronisation…", synced: "Synchronisé", localization: "Localisation", allDayEvents: "ÉVÉNEMENTS JOURNÉE ENTIÈRE", syncCalendar: "Synchroniser le calendrier", toggleDarkMode: "Basculer le mode sombre", signOut: "Se déconnecter", startHour: "Début de journée", endHour: "Fin de journée", weekAbbr: "S", goalOfWeek: "Objectif de la semaine", goalScope: "Portée de l'objectif", goalScopeWeek: "Par semaine", goalScopeDay: "Par jour", goalFallback: "Type d'objectif par défaut", defaultGoal: "Objectif par défaut personnalisé", showTaskCheckboxes: "Afficher les cases à cocher", showProjectIcons: "Afficher les icônes de projets", showSomeday: "Afficher la section Un jour", showAllDay: "Afficher la section Journée entière", allDayPosition: "Position des événements journée entière", allDayAbove: "Au-dessus", allDayBelow: "En dessous", newPasswordDesc: "Laisser vide pour conserver le mot de passe actuel.", dateAlignment: "Alignement de la date", dateVerticalAlign: "Alignement vertical de la date", alignTop: "Haut", alignMiddle: "Milieu", alignBottom: "Bas", dateLayout: "Disposition de la date", alignmentLeft: "Gauche", alignmentCenter: "Centre", alignmentRight: "Droite", alignmentTight: "Compact", backupRestore: "Sauvegarde et restauration", backupRestoreDesc: "Exportez toutes vos tâches, listes et projets au format JSON. Vous pouvez modifier le fichier et le réimporter.", exportAllData: "Exporter toutes les données (JSON)", importData: "Importer des données", importMode: "Mode d'importation", importModeMerge: "Fusionner", importModeMergeDesc: "Ajouter les données importées aux tâches existantes", importModeReplace: "Remplacer", importModeReplaceDesc: "Supprimer toutes les données existantes et les remplacer par les données importées", importReplaceWarning: "Attention : toutes vos tâches, listes et projets actuels seront définitivement supprimés !", importSelectFile: "Sélectionner un fichier JSON…", importButton: "Importer", importing: "Importation…", exporting: "Exportation…", projects: "Projets", projectsDesc: "Organisez vos tâches avec des projets colorés", addProject: "Ajouter un projet", projectName: "Nom", projectColor: "Couleur", noProjects: "Aucun projet", assignProject: "Attribuer un projet", removeProject: "Retirer le projet", weekdayFormat: "Format des jours", weekdayFormatFull: "Nom complet (lundi)", weekdayFormatShort: "Abrégé (lun.)", weekdayFormatNarrow: "Étroit (L)", weekdayFormatCustom: "Personnalisé", customWeekdayNamesMon: "Lu; Ma; Me; Je; Ve; Sa; Di", customWeekdayNamesSun: "Di; Lu; Ma; Me; Je; Ve; Sa", weekdayCase: "Casse des jours", weekdayCaseNormal: "Normal (lundi)", weekdayCaseCapitalize: "Majuscule (Lundi)", weekdayCaseUppercase: "Majuscules (LUNDI)", styling: "Style", motivation: "Motivation", about: "À propos", setupAssistant: "Lancer l'assistant de configuration", weekStartLabel: "La semaine commence le", startViewLabel: "Vue commence par", monday: "Lundi", sunday: "Dimanche", today: "Aujourd'hui", yesterday: "Hier", accountId: "ID du compte", accountIdDesc: "Votre identifiant de compte unique", accountNumberLabel: "Numéro de compte", accountNumberDesc: "Votre numéro de compte pour identification", connectOutlook: "Connecter Outlook", syncTasks: "Synchroniser les tâches", syncTasksDesc: "Synchronisez les tâches avec Google Tasks ou Microsoft To-Do.", unsyncConfirmMsg: "Arrêter la synchronisation de \"{title}\" ? Ses tâches seront mises à la corbeille.", unsyncConfirm: "Arrêter la sync", unsyncCancel: "Annuler", syncAll: "Tout synchroniser", unsyncAll: "Tout désynchroniser", fetchingLists: "(chargement des listes...)", listHeader: "Liste", syncHeader: "Sync", noTaskListsFound: "Aucune liste de tâches trouvée.", connectProviderAbove: "Connectez un fournisseur ci-dessus pour synchroniser les listes.", noCalendarsFound: "Aucun calendrier trouvé ou accès refusé.", noCalendarsApple: "Aucun calendrier chargé. Veuillez déconnecter et reconnecter.", noCalendarsSynology: "Aucun calendrier chargé. Veuillez déconnecter et reconnecter.", selectionAfterConnect: "Sélection disponible après connexion.", sharedCalendar: "Calendrier partagé", primaryCalendar: "(Principal)", fontCustomization: "Personnalisation des polices", dateLayoutRight: "Date à droite du jour", dateLayoutLeft: "Date à gauche du jour", dateLayoutAbove: "Date au-dessus du jour", dateLayoutBelow: "Date en dessous du jour", dateLayoutHidden: "Date masquée", dateLayoutMobile: "Disposition date (mobile)", dayWeekdayGap: "Espacement jour / semaine", weekdayFont: "Police du jour", dateFont: "Police de la date", taskFont: "Police des tâches", eventFont: "Police des événements", goalFont: "Police objectif / citation", cwFont: "Police semaine calendaire", yearFont: "Police de l'année", fontPlaceholder: "ex. Poppins, Bebas Neue...", fontSizePlaceholder: "Taille (ex. 1.25rem)", weightLight: "Léger", weightNormal: "Normal", weightMedium: "Moyen", weightSemi: "Semi-gras", weightBold: "Gras", weightBlack: "Noir", hourLabelFormat: "Format des heures", hourLabelShort: "Court (8, 9, 10)", hourLabelFull: "Complet (8:00, 9:00, 10:00)", showSubhourLabels: "Afficher les quarts d'heure (:15, :30, :45)", showScheduleCalendar: "Afficher le calendrier", showDoThisNow: '"Faire maintenant" au lieu de la devise', focusTimer: "Minuteur Focus (min)", focusBreak: "Pause Focus (min)", goalScopeTitle: "Période de l'objectif", goalFallbackTitle: "Fallback objectif", motivationalQuote: "Citation motivante / info jour férié", nextTodo: "Prochaine tâche", defaultText: "Texte par défaut", apiDataSources: "Sources de données API (URLs)", addSource: "Ajouter une source", urlFormatHelp: "URL retournant des citations JSON", quoteLanguages: "Langues des citations", quoteLanguagesDesc: "Choisissez les langues de vos citations. Au moins une doit être sélectionnée.", quoteFallbackDesc: "Si aucune source externe ne répond, des citations locales dans votre langue sont utilisées.", defaultGoalPlaceholder: "Entrez votre objectif ici...", saturdayColor: "Samedi", sundayColor: "Dimanche", todayHighlight: "Surbrillance aujourd'hui", pastDayColor: "Jours passés", deleteProjectConfirm: "Supprimer le projet", importConfirmReplace: "Cela supprimera TOUTES les tâches, listes et projets existants. Continuer ?", importSuccess: "Import terminé", importInvalidJson: "Fichier JSON invalide", }, es: { settings: "Ajustes", general: "General", calendar: "Conexiones", localisation: "Localización", account: "Cuenta", runningList: "Lista continua (pasar tareas a hoy)", protectEventTimes: "Proteger horarios de eventos", showTimeGrid: "Mostrar cuadrícula horaria", timeSlotDuration: "Duración de los intervalos", viewStyle: "Estilo de vista", simpleView: "Simple", calendarView: "Calendario", listView: "Lista", weekView: "Semana", kanbanView: "Kanban", filterByProject: "Todos los proyectos", filterByList: "Todas las listas", filterByWeek: "Todas las semanas", kanbanStages: "Etapas Kanban", kanbanStagesDesc: "Define las etapas de tu tablero Kanban. Arrastra tareas entre columnas para cambiar su etapa.", addStage: "Añadir etapa", stageName: "Nombre de etapa", noStage: "Sin etapa", headerDisplay: "Visualización de encabezado", headerDisplayKW: "Semana calendario (KW)", headerDisplayMonth: "Nombre del mes - Marzo", headerDisplayMonthYear: "Mes y Año - Marzo | 2026", headerDisplayDate: "Fecha completa - 13.03.2026", headerDisplayCustom: "Personalizado - Viernes - 13 Marzo", headerDisplayNone: "Ninguno", headerCustomFormatLabel: "Formato (ej. DD.MM.YYYY)", language: "Idioma", dateFormat: "Formato de fecha", timeFormat: "Formato de hora", saveChanges: "Guardar cambios", connectedCalendars: "Calendarios conectados", connectMore: "Conectar más", connectGoogle: "Conectar Google Calendar", connectApple: "Conectar calendario de Apple", appleRemindersNote: "Los recordatorios de Apple no son compatibles. Desde iOS 13 / macOS Catalina, Apple ya no ofrece CalDAV ni una API pública para recordatorios. Solo se pueden sincronizar eventos del calendario.", connectSynology: "Conectar Synology", connectNotion: "Conectar Notion", noCalendars: "No hay calendarios conectados.", dataPrivacy: "Datos y privacidad", downloadData: "Descargar mis datos", deleteAccount: "Eliminar cuenta", name: "Nombre", email: "Correo electrónico", timezone: "Zona horaria", changePassword: "Cambiar contraseña", newPassword: "Nueva contraseña", confirmPassword: "Confirmar contraseña", someday: "ALGÚN DÍA", lists: "Listas", newList: "Nueva lista", allTabs: "Todas", newTab: "Nueva pestaña", newTabName: "Nombre de nueva pestaña:", assignTab: "Asignar a pestaña", noTab: "Sin pestaña", renameTab: "Doble clic para renombrar", dissolveTab: "Eliminar pestaña (mantener listas)", loading: "Cargando tus tareas…", sycing: "Sincronizando…", synced: "Sincronizado", localization: "Localización", allDayEvents: "EVENTOS DE TODO EL DÍA", syncCalendar: "Sincronizar calendario", toggleDarkMode: "Alternar modo oscuro", signOut: "Cerrar sesión", startHour: "Inicio del día", endHour: "Fin del día", weekAbbr: "S", goalOfWeek: "Objetivo de la semana", goalScope: "Alcance del objetivo", goalScopeWeek: "Por semana", goalScopeDay: "Por día", goalFallback: "Tipo de objetivo por defecto", defaultGoal: "Objetivo predeterminado personalizado", showTaskCheckboxes: "Mostrar casillas en las tareas", showProjectIcons: "Mostrar iconos de proyectos", showSomeday: "Mostrar sección Algún día", showAllDay: "Mostrar sección Todo el día", allDayPosition: "Posición de eventos de todo el día", allDayAbove: "Arriba", allDayBelow: "Abajo", newPasswordDesc: "Dejar en blanco para conservar la contraseña actual.", dateAlignment: "Alineación de la fecha", dateVerticalAlign: "Alineación vertical de la fecha", alignTop: "Arriba", alignMiddle: "Centro", alignBottom: "Abajo", dateLayout: "Disposición de la fecha", alignmentLeft: "Izquierda", alignmentCenter: "Centro", alignmentRight: "Derecha", alignmentTight: "Compacto", backupRestore: "Copia de seguridad y restauración", backupRestoreDesc: "Exporta todas tus tareas, listas y proyectos como archivo JSON. Puedes editar el archivo y volver a importarlo.", exportAllData: "Exportar todos los datos (JSON)", importData: "Importar datos", importMode: "Modo de importación", importModeMerge: "Combinar", importModeMergeDesc: "Añadir los datos importados junto a las tareas existentes", importModeReplace: "Reemplazar", importModeReplaceDesc: "Eliminar todos los datos existentes y reemplazarlos con los datos importados", importReplaceWarning: "Advertencia: ¡Se eliminarán permanentemente todas tus tareas, listas y proyectos actuales!", importSelectFile: "Seleccionar archivo JSON…", importButton: "Importar", importing: "Importando…", exporting: "Exportando…", projects: "Proyectos", projectsDesc: "Organiza las tareas con proyectos de colores", addProject: "Añadir proyecto", projectName: "Nombre", projectColor: "Color", noProjects: "Aún no hay proyectos", assignProject: "Asignar proyecto", removeProject: "Quitar proyecto", weekdayFormat: "Formato de los días", weekdayFormatFull: "Nombre completo (lunes)", weekdayFormatShort: "Abreviado (lun.)", weekdayFormatNarrow: "Estrecho (L)", weekdayFormatCustom: "Personalizado", customWeekdayNamesMon: "Lu; Ma; Mi; Ju; Vi; Sá; Do", customWeekdayNamesSun: "Do; Lu; Ma; Mi; Ju; Vi; Sá", weekdayCase: "Mayúsculas de los días", weekdayCaseNormal: "Normal (lunes)", weekdayCaseCapitalize: "Mayúscula inicial (Lunes)", weekdayCaseUppercase: "Mayúsculas (LUNES)", styling: "Estilo", motivation: "Motivación", about: "Acerca de", setupAssistant: "Iniciar asistente de configuración", weekStartLabel: "La semana empieza el", startViewLabel: "Vista empieza con", monday: "Lunes", sunday: "Domingo", today: "Hoy", yesterday: "Ayer", accountId: "ID de cuenta", accountIdDesc: "Tu identificador único de cuenta", accountNumberLabel: "Número de cuenta", accountNumberDesc: "Tu número de cuenta para identificación", connectOutlook: "Conectar Outlook", syncTasks: "Sincronizar tareas", syncTasksDesc: "Sincroniza tareas con Google Tasks o Microsoft To-Do.", unsyncConfirmMsg: "¿Dejar de sincronizar \"{title}\"? Sus tareas se moverán a la papelera.", unsyncConfirm: "Dejar de sincronizar", unsyncCancel: "Cancelar", syncAll: "Sincronizar todo", unsyncAll: "Desincronizar todo", fetchingLists: "(cargando listas...)", listHeader: "Lista", syncHeader: "Sync", noTaskListsFound: "No se encontraron listas de tareas.", connectProviderAbove: "Conecta un proveedor arriba para sincronizar listas.", noCalendarsFound: "No se encontraron calendarios o acceso denegado.", noCalendarsApple: "No hay calendarios cargados. Desconecta y reconecta.", noCalendarsSynology: "No hay calendarios cargados. Desconecta y reconecta.", selectionAfterConnect: "Selección disponible tras conectar.", sharedCalendar: "Calendario compartido", primaryCalendar: "(Principal)", fontCustomization: "Personalización de fuentes", dateLayoutRight: "Fecha a la derecha del día", dateLayoutLeft: "Fecha a la izquierda del día", dateLayoutAbove: "Fecha encima del día", dateLayoutBelow: "Fecha debajo del día", dateLayoutHidden: "Fecha oculta", dateLayoutMobile: "Disposición fecha (móvil)", dayWeekdayGap: "Espacio día / semana", weekdayFont: "Fuente del día", dateFont: "Fuente de la fecha", taskFont: "Fuente de tareas", eventFont: "Fuente de eventos", goalFont: "Fuente objetivo / cita", cwFont: "Fuente semana calendario", yearFont: "Fuente del año", fontPlaceholder: "ej. Poppins, Bebas Neue...", fontSizePlaceholder: "Tamaño (ej. 1.25rem)", weightLight: "Ligero", weightNormal: "Normal", weightMedium: "Medio", weightSemi: "Semi-negrita", weightBold: "Negrita", weightBlack: "Negro", hourLabelFormat: "Formato de horas", hourLabelShort: "Corto (8, 9, 10)", hourLabelFull: "Completo (8:00, 9:00, 10:00)", showSubhourLabels: "Mostrar cuartos de hora (:15, :30, :45)", showScheduleCalendar: "Mostrar calendario", showDoThisNow: '"Hacer ahora" en vez de lema', focusTimer: "Temporizador Focus (min)", focusBreak: "Pausa Focus (min)", goalScopeTitle: "Periodo del objetivo", goalFallbackTitle: "Fallback del objetivo", motivationalQuote: "Cita motivacional / festivo", nextTodo: "Siguiente tarea", defaultText: "Texto predeterminado", apiDataSources: "Fuentes de datos API (URLs)", addSource: "Añadir fuente", urlFormatHelp: "URL que devuelve citas JSON", quoteLanguages: "Idiomas de citas", quoteLanguagesDesc: "Elige los idiomas de tus citas. Al menos uno debe estar seleccionado.", quoteFallbackDesc: "Si ninguna fuente externa responde, se usan citas locales en tu idioma.", defaultGoalPlaceholder: "Ingresa tu objetivo aquí...", saturdayColor: "Sábado", sundayColor: "Domingo", todayHighlight: "Resaltado de hoy", pastDayColor: "Días pasados", deleteProjectConfirm: "Eliminar proyecto", importConfirmReplace: "Esto eliminará TODAS las tareas, listas y proyectos existentes. ¿Continuar?", importSuccess: "Importación completada", importInvalidJson: "Archivo JSON inválido", }, it: { settings: "Impostazioni", general: "Generali", calendar: "Connessioni", localisation: "Localizzazione", account: "Account", runningList: "Lista continua (sposta le attività a oggi)", protectEventTimes: "Proteggi gli orari degli eventi", showTimeGrid: "Mostra griglia oraria", timeSlotDuration: "Durata degli intervalli", viewStyle: "Stile di visualizzazione", simpleView: "Semplice", calendarView: "Calendario", listView: "Lista", weekView: "Settimana", kanbanView: "Kanban", filterByProject: "Tutti i progetti", filterByList: "Tutte le liste", filterByWeek: "Tutte le settimane", kanbanStages: "Fasi Kanban", kanbanStagesDesc: "Definisci le fasi della tua board Kanban. Trascina le attività tra le colonne per cambiare la loro fase.", addStage: "Aggiungi fase", stageName: "Nome fase", noStage: "Nessuna fase", headerDisplay: "Visualizzazione intestazione", headerDisplayKW: "Settimana calendario (KW)", headerDisplayMonth: "Nome del mese - Marzo", headerDisplayMonthYear: "Mese e Anno - Marzo | 2026", headerDisplayDate: "Data completa - 13.03.2026", headerDisplayCustom: "Personalizzato - Venerdì - 13 Marzo", headerDisplayNone: "Nessuno", headerCustomFormatLabel: "Formato (es. DD.MM.YYYY)", language: "Lingua", dateFormat: "Formato data", timeFormat: "Formato ora", saveChanges: "Salva modifiche", connectedCalendars: "Calendari collegati", connectMore: "Collega altri", connectGoogle: "Collega Google Calendar", connectApple: "Collega il calendario Apple", appleRemindersNote: "I promemoria Apple non sono supportati. Da iOS 13 / macOS Catalina, Apple non fornisce più CalDAV o un'API pubblica per i promemoria. Solo gli eventi del calendario possono essere sincronizzati.", connectSynology: "Collega Synology", connectNotion: "Collega Notion", noCalendars: "Nessun calendario collegato.", dataPrivacy: "Dati e privacy", downloadData: "Scarica i miei dati", deleteAccount: "Elimina account", name: "Nome", email: "E-mail", timezone: "Fuso orario", changePassword: "Cambia password", newPassword: "Nuova password", confirmPassword: "Conferma password", someday: "UN GIORNO", lists: "Liste", newList: "Nuova lista", allTabs: "Tutte", newTab: "Nuova scheda", newTabName: "Nome nuova scheda:", assignTab: "Assegna a scheda", noTab: "Nessuna scheda", renameTab: "Doppio clic per rinominare", dissolveTab: "Rimuovi scheda (mantieni liste)", loading: "Caricamento delle attività…", sycing: "Sincronizzazione…", synced: "Sincronizzato", localization: "Localizzazione", allDayEvents: "EVENTI GIORNATA INTERA", syncCalendar: "Sincronizza calendario", toggleDarkMode: "Attiva/disattiva modalità scura", signOut: "Esci", startHour: "Inizio giornata", endHour: "Fine giornata", weekAbbr: "S", goalOfWeek: "Obiettivo della settimana", goalScope: "Ambito dell'obiettivo", goalScopeWeek: "Per settimana", goalScopeDay: "Per giorno", goalFallback: "Tipo di obiettivo predefinito", defaultGoal: "Obiettivo predefinito personalizzato", showTaskCheckboxes: "Mostra caselle di spunta sulle attività", showProjectIcons: "Mostra icone per i progetti", showSomeday: "Mostra sezione Un giorno", showAllDay: "Mostra sezione Giornata intera", allDayPosition: "Posizione eventi giornata intera", allDayAbove: "Sopra", allDayBelow: "Sotto", newPasswordDesc: "Lascia vuoto per mantenere la password attuale.", dateAlignment: "Allineamento della data", dateVerticalAlign: "Allineamento verticale della data", alignTop: "In alto", alignMiddle: "Al centro", alignBottom: "In basso", dateLayout: "Disposizione della data", alignmentLeft: "Sinistra", alignmentCenter: "Centro", alignmentRight: "Destra", alignmentTight: "Compatto", backupRestore: "Backup e ripristino", backupRestoreDesc: "Esporta tutte le attività, le liste e i progetti come file JSON. Puoi modificare il file e reimportarlo.", exportAllData: "Esporta tutti i dati (JSON)", importData: "Importa dati", importMode: "Modalità di importazione", importModeMerge: "Unisci", importModeMergeDesc: "Aggiungere i dati importati alle attività esistenti", importModeReplace: "Sostituisci", importModeReplaceDesc: "Elimina tutti i dati esistenti e sostituiscili con i dati importati", importReplaceWarning: "Attenzione: tutte le attività, le liste e i progetti attuali verranno eliminati definitivamente!", importSelectFile: "Seleziona file JSON…", importButton: "Importa", importing: "Importazione…", exporting: "Esportazione…", projects: "Progetti", projectsDesc: "Organizza le attività con progetti colorati", addProject: "Aggiungi progetto", projectName: "Nome", projectColor: "Colore", noProjects: "Nessun progetto", assignProject: "Assegna progetto", removeProject: "Rimuovi progetto", weekdayFormat: "Formato dei giorni", weekdayFormatFull: "Nome completo (lunedì)", weekdayFormatShort: "Abbreviato (lun)", weekdayFormatNarrow: "Stretto (L)", weekdayFormatCustom: "Personalizzato", customWeekdayNamesMon: "Lu; Ma; Me; Gi; Ve; Sa; Do", customWeekdayNamesSun: "Do; Lu; Ma; Me; Gi; Ve; Sa", weekdayCase: "Maiuscole dei giorni", weekdayCaseNormal: "Normale (lunedì)", weekdayCaseCapitalize: "Iniziale maiuscola (Lunedì)", weekdayCaseUppercase: "Maiuscolo (LUNEDÌ)", styling: "Stile", motivation: "Motivazione", about: "Info", setupAssistant: "Assistente di configurazione", weekStartLabel: "La settimana inizia il", startViewLabel: "Vista inizia con", monday: "Lunedì", sunday: "Domenica", today: "Oggi", yesterday: "Ieri", accountId: "ID account", accountIdDesc: "Il tuo identificatore account unico", accountNumberLabel: "Numero account", accountNumberDesc: "Il tuo numero account per identificazione", connectOutlook: "Connetti Outlook", syncTasks: "Sincronizza attività", syncTasksDesc: "Sincronizza le attività con Google Tasks o Microsoft To-Do.", unsyncConfirmMsg: "Interrompere la sincronizzazione di \"{title}\"? Le attività verranno spostate nel cestino.", unsyncConfirm: "Interrompi sync", unsyncCancel: "Annulla", syncAll: "Sincronizza tutto", unsyncAll: "Desincronizza tutto", fetchingLists: "(caricamento liste...)", listHeader: "Lista", syncHeader: "Sync", noTaskListsFound: "Nessuna lista di attività trovata.", connectProviderAbove: "Connetti un provider sopra per sincronizzare le liste.", noCalendarsFound: "Nessun calendario trovato o accesso negato.", noCalendarsApple: "Nessun calendario caricato. Disconnetti e riconnetti.", noCalendarsSynology: "Nessun calendario caricato. Disconnetti e riconnetti.", selectionAfterConnect: "Selezione disponibile dopo la connessione.", sharedCalendar: "Calendario condiviso", primaryCalendar: "(Principale)", fontCustomization: "Personalizzazione caratteri", dateLayoutRight: "Data a destra del giorno", dateLayoutLeft: "Data a sinistra del giorno", dateLayoutAbove: "Data sopra il giorno", dateLayoutBelow: "Data sotto il giorno", dateLayoutHidden: "Data nascosta", dateLayoutMobile: "Layout data (mobile)", dayWeekdayGap: "Spazio giorno / settimana", weekdayFont: "Carattere giorno", dateFont: "Carattere data", taskFont: "Carattere attività", eventFont: "Carattere eventi", goalFont: "Carattere obiettivo / citazione", cwFont: "Carattere settimana calendario", yearFont: "Carattere anno", fontPlaceholder: "es. Poppins, Bebas Neue...", fontSizePlaceholder: "Dimensione (es. 1.25rem)", weightLight: "Leggero", weightNormal: "Normale", weightMedium: "Medio", weightSemi: "Semi-grassetto", weightBold: "Grassetto", weightBlack: "Nero", hourLabelFormat: "Formato delle ore", hourLabelShort: "Breve (8, 9, 10)", hourLabelFull: "Completo (8:00, 9:00, 10:00)", showSubhourLabels: "Mostra quarti d'ora (:15, :30, :45)", showScheduleCalendar: "Mostra calendario", showDoThisNow: '"Fai ora" invece del motto', focusTimer: "Timer Focus (min)", focusBreak: "Pausa Focus (min)", goalScopeTitle: "Periodo dell'obiettivo", goalFallbackTitle: "Fallback obiettivo", motivationalQuote: "Citazione motivazionale / festività", nextTodo: "Prossima attività", defaultText: "Testo predefinito", apiDataSources: "Fonti dati API (URL)", addSource: "Aggiungi fonte", urlFormatHelp: "URL che restituisce citazioni JSON", quoteLanguages: "Lingue delle citazioni", quoteLanguagesDesc: "Scegli le lingue delle citazioni. Almeno una deve essere selezionata.", quoteFallbackDesc: "Se nessuna fonte esterna risponde, vengono usate citazioni locali nella tua lingua.", defaultGoalPlaceholder: "Inserisci il tuo obiettivo qui...", saturdayColor: "Sabato", sundayColor: "Domenica", todayHighlight: "Evidenziazione oggi", pastDayColor: "Giorni passati", deleteProjectConfirm: "Elimina progetto", importConfirmReplace: "Questo eliminerà TUTTE le attività, liste e progetti esistenti. Continuare?", importSuccess: "Importazione completata", importInvalidJson: "File JSON non valido", }, }; // 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 slotsPerHour = 60 / cellDuration; for (let hour = startHour; hour < endHour; hour++) { for (let slot = 0; slot < slotsPerHour; slot++) { const minutes = slot * cellDuration; slots.push( `${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`, ); } } return slots; } function getHourFromSlot(slot: string): number { return parseInt(slot.split(":")[0], 10); } function getWeekNumber(date: Date): number { // ISO 8601 week number: weeks start on Monday const d = new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), ); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); } // Get the Monday of the ISO week that the first visible day belongs to. // This ensures CW changes when the first visible day crosses into a new ISO week. function getCWReferenceDate(days: Date[]): Date { if (days.length === 0) return new Date(); const first = days[0]; const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat // Calculate distance back to Monday (ISO week start) // Sunday (0) → go back 6 days to previous Monday // Monday (1) → 0, Tuesday (2) → 1, etc. const distToMonday = dow === 0 ? 6 : dow - 1; return new Date(first.getTime() - distToMonday * 86400000); } // Format a custom header string using tokens function formatCustomHeader(format: string, days: Date[], language: string, t: any): 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 = 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); } // 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 const calendarEvents = useMemo(() => { return rawCalendarEvents.map((event) => { let isEditable = false; if (event.calendarId) { for (const conn of connections) { if (conn.calendars && Array.isArray(conn.calendars)) { const cal = conn.calendars.find( (c: any) => c.id === event.calendarId, ); if (cal && cal.editable) { isEditable = true; break; } } } } return { ...event, editable: isEditable }; }); }, [rawCalendarEvents, connections]); const [currentWeekStart, setCurrentWeekStart] = useState(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }); const [viewDays, setViewDays] = useState(7); const savedViewDaysRef = useRef(7); // Track user's saved preference for restoring on resize const [isLoading, setIsLoading] = useState(true); // Responsive: auto-adjust viewDays based on screen width useEffect(() => { const getResponsiveViewDays = (width: number): number => { if (width <= 480) return 1; if (width <= 768) return 3; if (width <= 1024) return Math.min(savedViewDaysRef.current, 5); return savedViewDaysRef.current; }; const handleResize = () => { const responsiveDays = getResponsiveViewDays(window.innerWidth); setViewDays(responsiveDays); }; // Set initial value handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // savedViewDaysRef is a ref, so no dependency needed const [isSyncing, setIsSyncing] = useState(false); const [isFetchingCalendar, setIsFetchingCalendar] = useState(false); const [syncError, setSyncError] = useState(null); const syncCountRef = useRef(0); const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []); const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []); const [darkMode, setDarkMode] = useState(false); const [timeFormat, setTimeFormat] = useState("24h"); const [dateFormat, setDateFormat] = useState("yyyy-MM-dd"); const [hourLabelFormat, setHourLabelFormat] = useState<"short" | "full">("short"); const [showSubHourSlots, setShowSubHourSlots] = useState(true); const [allDayPosition, setAllDayPosition] = useState<"above" | "below">("below"); const [somedayExpanded, setSomedayExpanded] = useState(true); const [isAllDayExpanded, setIsAllDayExpanded] = useState(true); const [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 resizingRef = useRef<{ target: 'someday' | 'allday'; startY: number; startHeight: number; handleOnTop: boolean } | null>(null); const startResize = useCallback((e: React.MouseEvent | React.TouchEvent, target: 'someday' | 'allday', handleOnTop = false) => { e.preventDefault(); e.stopPropagation(); const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; const section = target === 'someday' ? somedaySectionRef.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; 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 section2 = resizingRef.current.target === 'someday' ? somedaySectionRef.current : (document.querySelector('.all-day-events-section') as HTMLElement); if (section2) { const h = Math.round(section2.getBoundingClientRect().height); document.cookie = `${resizingRef.current.target === 'someday' ? 'somedayHeight' : 'allDayHeight'}=${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 [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(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("weekly_active_someday_tab"); if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved); try { const savedTabs = localStorage.getItem("weekly_custom_tabs"); if (savedTabs) setCustomTabs(JSON.parse(savedTabs)); } catch { /* ignore */ } } }, []); const saveCustomTabs = (tabs: string[]) => { setCustomTabs(tabs); localStorage.setItem("weekly_custom_tabs", JSON.stringify(tabs)); }; 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); localStorage.setItem("weekly_active_someday_tab", tab ?? "__all__"); }; const assignListToTab = async (listId: string, tab: string | null) => { setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, tab } : l)); 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); } } }; 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 [showMobileFabSheet, setShowMobileFabSheet] = useState(false); const [showMobileFabMenu, setShowMobileFabMenu] = 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 [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", 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: "", }); const [motivationalQuote, setMotivationalQuote] = useState(""); const [showSummary, setShowSummary] = useState(false); const [isAddingSomedayList, setIsAddingSomedayList] = useState(false); const [newSomedayListName, setNewSomedayListName] = useState(""); const [selectedSomedayProvider, setSelectedSomedayProvider] = useState< string | null >(null); const [language, setLanguage] = useState("de"); const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">( "idle", ); const [cellDuration, setCellDuration] = useState(30); const [draggedTask, setDraggedTask] = useState(null); const [showTimeGrid, setShowTimeGrid] = useState(true); const [slideDirection, setSlideDirection] = useState<"next" | "prev" | null>( null, ); const [activeSlot, setActiveSlot] = useState<{ day: number; slot: string; } | null>(null); const [newSlotTask, setNewSlotTask] = useState(""); const [selectedTaskForNotes, setSelectedTaskForNotes] = useState( null, ); const [currentTime, setCurrentTime] = useState(new Date()); const [dropPreview, setDropPreview] = useState<{ day?: number; slot?: string; listId?: string; slotIdx?: number; } | null>(null); const [viewStyle, setViewStyle] = useState("simple"); // 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; 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", "showSomeday", "showAllDayEvents", "allDayPosition", "showCompletedTasks", "cellDuration", "startHour", "endHour"] as const; const [viewSettings, setViewSettings] = useState>({}); const viewSettingsRef = useRef>({}); viewSettingsRef.current = viewSettings; const getEffective = (key: K, globalVal: PerViewOverrides[K]): PerViewOverrides[K] => { const vs = viewSettingsRef.current[viewStyle]; if (vs && vs[key] !== undefined) return vs[key] as PerViewOverrides[K]; return globalVal; }; const isPerView = (key: keyof PerViewOverrides): boolean => { const vs = viewSettingsRef.current[viewStyle]; return !!(vs && vs[key] !== undefined); }; const saveViewSetting = async (key: K, value: PerViewOverrides[K], perView: boolean) => { const updated = { ...viewSettingsRef.current }; if (perView) { updated[viewStyle] = { ...(updated[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[viewStyle]) { const { [key]: _, ...rest } = updated[viewStyle] as any; updated[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", hourLabelFormat); const effectiveShowSubHourSlots = getEffective("showSubHourSlots", showSubHourSlots); 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 effectiveShowSomeday = getEffective("showSomeday", showSomeday); const effectiveShowAllDay = getEffective("showAllDayEvents", showAllDay); const effectiveAllDayPosition = getEffective("allDayPosition", allDayPosition) || "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 [showProjectsSidebar, setShowProjectsSidebar] = useState(false); const [focusTimerDuration, setFocusTimerDuration] = useState(25); const [fontSize, setFontSize] = useState<"S" | "M" | "L">("M"); const [headlineFont, setHeadlineFont] = useState("Inter"); const [headlineFontSize, setHeadlineFontSize] = useState("1.25rem"); const [headlineFontWeight, setHeadlineFontWeight] = useState("900"); const [dateFontFamily, setDateFontFamily] = useState("Inter"); const [dateFontSize, setDateFontSize] = useState("0.65rem"); const [dateFontWeight, setDateFontWeight] = useState("400"); const [timeTaskFontFamily, setTimeTaskFontFamily] = useState("Inter"); const [timeTaskFontSize, setTimeTaskFontSize] = useState("0.75rem"); const [timeTaskFontWeight, setTimeTaskFontWeight] = useState("500"); const [bodyFont, setBodyFont] = useState("Inter"); const [taskFontFamily, setTaskFontFamily] = useState("Inter"); const [taskFontSize, setTaskFontSize] = useState("0.9rem"); const [taskFontWeight, setTaskFontWeight] = useState("400"); const [eventFontFamily, setEventFontFamily] = useState("Inter"); const [eventFontSize, setEventFontSize] = useState("0.85rem"); const [eventFontWeight, setEventFontWeight] = useState("400"); const [fontWeight, setFontWeight] = useState("400"); const [weekendColorSat, setWeekendColorSat] = useState("#666666"); const [weekendColorSun, setWeekendColorSun] = useState("#dc2626"); // Load fonts // Dynamic font loading is handled by the main useGoogleFonts hook call below // Collect custom font names from profile settings const customFonts = useMemo(() => { const fontProps = [ profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily, profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily, profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont, ]; return fontProps.filter((f): f is string => !!f && isCustomFont(f)); }, [profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily, profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily, profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont]); // Load ALL available fonts + any custom fonts at the top level useGoogleFonts([ ...AVAILABLE_FONTS.filter((f) => f.value !== "__custom__").map((f) => f.value), ...customFonts, ]); // Dynamic font loading is handled by useGoogleFonts hook call above // Calendar Event Modal State const [calendarEventModal, setCalendarEventModal] = useState<{ isOpen: boolean; event?: CalendarEvent; initialDate?: Date; initialStartTime?: string; }>({ isOpen: false }); // Dark Mode Persistence & Class Toggle const [mounted, setMounted] = useState(false); const [recurringDeleteModal, setRecurringDeleteModal] = useState<{ isOpen: boolean; taskId: string | null; }>({ isOpen: false, taskId: null }); useEffect(() => { setMounted(true); const savedDarkMode = localStorage.getItem("weekly-dark-mode"); if (savedDarkMode) { setDarkMode(JSON.parse(savedDarkMode)); } const savedWeekStart = localStorage.getItem("weekly-week-start"); if (savedWeekStart) { setWeekStartDay(Number(savedWeekStart)); } }, []); // Mobile detection — track viewport width useEffect(() => { const check = () => setIsMobile(window.innerWidth <= 768); check(); window.addEventListener("resize", check); return () => window.removeEventListener("resize", check); }, []); const profileLoadedRef = useRef(false); const autoSaveTimerRef = useRef(null); const fetchProfile = async () => { try { const res = await fetch("/api/user/profile"); if (res.ok) { const data = await res.json(); if (data && data.user) { const profileData = data.user; setProfile(profileData); // Sync individual states to profile data if (profileData.viewStyle) setViewStyle(profileData.viewStyle); if (profileData.viewDays) 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); // 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(weekStartDay)); // REMOVED: Re-align current week start when start day changes // This was forcing the view to snap to Monday, breaking the "Yesterday as first column" setting. // setCurrentWeekStart(prev => getStartOfWeek(prev, weekStartDay)); }, [weekStartDay, mounted]); // Translation helper const t = translations[language] || translations["en"]; // Refs for scroll synchronization const timeColumnRef = useRef(null); const dayColumnsRef = useRef([]); const isScrollSyncing = useRef(false); const 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, cellDuration, viewDays, isMobile, profile.mobileDateLayout, profile.dateLayout, profile.dateAlignment, profile.dayHeaderGap, profile.headlineFontSize, profile.headlineFontWeight, profile.dateFontSize, profile.dateVerticalAlign, profile.headerDisplay, profile.weekdayFormat, viewStyle]); const somedaySectionRef = useRef(null); // Unified scroll sync handlers const handleTimeColumnScroll = (e: React.UIEvent) => { const scrollTop = e.currentTarget.scrollTop; if (isScrollSyncing.current) return; isScrollSyncing.current = true; if (gridRef.current) { gridRef.current.scrollTop = scrollTop; } setTimeout(() => { isScrollSyncing.current = false; }, 50); }; const handleGridScroll = (e: React.UIEvent) => { const scrollTop = e.currentTarget.scrollTop; if (isScrollSyncing.current) return; isScrollSyncing.current = true; if (timeColumnRef.current) { timeColumnRef.current.scrollTop = scrollTop; } setTimeout(() => { isScrollSyncing.current = false; }, 50); }; const jumpToHour = (hour: number) => { const slotsPerHour = 60 / cellDuration; const slotHeight = getSlotHeight(cellDuration); 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 (timeGridWrapperRef.current) timeGridWrapperRef.current.scrollTop = scrollOffset; if (gridRef.current) gridRef.current.scrollTop = scrollOffset; if (timeColumnRef.current) timeColumnRef.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 = startHour; const workingHoursEnd = endHour; // Fetch calendar events const fetchCalendarEvents = useCallback(async (forceRefresh = false) => { startSync(); setIsFetchingCalendar(true); try { const response = await fetch("/api/calendar/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timeMin: new Date( currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000, ).toISOString(), timeMax: new Date( currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000, ).toISOString(), forceRefresh, }), }); if (response.ok) { const text = await response.text(); try { const data = JSON.parse(text); if (data.events) { setRawCalendarEvents(data.events); } // 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(); if (data.event) { // Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime) 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]; }); } // Re-read from cache to get the canonical version // The backend already cached the event via upsertCachedEvent await fetchCalendarEvents(false); } 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 === '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 )); } setTimeout(() => fetchCalendarEvents(false), 2000); } 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; const data = await res.json(); let quoteText = ""; if (Array.isArray(data) && data.length > 0) { const item = data[0]; quoteText = item.quote || item.text || item.content || (typeof item === 'string' ? item : ""); if (item.author) quoteText += ` - ${item.author}`; } else if (data && typeof data === 'object') { quoteText = data.quote || data.text || data.content || ""; if (data.author) quoteText += ` - ${data.author}`; } else if (typeof data === 'string') { quoteText = data; } if (quoteText) { setMotivationalQuote(quoteText); return; // Success! } } catch (error) { console.error(`Error fetching quote from ${url}:`, error); } } // Final fallback: use local curated quotes 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(); } }, []); // Periodic pull-sync from Google Tasks (every 2 minutes) useEffect(() => { if (!session) return; const interval = setInterval( async () => { try { const res = await fetch("/api/tasks/sync"); if (res.ok) { const data = await res.json(); if (data.updated > 0 || data.deleted > 0 || data.created > 0) { console.log( `[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`, ); fetchTasks(); // Reload to reflect changes } } } catch (e) { console.error("[SYNC] Task sync error:", e); setSyncError("Task sync failed"); setTimeout(() => setSyncError(null), 10000); } }, 5 * 60 * 1000, ); return () => 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(), forceRefresh: true, }), }); if (res.ok) { const data = await res.json(); if (data.queued > 0 || data.refreshed > 0) { // Cache was refreshed; re-fetch events after delay setTimeout(() => fetchCalendarEvents(), 8000); } } } catch (e) { console.error("[SYNC] Calendar sync error:", e); setSyncError("Calendar sync failed"); setTimeout(() => setSyncError(null), 10000); } }, 2 * 60 * 1000, ); return () => clearInterval(interval); }, [session, fetchCalendarEvents]); async function fetchConnections() { try { setIsLoading(true); const response = await fetch("/api/calendar/connections"); if (response.ok) { const data = await response.json(); setConnections(data.connections || []); } } catch (error) { console.error("Error fetching connections:", error); } finally { setIsLoading(false); } } const handleRemoveConnection = async (connectionId: string) => { console.log("Disconnecting connection:", connectionId); const res = await fetch(`/api/calendar/connections?id=${connectionId}`, { method: "DELETE", }); if (res.ok) { // Update state immediately setConnections((prev) => prev.filter((c) => c.id !== connectionId)); // Refresh connections to be sure fetchConnections(); // Optionally refresh events too as they might be gone fetchCalendarEvents(); } else { const err = await res.json(); console.error("Failed to disconnect calendar", err); throw new Error(err.error || "Unknown error"); } }; // Refetch calendar events when week changes useEffect(() => { if (session) { fetchCalendarEvents(); } }, [currentWeekStart, session, fetchCalendarEvents]); // Scroll to preferred start hour (initial load + when user changes startHour) useEffect(() => { if (!isLoading) { const performScroll = () => { const slotsPerHour = 60 / cellDuration; const slotHeight = getSlotHeight(cellDuration); const scrollOffset = workingHoursStart * slotsPerHour * slotHeight; // Scroll the time-grid-wrapper (the scrollable viewport) if (timeGridWrapperRef.current) timeGridWrapperRef.current.scrollTop = scrollOffset; // Also scroll inner refs as fallback if (gridRef.current) gridRef.current.scrollTop = scrollOffset; if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollOffset; intendedScrollTop.current = scrollOffset; // On initial load, re-enforce for 2s to fight browser auto-scroll restoration if (!isInitialScrollDone.current) { const interval = setInterval(() => { if (timeGridWrapperRef.current) timeGridWrapperRef.current.scrollTop = scrollOffset; if (gridRef.current) gridRef.current.scrollTop = scrollOffset; if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollOffset; }, 50); setTimeout(() => { clearInterval(interval); isInitialScrollDone.current = true; }, 2000); } }; // Delay slightly to ensure layout is stable const timer = setTimeout(performScroll, isInitialScrollDone.current ? 50 : 500); 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); }, []); // Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date const getGoalDateKey = useCallback( (date: Date): string => { const scope = profile.goalScope || "week"; if (scope === "day") { const d = new Date(date); d.setHours(0, 0, 0, 0); return d.toISOString(); } // Normalize to Monday of the week containing this date const d = new Date(date); d.setHours(0, 0, 0, 0); const day = d.getDay(); // 0=Sun, 1=Mon, ... const diff = day === 0 ? -6 : 1 - day; // Monday offset d.setDate(d.getDate() + diff); return d.toISOString(); }, [profile.goalScope], ); const goalDateKey = useMemo( () => getGoalDateKey(currentWeekStart), [currentWeekStart, getGoalDateKey], ); // Fetch goal for current week/day useEffect(() => { const fetchGoal = async () => { try { const res = await fetch(`/api/goal?weekStart=${goalDateKey}`); if (res.ok) { const data = await res.json(); setGoal(data.goal); } } catch (err) { console.error("Failed to fetch goal:", err); } }; fetchGoal(); }, [goalDateKey]); const saveGoal = async (newGoal: string) => { setGoal(newGoal); try { const res = await fetch("/api/goal", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ weekStart: goalDateKey, text: newGoal, }), }); if (!res.ok) { console.error("Goal save failed:", res.status); } } catch (error) { console.error("Error saving goal:", error); } }; // Horizontal scroll: convert vertical wheel to horizontal in someday area // Callback ref ensures handler is attached as soon as element mounts const somedayWheelCleanup = useRef<(() => void) | null>(null); const somedaySectionRefCb = useCallback((node: HTMLElement | null) => { // Cleanup previous if (somedayWheelCleanup.current) { somedayWheelCleanup.current(); somedayWheelCleanup.current = null; } somedaySectionRef.current = node; if (!node) return; const handler = (e: WheelEvent) => { const grid = somedayGridRef.current; if (!grid) return; // Let native horizontal scroll (trackpad) pass through if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; if (e.deltaY === 0) return; // Only convert if grid has horizontal overflow if (grid.scrollWidth <= grid.clientWidth + 1) return; // Check boundaries - allow page scroll when at edges const atLeft = grid.scrollLeft <= 0; const atRight = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 1; if (e.deltaY < 0 && atLeft) return; if (e.deltaY > 0 && atRight) return; e.preventDefault(); grid.scrollLeft += e.deltaY; }; node.addEventListener("wheel", handler, { passive: false }); somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler); }, []); const saveSetting = async (key: string, value: any) => { // Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value if (DEVICE_SETTINGS_KEYS.includes(key)) { setCookie(`setting_${key}`, String(value)); return; // Don't write to DB — that would overwrite other devices } try { await fetch("/api/user/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [key]: value }), }); } catch (err) { console.error(`Failed to save setting ${key}:`, err); } }; const handleSettingsChanged = (newSettings: any) => { setShowTimeGrid(newSettings.showTimeGrid); setCellDuration(newSettings.cellDuration); setViewStyle(newSettings.viewStyle); setLanguage(newSettings.language); setDateFormat(newSettings.dateFormat); setTimeFormat(newSettings.timeFormat); setStartHour(newSettings.startHour); setEndHour(newSettings.endHour); setFontSize(newSettings.fontSize); setShowNextTask(newSettings.showNextTask); setShowSomeday(newSettings.showSomeday); setShowAllDay(newSettings.showAllDayEvents); setShowSchedule(newSettings.showSchedule); 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 width = window.innerWidth; if (width <= 480) setViewDays(1); else if (width <= 768) setViewDays(3); else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5)); else setViewDays(data.user.viewDays); } if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration); // Cookie overrides for per-device settings (always apply, even if DB has no value) const cookieViewDays = getCookie("setting_viewDays"); if (cookieViewDays) { const v = Number(cookieViewDays); savedViewDaysRef.current = v; const width = window.innerWidth; if (width <= 480) setViewDays(1); else if (width <= 768) setViewDays(3); else if (width <= 1024) setViewDays(Math.min(v, 5)); else setViewDays(v); } 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"); 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); 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); } } } 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, 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, 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; // 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], ); // 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; // 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], ); // Get calendar events for a specific date const getEventsForDate = useCallback( (date: Date): CalendarEvent[] => { return calendarEvents.filter((event) => { // Skip all-day events (handled separately) if (isAllDayEvent(event)) return false; const eventDate = new Date(event.startTime); return isSameDay(eventDate, date); }); }, [calendarEvents], ); // Get calendar events for a specific time slot const getEventsForSlot = useCallback( (date: Date, slot: string): CalendarEvent[] => { return calendarEvents.filter((event) => { // Skip all-day events (handled separately) const isAllDay = isAllDayEvent(event); if (isAllDay) return false; if (event.title.includes("Valentinstag")) { // Debug removed } const eventDate = new Date(event.startTime); if (!isSameDay(eventDate, date)) return false; // Extract hour:minute from event start time and compare with slot const eventHour = eventDate.getHours(); const eventMinute = eventDate.getMinutes(); // Match if event starts within this slot const [slotHour, slotMinute] = slot.split(":").map(Number); const slotStart = slotHour * 60 + slotMinute; const slotEnd = slotStart + effectiveCellDuration; const eventStart = eventHour * 60 + eventMinute; return eventStart >= slotStart && eventStart < slotEnd; }); }, [calendarEvents, effectiveCellDuration], ); // 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), ); }; // 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; if (!autoRolling) return; const now = new Date(); const todayStr = formatDateToISO(now); const today = new Date(todayStr); const overdue = currentTasks.filter( (t) => !t.completed && t.isRolling && t.scheduledDate && formatDateToISO(new Date(t.scheduledDate)) < todayStr, ); if (overdue.length === 0) return; console.log( `[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`, ); const updatedTasks = [...currentTasks]; let hasChanges = false; const dailyEvents = getEventsForDate(today); for (const task of overdue) { const targetSlot = task.startTime || "09:00"; // Default to 9am if no time // Collision detection const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => { // Check other tasks in the updated list const taskConflict = tasksToCheck.find( (t) => t.id !== task.id && t.scheduledDate && formatDateToISO(new Date(t.scheduledDate)) === formatDateToISO(date) && t.startTime === slot, ); if (taskConflict) return true; // Check calendar events const [h, m] = slot.split(":").map(Number); const slotStart = new Date(date); slotStart.setHours(h, m, 0, 0); const slotEnd = new Date(slotStart); slotEnd.setMinutes(slotEnd.getMinutes() + effectiveCellDuration); return dailyEvents.some((event) => { const eventStart = new Date(event.startTime); const eventEnd = new Date(event.endTime); return slotStart < eventEnd && slotEnd > eventStart; }); }; const findFreeSlot = ( date: Date, preferred: string, tasksToCheck: Task[], ) => { let current = preferred; let [h, m] = current.split(":").map(Number); while (isBlocked(date, current, tasksToCheck)) { m += 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 (!profile.autoRolling) return; if (tasks.length === 0) 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 timeGridWrapperRef = useRef(null); const allSlideClasses = ["slide-animate-next", "slide-animate-prev", "slide-animate-week-next", "slide-animate-week-prev"]; const navigate = ( newDate: Date, direction: "left" | "right", type: "day" | "week", ) => { const grid = gridRef.current; if (grid) { // Remove any existing animation class grid.classList.remove(...allSlideClasses); // Trigger reflow to restart animation if same direction void grid.offsetWidth; // Pick class: week uses longer animation const prefix = type === "week" ? "slide-animate-week-" : "slide-animate-"; grid.classList.add(prefix + (direction === "left" ? "next" : "prev")); // Clean up after animation const cleanup = () => { grid.classList.remove(...allSlideClasses); grid.removeEventListener("animationend", cleanup); }; grid.addEventListener("animationend", cleanup, { once: true }); } setCurrentWeekStart(newDate); setSlideDirection(direction === "left" ? "next" : "prev"); }; const goToPrevWeek = () => navigate( new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000), "right", "week", ); const goToNextWeek = () => navigate( new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000), "left", "week", ); const goToPrevDay = () => navigate( new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000), "right", "day", ); const goToNextDay = () => navigate( new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000), "left", "day", ); const goToToday = () => { const d = new Date(); d.setHours(0, 0, 0, 0); // 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 { const res = await fetch(`/api/someday-lists?id=${existing.id}`, { method: "DELETE", }); if (res.ok) { setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id)); setImportStatusMsg({ type: "success", text: `Stopped syncing "${list.title}".`, }); } } catch (error) { console.error("Failed to delete list", error); setImportStatusMsg({ type: "error", text: "Failed to stop syncing list.", }); } 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 { const res = await fetch(`/api/someday-lists?id=${existing.id}`, { 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(); 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; 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 toggleTaskRolling = async (taskId: string) => { saveSnapshot(); const task = findTaskAnywhere(taskId); if (!task) return; const newRollingState = !task.isRolling; const isSomeday = !!task.somedayListId; if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t, ), })), ); } else { setTasks( tasks.map((t) => t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t, ), ); } try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, isRolling: newRollingState }), }); } catch (error) { console.error("Error updating task rolling state:", error); if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map((t) => t.id === taskId ? { ...t, isRolling: !newRollingState } : t, ), })), ); } else { setTasks( tasks.map((t) => t.id === taskId ? { ...t, isRolling: !newRollingState } : t, ), ); } } }; const moveTaskToSlot = async ( taskId: string, dayOfWeek: number, startTime: string, scheduledDate?: Date, ) => { const newScheduledDate = scheduledDate ? formatDateToISO(scheduledDate) : undefined; const task = tasks.find((t) => t.id === taskId); setTasks( tasks.map((t) => t.id === taskId ? { ...t, dayOfWeek, startTime, scheduledDate: newScheduledDate || t.scheduledDate, somedayListId: null, somedaySlotIndex: null, updatedAt: new Date(), } : t, ), ); try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, dayOfWeek, startTime, scheduledDate: newScheduledDate, somedayListId: null, somedaySlotIndex: null, }), }); // Sync due date change to external provider if (task?.externalId && task?.externalProvider && newScheduledDate) { fetch("/api/tasks/sync", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }), }).catch((e) => console.error("Sync error:", e)); } } catch (error) { console.error("Error moving task:", error); } }; const assignProject = async (taskId: string, projectId: string | null) => { const proj = projectId ? projects.find((p) => p.id === projectId) || null : null; // Optimistic update const updateTask = (t: Task) => t.id === taskId ? { ...t, projectId: projectId, project: proj } : t; setTasks((prev) => prev.map(updateTask)); setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.map(updateTask) })) ); try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, projectId }), }); } catch (e) { console.error("Failed to assign project:", e); } }; const deleteTask = async (taskId: string) => { saveSnapshot(); const taskToDelete = findTaskAnywhere(taskId); const isSomeday = !!taskToDelete?.somedayListId; const isVirtual = taskId.startsWith("virtual-"); let originalId = taskId; if (isVirtual) { const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/); if (match) { originalId = match[1]; } } // Check if it's a series (virtual or real recurring) const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring); if (isSeries) { setRecurringDeleteModal({ isOpen: true, taskId }); return; } // NORMAL DELETE (Single instance) if (isSomeday) { setSomedayLists((prev) => prev.map((l) => ({ ...l, tasks: l.tasks.filter((t) => t.id !== taskId), })), ); } else { setTasks((prev) => prev.filter((t) => t.id !== taskId)); } setEditingTaskId(null); try { if (taskToDelete?.externalId && taskToDelete?.externalProvider) { fetch("/api/tasks/sync", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId, action: "delete" }), }).catch((e) => console.error("Sync delete error:", e)); } await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" }); } catch (error) { console.error("Error deleting task:", error); } }; const handleConfirmDeleteSeries = async (taskId: string) => { let originalId = taskId; if (taskId.startsWith("virtual-")) { const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/); if (match) originalId = match[1]; } const taskToDelete = findTaskAnywhere(originalId); setTasks((prev) => prev.filter((t) => { if (taskToDelete && t.title === taskToDelete.title && t.recurrenceInterval === taskToDelete.recurrenceInterval && t.recurrenceUnit === taskToDelete.recurrenceUnit) { return false; } if (t.id === originalId) return false; if (t.id.startsWith(`virtual-${originalId}-`)) return false; if (t.id === taskId) return false; return true; }), ); setEditingTaskId(null); setRecurringDeleteModal({ isOpen: false, taskId: null }); try { const origTask = findTaskAnywhere(originalId); if (origTask?.externalId && origTask?.externalProvider) { fetch("/api/tasks/sync", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId: originalId, action: "delete" }), }).catch((e) => console.error("Sync delete error:", e)); } await fetch(`/api/tasks?id=${originalId}`, { method: "DELETE" }); } catch (error) { console.error("Error deleting series:", error); } }; const handleConfirmDeleteOccurrence = async (taskId: string) => { setTasks((prev) => prev.filter((t) => t.id !== taskId)); setEditingTaskId(null); setRecurringDeleteModal({ isOpen: false, taskId: null }); try { const taskToDelete = findTaskAnywhere(taskId); if (taskToDelete?.externalId && taskToDelete?.externalProvider) { fetch("/api/tasks/sync", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId, action: "delete" }), }).catch((e) => console.error("Sync delete error:", e)); } await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" }); } catch (error) { console.error("Error deleting instance:", error); } }; // Toggle rolling status const toggleRolling = async (taskId: string) => { const task = tasks.find((t) => t.id === taskId); if (!task) return; const updatedIsRolling = !task.isRolling; setTasks( tasks.map((t) => t.id === taskId ? { ...t, isRolling: updatedIsRolling, updatedAt: new Date() } : t, ), ); try { await fetch("/api/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: taskId, isRolling: updatedIsRolling }), }); } catch (error) { console.error("Error toggling rolling status:", error); } }; // Roll task to tomorrow or next week const rollTask = async ( taskId: string, rollType: "tomorrow" | "nextWeek", ) => { const task = tasks.find((t) => t.id === taskId); if (!task || task.completed) return; // Get current task date const currentDate = task.scheduledDate ? new Date(task.scheduledDate) : new Date(); // Calculate new date const newDate = new Date(currentDate); if (rollType === "tomorrow") { newDate.setDate(newDate.getDate() + 1); } else { newDate.setDate(newDate.getDate() + 7); } const newScheduledDate = formatDateToISO(newDate); // Preserve startTime — if the preferred slot is taken, find next free one let resolvedStartTime = task.startTime || undefined; if (resolvedStartTime) { const targetSlotTasks = tasks.filter((t) => { if (t.id === taskId || !t.scheduledDate) return false; const tDate = formatDateToISO(new Date(t.scheduledDate)); return tDate === newScheduledDate && t.startTime === resolvedStartTime; }); if (targetSlotTasks.length > 0) { // Slot is taken — find next free slot const allSlots = getTimeSlots( 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"); setTimeout(() => setSyncStatus("idle"), 3000); } catch (error) { console.error("Error syncing:", error); setSyncStatus("idle"); setSyncError("Sync failed"); setTimeout(() => setSyncError(null), 10000); } }; // Start adding someday list UI const handleStartAddSomedayList = () => { setIsAddingSomedayList(true); // Focus will happen in render logic if possible or via ref, but let's render conditional input first }; const saveSomedayList = async () => { if (!newSomedayListName.trim()) { setIsAddingSomedayList(false); setNewSomedayListName(""); setSelectedSomedayProvider(null); return; } try { const url = selectedSomedayProvider ? "/api/someday-lists/external" : "/api/someday-lists"; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newSomedayListName.trim(), provider: selectedSomedayProvider, }), }); if (response.ok) { const data = await response.json(); setSomedayLists((prev) => [ ...prev, { ...(data.somedayList || data.list), tasks: [], // Initially empty }, ]); setNewSomedayListName(""); setSelectedSomedayProvider(null); setIsAddingSomedayList(false); } else { const error = await response.json(); alert(error.error || "Failed to create list"); } } catch (error) { console.error("Error adding someday list:", error); alert("An error occurred while creating the list"); } }; // Get time slots to display const visibleSlots = getTimeSlots( effectiveCellDuration, 0, 24, ); const fontSizeScale = fontSize === "S" ? 0.85 : 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 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) || headlineFont ? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif` : "var(--font-headline)", "--weekly-headline-size": scaleRem(profile.headlineFontSize || "1.25rem"), "--weekly-headline-weight": profile.headlineFontWeight || "900", "--weekly-date-font": fontVal(profile.dateFontFamily) ? `"${fontVal(profile.dateFontFamily)}", sans-serif` : "var(--weekly-font-headline)", "--weekly-date-size": scaleRem(profile.dateFontSize || "0.65rem"), "--weekly-date-weight": profile.dateFontWeight || "400", "--weekly-time-task-font": (() => { // 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) || eventFontFamily ? `"${fontVal(profile.eventFontFamily) || eventFontFamily}", sans-serif` : "var(--weekly-font)", "--weekly-event-size": scaleRem(profile.eventFontSize || eventFontSize || "0.85rem"), "--weekly-event-weight": profile.eventFontWeight || eventFontWeight || "400", "--font-weight-body": profile.fontWeight || fontWeight || "400", "--weekly-weekend-sat": 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"), } as React.CSSProperties; if (isLoading) { return (
{translations[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, }); // 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}
{showTimeGrid && (
setIsAllDayExpanded(!isAllDayExpanded)} style={{ width: "55px", flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "pointer", borderRight: "1px solid var(--weekly-border)", padding: "2px 4px", gap: "0px", marginLeft: "-1px", position: "relative", }} title={isAllDayExpanded ? "Collapse" : "Expand"} > all day {allDayEvents.length}
)} {/* 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 (
{/* Quick Settings Sidebar (TeuxDeux-style) */} {showQuickSettings && ( <>
setShowQuickSettings(false)} style={{ position: "fixed", inset: 0, zIndex: 999 }} />
{language === "de" ? "Einstellungen" : "Preferences"}
{/* Navigation Row */}
{/* Quick Actions */}
{/* Separator */}
{/* View Style */}
{language === "de" ? "Ansicht" : "View"}
{[ { key: "simple", icon: }, { key: "calendar", icon: }, { key: "list", icon: }, { key: "kanban", icon: }, ].map((v) => ( ))}
{/* Columns / Days */}
{language === "de" ? "Spalten" : "Days"}
{[1, 2, 3, 5, 7].map((num) => ( ))}
{/* Slot Duration (only with time grid) */} {showTimeGrid && (
{language === "de" ? "Zeitfenster" : "Slot"}
{[15, 30, 60].map((d) => ( ))}
)} {/* Visible Hours (only with time grid) */} {showTimeGrid && (
{ const h = Math.max(0, Math.min(23, parseInt(e.target.value) || 0)); setStartHour(h); saveSetting("startHour", h); }} style={{ width: "44px", padding: "4px 4px", borderRadius: "6px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#1f2937" : "#f3f4f6", color: darkMode ? "#e5e7eb" : "#333", fontSize: "0.8rem", textAlign: "center", outline: "none" }} /> { const h = Math.max(1, Math.min(24, parseInt(e.target.value) || 24)); setEndHour(h); saveSetting("endHour", h); }} style={{ width: "44px", padding: "4px 4px", borderRadius: "6px", border: `1px solid ${darkMode ? "#555" : "#e5e7eb"}`, background: darkMode ? "#1f2937" : "#f3f4f6", color: darkMode ? "#e5e7eb" : "#333", fontSize: "0.8rem", textAlign: "center", outline: "none" }} /> h
)} {/* Text size */}
{language === "de" ? "Textgröße" : "Text size"}
{(["S", "M", "L"] as const).map((size) => ( ))}
{/* Separator */}
{/* Toggle switches */}
{language === "de" ? "Irgendwann" : "Someday"}
{language === "de" ? "Zeitplan" : "Schedule"}
{language === "de" ? "Ganztägig" : "All-day"}
{language === "de" ? "Checkboxen" : "Checkboxes"}
{language === "de" ? "Projekt-Icons" : "Project Icons"}
{/* Start on */}
{language === "de" ? "Starten mit" : "Start on"}
{/* Display mode */}
{language === "de" ? "Anzeige" : "Display"}
{/* Separator */}
{/* Undo / Redo / Refresh */}
{/* Spacer */}
{/* Close button at bottom */}
)} {/* Projects Sidebar */} {showProjectsSidebar && ( setShowProjectsSidebar(false)} /> )} {/* Quick Settings toggle button is now in the desktop header toolbar */} {/* View Transitions Style Block */}