My-Weekly-ToDo-List/src/components/WeeklyView.tsx
mARTin af2de09f6b feat: add Kanban board view with customizable stages
- New "Kanban" view style alongside Simple, Calendar, and List
- Drag tasks between columns to change their stage
- Customizable stages with colors in Settings > View Style
- Stage colors appear as left border indicators on tasks in weekly view
- Default stages: Backlog, To Do, In Progress, Review, Done
- Stages persist in database (User.kanbanStages as JSON)
- Task stage persists in database (Task.kanbanStage)
- Full i18n support (EN, DE, FR, ES, IT)
- Unassigned tasks shown in separate column

v1.28.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:15:11 +01:00

13973 lines
745 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
DragEvent,
} from "react";
import { useSession, signOut } from "next-auth/react";
import CalendarEventModal from "./CalendarEventModal";
import TaskRecurrenceModal from "./RecurrenceModal";
import { GridTaskBlock } from "./GridTaskBlock";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import FocusModeOverlay from "./FocusModeOverlay";
import {
LayoutGrid,
Calendar,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
Settings,
User,
Clock,
Menu,
Target,
Sun,
Moon,
Repeat,
GripVertical,
Play,
Zap,
Plus,
RefreshCcw,
Layout,
Palette,
Sparkles,
Info,
Trash2,
Undo2,
Redo2,
AlertCircle,
MoreVertical,
Check,
Eye,
EyeOff,
PanelLeftClose,
PanelLeftOpen,
Type,
FolderOpen,
FolderPlus,
ListPlus,
Circle,
X,
Cable,
Link,
Globe,
Tag,
} from "lucide-react";
// Types
import UserMenu from "./UserMenu";
import SearchModal from "./SearchModal";
import SimpleDatePicker from "./SimpleDatePicker";
import RecurringTasksManager from "./RecurringTasksManager";
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
import { ImportListModal } from "./ImportListModal";
import { getRandomLocalQuote } from "@/lib/quotes";
// Cookie helpers for per-device settings
const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour"];
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function setCookie(name: string, value: string, days: number = 365) {
if (typeof document === "undefined") return;
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
}
export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "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";
calendarId?: string;
calendarTitle?: string;
calendarColor?: string;
editable?: boolean;
}
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 | 30 | 60 | 120;
const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
const getSomedaySlotCount = (tasks: Task[]) => {
const maxIdx = tasks.reduce((max, t) => {
if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) {
return Math.max(max, t.somedaySlotIndex);
}
return max;
}, -1);
// Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total.
// "add 5 rows and then when 4 are taken add another row"
// Let's ensure there's always at least one empty slot at the bottom.
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
};
// Font options
const AVAILABLE_FONTS = [
{ name: "Default (Inter)", value: "Inter" },
{ name: "Roboto", value: "Roboto" },
{ name: "Open Sans", value: "Open Sans" },
{ name: "Lato", value: "Lato" },
{ name: "Montserrat", value: "Montserrat" },
{ name: "Oswald", value: "Oswald" },
{ name: "Raleway", value: "Raleway" },
{ name: "Playfair Display", value: "Playfair Display" },
{ name: "Merriweather", value: "Merriweather" },
{ name: "Nunito", value: "Nunito" },
{ name: "Dancing Script", value: "Dancing Script" },
{ name: "Pacifico", value: "Pacifico" },
{ name: "Custom Google Font...", value: "__custom__" },
];
// Check if a font value is a custom (non-preset) font
const isCustomFont = (value: string): boolean =>
!!value && value !== "__custom__" && !AVAILABLE_FONTS.slice(0, -1).some((f) => f.value === value);
const FONT_WEIGHTS = [
{ name: "Light", value: "300" },
{ name: "Normal", value: "400" },
{ name: "Medium", value: "500" },
{ name: "Bold", value: "700" },
];
// Helper to load Google Fonts
const useGoogleFonts = (fonts: string[]) => {
useEffect(() => {
if (typeof window === "undefined") return;
const fontsToLoad = fonts.filter((f) => f && f !== "Inter");
if (fontsToLoad.length === 0) return;
const linkId = "google-fonts-link";
let link = document.getElementById(linkId) as HTMLLinkElement;
const fontQuery = fontsToLoad.map((f) => f.replace(" ", "+")).join("|");
const href = `https://fonts.googleapis.com/css2?family=${fontsToLoad.map((f) => `${f.replace(" ", "+")}:wght@300;400;500;700`).join("&family=")}&display=swap`;
if (!link) {
link = document.createElement("link");
link.id = linkId;
link.rel = "stylesheet";
document.head.appendChild(link);
}
link.href = href;
}, [fonts]);
};
// Translations
const translations: Record<string, any> = {
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",
kanbanView: "Kanban",
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",
language: "Language",
dateFormat: "Date Format",
timeFormat: "Time Format",
saveChanges: "Save Changes",
connectedCalendars: "Connected Calendars",
connectMore: "Connect More",
connectGoogle: "Connect Google Calendar",
connectApple: "Connect Apple Calendar",
connectSynology: "Connect Synology",
noCalendars: "No calendars connected yet.",
dataPrivacy: "Data & Privacy",
downloadData: "Download My Data",
deleteAccount: "Delete Account",
name: "Name",
email: "Email",
timezone: "Timezone",
changePassword: "Change Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
someday: "SOMEDAY",
lists: "Lists",
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",
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",
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",
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",
listView: "Liste",
notes: "Notizen",
notesSidebar: "Notizen-Seitenleiste",
language: "Sprache",
dateFormat: "Datumsformat",
timeFormat: "Zeitformat",
saveChanges: "Änderungen speichern",
connectedCalendars: "Verbundene Kalender",
connectMore: "Mehr verbinden",
connectGoogle: "Google Kalender verbinden",
connectApple: "Apple Kalender verbinden",
noCalendars: "Keine Kalender verbunden.",
dataPrivacy: "Daten & Datenschutz",
downloadData: "Meine Daten herunterladen",
deleteAccount: "Konto löschen",
name: "Name",
email: "E-Mail",
timezone: "Zeitzone",
changePassword: "Passwort ändern",
newPassword: "Neues Passwort",
confirmPassword: "Passwort bestätigen",
someday: "IRGENDWANN",
lists: "Listen",
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",
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",
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",
kanbanView: "Kanban",
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",
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",
connectSynology: "Connecter Synology",
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",
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",
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",
kanbanView: "Kanban",
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",
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",
connectSynology: "Conectar Synology",
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",
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",
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",
kanbanView: "Kanban",
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",
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",
connectSynology: "Collega Synology",
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à",
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",
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}`;
}
function formatHour(hour: number, format: "short" | "full" = "short", timeFormat: string = "24h"): string {
if (timeFormat === "12h") {
const h = hour % 12 || 12;
const ampm = hour >= 12 ? "PM" : "AM";
return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`;
}
return format === "full" ? `${hour}:00` : `${hour}`;
}
function getTimeSlots(
cellDuration: CellDuration,
startHour: number,
endHour: number,
): string[] {
const slots: string[] = [];
const slotsPerHour = 60 / cellDuration;
for (let hour = startHour; hour < endHour; hour++) {
for (let slot = 0; slot < slotsPerHour; slot++) {
const minutes = slot * cellDuration;
slots.push(
`${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`,
);
}
}
return slots;
}
function getHourFromSlot(slot: string): number {
return parseInt(slot.split(":")[0], 10);
}
function getWeekNumber(date: Date): number {
// ISO 8601 week number: weeks start on Monday
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
);
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
// Get the Monday of the ISO week that the first visible day belongs to.
// This ensures CW changes when the first visible day crosses into a new ISO week.
function getCWReferenceDate(days: Date[]): Date {
if (days.length === 0) return new Date();
const first = days[0];
const dow = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
// Calculate distance back to Monday (ISO week start)
// Sunday (0) → go back 6 days to previous Monday
// Monday (1) → 0, Tuesday (2) → 1, etc.
const distToMonday = dow === 0 ? 6 : dow - 1;
return new Date(first.getTime() - distToMonday * 86400000);
}
// Check if an event is an all-day event
// Defined outside component to avoid stale closure issues in useCallbacks
const isAllDayEvent = (event: CalendarEvent): boolean => {
if (!event.startTime) return false;
// Date-only format (YYYY-MM-DD)
if (!event.startTime.includes("T")) return true;
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
// Check if strictly midnight to midnight in local time
const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0;
// Check if UTC midnight (common for API-converted date strings)
const isUTCMidnight =
start.getUTCHours() === 0 && start.getUTCMinutes() === 0;
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
};
// Helper to invert colors for dark mode
function invertColor(hex: string): string {
if (!hex) return hex;
let color = hex.startsWith("#") ? hex.slice(1) : hex;
if (color.length === 3) {
color = color
.split("")
.map((c) => c + c)
.join("");
}
if (color.length !== 6) return hex;
try {
const r = (255 - parseInt(color.slice(0, 2), 16))
.toString(16)
.padStart(2, "0");
const g = (255 - parseInt(color.slice(2, 4), 16))
.toString(16)
.padStart(2, "0");
const b = (255 - parseInt(color.slice(4, 6), 16))
.toString(16)
.padStart(2, "0");
return `#${r}${g}${b}`;
} catch (e) {
return hex;
}
}
// Helper to lighten color for dark mode
function adjustColorForDarkMode(hex: string, isDarkMode: boolean): string {
if (!isDarkMode || !hex || !hex.startsWith("#")) return hex;
// Simple hex to RGB
let r = parseInt(hex.slice(1, 3), 16);
let g = parseInt(hex.slice(3, 5), 16);
let b = parseInt(hex.slice(5, 7), 16);
// Calculate brightness (0-255)
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
// If it's too dark for dark mode, lighten it
if (brightness < 120) {
r = Math.min(255, r + 100);
g = Math.min(255, g + 100);
b = Math.min(255, b + 100);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
return hex;
}
// Main Component
export default function WeeklyView() {
const { data: session } = useSession();
const [tasks, setTasks] = useState<Task[]>([]);
const [connections, setConnections] = useState<any[]>([]); // Lifted state
const [rawCalendarEvents, setRawCalendarEvents] = useState<CalendarEvent[]>(
[],
);
// 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<string | null>(null);
const syncCountRef = useRef(0);
const startSync = useCallback(() => { syncCountRef.current++; setIsSyncing(true); }, []);
const endSync = useCallback(() => { syncCountRef.current = Math.max(0, syncCountRef.current - 1); if (syncCountRef.current === 0) setIsSyncing(false); }, []);
const [darkMode, setDarkMode] = useState(false);
const [timeFormat, setTimeFormat] = useState("24h");
const [dateFormat, setDateFormat] = useState("yyyy-MM-dd");
const [hourLabelFormat, setHourLabelFormat] = useState<"short" | "full">("short");
const [showSubHourSlots, setShowSubHourSlots] = useState(true);
const [allDayPosition, setAllDayPosition] = useState<"above" | "below">("below");
const [somedayExpanded, setSomedayExpanded] = useState(true);
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [listToDelete, setListToDelete] = useState<string | null>(null);
const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null);
const [editingTabName, setEditingTabName] = useState<string | null>(null);
const [renamingTabValue, setRenamingTabValue] = useState("");
const [newTabForListId, setNewTabForListId] = useState<string | null>(null);
const [newTabNameValue, setNewTabNameValue] = useState("");
const [creatingNewTab, setCreatingNewTab] = useState(false);
const [creatingNewTabName, setCreatingNewTabName] = useState("");
const [dragOverTab, setDragOverTab] = useState<string | null>(null);
const [customTabs, setCustomTabs] = useState<string[]>([]);
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<string>();
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<number | null>(null);
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
const isDragFromHandle = useRef(false);
// Undo/Redo state
const undoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
const redoStackRef = useRef<{ tasks: Task[]; somedayLists: SomedayList[] }[]>([]);
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const skipSnapshotRef = useRef(false);
// Mobile detection
const [isMobile, setIsMobile] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showMobileFabSheet, setShowMobileFabSheet] = useState(false);
const [fabTaskTitle, setFabTaskTitle] = useState("");
const mobileMenuRef = useRef<HTMLDivElement>(null);
const fabTextareaRef = useRef<HTMLTextAreaElement>(null);
// Moved state definitions to the top
const [showSettings, setShowSettings] = useState(false);
const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
>("general");
const [exportStartDate, setExportStartDate] = useState("");
const [exportEndDate, setExportEndDate] = useState("");
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
const [accountMsg, setAccountMsg] = useState<string>("");
const [importingTasksState, setImportingTasksState] =
useState<boolean>(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<string, boolean>
>({});
const [isVisible, setIsVisible] = useState(false);
const [profile, setProfile] = useState<{
name: string;
email: string;
timezone: string;
autoRolling?: boolean;
protectEventTimes?: boolean;
language?: string;
dateFormat?: string;
timeFormat?: string;
startHour?: number;
endHour?: number;
focusTimerDuration?: number;
focusBreakDuration?: number;
showTimeGrid?: boolean;
cellDuration?: number;
viewStyle?: string;
fontSize?: "S" | "M" | "L";
showNextTask?: boolean;
showSomeday?: boolean;
showAllDayEvents?: boolean;
showSchedule?: boolean;
headlineFont?: string;
headlineFontSize?: string;
headlineFontWeight?: string;
dateFontFamily?: string;
dateFontSize?: string;
dateFontWeight?: string;
timeTaskFontFamily?: string;
timeTaskFontSize?: string;
timeTaskFontWeight?: string;
bodyFont?: string;
taskFontFamily?: string;
taskFontSize?: string;
taskFontWeight?: string;
eventFontFamily?: string;
eventFontSize?: string;
eventFontWeight?: string;
fontWeight?: string;
weekendColorSat?: string;
weekendColorSun?: string;
weekdayColor?: string;
dateColor?: string;
taskColor?: string;
todayHighlightColor?: string;
pastDayColor?: string;
goalFallbackType?: "quote" | "next_todo" | "default";
quoteSourceUrl?: string;
goalDefaultSentence?: string;
goalFontFamily?: string;
goalFontSize?: string;
goalFontWeight?: string;
goalScope?: "week" | "day";
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
dateAlignment?: "left" | "center" | "right" | "tight";
hourLabelFormat?: "short" | "full";
showSubHourSlots?: boolean;
allDayPosition?: "above" | "below";
cwFontFamily?: string;
cwFontSize?: string;
cwFontWeight?: string;
cwColor?: string;
yearFontFamily?: string;
yearFontSize?: string;
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
showTaskCheckboxes?: boolean;
quoteSourceUrls?: string[];
quoteLanguages?: string[];
startDayOffset?: number;
id?: string;
accountNumber?: number;
weekdayFormat?: "long" | "short" | "narrow" | "custom";
weekdayCase?: "normal" | "capitalize" | "uppercase";
customWeekdayNames?: string;
dateVerticalAlign?: "top" | "middle" | "bottom";
}>({
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"],
});
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<CellDuration>(30);
const [draggedTask, setDraggedTask] = useState<Task | null>(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<Task | null>(
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<ViewStyle>("simple");
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<KanbanStage[]>(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); }
};
const [protectEventTimes, setProtectEventTimes] = useState(false);
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
const [startHour, setStartHour] = useState(8);
const [endHour, setEndHour] = useState(18);
const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday
const [showSomeday, setShowSomeday] = useState(true);
const [showAllDay, setShowAllDay] = useState(true);
const [goal, setGoal] = useState("your goal of this week");
const [isEditingGoal, setIsEditingGoal] = useState(false);
const [showNextTask, setShowNextTask] = useState(false);
const [calendarEditMode, setCalendarEditMode] = useState(false);
const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] =
useState<Task | null>(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<HTMLButtonElement>(null);
const [showQuickSettings, setShowQuickSettings] = useState(false);
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
const [fontSize, setFontSize] = useState<"S" | "M" | "L">("M");
const [headlineFont, setHeadlineFont] = useState("Inter");
const [headlineFontSize, setHeadlineFontSize] = useState("1.25rem");
const [headlineFontWeight, setHeadlineFontWeight] = useState("900");
const [dateFontFamily, setDateFontFamily] = useState("Inter");
const [dateFontSize, setDateFontSize] = useState("0.65rem");
const [dateFontWeight, setDateFontWeight] = useState("400");
const [timeTaskFontFamily, setTimeTaskFontFamily] = useState("Inter");
const [timeTaskFontSize, setTimeTaskFontSize] = useState("0.75rem");
const [timeTaskFontWeight, setTimeTaskFontWeight] = useState("500");
const [bodyFont, setBodyFont] = useState("Inter");
const [taskFontFamily, setTaskFontFamily] = useState("Inter");
const [taskFontSize, setTaskFontSize] = useState("0.9rem");
const [taskFontWeight, setTaskFontWeight] = useState("400");
const [eventFontFamily, setEventFontFamily] = useState("Inter");
const [eventFontSize, setEventFontSize] = useState("0.85rem");
const [eventFontWeight, setEventFontWeight] = useState("400");
const [fontWeight, setFontWeight] = useState("400");
const [weekendColorSat, setWeekendColorSat] = useState("#666666");
const [weekendColorSun, setWeekendColorSun] = useState("#dc2626");
// Load fonts
// Dynamic font loading is handled by the main useGoogleFonts hook call below
// Collect custom font names from profile settings
const customFonts = useMemo(() => {
const fontProps = [
profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily,
profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily,
profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont,
];
return fontProps.filter((f): f is string => !!f && isCustomFont(f));
}, [profile.headlineFont, profile.dateFontFamily, profile.taskFontFamily,
profile.timeTaskFontFamily, profile.eventFontFamily, profile.goalFontFamily,
profile.cwFontFamily, profile.yearFontFamily, profile.bodyFont]);
// Load ALL available fonts + any custom fonts at the top level
useGoogleFonts([
...AVAILABLE_FONTS.filter((f) => f.value !== "__custom__").map((f) => f.value),
...customFonts,
]);
// Dynamic font loading is handled by useGoogleFonts hook call above
// Calendar Event Modal State
const [calendarEventModal, setCalendarEventModal] = useState<{
isOpen: boolean;
event?: CalendarEvent;
initialDate?: Date;
initialStartTime?: string;
}>({ isOpen: false });
// Dark Mode Persistence & Class Toggle
const [mounted, setMounted] = useState(false);
const [recurringDeleteModal, setRecurringDeleteModal] = useState<{
isOpen: boolean;
taskId: string | null;
}>({ isOpen: false, taskId: null });
useEffect(() => {
setMounted(true);
const savedDarkMode = localStorage.getItem("weekly-dark-mode");
if (savedDarkMode) {
setDarkMode(JSON.parse(savedDarkMode));
}
const savedWeekStart = localStorage.getItem("weekly-week-start");
if (savedWeekStart) {
setWeekStartDay(Number(savedWeekStart));
}
}, []);
// Mobile detection — track viewport width
useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= 768);
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
// Close mobile menu on outside click
useEffect(() => {
if (!showMobileMenu) return;
const handler = (e: MouseEvent) => {
if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) {
setShowMobileMenu(false);
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("touchstart", handler as EventListener);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("touchstart", handler as EventListener);
};
}, [showMobileMenu]);
// Auto-focus FAB bottom sheet textarea
useEffect(() => {
if (showMobileFabSheet && fabTextareaRef.current) {
setTimeout(() => fabTextareaRef.current?.focus(), 100);
}
}, [showMobileFabSheet]);
useEffect(() => {
if (!mounted) return;
localStorage.setItem("weekly-dark-mode", JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, [darkMode, mounted]);
useEffect(() => {
if (!mounted) return;
localStorage.setItem("weekly-week-start", String(weekStartDay));
// REMOVED: Re-align current week start when start day changes
// This was forcing the view to snap to Monday, breaking the "Yesterday as first column" setting.
// setCurrentWeekStart(prev => getStartOfWeek(prev, weekStartDay));
}, [weekStartDay, mounted]);
// Translation helper
const t = translations[language] || translations["en"];
// Refs for scroll synchronization
const timeColumnRef = useRef<HTMLDivElement>(null);
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
const isScrollSyncing = useRef(false);
const dayHeaderRef = useRef<HTMLElement>(null);
const somedayGridRef = useRef<HTMLDivElement>(null);
const somedaySectionRef = useRef<HTMLElement | null>(null);
// Scroll sync handler
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (isScrollSyncing.current) return;
isScrollSyncing.current = true;
const scrollTop = e.currentTarget.scrollTop;
dayColumnsRef.current.forEach((col) => {
if (col) col.scrollTop = scrollTop;
});
setTimeout(() => {
isScrollSyncing.current = false;
}, 10);
};
const handleDayColumnScroll = (
e: React.UIEvent<HTMLDivElement>,
index: number,
) => {
if (isScrollSyncing.current) return;
isScrollSyncing.current = true;
const scrollTop = e.currentTarget.scrollTop;
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollTop;
dayColumnsRef.current.forEach((col, i) => {
if (col && i !== index) col.scrollTop = scrollTop;
});
setTimeout(() => {
isScrollSyncing.current = false;
}, 10);
};
// Slot height based on cell duration
const getSlotHeight = (duration: number) => {
switch (duration) {
case 15:
return 25;
case 30:
return 35;
case 60:
return 50;
case 120:
return 80;
default:
return 50;
}
};
// Header height based on cell duration for alignment
const getHeaderHeight = (duration: CellDuration) => {
switch (duration) {
case 15:
return 65;
case 30:
return 55;
case 60:
return 50;
case 120:
return 50;
default:
return 50;
}
};
// Working hours range (configurable)
const workingHoursStart = startHour;
const workingHoursEnd = endHour;
// Fetch calendar events
const fetchCalendarEvents = useCallback(async (forceRefresh = false) => {
startSync();
setIsFetchingCalendar(true);
try {
const response = await fetch("/api/calendar/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
timeMin: new Date(
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
).toISOString(),
timeMax: new Date(
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
).toISOString(),
forceRefresh,
}),
});
if (response.ok) {
const text = await response.text();
try {
const data = JSON.parse(text);
if (data.events) {
setRawCalendarEvents(data.events);
}
} catch (e) {
console.error(
"Failed to parse calendar sync response:",
text.substring(0, 100),
);
}
}
} catch (error) {
console.error("Error fetching calendar events:", error);
} finally {
setIsFetchingCalendar(false);
endSync();
}
}, [currentWeekStart, startSync, endSync]);
// Calendar Event Handlers
const handleEventSave = async (eventData: any) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
try {
const method = eventData.id ? "PATCH" : "POST";
const body = {
...eventData,
eventId: eventData.id, // For PATCH
};
const res = await fetch("/api/calendar/events", {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to save event");
}
// Optimistically add/update from API response, then force refresh cache
const data = await res.json();
if (data.event) {
// Transform API shape (start.dateTime/end.dateTime) to frontend shape (startTime/endTime)
const ev = data.event;
const frontendEvent: CalendarEvent = {
id: ev.id,
title: ev.title,
startTime: ev.start?.dateTime || ev.start?.date || ev.startTime || '',
endTime: ev.end?.dateTime || ev.end?.date || ev.endTime || '',
source: ev.source,
calendarId: ev.calendarId,
calendarTitle: ev.calendarTitle,
calendarColor: ev.backgroundColor || ev.calendarColor,
};
setRawCalendarEvents(prev => {
if (eventData.id) {
return prev.map(e => e.id === eventData.id ? frontendEvent : e);
}
return [...prev, frontendEvent];
});
}
// Re-read from cache (not a force-refresh from provider, which could
// overwrite the optimistic update if the provider hasn't propagated yet).
// The backend already cached the event via upsertCachedEvent.
setTimeout(() => fetchCalendarEvents(false), 2000);
} catch (error: any) {
console.error("Error saving event:", error);
if (error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
};
const handleEventDelete = async (eventId: string, calendarId: string) => {
try {
const res = await fetch(
`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`,
{
method: "DELETE",
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete event");
}
// Optimistically remove, then re-read cache
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
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, fetchMotivationalQuote]);
// Auto-open settings to calendar tab after OAuth redirect
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('openSettings') === 'calendars') {
setShowSettings(true);
setActiveTab('calendar');
// Clean up URL
const url = new URL(window.location.href);
url.searchParams.delete('openSettings');
url.searchParams.delete('calendar');
window.history.replaceState({}, '', url.pathname);
// Refresh connections to pick up the new one
fetchConnections();
}
}, []);
// Periodic pull-sync from Google Tasks (every 2 minutes)
useEffect(() => {
if (!session) return;
const interval = setInterval(
async () => {
try {
const res = await fetch("/api/tasks/sync");
if (res.ok) {
const data = await res.json();
if (data.updated > 0 || data.deleted > 0 || data.created > 0) {
console.log(
`[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions, ${data.created || 0} new tasks`,
);
fetchTasks(); // Reload to reflect changes
}
}
} catch (e) {
console.error("[SYNC] Task sync error:", e);
setSyncError("Task sync failed");
setTimeout(() => setSyncError(null), 10000);
}
},
2 * 60 * 1000,
);
return () => clearInterval(interval);
}, [session]);
// Periodic background calendar cache refresh (every 2 minutes)
useEffect(() => {
if (!session) return;
const interval = setInterval(
async () => {
try {
const now = new Date();
const res = await fetch("/api/calendar/background-sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
timeMin: new Date(
now.getTime() - 7 * 24 * 60 * 60 * 1000,
).toISOString(),
timeMax: new Date(
now.getTime() + 14 * 24 * 60 * 60 * 1000,
).toISOString(),
forceRefresh: true,
}),
});
if (res.ok) {
const data = await res.json();
if (data.queued > 0 || data.refreshed > 0) {
// Cache was refreshed; re-fetch events after delay
setTimeout(() => fetchCalendarEvents(), 8000);
}
}
} catch (e) {
console.error("[SYNC] Calendar sync error:", e);
setSyncError("Calendar sync failed");
setTimeout(() => setSyncError(null), 10000);
}
},
2 * 60 * 1000,
);
return () => clearInterval(interval);
}, [session, fetchCalendarEvents]);
async function fetchConnections() {
try {
setIsLoading(true);
const response = await fetch("/api/calendar/connections");
if (response.ok) {
const data = await response.json();
setConnections(data.connections || []);
}
} catch (error) {
console.error("Error fetching connections:", error);
} finally {
setIsLoading(false);
}
}
const handleRemoveConnection = async (connectionId: string) => {
console.log("Disconnecting connection:", connectionId);
const res = await fetch(`/api/calendar/connections?id=${connectionId}`, {
method: "DELETE",
});
if (res.ok) {
// Update state immediately
setConnections((prev) => prev.filter((c) => c.id !== connectionId));
// Refresh connections to be sure
fetchConnections();
// Optionally refresh events too as they might be gone
fetchCalendarEvents();
} else {
const err = await res.json();
console.error("Failed to disconnect calendar", err);
throw new Error(err.error || "Unknown error");
}
};
// Refetch calendar events when week changes
useEffect(() => {
if (session) {
fetchCalendarEvents();
}
}, [currentWeekStart, session, fetchCalendarEvents]);
// Update current time every 30 seconds for the "Now" line and clock
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 30000);
return () => clearInterval(interval);
}, []);
// Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date
const getGoalDateKey = useCallback(
(date: Date): string => {
const scope = profile.goalScope || "week";
if (scope === "day") {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d.toISOString();
}
// Normalize to Monday of the week containing this date
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const day = d.getDay(); // 0=Sun, 1=Mon, ...
const diff = day === 0 ? -6 : 1 - day; // Monday offset
d.setDate(d.getDate() + diff);
return d.toISOString();
},
[profile.goalScope],
);
const goalDateKey = useMemo(
() => getGoalDateKey(currentWeekStart),
[currentWeekStart, getGoalDateKey],
);
// Fetch goal for current week/day
useEffect(() => {
const fetchGoal = async () => {
try {
const res = await fetch(`/api/goal?weekStart=${goalDateKey}`);
if (res.ok) {
const data = await res.json();
setGoal(data.goal);
}
} catch (err) {
console.error("Failed to fetch goal:", err);
}
};
fetchGoal();
}, [goalDateKey]);
const saveGoal = async (newGoal: string) => {
setGoal(newGoal);
try {
const res = await fetch("/api/goal", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
weekStart: goalDateKey,
text: newGoal,
}),
});
if (!res.ok) {
console.error("Goal save failed:", res.status);
}
} catch (error) {
console.error("Error saving goal:", error);
}
};
// Horizontal scroll: convert vertical wheel to horizontal in someday area
// Callback ref ensures handler is attached as soon as element mounts
const somedayWheelCleanup = useRef<(() => void) | null>(null);
const somedaySectionRefCb = useCallback((node: HTMLElement | null) => {
// Cleanup previous
if (somedayWheelCleanup.current) {
somedayWheelCleanup.current();
somedayWheelCleanup.current = null;
}
somedaySectionRef.current = node;
if (!node) return;
const handler = (e: WheelEvent) => {
const grid = somedayGridRef.current;
if (!grid) return;
// Let native horizontal scroll (trackpad) pass through
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
if (e.deltaY === 0) return;
// Only convert if grid has horizontal overflow
if (grid.scrollWidth <= grid.clientWidth + 1) return;
// Check boundaries - allow page scroll when at edges
const atLeft = grid.scrollLeft <= 0;
const atRight = grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 1;
if (e.deltaY < 0 && atLeft) return;
if (e.deltaY > 0 && atRight) return;
e.preventDefault();
grid.scrollLeft += e.deltaY;
};
node.addEventListener("wheel", handler, { passive: false });
somedayWheelCleanup.current = () => node.removeEventListener("wheel", handler);
}, []);
const saveSetting = async (key: string, value: any) => {
// Per-device settings: save to cookie ONLY (not DB) so each device keeps its own value
if (DEVICE_SETTINGS_KEYS.includes(key)) {
setCookie(`setting_${key}`, String(value));
return; // Don't write to DB — that would overwrite other devices
}
try {
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
} catch (err) {
console.error(`Failed to save setting ${key}:`, err);
}
};
const handleSettingsChanged = (newSettings: any) => {
setShowTimeGrid(newSettings.showTimeGrid);
setCellDuration(newSettings.cellDuration);
setViewStyle(newSettings.viewStyle);
setLanguage(newSettings.language);
setDateFormat(newSettings.dateFormat);
setTimeFormat(newSettings.timeFormat);
setStartHour(newSettings.startHour);
setEndHour(newSettings.endHour);
setFontSize(newSettings.fontSize);
setShowNextTask(newSettings.showNextTask);
setShowSomeday(newSettings.showSomeday);
setShowAllDay(newSettings.showAllDayEvents);
setShowSchedule(newSettings.showSchedule);
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.headlineFont) setHeadlineFont(data.user.headlineFont);
if (data.user.headlineFontSize)
setHeadlineFontSize(data.user.headlineFontSize);
if (data.user.headlineFontWeight)
setHeadlineFontWeight(data.user.headlineFontWeight);
if (data.user.dateFontFamily)
setDateFontFamily(data.user.dateFontFamily);
if (data.user.dateFontSize) setDateFontSize(data.user.dateFontSize);
if (data.user.dateFontWeight)
setDateFontWeight(data.user.dateFontWeight);
if (data.user.timeTaskFontFamily)
setTimeTaskFontFamily(data.user.timeTaskFontFamily);
if (data.user.timeTaskFontSize)
setTimeTaskFontSize(data.user.timeTaskFontSize);
if (data.user.timeTaskFontWeight)
setTimeTaskFontWeight(data.user.timeTaskFontWeight);
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
if (data.user.taskFontFamily)
setTaskFontFamily(data.user.taskFontFamily);
if (data.user.taskFontSize) setTaskFontSize(data.user.taskFontSize);
if (data.user.taskFontWeight)
setTaskFontWeight(data.user.taskFontWeight);
if (data.user.eventFontFamily)
setEventFontFamily(data.user.eventFontFamily);
if (data.user.eventFontSize)
setEventFontSize(data.user.eventFontSize);
if (data.user.eventFontWeight)
setEventFontWeight(data.user.eventFontWeight);
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
if (data.user.weekendColorSat)
setWeekendColorSat(data.user.weekendColorSat);
if (data.user.weekendColorSun)
setWeekendColorSun(data.user.weekendColorSun);
setProfile((prev) => ({
...prev,
...data.user,
name: data.user.name || prev.name,
email: data.user.email || prev.email,
weekdayColor: data.user.weekdayColor || "#888888",
dateColor: data.user.dateColor || "#888888",
taskColor: data.user.taskColor || "#333333",
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
}));
// Apply start day offset (e.g. -1 for yesterday)
if (data.user.startDayOffset && data.user.startDayOffset !== 0) {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + data.user.startDayOffset);
setCurrentWeekStart(d);
}
if (data.user.focusTimerDuration)
setFocusTimerDuration(data.user.focusTimerDuration);
if (data.user.focusBreakDuration)
setFocusBreakDuration(data.user.focusBreakDuration);
if (data.user.showTimeGrid !== undefined)
setShowTimeGrid(data.user.showTimeGrid);
if (data.user.cellDuration)
setCellDuration(data.user.cellDuration as CellDuration);
if (data.user.viewStyle)
setViewStyle(data.user.viewStyle as ViewStyle);
}
}
} catch (e) {
console.error(e);
}
};
useEffect(() => {
fetchUserInfo();
}, []);
async function fetchSomedayLists() {
try {
const response = await fetch("/api/someday-lists");
if (response.ok) {
const data = await response.json();
// Map tasks is handled in fetchTasks or we can merge here if needed.
// But fetchTasks fetches ALL tasks.
// Optimally we fetch lists, then tasks, then merge.
// For now, let's just set the lists structure.
setSomedayLists(
data.lists.map((l: any) => ({
id: l.id,
title: l.title,
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();
setProjects(data.projects || []);
}
} catch (error) {
console.error("Error fetching projects:", error);
}
}
async function fetchTasks() {
startSync();
try {
const [tasksResponse, listsResponse] = await Promise.all([
fetch("/api/tasks"),
fetch("/api/someday-lists"), // Fetch lists in parallel
]);
// Also fetch projects in background
fetchProjects();
let fetchedLists: SomedayList[] = [];
if (listsResponse.ok) {
const listData = await listsResponse.json();
fetchedLists = listData.lists.map((l: any) => ({
id: l.id,
title: l.title,
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);
// Roll overdue tasks
if (dayTasks.length > 0) {
rollOverdueTasks(dayTasks);
}
}
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setIsLoading(false);
endSync();
}
}
// Get visible days based on current view setting
const getVisibleDays = useCallback(() => {
const days: Date[] = [];
for (let i = 0; i < viewDays; i++) {
days.push(new Date(currentWeekStart.getTime() + i * 24 * 60 * 60 * 1000));
}
return days;
}, [currentWeekStart, viewDays]);
// Get tasks for a specific date
const getTasksForDate = useCallback(
(date: Date): Task[] => {
const dateStr = formatDateToISO(date); // Use local date formatting
return tasks
.filter((task) => {
if (!task.scheduledDate) return false;
// Exclude sub-tasks from top-level list (they render inside their parent)
if (task.parentTaskId) return false;
// Use string comparison to avoid timezone shifts
const taskDateStr =
typeof task.scheduledDate === "string"
? task.scheduledDate.substring(0, 10)
: formatDateToISO(new Date(task.scheduledDate));
return taskDateStr === dateStr;
})
.sort((a, b) => {
// Sort by time if available
if (a.startTime && b.startTime) {
return a.startTime.localeCompare(b.startTime);
}
if (a.startTime) return -1;
if (b.startTime) return 1;
return a.order - b.order;
});
},
[tasks],
);
// Get tasks for a specific time slot
const getTasksForSlot = useCallback(
(date: Date, slot: string): Task[] => {
const dateStr = formatDateToISO(date);
return tasks.filter((task) => {
if (!task.scheduledDate) return false;
// Exclude sub-tasks from top-level list
if (task.parentTaskId) return false;
// Use string comparison to avoid timezone shifts
const taskDateStr =
typeof task.scheduledDate === "string"
? task.scheduledDate.substring(0, 10)
: formatDateToISO(new Date(task.scheduledDate));
if (taskDateStr !== dateStr || !task.startTime) return false;
// Extract hour:minute from task start time and compare with slot
const [taskHour, taskMinute] = task.startTime.split(":").map(Number);
const taskStart = taskHour * 60 + taskMinute;
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
const slotEnd = slotStart + cellDuration;
return taskStart >= slotStart && taskStart < slotEnd;
});
},
[tasks, cellDuration],
);
// Get calendar events for a specific date
const getEventsForDate = useCallback(
(date: Date): CalendarEvent[] => {
return calendarEvents.filter((event) => {
// Skip all-day events (handled separately)
if (isAllDayEvent(event)) return false;
const eventDate = new Date(event.startTime);
return isSameDay(eventDate, date);
});
},
[calendarEvents],
);
// Get calendar events for a specific time slot
const getEventsForSlot = useCallback(
(date: Date, slot: string): CalendarEvent[] => {
return calendarEvents.filter((event) => {
// Skip all-day events (handled separately)
const isAllDay = isAllDayEvent(event);
if (isAllDay) return false;
if (event.title.includes("Valentinstag")) {
// Debug removed
}
const eventDate = new Date(event.startTime);
if (!isSameDay(eventDate, date)) return false;
// Extract hour:minute from event start time and compare with slot
const eventHour = eventDate.getHours();
const eventMinute = eventDate.getMinutes();
// Match if event starts within this slot
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
const slotEnd = slotStart + cellDuration;
const eventStart = eventHour * 60 + eventMinute;
return eventStart >= slotStart && eventStart < slotEnd;
});
},
[calendarEvents, cellDuration],
);
// Calculate event duration in pixels for proper height display
const getEventDuration = (event: CalendarEvent): number => {
if (isAllDayEvent(event)) return 0; // All-day events handled separately
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
// Calculate height based on duration and slot height
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
return Math.max(
durationMinutes * pixelsPerMinute,
getSlotHeight(cellDuration),
);
};
// Get all-day events for a specific date
const getAllDayEventsForDate = useCallback(
(date: Date): CalendarEvent[] => {
return calendarEvents.filter((event) => {
if (!isAllDayEvent(event)) return false;
// Parse date from startTime
const eventStart = new Date(event.startTime);
const eventEnd = event.endTime
? new Date(event.endTime)
: new Date(eventStart);
// Normalize dates to start of day for comparison
const targetDate = new Date(date);
targetDate.setHours(0, 0, 0, 0);
const start = new Date(eventStart);
start.setHours(0, 0, 0, 0);
const end = new Date(eventEnd);
end.setHours(0, 0, 0, 0);
// If strictly dates, often end date is exclusive or same day?
// Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day).
// If start == end, it's 1 day (but usually GCal sends next day).
// Let's assume inclusive start, exclusive end logic or "overlaps" logic.
// Check if targetDate is >= start AND targetDate < end
// Handle single day case where start == end or end is not provided
if (!event.endTime || start.getTime() === end.getTime()) {
return start.getTime() === targetDate.getTime();
}
return (
targetDate.getTime() >= start.getTime() &&
targetDate.getTime() < end.getTime()
);
});
},
[calendarEvents],
);
// Get all all-day events for the visible week
const getAllDayEventsForWeek = useCallback((): Map<
string,
CalendarEvent[]
> => {
const eventsByDay = new Map<string, CalendarEvent[]>();
const visibleDays = getVisibleDays();
visibleDays.forEach((date) => {
const dateKey = formatDateToISO(date);
eventsByDay.set(dateKey, getAllDayEventsForDate(date));
});
return eventsByDay;
}, [calendarEvents, currentWeekStart, viewDays]);
const rollOverdueTasks = useCallback(
async (currentTasks: Task[]) => {
const autoRolling = profile.autoRolling ?? false;
if (!autoRolling) return;
const now = new Date();
const todayStr = formatDateToISO(now);
const today = new Date(todayStr);
const overdue = currentTasks.filter(
(t) =>
!t.completed &&
t.isRolling &&
t.scheduledDate &&
formatDateToISO(new Date(t.scheduledDate)) < todayStr,
);
if (overdue.length === 0) return;
console.log(
`[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`,
);
const updatedTasks = [...currentTasks];
let hasChanges = false;
const dailyEvents = getEventsForDate(today);
for (const task of overdue) {
const targetSlot = task.startTime || "09:00"; // Default to 9am if no time
// Collision detection
const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => {
// Check other tasks in the updated list
const taskConflict = tasksToCheck.find(
(t) =>
t.id !== task.id &&
t.scheduledDate &&
formatDateToISO(new Date(t.scheduledDate)) ===
formatDateToISO(date) &&
t.startTime === slot,
);
if (taskConflict) return true;
// Check calendar events
const [h, m] = slot.split(":").map(Number);
const slotStart = new Date(date);
slotStart.setHours(h, m, 0, 0);
const slotEnd = new Date(slotStart);
slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration);
return dailyEvents.some((event) => {
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
return slotStart < eventEnd && slotEnd > eventStart;
});
};
const findFreeSlot = (
date: Date,
preferred: string,
tasksToCheck: Task[],
) => {
let current = preferred;
let [h, m] = current.split(":").map(Number);
while (isBlocked(date, current, tasksToCheck)) {
m += cellDuration;
if (m >= 60) {
h += 1;
m = 0;
}
if (h >= endHour) break;
current = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
return current;
};
const nextSlot = findFreeSlot(today, targetSlot, updatedTasks);
// Update in DB
try {
const res = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: task.id,
scheduledDate: todayStr,
startTime: nextSlot,
}),
});
if (res.ok) {
const data = await res.json();
const taskIndex = updatedTasks.findIndex((t) => t.id === task.id);
if (taskIndex !== -1) {
updatedTasks[taskIndex] = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
hasChanges = true;
}
}
} catch (err) {
console.error(`Failed to roll task ${task.id}:`, err);
}
}
if (hasChanges) {
setTasks(updatedTasks.filter((t) => !t.somedayListId));
}
},
[profile.autoRolling, cellDuration, endHour, getEventsForDate],
);
// Check if a slot is protected by calendar events (only if slot starts within event time range)
const isSlotProtected = useCallback(
(date: Date, slot: string): boolean => {
if (!protectEventTimes) return false;
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
return calendarEvents.some((event) => {
if (isAllDayEvent(event)) return false;
// Skip events that have been unlocked by the user
if (unlockedEvents.has(event.id)) return false;
const eventDate = new Date(event.startTime);
if (!isSameDay(eventDate, date)) return false;
const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes();
const eventEndDate = new Date(event.endTime);
const eventEndMinutes =
eventEndDate.getHours() * 60 + eventEndDate.getMinutes();
// Only protect if the slot start time falls within the event's actual duration
// This ensures protection matches exactly what the event covers
return slotStart >= eventStart && slotStart < eventEndMinutes;
});
},
[protectEventTimes, calendarEvents, unlockedEvents],
);
// Check if a slot is occupied by any task (to prevent stacking)
const isSlotOccupiedByTask = useCallback(
(date: Date, slot: string, excludeTaskId?: string): boolean => {
const dateStr = formatDateToISO(date);
const [slotHour, slotMinute] = slot.split(":").map(Number);
const slotStart = slotHour * 60 + slotMinute;
const slotEnd = slotStart + cellDuration;
return tasks.some(task => {
if (!task.scheduledDate || !task.startTime) return false;
if (excludeTaskId && task.id === excludeTaskId) return false;
// Exclude sub-tasks
if (task.parentTaskId) return false;
const taskDateStr =
typeof task.scheduledDate === "string"
? task.scheduledDate.substring(0, 10)
: formatDateToISO(new Date(task.scheduledDate));
if (taskDateStr !== dateStr) return false;
const [taskHour, taskMinute] = task.startTime.split(":").map(Number);
const taskStart = taskHour * 60 + taskMinute;
const taskDuration = task.duration || 15;
const taskEnd = taskStart + taskDuration;
// Skip completed tasks (they don't render in the grid)
if (task.completed) return false;
// Overlap condition: task starts before slot ends AND task ends after slot starts
return taskStart < slotEnd && taskEnd > slotStart;
});
},
[tasks, cellDuration],
);
// Navigation handlers with CSS class-based slide animation (works in all browsers)
const gridRef = useRef<HTMLElement>(null);
const allSlideClasses = ["slide-animate-next", "slide-animate-prev", "slide-animate-week-next", "slide-animate-week-prev"];
const navigate = (
newDate: Date,
direction: "left" | "right",
type: "day" | "week",
) => {
const grid = gridRef.current;
if (grid) {
// Remove any existing animation class
grid.classList.remove(...allSlideClasses);
// Trigger reflow to restart animation if same direction
void grid.offsetWidth;
// Pick class: week uses longer animation
const prefix = type === "week" ? "slide-animate-week-" : "slide-animate-";
grid.classList.add(prefix + (direction === "left" ? "next" : "prev"));
// Clean up after animation
const cleanup = () => {
grid.classList.remove(...allSlideClasses);
grid.removeEventListener("animationend", cleanup);
};
grid.addEventListener("animationend", cleanup, { once: true });
}
setCurrentWeekStart(newDate);
setSlideDirection(direction === "left" ? "next" : "prev");
};
const goToPrevWeek = () =>
navigate(
new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000),
"right",
"week",
);
const goToNextWeek = () =>
navigate(
new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000),
"left",
"week",
);
const goToPrevDay = () =>
navigate(
new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000),
"right",
"day",
);
const goToNextDay = () =>
navigate(
new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000),
"left",
"day",
);
const goToToday = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + (profile?.startDayOffset || 0));
setCurrentWeekStart(d);
};
// Touch swipe navigation for mobile
useEffect(() => {
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
const handleTouchStart = (e: TouchEvent) => {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
};
const handleTouchEnd = (e: TouchEvent) => {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
const diffX = touchEndX - touchStartX;
const diffY = touchEndY - touchStartY;
// Only trigger if horizontal swipe is dominant and > 80px
if (Math.abs(diffX) > 80 && Math.abs(diffX) > Math.abs(diffY) * 1.5) {
if (diffX > 0) {
// Swipe right → go to previous day
goToPrevDay();
} else {
// Swipe left → go to next day
goToNextDay();
}
}
};
const container = document.querySelector('.weekly-container') as HTMLElement | null;
if (container) {
container.addEventListener('touchstart', handleTouchStart as EventListener, { passive: true });
container.addEventListener('touchend', handleTouchEnd as EventListener, { passive: true });
}
return () => {
if (container) {
container.removeEventListener('touchstart', handleTouchStart as EventListener);
container.removeEventListener('touchend', handleTouchEnd as EventListener);
}
};
}, [currentWeekStart]); // Re-attach when week changes so closures are fresh
const executeImport = async (provider: "google" | "apple" | "outlook" | "synology") => {
setImportProvider(provider);
setIsImportModalOpen(true);
setIsFetchingLists(true);
setImportLists([]);
setImportStatusMsg(null);
try {
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
if (res.ok) {
const data = await res.json();
setImportLists(data.lists || []);
} else {
const errData = await res.json();
console.error("Failed to fetch lists", errData);
setIsImportModalOpen(false);
setImportStatusMsg({
type: "error",
text: errData.error || "Failed to fetch task lists.",
});
}
} catch (e) {
console.error("Error fetching lists:", e);
setIsImportModalOpen(false);
setImportStatusMsg({ type: "error", text: "Error fetching task lists." });
} finally {
setIsFetchingLists(false);
}
};
const fetchAvailableTaskLists = useCallback(
async (provider: "google" | "apple" | "outlook" | "synology") => {
setIsFetchingProviderLists((prev) => ({
...prev,
[provider]: true,
}));
try {
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
if (res.ok) {
const data = await res.json();
setAvailableTaskLists((prev) => ({
...prev,
[provider]: data.lists || [],
}));
}
} catch (error) {
console.error(`Failed to fetch lists for ${provider}`, error);
} finally {
setIsFetchingProviderLists((prev) => ({
...prev,
[provider]: false,
}));
}
},
[],
);
const handleToggleTaskList = async (
provider: "google" | "apple" | "outlook" | "synology",
list: { id: string; title: string },
) => {
const existing = somedayLists.find(
(l) => l.externalId === list.id && l.externalProvider === provider,
);
if (existing) {
// 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);
}
};
// 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<Task>) => {
setTasks(
tasks.map((t) =>
t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t,
),
);
// Also update someday lists if the task is there
setSomedayLists((lists) =>
lists.map((list) => ({
...list,
tasks: list.tasks.map((t) =>
t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t,
),
})),
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, ...fields }),
});
} catch (error) {
console.error("Error updating task fields:", error);
}
};
// Sub-task CRUD operations
const addSubTask = async (parentId: string, title: string) => {
if (!title.trim() || !session?.user) return;
// Find parent task to inherit scheduling
const parentTask = findTaskAnywhere(parentId);
try {
const response = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
parentTaskId: parentId,
scheduledDate: parentTask?.scheduledDate || null,
dayOfWeek: parentTask?.dayOfWeek ?? null,
order: (parentTask?.subTasks?.length || 0),
}),
});
if (response.ok) {
const data = await response.json();
const newSubTask = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
// Update local state: add sub-task to parent
setTasks((prev) =>
prev.map((t) =>
t.id === parentId
? { ...t, subTasks: [...(t.subTasks || []), newSubTask] }
: t,
),
);
setSomedayLists((prev) =>
prev.map((l) => ({
...l,
tasks: l.tasks.map((t) =>
t.id === parentId
? { ...t, subTasks: [...(t.subTasks || []), newSubTask] }
: t,
),
})),
);
}
} catch (error) {
console.error("Error adding sub-task:", error);
}
};
const toggleSubTask = async (subTaskId: string) => {
// Find the sub-task in any parent
let foundSubTask: Task | undefined;
for (const task of tasks) {
foundSubTask = task.subTasks?.find((st) => st.id === subTaskId);
if (foundSubTask) break;
}
if (!foundSubTask) {
for (const list of somedayLists) {
for (const task of list.tasks) {
foundSubTask = task.subTasks?.find((st) => st.id === subTaskId);
if (foundSubTask) break;
}
if (foundSubTask) break;
}
}
if (!foundSubTask) return;
const newCompleted = !foundSubTask.completed;
// Optimistic update
const updateSubTasks = (taskList: Task[]) =>
taskList.map((t) => ({
...t,
subTasks: t.subTasks?.map((st) =>
st.id === subTaskId ? { ...st, completed: newCompleted } : st,
),
}));
setTasks((prev) => updateSubTasks(prev));
setSomedayLists((prev) =>
prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })),
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: subTaskId, completed: newCompleted }),
});
} catch (error) {
console.error("Error toggling sub-task:", error);
}
};
const deleteSubTask = async (subTaskId: string) => {
// Optimistic update: remove from parent's subTasks
const removeSubTask = (taskList: Task[]) =>
taskList.map((t) => ({
...t,
subTasks: t.subTasks?.filter((st) => st.id !== subTaskId),
}));
setTasks((prev) => removeSubTask(prev));
setSomedayLists((prev) =>
prev.map((l) => ({ ...l, tasks: removeSubTask(l.tasks) })),
);
try {
await fetch(`/api/tasks?id=${subTaskId}`, { method: "DELETE" });
} catch (error) {
console.error("Error deleting sub-task:", error);
}
};
const updateSubTask = async (subTaskId: string, newTitle: string) => {
if (!newTitle.trim()) {
await deleteSubTask(subTaskId);
return;
}
const updateSubTasks = (taskList: Task[]) =>
taskList.map((t) => ({
...t,
subTasks: t.subTasks?.map((st) =>
st.id === subTaskId ? { ...st, title: newTitle.trim() } : st,
),
}));
setTasks((prev) => updateSubTasks(prev));
setSomedayLists((prev) =>
prev.map((l) => ({ ...l, tasks: updateSubTasks(l.tasks) })),
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: subTaskId, title: newTitle.trim() }),
});
} catch (error) {
console.error("Error updating sub-task:", error);
}
};
const updateTaskDuration = async (
taskId: string,
durationMinutes: number,
) => {
const task = tasks.find((t) => t.id === taskId);
if (!task || !task.startTime) return;
try {
// Parse start time (HH:mm)
const [startHour, startMinute] = task.startTime.split(":").map(Number);
// Calculate end time
const totalStartMinutes = startHour * 60 + startMinute;
const totalEndMinutes = totalStartMinutes + durationMinutes;
const endHour = Math.floor(totalEndMinutes / 60) % 24; // Wrap around 24h
const endMinute = totalEndMinutes % 60;
const endTimeStr = `${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}`;
// Optimistic update
setTasks(
tasks.map((t) =>
t.id === taskId
? { ...t, endTime: endTimeStr, updatedAt: new Date() }
: t,
),
);
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, endTime: endTimeStr }),
});
} catch (error) {
console.error("Error updating task duration:", error);
}
};
const updateTaskNotes = async (taskId: string, notes: string) => {
const task = findTaskAnywhere(taskId);
const isSomeday = !!task?.somedayListId;
if (isSomeday) {
setSomedayLists((prev) =>
prev.map((l) => ({
...l,
tasks: l.tasks.map((t) =>
t.id === taskId
? { ...t, markdownContent: notes, updatedAt: new Date() }
: t,
),
})),
);
} else {
setTasks(
tasks.map((t) =>
t.id === taskId
? { ...t, markdownContent: notes, updatedAt: new Date() }
: t,
),
);
}
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, markdownContent: notes }),
});
if (task?.externalId && task?.externalProvider) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, notes }),
}).catch((e) => console.error("Sync error:", e));
}
} catch (error) {
console.error("Error updating task notes:", error);
}
};
const toggleTaskRolling = async (taskId: string) => {
saveSnapshot();
const task = findTaskAnywhere(taskId);
if (!task) return;
const newRollingState = !task.isRolling;
const isSomeday = !!task.somedayListId;
if (isSomeday) {
setSomedayLists((prev) =>
prev.map((l) => ({
...l,
tasks: l.tasks.map((t) =>
t.id === taskId
? { ...t, isRolling: newRollingState, updatedAt: new Date() }
: t,
),
})),
);
} else {
setTasks(
tasks.map((t) =>
t.id === taskId
? { ...t, isRolling: newRollingState, updatedAt: new Date() }
: t,
),
);
}
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, isRolling: newRollingState }),
});
} catch (error) {
console.error("Error updating task rolling state:", error);
if (isSomeday) {
setSomedayLists((prev) =>
prev.map((l) => ({
...l,
tasks: l.tasks.map((t) =>
t.id === taskId ? { ...t, isRolling: !newRollingState } : t,
),
})),
);
} else {
setTasks(
tasks.map((t) =>
t.id === taskId ? { ...t, isRolling: !newRollingState } : t,
),
);
}
}
};
const moveTaskToSlot = async (
taskId: string,
dayOfWeek: number,
startTime: string,
scheduledDate?: Date,
) => {
const newScheduledDate = scheduledDate
? formatDateToISO(scheduledDate)
: undefined;
const task = tasks.find((t) => t.id === taskId);
setTasks(
tasks.map((t) =>
t.id === taskId
? {
...t,
dayOfWeek,
startTime,
scheduledDate: newScheduledDate || t.scheduledDate,
somedayListId: null,
somedaySlotIndex: null,
updatedAt: new Date(),
}
: t,
),
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: taskId,
dayOfWeek,
startTime,
scheduledDate: newScheduledDate,
somedayListId: null,
somedaySlotIndex: null,
}),
});
// Sync due date change to external provider
if (task?.externalId && task?.externalProvider && newScheduledDate) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
}).catch((e) => console.error("Sync error:", e));
}
} catch (error) {
console.error("Error moving task:", error);
}
};
const assignProject = async (taskId: string, projectId: string | null) => {
const proj = projectId ? projects.find((p) => p.id === projectId) || null : null;
// Optimistic update
const updateTask = (t: Task) =>
t.id === taskId ? { ...t, projectId: projectId, project: proj } : t;
setTasks((prev) => prev.map(updateTask));
setSomedayLists((prev) =>
prev.map((l) => ({ ...l, tasks: l.tasks.map(updateTask) }))
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, projectId }),
});
} catch (e) {
console.error("Failed to assign project:", e);
}
};
const deleteTask = async (taskId: string) => {
saveSnapshot();
const taskToDelete = findTaskAnywhere(taskId);
const isSomeday = !!taskToDelete?.somedayListId;
const isVirtual = taskId.startsWith("virtual-");
let originalId = taskId;
if (isVirtual) {
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
if (match) {
originalId = match[1];
}
}
// Check if it's a series (virtual or real recurring)
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
if (isSeries) {
setRecurringDeleteModal({ isOpen: true, taskId });
return;
}
// NORMAL DELETE (Single instance)
if (isSomeday) {
setSomedayLists((prev) =>
prev.map((l) => ({
...l,
tasks: l.tasks.filter((t) => t.id !== taskId),
})),
);
} else {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
}
setEditingTaskId(null);
try {
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, action: "delete" }),
}).catch((e) => console.error("Sync delete error:", e));
}
await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" });
} catch (error) {
console.error("Error deleting task:", error);
}
};
const handleConfirmDeleteSeries = async (taskId: string) => {
let originalId = taskId;
if (taskId.startsWith("virtual-")) {
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
if (match) originalId = match[1];
}
const taskToDelete = findTaskAnywhere(originalId);
setTasks((prev) =>
prev.filter((t) => {
if (taskToDelete && t.title === taskToDelete.title &&
t.recurrenceInterval === taskToDelete.recurrenceInterval &&
t.recurrenceUnit === taskToDelete.recurrenceUnit) {
return false;
}
if (t.id === originalId) return false;
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
if (t.id === taskId) return false;
return true;
}),
);
setEditingTaskId(null);
setRecurringDeleteModal({ isOpen: false, taskId: null });
try {
const origTask = findTaskAnywhere(originalId);
if (origTask?.externalId && origTask?.externalProvider) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId: originalId, action: "delete" }),
}).catch((e) => console.error("Sync delete error:", e));
}
await fetch(`/api/tasks?id=${originalId}`, { method: "DELETE" });
} catch (error) {
console.error("Error deleting series:", error);
}
};
const handleConfirmDeleteOccurrence = async (taskId: string) => {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
setEditingTaskId(null);
setRecurringDeleteModal({ isOpen: false, taskId: null });
try {
const taskToDelete = findTaskAnywhere(taskId);
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, action: "delete" }),
}).catch((e) => console.error("Sync delete error:", e));
}
await fetch(`/api/tasks?id=${taskId}`, { method: "DELETE" });
} catch (error) {
console.error("Error deleting instance:", error);
}
};
// Toggle rolling status
const toggleRolling = async (taskId: string) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const updatedIsRolling = !task.isRolling;
setTasks(
tasks.map((t) =>
t.id === taskId
? { ...t, isRolling: updatedIsRolling, updatedAt: new Date() }
: t,
),
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: taskId, isRolling: updatedIsRolling }),
});
} catch (error) {
console.error("Error toggling rolling status:", error);
}
};
// Roll task to tomorrow or next week
const rollTask = async (
taskId: string,
rollType: "tomorrow" | "nextWeek",
) => {
const task = tasks.find((t) => t.id === taskId);
if (!task || task.completed) return;
// Get current task date
const currentDate = task.scheduledDate
? new Date(task.scheduledDate)
: new Date();
// Calculate new date
const newDate = new Date(currentDate);
if (rollType === "tomorrow") {
newDate.setDate(newDate.getDate() + 1);
} else {
newDate.setDate(newDate.getDate() + 7);
}
const newScheduledDate = formatDateToISO(newDate);
// Preserve startTime — if the preferred slot is taken, find next free one
let resolvedStartTime = task.startTime || undefined;
if (resolvedStartTime) {
const targetSlotTasks = tasks.filter((t) => {
if (t.id === taskId || !t.scheduledDate) return false;
const tDate = formatDateToISO(new Date(t.scheduledDate));
return tDate === newScheduledDate && t.startTime === resolvedStartTime;
});
if (targetSlotTasks.length > 0) {
// Slot is taken — find next free slot
const allSlots = getTimeSlots(
cellDuration,
workingHoursStart,
workingHoursEnd,
);
const startIndex = allSlots.indexOf(resolvedStartTime);
if (startIndex !== -1) {
for (let i = startIndex + 1; i < allSlots.length; i++) {
const candidate = allSlots[i];
const candidateTasks = tasks.filter((t) => {
if (t.id === taskId || !t.scheduledDate) return false;
const tDate = formatDateToISO(new Date(t.scheduledDate));
return tDate === newScheduledDate && t.startTime === candidate;
});
if (candidateTasks.length === 0) {
resolvedStartTime = candidate;
break;
}
}
}
}
}
setTasks(
tasks.map((t) =>
t.id === taskId
? {
...t,
scheduledDate: newScheduledDate,
dayOfWeek: newDate.getDay(),
startTime: resolvedStartTime || t.startTime,
updatedAt: new Date(),
}
: t,
),
);
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: taskId,
scheduledDate: newScheduledDate,
dayOfWeek: newDate.getDay(),
startTime: resolvedStartTime,
}),
});
// Sync due date change to external provider
if (task.externalId && task.externalProvider) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
}).catch((e) => console.error("Sync error:", e));
}
} catch (error) {
console.error("Error rolling task:", error);
}
};
// Drag and drop handlers
const handleDragStart = (e: DragEvent, task: Task) => {
setDraggedTask(task);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", task.id);
}
// Add drag-source class for styling
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.classList.add("drag-source");
}
};
const handleDragOver = (
e: DragEvent | React.DragEvent,
dayOfWeek?: number,
slot?: string,
) => {
// Reject someday list drags on day slots
if (draggingListId) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "none";
}
return;
}
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "move";
}
// Update drop preview if we have day and slot info
if (dayOfWeek !== undefined && slot) {
setDropPreview({ day: dayOfWeek, slot });
}
};
const handleDrop = async (e: DragEvent, dayOfWeek: number, slot?: string) => {
e.preventDefault();
if (draggedTask) {
const visibleDays = getVisibleDays();
const targetDateObj =
visibleDays.find((d) => d.getDay() === dayOfWeek) || new Date();
let targetSlot = slot;
// If no slot provided (dropped on header/background), try to keep original time
if (!targetSlot && draggedTask.startTime) {
targetSlot = draggedTask.startTime;
}
// Collision detection / Find next free slot
if (targetSlot) {
if (isSlotOccupiedByTask(targetDateObj, targetSlot, draggedTask.id)) {
const allSlots = getTimeSlots(
cellDuration,
workingHoursStart,
workingHoursEnd,
);
const startIndex = allSlots.indexOf(targetSlot);
if (startIndex !== -1) {
for (let i = startIndex + 1; i < allSlots.length; i++) {
const nextSlot = allSlots[i];
if (!isSlotOccupiedByTask(targetDateObj, nextSlot, draggedTask.id) && !isSlotProtected(targetDateObj, nextSlot)) {
targetSlot = nextSlot;
break;
}
}
}
}
}
// If the task is a subtask, promote it to a standalone task
if (draggedTask.parentTaskId) {
const newScheduledDate = formatDateToISO(targetDateObj);
// Remove subtask from parent in UI
setTasks((prev) =>
prev.map((t) =>
t.id === draggedTask.parentTaskId
? { ...t, subTasks: (t.subTasks || []).filter((s) => s.id !== draggedTask.id) }
: t
)
);
// Add as standalone task in UI
setTasks((prev) => [
...prev,
{
...draggedTask,
parentTaskId: null,
scheduledDate: newScheduledDate,
dayOfWeek,
startTime: targetSlot || "",
} as Task,
]);
// Persist
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: draggedTask.id,
parentTaskId: null,
scheduledDate: newScheduledDate,
dayOfWeek,
startTime: targetSlot || "",
}),
});
} catch (error) {
console.error("Error promoting subtask:", error);
}
setDraggedTask(null);
setDropPreview(null);
return;
}
// If the task was from a someday list, move it to the calendar
if (draggedTask.somedayListId) {
const newScheduledDate = formatDateToISO(targetDateObj);
// 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,
}),
});
} catch (error) {
console.error("Error moving task to someday slot:", error);
}
setDraggedTask(null);
setDropPreview(null);
}
};
// Sync calendar
const handleSync = async () => {
setSyncStatus("syncing");
try {
// Pull changes from Google Tasks, then force-refresh calendar cache
await fetch("/api/tasks/sync").catch((e) =>
console.error("Task pull sync error:", e),
);
// Force live refresh from providers (bypass staleness check)
const syncRes = await fetch("/api/calendar/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
timeMin: new Date(
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
).toISOString(),
timeMax: new Date(
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
).toISOString(),
forceRefresh: true,
}),
});
if (syncRes.ok) {
const data = await syncRes.json();
if (data.events) setRawCalendarEvents(data.events);
}
await fetchTasks();
// Re-fetch after background refresh completes
if (true) {
setTimeout(() => fetchCalendarEvents(), 8000);
}
setSyncStatus("synced");
setTimeout(() => setSyncStatus("idle"), 3000);
} catch (error) {
console.error("Error syncing:", error);
setSyncStatus("idle");
setSyncError("Sync failed");
setTimeout(() => setSyncError(null), 10000);
}
};
// Start adding someday list UI
const handleStartAddSomedayList = () => {
setIsAddingSomedayList(true);
// Focus will happen in render logic if possible or via ref, but let's render conditional input first
};
const saveSomedayList = async () => {
if (!newSomedayListName.trim()) {
setIsAddingSomedayList(false);
setNewSomedayListName("");
setSelectedSomedayProvider(null);
return;
}
try {
const url = selectedSomedayProvider
? "/api/someday-lists/external"
: "/api/someday-lists";
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: newSomedayListName.trim(),
provider: selectedSomedayProvider,
}),
});
if (response.ok) {
const data = await response.json();
setSomedayLists((prev) => [
...prev,
{
...(data.somedayList || data.list),
tasks: [], // Initially empty
},
]);
setNewSomedayListName("");
setSelectedSomedayProvider(null);
setIsAddingSomedayList(false);
} else {
const error = await response.json();
alert(error.error || "Failed to create list");
}
} catch (error) {
console.error("Error adding someday list:", error);
alert("An error occurred while creating the list");
}
};
// Get time slots to display
// Get time slots to display
const visibleSlots = getTimeSlots(
cellDuration,
workingHoursStart,
workingHoursEnd,
);
const fontSizeScale = fontSize === "S" ? 0.85 : fontSize === "L" ? 1.15 : 1;
const fontVal = (v: string | undefined) => v && v !== "__custom__" ? v : "";
const scaleRem = (base: string) => {
const num = parseFloat(base);
return `${(num * fontSizeScale).toFixed(3)}rem`;
};
const containerStyle = {
"--weekly-font-headline":
fontVal(profile.headlineFont) || headlineFont
? `"${fontVal(profile.headlineFont) || headlineFont}", sans-serif`
: "var(--font-headline)",
"--weekly-headline-size": scaleRem(profile.headlineFontSize || "1.25rem"),
"--weekly-headline-weight": profile.headlineFontWeight || "900",
"--weekly-date-font": fontVal(profile.dateFontFamily)
? `"${fontVal(profile.dateFontFamily)}", sans-serif`
: "var(--weekly-font-headline)",
"--weekly-date-size": scaleRem(profile.dateFontSize || "0.65rem"),
"--weekly-date-weight": profile.dateFontWeight || "400",
"--weekly-time-task-font": (() => {
// 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": darkMode
? invertColor(profile.weekendColorSat || "#666666")
: profile.weekendColorSat || "#666666",
"--weekly-weekend-sun": darkMode
? invertColor(profile.weekendColorSun || "#dc2626")
: profile.weekendColorSun || "#dc2626",
"--weekly-weekday-color": darkMode
? invertColor(profile.weekdayColor || "#888888")
: profile.weekdayColor || "#888888",
"--weekly-date-color": darkMode
? invertColor(profile.dateColor || "#888888")
: profile.dateColor || "#888888",
"--weekly-task-color": darkMode
? invertColor(profile.taskColor || "#333333")
: profile.taskColor || "#333333",
"--weekly-today-highlight": darkMode
? invertColor(profile.todayHighlightColor || "#f0fafa")
: profile.todayHighlightColor || "#f0fafa",
"--weekly-past-color": darkMode
? invertColor(profile.pastDayColor || "#a6a6a7")
: profile.pastDayColor || "#a6a6a7",
} as React.CSSProperties;
if (isLoading) {
return (
<div
className="weekly-container"
style={{ alignItems: "center", justifyContent: "center" }}
>
<div style={{ color: "var(--weekly-text-light)" }}>
{translations[language]?.loading || translations["en"].loading}
</div>
</div>
);
}
const activeDateLayout = isMobile ? (profile.mobileDateLayout || "below") : (profile.dateLayout || "right");
// All-Day Events Section (reusable for above/below positioning)
const allDaySection = (() => {
if (!showAllDay) return null;
const allDayEvents = calendarEvents.filter((event) =>
isAllDayEvent(event),
);
if (allDayEvents.length === 0) return null;
return (
<section
className={`all-day-events-section ${isAllDayExpanded ? "expanded" : "collapsed"}`}
>
<div style={{ display: "flex", flexDirection: "row" }}>
{showTimeGrid && (
<div
className="all-day-label-column"
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
style={{
width: "50px",
flexShrink: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
borderRight: "1px solid var(--weekly-border)",
padding: "2px 4px",
gap: "0px",
position: "relative",
}}
title={isAllDayExpanded ? "Collapse" : "Expand"}
>
<span
style={{
fontSize: "0.6rem",
fontWeight: 600,
color: "var(--weekly-text-light)",
textTransform: "uppercase",
letterSpacing: "0.05em",
lineHeight: 1.1,
textAlign: "center",
}}
>
all day
</span>
<span
className="all-day-events-count"
style={{
fontSize: "0.55rem",
padding: "0px 3px",
marginTop: "1px",
}}
>
{allDayEvents.length}
</span>
</div>
)}
{!showTimeGrid && (
<div
className="all-day-label-column"
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
padding: "2px 8px",
gap: "6px",
}}
title={isAllDayExpanded ? "Collapse" : "Expand"}
>
<span
style={{
fontSize: "0.6rem",
fontWeight: 600,
color: "var(--weekly-text-light)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
all day
</span>
<span
className="all-day-events-count"
style={{ fontSize: "0.55rem", padding: "0px 3px" }}
>
{allDayEvents.length}
</span>
</div>
)}
{isAllDayExpanded && (
<div
className={`all-day-events-grid cols-${viewDays}`}
style={{ flex: 1 }}
>
{getVisibleDays().map((date) => {
const dayEvents = getAllDayEventsForDate(date);
return (
<div
key={date.toISOString()}
className="all-day-events-column"
>
{dayEvents.length > 0 ? (
dayEvents.map((event) => (
<div
key={event.id}
className="all-day-event"
title={`${event.calendarTitle}: ${event.title}`}
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",
}}
>
<span className="event-indicator">📅</span>
<span className="all-day-event-title">
{event.title}
</span>
</div>
))
) : (
<div className="all-day-empty"></div>
)}
</div>
);
})}
</div>
)}
</div>
</section>
);
})();
return (
<div
className={`weekly-container ${darkMode ? "dark-mode" : ""} font-size-${fontSize.toLowerCase()} ${viewStyle}-view ${showTimeGrid ? "time-grid-on" : "time-grid-off"}`}
style={containerStyle}
>
{/* Quick Settings Sidebar (TeuxDeux-style) */}
{showQuickSettings && (
<>
<div
onClick={() => setShowQuickSettings(false)}
style={{ position: "fixed", inset: 0, zIndex: 999 }}
/>
<div
style={{
position: "fixed",
left: 0,
top: 0,
bottom: 0,
width: "220px",
background: darkMode ? "#1a1a2e" : "#fafafa",
borderRight: `1px solid ${darkMode ? "#333" : "#e5e7eb"}`,
zIndex: 1000,
display: "flex",
flexDirection: "column",
padding: "20px 16px",
gap: "18px",
overflowY: "auto",
boxShadow: "2px 0 12px rgba(0,0,0,0.08)",
}}
>
<div style={{ fontSize: "0.85rem", fontWeight: 700, color: darkMode ? "#e5e7eb" : "#333", marginBottom: "4px" }}>
{language === "de" ? "Einstellungen" : "Preferences"}
</div>
{/* Columns */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Spalten" : "Columns"}
</span>
<div style={{ display: "flex", gap: "4px" }}>
{[1, 3, 5, 7].map((num) => (
<button
key={num}
onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }}
style={{
padding: "4px 10px",
fontSize: "0.8rem",
borderRadius: "6px",
border: "none",
cursor: "pointer",
fontWeight: viewDays === num ? 700 : 400,
background: viewDays === num ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
}}
>
{num}
</button>
))}
</div>
</div>
{/* Text size */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Textgröße" : "Text size"}
</span>
<div style={{ display: "flex", gap: "4px" }}>
{(["S", "M", "L"] as const).map((size) => (
<button
key={size}
onClick={() => { setFontSize(size); saveSetting("fontSize", size); }}
style={{
padding: "4px 10px",
fontSize: "0.8rem",
borderRadius: "6px",
border: "none",
cursor: "pointer",
fontWeight: fontSize === size ? 700 : 400,
background: fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
color: fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
}}
>
{size}
</button>
))}
</div>
</div>
{/* Someday section */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Irgendwann" : "Someday"}
</span>
<button
onClick={() => { const v = !showSomeday; setShowSomeday(v); saveSetting("showSomeday", v); }}
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
>
{showSomeday ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
{/* Schedule / Time Grid */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Zeitplan" : "Schedule"}
</span>
<button
onClick={() => { const v = !showTimeGrid; setShowTimeGrid(v); saveSetting("showTimeGrid", v); }}
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
>
{showTimeGrid ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
{/* All-day events */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Ganztägig" : "All-day"}
</span>
<button
onClick={() => { const v = !showAllDay; setShowAllDay(v); saveSetting("showAllDayEvents", v); }}
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
>
{showAllDay ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
{/* Checkboxes */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Checkboxen" : "Checkboxes"}
</span>
<button
onClick={() => {
const v = !profile.showTaskCheckboxes;
setProfile({ ...profile, showTaskCheckboxes: v });
saveSetting("showTaskCheckboxes", v);
}}
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}
>
{profile.showTaskCheckboxes ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
</div>
{/* Start on */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Starten mit" : "Start on"}
</span>
<div style={{ display: "flex", gap: "4px" }}>
<button
onClick={() => {
setProfile({ ...profile, startDayOffset: 0 });
saveSetting("startDayOffset", 0);
const d = new Date(); d.setHours(0, 0, 0, 0); setCurrentWeekStart(d);
}}
style={{
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
fontWeight: (profile.startDayOffset || 0) === 0 ? 700 : 400,
background: (profile.startDayOffset || 0) === 0 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
color: (profile.startDayOffset || 0) === 0 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
}}
>
{language === "de" ? "Heute" : "Today"}
</button>
<button
onClick={() => {
setProfile({ ...profile, startDayOffset: -1 });
saveSetting("startDayOffset", -1);
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); setCurrentWeekStart(d);
}}
style={{
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
fontWeight: profile.startDayOffset === -1 ? 700 : 400,
background: profile.startDayOffset === -1 ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
color: profile.startDayOffset === -1 ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
}}
>
{language === "de" ? "Gestern" : "Yesterday"}
</button>
</div>
</div>
{/* Display mode */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>
{language === "de" ? "Anzeige" : "Display"}
</span>
<div style={{ display: "flex", gap: "4px" }}>
<button
onClick={() => setDarkMode(false)}
style={{
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", gap: "4px",
fontWeight: !darkMode ? 700 : 400,
background: !darkMode ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"),
color: !darkMode ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280"),
}}
>
<Sun size={12} /> {language === "de" ? "Hell" : "Light"}
</button>
<button
onClick={() => setDarkMode(true)}
style={{
padding: "4px 10px", fontSize: "0.75rem", borderRadius: "6px", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", gap: "4px",
fontWeight: darkMode ? 700 : 400,
background: darkMode ? "#374151" : "#e5e7eb",
color: darkMode ? "#fff" : "#6b7280",
}}
>
<Moon size={12} /> {language === "de" ? "Dunkel" : "Dark"}
</button>
</div>
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Close button at bottom */}
<button
onClick={() => setShowQuickSettings(false)}
style={{
display: "flex", alignItems: "center", gap: "6px",
background: "none", border: "none", cursor: "pointer",
fontSize: "0.75rem", color: darkMode ? "#6b7280" : "#9ca3af",
padding: "4px 0",
}}
>
<PanelLeftClose size={16} />
{language === "de" ? "Ausblenden" : "Hide"}
</button>
</div>
</>
)}
{/* Quick Settings toggle button (bottom-left) */}
{!showQuickSettings && (
<button
onClick={() => setShowQuickSettings(true)}
style={{
position: "fixed",
left: "8px",
bottom: "8px",
zIndex: 999,
background: darkMode ? "#1f2937" : "#f3f4f6",
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
borderRadius: "8px",
padding: "6px 8px",
cursor: "pointer",
color: darkMode ? "#9ca3af" : "#6b7280",
display: "flex",
alignItems: "center",
gap: "4px",
fontSize: "0.75rem",
boxShadow: "0 1px 4px rgba(0,0,0,0.08)",
}}
title={language === "de" ? "Schnelleinstellungen" : "Quick Settings"}
>
<PanelLeftOpen size={14} />
</button>
)}
{/* View Transitions Style Block */}
<style
dangerouslySetInnerHTML={{
__html: (() => {
// Generate View Transition styles for a wide range of days around current view
// to ensure both entering and exiting days have the 500ms duration.
const center = currentWeekStart;
const validNames = [];
// Cover +/- 2 weeks just to be safe (exiting days need styles too)
for (let i = -14; i <= 21; i++) {
const d = new Date(center);
d.setDate(d.getDate() + i);
validNames.push(
`day-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`,
);
}
return validNames
.map(
(name) => `
::view-transition-group(${name}) {
animation-duration: 0.5s;
animation-timing-function: ease-in-out;
}
`,
)
.join("");
})(),
}}
/>
{/* Mobile Header */}
{isMobile && (
<header className="flex items-center justify-between w-full px-3 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white" style={{ minHeight: "48px" }}>
{/* Left: Navigation */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToPrevWeek} title="Previous Week">
<ChevronsLeft size={16} />
</button>
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToPrevDay} title="Previous Day">
<ChevronLeft size={16} />
</button>
<button className="w-7 h-7 flex items-center justify-center text-gray-600 dark:text-gray-300 rounded active:bg-white active:shadow-sm" onClick={goToToday} title="Today">
<span style={{ fontSize: "8px" }}></span>
</button>
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToNextDay} title="Next Day">
<ChevronLeft size={16} className="rotate-180" />
</button>
<button className="p-1.5 rounded text-gray-500 active:bg-white active:shadow-sm" onClick={goToNextWeek} title="Next Week">
<ChevronsLeft size={16} className="rotate-180" />
</button>
</div>
{/* Center: Week info */}
<div className="flex items-center gap-1 text-sm font-semibold" style={{ color: darkMode ? "#e5e7eb" : "#333" }}>
{(isLoading || isSyncing || syncStatus === "syncing") ? (
<div className="weekly-spinner" title="Syncing..."></div>
) : syncError ? (
<AlertCircle size={14} className="text-red-500" />
) : null}
<div className="flex flex-col items-center justify-center leading-tight" style={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
<span>KW{getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")}</span>
<span>{getCWReferenceDate(getVisibleDays()).getFullYear()}</span>
</div>
</div>
{/* Right: Settings + Overflow */}
<div className="flex items-center gap-1" ref={mobileMenuRef}>
<button className="p-2 rounded-md text-gray-500 active:bg-gray-100" onClick={() => setShowSettings(true)} title="Settings">
<Settings size={18} />
</button>
<div className="relative">
<button className="p-2 rounded-md text-gray-500 active:bg-gray-100" onClick={() => setShowMobileMenu(!showMobileMenu)} title="More">
<MoreVertical size={18} />
</button>
{showMobileMenu && (
<div className="mobile-overflow-menu" onClick={() => setShowMobileMenu(false)}>
<button onClick={() => { setShowDatePicker(true); }}>
<Calendar size={16} /> Jump to date
</button>
<button onClick={() => setIsSearchOpen(true)}>
<Search size={16} /> Search
</button>
<div className="mobile-menu-divider" />
<button onClick={() => {
const now = new Date();
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
}}>
<Plus size={16} /> Add Calendar Event
</button>
<button onClick={() => setIsRecurringTasksOpen(true)}>
<Repeat size={16} /> Recurring Tasks
</button>
<div className="mobile-menu-divider" />
<button onClick={() => { const newVal = !showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); }}>
{showNextTask ? <Play size={16} className="text-teal-600" /> : <Target size={16} />}
{showNextTask ? "Showing Next Task" : "Showing Goal"}
</button>
<button onClick={() => setShowFocusMode(true)}>
<Zap size={16} /> Focus Mode
</button>
<button onClick={() => setDarkMode(!darkMode)}>
{darkMode ? <Sun size={16} className="text-yellow-500" /> : <Moon size={16} />}
{darkMode ? "Light Mode" : "Dark Mode"}
</button>
<div className="mobile-menu-divider" />
<div className="mobile-menu-item" style={{ flexDirection: "column", alignItems: "flex-start", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>Days to show</span>
<div className="flex items-center gap-1">
{[1, 3, 5, 7].map((num) => (
<button
key={num}
onClick={(e) => { e.stopPropagation(); setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); setShowMobileMenu(false); }}
className={`px-3 py-1 text-xs rounded ${viewDays === num ? "bg-sky-500 text-white font-bold" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{num}
</button>
))}
</div>
</div>
{showTimeGrid && (
<div className="mobile-menu-item" style={{ flexDirection: "column", alignItems: "flex-start", gap: "6px" }}>
<span style={{ fontSize: "0.75rem", color: "#9ca3af" }}>Slot duration</span>
<div className="flex items-center gap-1">
{[15, 30, 60].map((d) => (
<button
key={d}
onClick={(e) => { e.stopPropagation(); setCellDuration(d as CellDuration); saveSetting("cellDuration", d); setShowMobileMenu(false); }}
className={`px-3 py-1 text-xs rounded ${cellDuration === d ? "bg-sky-500 text-white font-bold" : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"}`}
>
{d}m
</button>
))}
</div>
</div>
)}
<div className="mobile-menu-divider" />
<button onClick={handleUndo} disabled={undoCount === 0} style={undoCount === 0 ? { opacity: 0.3 } : {}}>
<Undo2 size={16} /> Undo
</button>
<button onClick={handleRedo} disabled={redoCount === 0} style={redoCount === 0 ? { opacity: 0.3 } : {}}>
<Redo2 size={16} /> Redo
</button>
<div className="mobile-menu-divider" />
<button onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}>
<RefreshCcw size={16} /> Refresh
</button>
</div>
)}
</div>
</div>
</header>
)}
{/* Desktop Header: Left, Center, Right */}
<header className="group relative flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}>
{/* LEFT SECTION: Slot Duration & Days to Show */}
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Slot Duration */}
{showTimeGrid && (
<div
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
title="Slot Duration"
>
<Clock size={16} className="text-gray-500 mr-1" />
{[15, 30, 60].map((duration) => (
<button
key={duration}
onClick={() => {
setCellDuration(duration as CellDuration);
saveSetting("cellDuration", duration);
}}
className={`px-2 py-0.5 text-xs rounded transition-colors ${cellDuration === duration ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{duration}m
</button>
))}
</div>
)}
{/* Days to Show */}
<div
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
title="Days to show"
>
<LayoutGrid size={16} className="text-gray-500 mr-1" />
{[1, 3, 5, 7].map((num) => (
<button
key={num}
onClick={() => {
setViewDays(num);
savedViewDaysRef.current = num;
saveSetting("viewDays", num);
}}
className={`px-2 py-0.5 text-xs rounded transition-colors ${viewDays === num ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{num}
</button>
))}
</div>
{/* Time Range */}
{showTimeGrid && (
<div
className="flex items-center gap-2 text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 rounded p-1 px-2"
title="Visible Hours"
>
<Clock size={16} className="text-gray-500" />
<div className="flex items-center gap-1">
<input
type="number"
min="0"
max={endHour - 1}
value={startHour}
onChange={(e) => {
const val = Math.max(
0,
Math.min(parseInt(e.target.value) || 0, endHour - 1),
);
setStartHour(val);
saveSetting("startHour", val);
}}
className="w-10 p-0.5 border border-gray-200 dark:border-gray-700 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
/>
<span>-</span>
<input
type="number"
min={startHour + 1}
max="24"
value={endHour}
onChange={(e) => {
const val = Math.max(
startHour + 1,
Math.min(parseInt(e.target.value) || 24, 24),
);
setEndHour(val);
saveSetting("endHour", val);
}}
className="w-10 p-0.5 border border-gray-200 dark:border-gray-700 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
/>
</div>
</div>
)}
</div>
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group z-10 opacity-100" style={{ pointerEvents: "auto" }}>
{/* Week & Year */}
<div className="whitespace-nowrap flex items-center gap-2">
{/* Date Picker Toggle - Moved to front */}
<div className="relative">
<button
ref={datePickerBtnRef}
className={`p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50 opacity-100" : "text-gray-500 hover:text-black opacity-0 group-hover:opacity-100"}`}
onClick={() => setShowDatePicker(!showDatePicker)}
title="Jump to date"
>
<Calendar size={18} />
</button>
{showDatePicker && (
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
setShowDatePicker(false);
}}
onClose={() => setShowDatePicker(false)}
language={language}
anchorRef={datePickerBtnRef}
/>
)}
</div>
{/* Clickable Week & Year */}
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80"
onClick={() => setShowDatePicker(!showDatePicker)}
title="Jump to date"
>
<span style={{
fontFamily: profile.cwFontFamily || "Inter",
fontSize: profile.cwFontSize || "1.125rem",
fontWeight: Number(profile.cwFontWeight || "700"),
color: adjustColorForDarkMode(profile.cwColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}>
KW {getWeekNumber(getCWReferenceDate(getVisibleDays())).toString().padStart(2, "0")}
</span>
<span className="text-gray-400">|</span>
<span
style={{
fontFamily: profile.yearFontFamily || "Inter",
fontSize: profile.yearFontSize || "1.125rem",
fontWeight: Number(profile.yearFontWeight || "700"),
color: adjustColorForDarkMode(profile.yearColor || "#333333", darkMode),
filter: "brightness(var(--weekly-header-brightness, 1))"
}}
>
{currentWeekStart.getFullYear()}
</span>
</div>
</div>
{/* Goal */}
<div className="flex items-center text-sm">
{syncError ? (
<div className="flex items-center gap-1 text-red-500 mr-2" title={syncError}>
<AlertCircle size={14} />
<span className="text-xs">{syncError}</span>
</div>
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
<div className="weekly-spinner mr-2" title="Syncing..."></div>
) : (
<button
onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 mr-1"
title="Refresh Calendar & Tasks"
>
<RefreshCcw size={14} />
</button>
)}
{isEditingGoal ? (
<input
type="text"
value={goal}
onChange={(e) => setGoal(e.target.value)}
onBlur={() => {
saveGoal(goal);
setIsEditingGoal(false);
}}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
autoFocus
className="border-b border-gray-300 focus:outline-none focus:border-black px-1 text-center font-medium italic"
style={{
width: `${Math.max(10, goal.length)}ch`,
fontFamily: profile.goalFontFamily
? `"${profile.goalFontFamily}", sans-serif`
: undefined,
fontSize: profile.goalFontSize || undefined,
fontWeight: profile.goalFontWeight || undefined,
}}
/>
) : (
<span
onClick={() => !showNextTask && setIsEditingGoal(true)}
className={`cursor-pointer font-medium italic transition-colors ${showNextTask ? "cursor-default text-gray-600 dark:text-white hover:text-black dark:hover:text-gray-100" : "text-gray-600 dark:text-yellow-400 hover:text-black dark:hover:text-yellow-300"}`}
title={showNextTask ? "Next task" : "Edit goal"}
style={{
fontFamily: profile.goalFontFamily
? `"${profile.goalFontFamily}", sans-serif`
: undefined,
fontSize: profile.goalFontSize || undefined,
fontWeight: profile.goalFontWeight || undefined,
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
filter: "brightness(var(--weekly-goal-brightness, 1))",
maxWidth: "800px",
textAlign: "center" as const,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical" as const,
}}
>
{showNextTask
? (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = formatDateToISO(today);
const todayDay = today.getDay();
const todaysTasks = tasks
.filter((t) => {
if (t.completed || t.somedayListId) return false;
if (t.scheduledDate)
return (
formatDateToISO(new Date(t.scheduledDate)) ===
todayStr
);
if (t.dayOfWeek === todayDay && !t.scheduledDate)
return true;
return false;
})
.sort((a, b) => {
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;
});
const nextTask = todaysTasks[0];
const nextTaskText = nextTask ? `Do this now: ${nextTask.title}` : (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal));
return nextTaskText;
})()
: (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))}
</span>
)}
</div>
</div>
{/* RIGHT SECTION: Navigation & Tools */}
<div className="weekly-header-controls flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
{/* Undo/Redo */}
<button
onClick={handleUndo}
disabled={undoCount === 0}
className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default"
title="Undo (Ctrl+Z)"
>
<Undo2 size={18} className="text-gray-600 hover:text-black transition-colors" />
</button>
<button
onClick={handleRedo}
disabled={redoCount === 0}
className="weekly-btn-icon disabled:opacity-30 disabled:cursor-default"
title="Redo (Ctrl+Y)"
>
<Redo2 size={18} className="text-gray-600 hover:text-black transition-colors" />
</button>
{/* Add Event Button */}
<button
onClick={() => {
const now = new Date();
setCalendarEventModal({
isOpen: true,
event: undefined,
initialDate: now,
initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00`,
});
}}
className="weekly-btn-icon"
title="Add Calendar Event"
>
<Plus
size={18}
className="text-gray-600 hover:text-black transition-colors"
/>
</button>
<button
onClick={() => {
const newVal = !showNextTask;
setShowNextTask(newVal);
saveSetting("showNextTask", newVal);
}}
className={`weekly-btn-icon ${showNextTask ? "active" : ""}`}
title={showNextTask ? "Showing Next Task" : "Showing Goal"}
>
{showNextTask ? (
<Play size={18} className="text-teal-600" />
) : (
<Target size={18} className="text-gray-400" />
)}
</button>
{/* Focus Mode Toggle */}
<button
onClick={() => setShowFocusMode(true)}
className="weekly-btn-icon"
title="Enter Focus Mode"
>
<Zap
size={18}
className="text-gray-600 hover:text-yellow-500 transition-colors"
/>
</button>
{/* Day/Night Mode Switch */}
<button
onClick={() => setDarkMode(!darkMode)}
className="p-1.5 rounded-md hover:bg-gray-100 transition-colors"
title={darkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
{darkMode ? (
<Sun size={18} className="text-yellow-500" />
) : (
<Moon size={18} className="text-gray-500" />
)}
</button>
{/* Navigation Controls */}
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
<button
className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all"
onClick={goToPrevWeek}
title="Previous Week"
>
<ChevronsLeft size={16} />
</button>
<button
className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all"
onClick={goToPrevDay}
title="Previous Day"
>
<ChevronLeft size={16} />
</button>
<button
className="px-3 py-1 text-xs font-bold text-gray-600 hover:text-black hover:bg-white hover:shadow-sm rounded transition-all"
onClick={goToToday}
title="Go to Today"
>
Today
</button>
<button
className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all"
onClick={goToNextDay}
title="Next Day"
>
<ChevronLeft size={16} className="rotate-180" />
</button>
<button
className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all"
onClick={goToNextWeek}
title="Next Week"
>
<ChevronsLeft size={16} className="rotate-180" />
</button>
</div>
{/* Search */}
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
onClick={() => setIsSearchOpen(true)}
title="Search"
>
<Search size={18} />
</button>
{/* Recurring Tasks */}
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
onClick={() => setIsRecurringTasksOpen(true)}
title="Recurring Tasks"
>
<Repeat size={18} />
</button>
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
onClick={() => setShowSettings(true)}
title="Settings"
>
<Settings size={18} />
</button>
{/* User Menu */}
<UserMenu
userEmail={session?.user?.email}
onOpenSettings={() => setShowSettings(true)}
language={profile.language}
trigger={
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
title="User Menu"
>
<User size={18} />
</button>
}
/>
</div>
</header>
{/* All-Day Events Section (above position) */}
{allDayPosition === "above" && allDaySection}
{/* Kanban Board View */}
{viewStyle === "kanban" && (
<div className="kanban-board">
{kanbanStages.map((stage) => {
const stageTasks = tasks.filter(t => (t.kanbanStage || null) === stage.id && !t.somedayListId);
return (
<div
key={stage.id}
className="kanban-column"
onDragOver={(e) => {
if (e.dataTransfer.types.includes("text/kanban-task")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
e.currentTarget.classList.add("kanban-column-drag-over");
}
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
e.currentTarget.classList.remove("kanban-column-drag-over");
}
}}
onDrop={async (e) => {
e.currentTarget.classList.remove("kanban-column-drag-over");
const taskId = e.dataTransfer.getData("text/kanban-task");
if (taskId) {
e.preventDefault();
await updateTaskFields(taskId, { kanbanStage: stage.id });
}
}}
>
<div className="kanban-column-header" style={{ borderBottomColor: stage.color }}>
<span className="kanban-column-dot" style={{ background: stage.color }} />
<span className="kanban-column-title">{stage.name}</span>
<span className="kanban-column-count">{stageTasks.length}</span>
</div>
<div className="kanban-column-body">
{stageTasks.map(task => (
<div
key={task.id}
className={`kanban-card ${task.completed ? "kanban-card-done" : ""}`}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/kanban-task", task.id);
e.dataTransfer.effectAllowed = "move";
}}
>
<div className="kanban-card-header">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
className="kanban-card-checkbox"
/>
<span
className="kanban-card-title"
contentEditable
suppressContentEditableWarning
onBlur={(e) => {
const text = (e.target as HTMLElement).textContent || "";
if (text !== task.title) updateTask(task.id, text);
}}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); }
}}
>
{task.title}
</span>
</div>
{task.scheduledDate && (
<div className="kanban-card-date">
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
</div>
)}
{task.project && (
<div className="kanban-card-project" style={{ color: task.project.color || "#888" }}>
{task.project.icon || "📁"} {task.project.name}
</div>
)}
</div>
))}
</div>
</div>
);
})}
{/* Unassigned column */}
{(() => {
const unassigned = tasks.filter(t => !t.kanbanStage && !t.somedayListId && !t.completed);
if (unassigned.length === 0) return null;
return (
<div
className="kanban-column kanban-column-unassigned"
onDragOver={(e) => {
if (e.dataTransfer.types.includes("text/kanban-task")) {
e.preventDefault();
e.currentTarget.classList.add("kanban-column-drag-over");
}
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
e.currentTarget.classList.remove("kanban-column-drag-over");
}
}}
onDrop={async (e) => {
e.currentTarget.classList.remove("kanban-column-drag-over");
const taskId = e.dataTransfer.getData("text/kanban-task");
if (taskId) {
e.preventDefault();
await updateTaskFields(taskId, { kanbanStage: null });
}
}}
>
<div className="kanban-column-header" style={{ borderBottomColor: "#d1d5db" }}>
<span className="kanban-column-dot" style={{ background: "#d1d5db" }} />
<span className="kanban-column-title">{t.noStage}</span>
<span className="kanban-column-count">{unassigned.length}</span>
</div>
<div className="kanban-column-body">
{unassigned.map(task => (
<div
key={task.id}
className="kanban-card"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/kanban-task", task.id);
e.dataTransfer.effectAllowed = "move";
}}
>
<div className="kanban-card-header">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
className="kanban-card-checkbox"
/>
<span className="kanban-card-title">{task.title}</span>
</div>
{task.scheduledDate && (
<div className="kanban-card-date">
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
</div>
)}
</div>
))}
</div>
</div>
);
})()}
</div>
)}
{/* Main Grid with Time Column */}
{viewStyle !== "kanban" && <div className="time-grid-wrapper">
{/* Side Navigation Arrows (hover overlays) */}
<div className="side-nav side-nav-left">
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
<ChevronLeft size={16} />
</button>
<button onClick={goToPrevWeek} title="Previous Week" className="side-nav-btn">
<ChevronsLeft size={16} />
</button>
</div>
{/* Time Column */}
{showTimeGrid && (
<div className="time-column">
<div className="time-column-header" style={{ border: 'none', background: 'transparent' }}>
{/* Invisible structural match of day header to guarantee perfect height alignment */}
<div
style={{
visibility: "hidden", pointerEvents: "none",
display: "flex",
width: "100%",
alignItems:
activeDateLayout === "above" ||
activeDateLayout === "below"
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
: "center",
justifyContent:
profile.dateAlignment === "left"
? "flex-start"
: profile.dateAlignment === "right"
? "flex-end"
: "center",
flexDirection:
activeDateLayout === "above"
? "column-reverse"
: activeDateLayout === "below"
? "column"
: "row",
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
}}
>
{activeDateLayout === "left" && (
<span className="weekly-day-date">W</span>
)}
<h3 className="weekly-day-name" style={{ marginBottom: 0 }}>
X
</h3>
{(activeDateLayout === "right" ||
activeDateLayout === "above" ||
activeDateLayout === "below" ||
activeDateLayout === undefined) && (
<span className="weekly-day-date">W</span>
)}
</div>
</div>
<div
className="time-column-slots"
ref={timeColumnRef}
onScroll={handleTimeColumnScroll}
>
{visibleSlots.map((slot, index) => {
const hour = getHourFromSlot(slot);
const minutes = slot.split(":")[1];
const isHourStart = minutes === "00";
if (!isHourStart && !showSubHourSlots) return (
<div
key={slot}
className="time-slot-label"
style={{ height: `${getSlotHeight(cellDuration)}px` }}
/>
);
return (
<div
key={slot}
className={`time-slot-label ${isHourStart ? "hour-start" : "sub-hour"}`}
style={{ height: `${getSlotHeight(cellDuration)}px` }}
>
{isHourStart && <span>{formatHour(hour, hourLabelFormat, timeFormat)}</span>}
{!isHourStart && showSubHourSlots && <span className="sub-hour-label">:{minutes}</span>}
</div>
);
})}
</div>
</div>
)}
{/* Day Columns */}
<main
ref={gridRef}
className={`weekly-days-grid cols-${viewDays}`}
data-slide-direction={slideDirection}
data-nav-type={viewDays > 1 ? "week" : "day"}
>
{getVisibleDays().map((date, colIndex) => {
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isToday = isSameDay(date, todayMidnight);
const isPast = date < todayMidnight && !isToday;
return (
<div
key={date.toISOString()}
className={`weekly-day-column ${date.getDay() === 6 ? "is-sat" : ""} ${date.getDay() === 0 ? "is-sun" : ""} ${isToday ? "is-today" : ""} ${isPast ? "is-past" : ""}`}
>
{/* Day Header */}
<header className="weekly-day-header" ref={colIndex === 0 ? dayHeaderRef : undefined}>
<div
style={{
display: "flex",
width: "100%",
alignItems:
activeDateLayout === "above" ||
activeDateLayout === "below"
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
: (profile.dateVerticalAlign === "top" ? "flex-start" : profile.dateVerticalAlign === "bottom" ? "flex-end" : "center"),
justifyContent:
profile.dateAlignment === "left"
? "flex-start"
: profile.dateAlignment === "right"
? "flex-end"
: "center",
flexDirection:
activeDateLayout === "above"
? "column-reverse"
: activeDateLayout === "below"
? "column"
: "row",
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
}}
>
{activeDateLayout === "left" && (
<span className="weekly-day-date" style={{ flexShrink: 0 }}>
{formatDateHeader(date, language)}
</span>
)}
<h3
className={`weekly-day-name ${isSameDay(date, new Date()) ? "is-today" : ""}`}
style={{ marginBottom: 0, flexShrink: 0 }}
>
{getDayName(date, language, weekdayFormat, customWeekdayNames, weekStartDay, weekdayCase)}
</h3>
{(activeDateLayout === "right" ||
activeDateLayout === "above" ||
activeDateLayout === "below" ||
activeDateLayout === undefined) && (
<span className="weekly-day-date" style={{ flexShrink: 0 }}>
{formatDateHeader(date, language)}
</span>
)}
</div>
</header>
{/* Time Grid or Simple List */}
{showTimeGrid ? (
<div
className="time-slots-container"
ref={(el) => {
if (el) dayColumnsRef.current[colIndex] = el;
}}
onScroll={(e) => handleDayColumnScroll(e, colIndex)}
onDragLeave={handleDragLeave}
style={{ position: "relative" }}
>
{/* Calendar Fetching Indicator */}
{showTimeGrid && colIndex === 0 && isFetchingCalendar && (
<div className="absolute top-2 left-2 z-[60] flex items-center gap-2 bg-white/90 dark:bg-zinc-800/90 px-3 py-1.5 rounded-full shadow-sm border border-zinc-200 dark:border-zinc-700 text-xs text-zinc-600 dark:text-zinc-300 pointer-events-none">
<div className="w-3 h-3 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
Syncing Calendar...
</div>
)}
{/* Now Line - only show on today's column */}
{isSameDay(date, new Date()) &&
(() => {
const now = currentTime;
const nowHour = now.getHours();
const nowMinute = now.getMinutes();
// Only show if within visible time range
if (
nowHour >= workingHoursStart &&
nowHour < workingHoursEnd
) {
const minutesSinceStart =
(nowHour - workingHoursStart) * 60 + nowMinute;
const pixelsPerMinute =
getSlotHeight(cellDuration) / cellDuration;
const topPosition =
minutesSinceStart * pixelsPerMinute;
const timeString = `${String(nowHour).padStart(2, "0")}:${String(nowMinute).padStart(2, "0")}`;
return (
<div
className="now-line"
data-time={timeString}
style={{ top: `${topPosition}px` }}
/>
);
}
return null;
})()}
{/* Protection overlays - render at exact event positions */}
{protectEventTimes &&
getEventsForDate(date)
.filter((e) => !isAllDayEvent(e))
.map((event) => {
const eventStart = new Date(event.startTime);
const eventEnd = new Date(event.endTime);
const eventStartHour = eventStart.getHours();
const eventStartMinute = eventStart.getMinutes();
// Only show if event is within visible time range
if (
eventStartHour < workingHoursStart ||
eventStartHour >= workingHoursEnd
)
return null;
const minutesSinceStart =
(eventStartHour - workingHoursStart) * 60 +
eventStartMinute;
const pixelsPerMinute =
getSlotHeight(cellDuration) / cellDuration;
const topPosition =
minutesSinceStart * pixelsPerMinute;
// Calculate height based on event duration
const durationMinutes =
(eventEnd.getTime() - eventStart.getTime()) /
(1000 * 60);
const calculatedHeight =
durationMinutes * pixelsPerMinute;
// Ensure minimum height of 15px for visibility
const height = Math.max(calculatedHeight, 15);
const isUnlocked = unlockedEvents.has(event.id);
return (
<div
key={`protection-${event.id}`}
className="event-protection-overlay"
style={{
position: "absolute",
top: `${topPosition}px`,
left: 0,
right: 0,
height: `${height}px`,
zIndex: 1,
pointerEvents: "none",
}}
>
<button
className="event-unlock-btn"
onClick={(e) => {
e.stopPropagation();
setUnlockedEvents((prev) => {
const next = new Set(prev);
if (next.has(event.id)) {
next.delete(event.id);
} else {
next.add(event.id);
}
return next;
});
}}
title={
isUnlocked
? "Lock this time slot"
: "Unlock this time slot"
}
style={{ pointerEvents: "auto" }}
>
{isUnlocked ? "🔓" : "🔒"}
</button>
</div>
);
})}
{/* GridTaskBlocks for timed tasks */}
{getTasksForDate(date)
.filter(t => !!t.startTime)
.map(task => (
<GridTaskBlock
key={task.id}
task={task}
date={date}
activeDate={currentWeekStart}
cellDuration={cellDuration}
darkMode={darkMode}
isProtected={false}
editingTaskId={editingTaskId}
setEditingTaskId={setEditingTaskId}
updateTask={updateTask}
updateTaskNotes={updateTaskNotes}
updateTaskDuration={updateTaskDuration}
toggleTask={toggleTask}
deleteTask={deleteTask}
toggleTaskRolling={toggleTaskRolling}
setSelectedTaskForNotes={setSelectedTaskForNotes}
setSelectedTaskForRecurrence={setSelectedTaskForRecurrence}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
getSlotHeight={getSlotHeight}
draggedTask={draggedTask as any}
addSubTask={addSubTask}
toggleSubTask={toggleSubTask}
updateSubTask={updateSubTask}
deleteSubTask={deleteSubTask}
onSetEditingTaskId={setEditingTaskId}
workingHoursStart={workingHoursStart}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
/>
))}
{visibleSlots.map((slot) => {
const hour = getHourFromSlot(slot);
const minutes = slot.split(":")[1];
const isHourStart = minutes === "00";
const slotEvents = getEventsForSlot(date, slot);
const isActive =
activeSlot?.day === date.getDay() &&
activeSlot?.slot === slot;
const isProtected = isSlotProtected(date, slot);
const isOccupiedByTask = isSlotOccupiedByTask(date, slot, draggedTask?.id);
const isOccupiedByAnyTask = isSlotOccupiedByTask(date, slot);
const handleSlotClick = (e: React.MouseEvent) => {
if (isProtected || isOccupiedByAnyTask) return; // Don't allow adding tasks to protected or occupied slots
// Alt+Click to Create Calendar Event
if (e.altKey) {
e.stopPropagation();
setCalendarEventModal({
isOpen: true,
initialDate: date,
initialStartTime: slot,
});
return;
}
if (!isActive) {
setActiveSlot({ day: date.getDay(), slot });
setNewSlotTask("");
}
};
const handleSlotSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
const taskTitle = newSlotTask.trim();
// Clear state immediately to prevent double submit
setActiveSlot(null);
setNewSlotTask("");
if (taskTitle) {
await addTask(date, taskTitle, slot);
}
};
const handleSlotDrop = (e: React.DragEvent) => {
e.preventDefault();
if (isProtected || isOccupiedByTask) return; // Don't allow dropping on protected or occupied slots
handleDrop(e, date.getDay(), slot);
};
const isDropTarget =
dropPreview?.day === date.getDay() &&
dropPreview?.slot === slot;
return (
<div
key={slot}
className={`time-slot ${isHourStart ? "hour-start" : ""} ${draggedTask && !isProtected && !isOccupiedByTask ? "drop-target" : ""} ${isActive ? "active" : ""}`}
style={{
height: `${getSlotHeight(cellDuration)}px`,
position: "relative",
cursor: isProtected || isOccupiedByAnyTask ? "not-allowed" : "text",
}}
onClick={handleSlotClick}
onDragOver={(e) =>
!isProtected && !isOccupiedByTask &&
handleDragOver(e, date.getDay(), slot)
}
onDrop={handleSlotDrop}
>
{/* Drop preview indicator */}
{isDropTarget && !isProtected && !isOccupiedByTask && (
<div className="drop-preview" />
)}
{/* Calendar Events in time slot */}
{slotEvents.map((event) => {
const eventHeight = getEventDuration(event);
const startTime = new Date(event.startTime);
const endTime = new Date(event.endTime);
const timeStr = `${startTime.getHours().toString().padStart(2, "0")}:${startTime.getMinutes().toString().padStart(2, "0")} - ${endTime.getHours().toString().padStart(2, "0")}:${endTime.getMinutes().toString().padStart(2, "0")}`;
// Calculate offset within the slot based on event start time
const [slotHour, slotMinute] = slot
.split(":")
.map(Number);
const slotStartMinutes = slotHour * 60 + slotMinute;
const eventStartMinutes =
startTime.getHours() * 60 +
startTime.getMinutes();
const offsetMinutes =
eventStartMinutes - slotStartMinutes;
const pixelsPerMinute =
getSlotHeight(cellDuration) / cellDuration;
const topOffset = offsetMinutes * pixelsPerMinute;
// Convert hex to rgba for background, or use default
const eventColor = event.calendarColor || "#009a9a";
const bgColor = eventColor.startsWith("#")
? `${eventColor}20` // Add alpha for transparency
: eventColor;
const borderColor = eventColor.startsWith("#")
? eventColor
: "var(--weekly-teal)";
return (
<div
key={event.id}
className="time-slot-event"
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
style={{
height: `${Math.max(eventHeight, 15)}px`,
minHeight: `15px`,
position: "absolute",
top: `${topOffset}px`,
left: "-10px",
right: "-15px",
zIndex: 1,
flexDirection: "column",
alignItems: "flex-start",
backgroundColor: bgColor,
borderLeftColor: borderColor,
color: borderColor,
cursor: event.editable
? "pointer"
: "default",
}}
onClick={(e) => {
e.stopPropagation();
if (event.editable) {
setCalendarEventModal({
isOpen: true,
event: event,
});
}
}}
>
<div className="event-title-row">
<span className="event-indicator">📅</span>
<span className="event-title">
{event.title}
</span>
</div>
<div className="event-time-row">{timeStr}</div>
</div>
);
})}
{isActive && (
<form
onSubmit={handleSlotSubmit}
className="slot-input-form"
>
<input
type="text"
value={newSlotTask}
onChange={(e) => setNewSlotTask(e.target.value)}
onBlur={async (e) => {
// Prevent double submission if form was submitted
if (activeSlot && newSlotTask.trim()) {
// Delay slightly to let onSubmit fire if that was the cause
setTimeout(async () => {
if (activeSlot && newSlotTask.trim()) {
const taskTitle = newSlotTask.trim();
setActiveSlot(null);
setNewSlotTask("");
await addTask(date, taskTitle, slot);
}
}, 100);
} else {
setActiveSlot(null);
setNewSlotTask("");
}
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setActiveSlot(null);
setNewSlotTask("");
}
}}
autoFocus
className="weekly-task-input"
style={{
width: "100%",
background: "transparent",
outline: "none",
minHeight: "24px",
paddingLeft: "0",
}}
/>
</form>
)}
</div>
);
})}
{/* All Day Events Section */}
</div>
) : (
<div
onDragOver={(e) => handleDragOver(e, date.getDay())}
onDrop={(e) => handleDrop(e, date.getDay())}
onDragLeave={handleDragLeave}
style={{ flex: 1 }}
>
{/* Calendar Events */}
{getEventsForDate(date).map((event) => {
const eventColor = event.calendarColor || "#009a9a";
const bgColor = eventColor.startsWith("#")
? `${eventColor}20`
: eventColor;
const borderColor = eventColor.startsWith("#")
? eventColor
: "var(--weekly-teal)";
return (
<div
key={event.id}
className="weekly-calendar-event"
onClick={(e) => {
e.stopPropagation();
if (event.editable) {
setCalendarEventModal({
isOpen: true,
event: event,
});
}
}}
style={{
backgroundColor: bgColor,
borderLeftColor: borderColor,
color: borderColor,
cursor: event.editable ? "pointer" : "default",
}}
>
<div
className="weekly-calendar-event-time"
style={{ color: "inherit", opacity: 0.8 }}
>
{new Date(event.startTime).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
)}
</div>
<div
className="weekly-calendar-event-title"
style={{ color: "inherit" }}
>
{event.title}
</div>
</div>
);
})}
{/* Tasks */}
<ol className="weekly-task-list">
{getTasksForDate(date).map((task) => (
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() =>
setSelectedTaskForRecurrence(task)
}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
kanbanStages={kanbanStages}
/>
))}
</ol>
</div>
)}
</div>
);
})}
</main>
{/* Right Navigation Arrows (after grid so it paints on top) */}
<div className="side-nav side-nav-right">
<button onClick={goToNextDay} title="Next Day" className="side-nav-btn">
<ChevronLeft size={16} className="rotate-180" />
</button>
<button onClick={goToNextWeek} title="Next Week" className="side-nav-btn">
<ChevronsLeft size={16} className="rotate-180" />
</button>
</div>
</div>}
{/* All-Day Events Section (below position) */}
{allDayPosition === "below" && allDaySection}
{/* Someday Section */}
{showSomeday && (
<section
ref={somedaySectionRefCb}
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
>
{/* Someday tabs bar */}
<div className="someday-tabs-bar">
<div
onClick={() => setSomedayExpanded(!somedayExpanded)}
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px",
marginRight: "4px",
}}
title={somedayExpanded ? "Collapse" : "Expand"}
>
<ChevronRight
size={14}
style={{
transform: somedayExpanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.15s",
color: "var(--weekly-text-light)",
flexShrink: 0,
}}
/>
<span style={{
fontSize: "0.7rem",
fontWeight: 600,
color: "var(--weekly-text-light)",
textTransform: "uppercase",
letterSpacing: "0.05em",
whiteSpace: "nowrap",
}}>
any day
</span>
</div>
<button
className="someday-bar-icon-btn"
onClick={() => handleStartAddSomedayList()}
title={t.newList}
>
<ListPlus size={14} />
</button>
<button
className="someday-bar-icon-btn"
onClick={() => { setCreatingNewTab(true); setCreatingNewTabName(""); }}
title={t.newTab}
>
<FolderPlus size={14} />
</button>
{creatingNewTab && (
<input
className="someday-tab-rename-input"
value={creatingNewTabName}
onChange={(e) => setCreatingNewTabName(e.target.value)}
onBlur={() => {
const name = creatingNewTabName.trim();
if (name && !somedayTabs.includes(name)) {
saveCustomTabs([...customTabs, name]);
setSomedayTab(name);
} else if (name) {
setSomedayTab(name);
}
setCreatingNewTab(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") setCreatingNewTab(false);
}}
placeholder={t.newTab}
autoFocus
style={{
fontSize: "0.75rem",
border: "1px solid var(--weekly-border)",
borderRadius: "3px",
padding: "2px 6px",
background: "var(--weekly-bg)",
color: "var(--weekly-text)",
width: "80px",
}}
/>
)}
<div className="someday-tabs-bar-divider" />
<button
className={`someday-tab-btn-h ${activeSomedayTab === null ? "active" : ""} ${dragOverTab === "__all__" ? "drag-over" : ""}`}
onClick={() => setSomedayTab(null)}
onDragOver={(e) => {
if (e.dataTransfer.types.includes("text/list-id")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverTab("__all__");
}
}}
onDragLeave={() => setDragOverTab(null)}
onDrop={(e) => {
const listId = e.dataTransfer.getData("text/list-id");
if (listId) {
e.preventDefault();
e.stopPropagation();
assignListToTab(listId, null);
setDraggingListId(null);
setDropTargetListIndex(null);
}
setDragOverTab(null);
}}
>{t.allTabs} <span className="someday-tab-count">{somedayLists.length}</span></button>
{somedayTabs.map(tab => (
editingTabName === tab ? (
<input
key={tab}
className="someday-tab-rename-input"
value={renamingTabValue}
onChange={(e) => setRenamingTabValue(e.target.value)}
onBlur={() => {
renameTab(tab, renamingTabValue);
setEditingTabName(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") setEditingTabName(null);
}}
autoFocus
style={{
fontSize: "0.75rem",
border: "1px solid var(--weekly-border)",
borderRadius: "3px",
padding: "2px 6px",
background: "var(--weekly-bg)",
color: "var(--weekly-text)",
width: "80px",
}}
/>
) : (
<div
key={tab}
className="someday-tab-wrapper-h"
onDragOver={(e) => {
if (e.dataTransfer.types.includes("text/list-id")) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverTab(tab);
}
}}
onDragLeave={() => setDragOverTab(null)}
onDrop={(e) => {
const listId = e.dataTransfer.getData("text/list-id");
if (listId) {
e.preventDefault();
e.stopPropagation();
assignListToTab(listId, tab);
setDraggingListId(null);
setDropTargetListIndex(null);
}
setDragOverTab(null);
}}
>
<button
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""} ${dragOverTab === tab ? "drag-over" : ""}`}
onClick={() => setSomedayTab(tab)}
onDoubleClick={() => {
setEditingTabName(tab);
setRenamingTabValue(tab);
}}
title={t.renameTab}
>{tab} <span className="someday-tab-count">{somedayLists.filter(l => l.tab === tab).length}</span></button>
<button
className="someday-tab-dissolve-h"
onClick={(e) => { e.stopPropagation(); dissolveTab(tab); }}
title={t.dissolveTab}
><X size={10} /></button>
</div>
)
))}
</div>
<div style={{ display: "flex", flexDirection: "row", maxWidth: "100%", width: "100%" }}>
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
{somedayExpanded && (
<div
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap" }}
onDragLeave={(e) => {
// Clear indicator when leaving the someday grid entirely
if (draggingListId && !e.currentTarget.contains(e.relatedTarget as Node)) {
setDropTargetListIndex(null);
}
}}
>
{(() => {
const baseLists = filteredSomedayLists.length > 0
? filteredSomedayLists
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
return baseLists.slice(0, Math.max(filteredSomedayLists.length, viewDays));
})()
.flatMap((list, listIdx, arr) => {
const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? (
<div key={`drop-indicator-${listIdx}`} style={{
width: "3px",
flexShrink: 0,
background: "#6366f1",
borderRadius: "2px",
alignSelf: "stretch",
transition: "opacity 0.15s",
}} />
) : null;
// After last item, check for drop at end
const endIndicator = listIdx === arr.length - 1 && draggingListId && dropTargetListIndex === arr.length && draggingListId !== list.id ? (
<div key="drop-indicator-end" style={{
width: "3px",
flexShrink: 0,
background: "#6366f1",
borderRadius: "2px",
alignSelf: "stretch",
transition: "opacity 0.15s",
}} />
) : null;
const listEl = (
<div
key={list.id}
className={`weekly-someday-list ${draggingListId === list.id ? "is-dragging" : ""} p-2 transition-colors duration-200`}
style={{
minHeight: "200px",
cursor: "text",
display: "flex",
flexDirection: "column",
}}
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (
target.closest(".task-list-slot") ||
target.closest(".someday-list-header") ||
target.closest("button") ||
target.tagName === "INPUT"
) {
return;
}
const input = e.currentTarget.querySelector(
`[data-someday-add-input="${list.id}"]`,
) as HTMLInputElement;
if (input) {
input.focus();
}
}}
draggable
onDragStart={(e) => {
const target = e.target as HTMLElement;
if (target.closest(".weekly-task-item")) {
return;
}
if (!isDragFromHandle.current) {
e.preventDefault();
return;
}
isDragFromHandle.current = false;
setDraggingListId(list.id);
e.dataTransfer.setData("text/list-id", list.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => { setDraggingListId(null); setDropTargetListIndex(null); setDragOverTab(null); }}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!draggingListId || draggingListId === list.id) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midX = rect.left + rect.width / 2;
// Drop before or after this list
if (e.clientX < midX) {
setDropTargetListIndex(listIdx);
} else {
setDropTargetListIndex(listIdx + 1);
}
}}
onDrop={async (e) => {
e.preventDefault();
const droppedListId =
e.dataTransfer.getData("text/list-id");
const draggedTaskId =
e.dataTransfer.getData("text/plain");
if (droppedListId === list.id && !draggedTaskId) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
// If a task is dropped on the list generally (not on a specific slot),
// find the first free slot and place it there.
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
let nextFreeSlot = 0;
while (occupiedSlots.includes(nextFreeSlot)) {
nextFreeSlot++;
}
handleSomedayDrop(e, list.id, nextFreeSlot);
return;
}
// Reorder logic (list drag) - apply the visual order
if (!droppedListId || dropTargetListIndex === null) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
const dragIdx = somedayLists.findIndex(
(l) => l.id === droppedListId,
);
if (dragIdx === -1) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
const newLists = [...somedayLists];
const [moved] = newLists.splice(dragIdx, 1);
// Adjust target index since we removed an item before it
const insertIdx = dragIdx < dropTargetListIndex
? dropTargetListIndex - 1
: dropTargetListIndex;
newLists.splice(insertIdx, 0, moved);
setSomedayLists(newLists);
setDraggingListId(null);
setDropTargetListIndex(null);
// Persist order
const orderUpdates = newLists.map((l, index) => ({
id: l.id,
order: index,
}));
try {
await fetch("/api/someday-lists", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(orderUpdates),
});
} catch (err) {
console.error("Failed to update list order", err);
}
}}
>
<div
className="weekly-someday-list-title-header"
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
}}
>
{listToDelete === list.id ? (
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "8px", padding: "4px" }}>
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
{list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
<button onClick={(e) => {
e.stopPropagation();
setListToDelete(null);
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Cancel</button>
<button onClick={async (e) => {
e.stopPropagation();
try {
await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
setListToDelete(null);
} catch (err) { console.error(err); }
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Delete</button>
</div>
</div>
) : (
<>
<div
className="someday-drag-handle"
title="Drag to reorder"
onMouseDown={() => { isDragFromHandle.current = true; }}
onMouseUp={() => { isDragFromHandle.current = false; }}
>
<GripVertical size={14} />
</div>
<input
type="text"
defaultValue={list.title}
className="weekly-someday-list-title-input dark:bg-transparent dark:text-white"
onBlur={async (e) => {
const newTitle = e.target.value.trim();
if (newTitle && newTitle !== list.title) {
try {
await fetch("/api/someday-lists", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: list.id,
title: newTitle,
}),
});
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, title: newTitle }
: l,
),
);
} catch (err) {
console.error(err);
e.target.value = list.title;
}
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
{list.externalProvider && (() => {
const providerUrl = list.externalProvider === "google" ? "https://tasks.google.com/tasks/" :
list.externalProvider === "outlook" ? "https://to-do.live.com/tasks/" :
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
const iconContent = (
<span
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
>
{list.externalProvider === "outlook" ? (
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" />
) : list.externalProvider === "google" ? (
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#4285F4] dark:hover:text-[#8AB4F8] transition-colors" />
) : list.externalProvider === "apple" ? (
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
) : list.externalProvider === "synology" ? (
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
) : (
<RefreshCcw size={14} className="text-zinc-400" />
)}
</span>
);
return providerUrl ? (
<a href={providerUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
{iconContent}
</a>
) : iconContent;
})()}
<div className="someday-tab-assign" style={{ marginLeft: "auto", position: "relative", display: "flex", alignItems: "center" }}>
<FolderPlus size={13} style={{ color: list.tab ? "var(--weekly-accent, #6366f1)" : "#bbb", flexShrink: 0 }} />
<select
className="someday-tab-select"
value={list.tab || ""}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const val = e.target.value;
if (val === "__new__") {
e.target.value = list.tab || "";
setNewTabForListId(list.id);
setNewTabNameValue("");
} else {
assignListToTab(list.id, val || null);
}
}}
style={{
position: "absolute",
inset: 0,
opacity: 0,
cursor: "pointer",
width: "100%",
}}
title={t.assignTab || "Assign to tab"}
>
<option value="">{t.noTab}</option>
{somedayTabs.map(tab => (
<option key={tab} value={tab}>{tab}</option>
))}
<option value="__new__">+ {t.newTab || "New tab"}</option>
</select>
</div>
{newTabForListId === list.id && (
<input
className="someday-tab-new-input"
value={newTabNameValue}
onChange={(e) => setNewTabNameValue(e.target.value)}
onBlur={() => {
if (newTabNameValue.trim()) {
assignListToTab(list.id, newTabNameValue.trim());
}
setNewTabForListId(null);
setNewTabNameValue("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") {
setNewTabForListId(null);
setNewTabNameValue("");
}
}}
placeholder={t.newTabName || "Tab name..."}
autoFocus
onClick={(e) => e.stopPropagation()}
style={{
fontSize: "0.7rem",
border: "1px solid var(--weekly-border)",
borderRadius: "4px",
padding: "2px 6px",
background: "var(--weekly-bg)",
color: "var(--weekly-text)",
width: "80px",
outline: "none",
}}
/>
)}
<button
className="someday-list-delete-btn"
onClick={(e) => {
e.stopPropagation();
setListToDelete(list.id);
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
color: "#ccc",
marginLeft: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px"
}}
title="Delete List"
>
<Trash2 size={16} />
</button>
</>
)}
</div>
<div
className="weekly-task-list"
style={{
flex: 1,
flexDirection: "column",
justifyContent: "flex-start",
position: "relative",
display: "flex",
}}
>
{(() => {
const slotCount = getSomedaySlotCount(list.tasks);
const indexedTasks = list.tasks.filter(t => t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined && t.somedaySlotIndex < slotCount);
const unindexedTasks = list.tasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= slotCount);
// Fill indexed slots and put unindexed tasks in empty slots starting from top
const slots = Array.from({ length: slotCount }, (_, i) => ({ index: i, task: indexedTasks.find(t => t.somedaySlotIndex === i) || null }));
let unindexedIdx = 0;
const finalSlots = slots.map(slot => {
if (!slot.task && unindexedIdx < unindexedTasks.length) {
return { ...slot, task: unindexedTasks[unindexedIdx++] };
}
return slot;
});
// Any remaining unindexed tasks that didn't fit in slots
const remainingTasks = unindexedTasks.slice(unindexedIdx);
return (
<>
<SomedayAddTask
listId={list.id}
onAdd={async (title) => {
const occupiedSlots = list.tasks.map(t => t.somedaySlotIndex).filter(s => s !== null && s !== undefined) as number[];
let nextFreeSlot = 0;
while (occupiedSlots.includes(nextFreeSlot)) {
nextFreeSlot++;
}
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
somedayListId: list.id,
somedaySlotIndex: nextFreeSlot
}),
});
if (res.ok) {
const data = await res.json();
const newTask = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, tasks: [...l.tasks, newTask] }
: l,
),
);
}
} catch (e) {
console.error(e);
}
}}
/>
{finalSlots.map((slot) => {
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slot.index;
const task = slot.task;
return (
<div
key={slot.index}
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
onDragOver={(e) => handleSomedayDragOver(e, list.id, slot.index)}
onDrop={(e) => handleSomedayDrop(e, list.id, slot.index)}
onDragLeave={() => setDropPreview(null)}
onMouseDown={(e) => {
e.stopPropagation();
if (!task) {
setActiveAddSlot({ listId: list.id, slotIdx: slot.index });
}
}}
style={{ minHeight: "24px", cursor: task ? "default" : "text" }}
>
{task ? (
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
kanbanStages={kanbanStages}
/>
) : (
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
<SomedayAddTask
listId={list.id}
slotIdx={slot.index}
onAdd={async (title) => {
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
somedayListId: list.id,
somedaySlotIndex: slot.index
}),
});
if (res.ok) {
const data = await res.json();
const newTask = {
...data.task,
createdAt: new Date(data.task.createdAt),
updatedAt: new Date(data.task.updatedAt),
};
setSomedayLists((prev) =>
prev.map((l) =>
l.id === list.id
? { ...l, tasks: [...l.tasks, newTask] }
: l,
),
);
}
} catch (e) {
console.error(e);
}
setActiveAddSlot(null);
}}
onCancel={() => setActiveAddSlot(null)}
/>
)
)}
</div>
);
})}
{remainingTasks.map((task) => (
<div key={task.id} className="task-list-slot">
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
onAddSubTask={addSubTask}
onToggleSubTask={toggleSubTask}
onDeleteSubTask={deleteSubTask}
onUpdateSubTask={updateSubTask}
editingTaskId={editingTaskId}
onSetEditingTaskId={setEditingTaskId}
showTaskCheckboxes={profile.showTaskCheckboxes}
projects={projects}
onProjectAssign={assignProject}
kanbanStages={kanbanStages}
/>
</div>
))}
</>
);
})()}
</div>
</div>
);
return [indicator, listEl, endIndicator].filter(Boolean);
})}
{/* Modal for adding lists if needed, or just rely on placeholders */}
{isAddingSomedayList && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4"
onClick={(e) => {
e.stopPropagation();
setIsAddingSomedayList(false);
setSelectedSomedayProvider(null);
}}
>
<div
className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xl font-black mb-1 flex items-center gap-2">
<Plus className="w-6 h-6" />
NEW LIST
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-6 uppercase tracking-widest font-bold">
Create a new section for your tasks
</p>
<div className="mb-6">
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-2 uppercase tracking-tighter">
List Title
</label>
<input
autoFocus
type="text"
placeholder="NAME YOUR LIST..."
value={newSomedayListName}
onChange={(e) =>
setNewSomedayListName(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") saveSomedayList();
if (e.key === "Escape") {
setIsAddingSomedayList(false);
setNewSomedayListName("");
setSelectedSomedayProvider(null);
}
}}
className="w-full p-4 bg-zinc-50 dark:bg-zinc-800/50 border-2 border-zinc-100 dark:border-zinc-800 rounded-xl focus:border-zinc-900 dark:focus:border-zinc-100 outline-none transition-all font-bold text-lg"
style={{
fontFamily: profile.taskFontFamily
? `"${profile.taskFontFamily}"`
: "inherit",
}}
/>
</div>
<div className="mb-8">
<label className="block text-xs font-black text-zinc-400 dark:text-zinc-500 mb-3 uppercase tracking-tighter">
Sync with External Provider (Optional)
</label>
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${1 + (connections?.some(c => c.provider === "google") ? 1 : 0) + (connections?.some(c => c.provider === "outlook") ? 1 : 0) + (connections?.some(c => c.provider === "synology") ? 1 : 0)}, minmax(0, 1fr))` }}
>
<button
onClick={() => setSelectedSomedayProvider(null)}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${!selectedSomedayProvider ? "border-zinc-900 bg-zinc-900 text-white" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<Layout size={18} />
</div>
<span className="text-[10px] font-black uppercase">
Local
</span>
</button>
{connections?.some(c => c.provider === "google") && (
<button
onClick={() =>
setSelectedSomedayProvider("google")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "google" ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-200 dark:hover:border-blue-800/40"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<FontAwesomeIcon icon={faGoogle} className="w-4 h-4 text-[#4285F4]" />
</div>
<span className="text-[10px] font-black uppercase">
Google
</span>
</button>
)}
{connections?.some(c => c.provider === "outlook") && (
<button
onClick={() =>
setSelectedSomedayProvider("outlook")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "outlook" ? "border-blue-600 bg-blue-100 dark:bg-blue-900/30" : "border-zinc-100 dark:border-zinc-800 hover:border-blue-300 dark:hover:border-blue-800/50"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-[#00A4EF]" />
</div>
<span className="text-[10px] font-black uppercase">
Outlook
</span>
</button>
)}
{connections?.some(c => c.provider === "synology") && (
<button
onClick={() =>
setSelectedSomedayProvider("synology")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "synology" ? "border-zinc-900 bg-zinc-50 dark:bg-zinc-800" : "border-zinc-100 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600"}`}
>
<div className="w-6 h-6 flex items-center justify-center mb-1">
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-500" />
</div>
<span className="text-[10px] font-black uppercase">
Synology
</span>
</button>
)}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setIsAddingSomedayList(false);
setNewSomedayListName("");
setSelectedSomedayProvider(null);
}}
className="flex-1 px-4 py-4 text-sm font-black text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl transition-all uppercase tracking-widest"
>
Cancel
</button>
<button
onClick={saveSomedayList}
disabled={!newSomedayListName.trim()}
className="flex-[2] px-4 py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-black rounded-xl hover:opacity-90 transition-all uppercase tracking-widest shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
Create List
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* close flex row */}
</section>
)
}
{/* Search Modal */}
<SearchModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
tasks={tasks}
events={calendarEvents}
onSelectTask={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
}}
/>
{/* Recurring Tasks Manager */}
<RecurringTasksManager
isOpen={isRecurringTasksOpen}
onClose={() => setIsRecurringTasksOpen(false)}
tasks={tasks}
onStopRecurring={async (task) => {
const idToUpdate = task.id.startsWith("virtual-")
? task.id.split("-")[1]
: task.id;
const dateStr = new Date().toISOString();
// Update locally
setTasks((prev) => {
const newTasks = [];
for (const t of prev) {
const isMatch =
t.title === task.title &&
t.userId === task.userId &&
t.recurrenceInterval === task.recurrenceInterval &&
t.recurrenceUnit === task.recurrenceUnit;
if (isMatch) {
// Remove future occurrences from the UI
if (t.scheduledDate && new Date(t.scheduledDate) > new Date(dateStr)) {
continue;
}
newTasks.push({ ...t, recurrenceEndDate: new Date(dateStr) });
} else {
newTasks.push(t);
}
}
return newTasks;
});
// Update DB
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: idToUpdate,
recurrenceEndDate: dateStr,
}),
});
fetchTasks();
} catch (error) {
console.error("Failed to stop recurring series:", error);
}
}}
/>
{/* Recurring Task Delete Confirmation Modal */}
{
recurringDeleteModal.isOpen && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] px-4"
onClick={() =>
setRecurringDeleteModal({ isOpen: false, taskId: null })
}
>
<div
className="bg-white dark:bg-zinc-900 p-8 rounded-2xl shadow-2xl w-full max-w-md border border-zinc-100 dark:border-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
<Repeat size={24} />
</div>
<div>
<h3 className="text-xl font-black uppercase tracking-tight">
Recurring Task
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-bold uppercase tracking-widest">
Deletion Options
</p>
</div>
</div>
<div className="space-y-4 mb-8">
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl border border-zinc-100 dark:border-zinc-800">
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300 leading-relaxed">
How would you like to delete this task?
</p>
</div>
</div>
<div className="grid gap-3">
<button
onClick={() =>
recurringDeleteModal.taskId &&
handleConfirmDeleteSeries(recurringDeleteModal.taskId)
}
className="group flex items-center gap-4 p-4 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-all shadow-lg hover:shadow-red-500/20 text-left"
>
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<Repeat size={20} />
</div>
<div>
<span className="block font-black uppercase text-xs tracking-widest">
Entire Series
</span>
<span className="text-[10px] opacity-80 font-bold">
Stop recurrence & remove all future
</span>
</div>
</button>
<button
onClick={() =>
recurringDeleteModal.taskId &&
handleConfirmDeleteOccurrence(recurringDeleteModal.taskId)
}
className="group flex items-center gap-4 p-4 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-zinc-100 rounded-xl transition-all text-left"
>
<div className="w-10 h-10 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<Calendar size={20} />
</div>
<div>
<span className="block font-black uppercase text-xs tracking-widest">
Only This One
</span>
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-bold">
Only remove the selected instance
</span>
</div>
</button>
<button
onClick={() =>
setRecurringDeleteModal({ isOpen: false, taskId: null })
}
className="w-full mt-2 p-3 text-xs font-black text-zinc-400 dark:text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 uppercase tracking-widest transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)
}
{/* Calendar Event Modal */}
{
calendarEventModal.isOpen && (
<CalendarEventModal
event={calendarEventModal.event}
initialDate={calendarEventModal.initialDate}
initialStartTime={calendarEventModal.initialStartTime}
connections={connections}
onClose={() =>
setCalendarEventModal({ ...calendarEventModal, isOpen: false })
}
onSave={handleEventSave}
onDelete={handleEventDelete}
/>
)
}
{/* Focus Mode Overlay */}
{
showFocusMode && (
<FocusModeOverlay
task={(() => {
// Logic to find the "Next Task"
// 1. Tasks for today with start time, sorted by time
// 2. Tasks for today without start time, sorted by order
// 3. Tasks rolling over from previous days
const now = new Date();
const todayStr = now.toISOString().split("T")[0];
// Get all tasks relevant for "Now"
const activeTasks = tasks.filter(
(t) =>
!t.completed &&
!t.somedayListId &&
// Scheduled for today
((t.scheduledDate &&
new Date(t.scheduledDate).toISOString().split("T")[0] ===
todayStr) ||
// Or rolling and overdue (simplified, assuming rolling means show on today if not done)
(t.isRolling &&
(!t.scheduledDate || new Date(t.scheduledDate) <= now)) ||
// Or implicitly today if within current view logic (e.g. dayOfWeek match in current week)
// But let's stick to explicit date or rolling for Focus Mode to be precise.
(!t.scheduledDate &&
t.dayOfWeek === now.getDay() &&
isSameDay(
currentWeekStart,
getStartOfWeek(now, weekStartDay),
))),
);
// Sort: Time-based first, then Order
activeTasks.sort((a, b) => {
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;
});
return activeTasks.length > 0 ? activeTasks[0] : null;
})()}
duration={focusTimerDuration}
onClose={() => setShowFocusMode(false)}
onComplete={(taskId) => toggleTask(taskId)}
/>
)
}
{/* Settings Sidebar */}
{
showSettings && (
<SettingsSidebar
initialTab={activeTab}
onRemoveConnection={handleRemoveConnection}
onClose={() => setShowSettings(false)}
onSettingsChanged={handleSettingsChanged}
showTimeGrid={showTimeGrid}
setShowTimeGrid={setShowTimeGrid}
cellDuration={cellDuration}
setCellDuration={setCellDuration}
weekStartDay={weekStartDay}
setWeekStartDay={setWeekStartDay}
viewStyle={viewStyle}
setViewStyle={setViewStyle}
showSomeday={showSomeday}
setShowSomeday={setShowSomeday}
showAllDay={showAllDay}
setShowAllDay={setShowAllDay}
showSchedule={showSchedule}
setShowSchedule={setShowSchedule}
goal={goal}
setGoal={setGoal}
saveGoal={saveGoal}
connections={connections}
onUpdateConnections={setConnections}
focusTimerDuration={focusTimerDuration}
setFocusTimerDuration={setFocusTimerDuration}
focusBreakDuration={focusBreakDuration}
setFocusBreakDuration={setFocusBreakDuration}
fontSize={fontSize}
setFontSize={setFontSize}
showNextTask={showNextTask}
setShowNextTask={setShowNextTask}
headlineFont={headlineFont}
headlineFontSize={headlineFontSize}
headlineFontWeight={headlineFontWeight}
dateFontFamily={dateFontFamily}
dateFontSize={dateFontSize}
dateFontWeight={dateFontWeight}
timeTaskFontFamily={timeTaskFontFamily}
timeTaskFontSize={timeTaskFontSize}
timeTaskFontWeight={timeTaskFontWeight}
bodyFont={bodyFont}
taskFontFamily={taskFontFamily}
taskFontSize={taskFontSize}
taskFontWeight={taskFontWeight}
fontWeight={fontWeight}
weekendColorSat={weekendColorSat}
weekendColorSun={weekendColorSun}
protectEventTimes={protectEventTimes}
setProtectEventTimes={setProtectEventTimes}
goalFontWeight={profile.goalFontWeight || "500"}
goalFallbackType={profile.goalFallbackType}
goalDefaultSentence={profile.goalDefaultSentence}
importingTasksState={importingTasksState}
executeImport={executeImport}
onImportLists={(lists) => doImport("apple", lists)}
importStatusMsg={importStatusMsg}
hourLabelFormat={hourLabelFormat}
setHourLabelFormat={setHourLabelFormat}
showSubHourSlots={showSubHourSlots}
setShowSubHourSlots={setShowSubHourSlots}
allDayPosition={allDayPosition}
setAllDayPosition={setAllDayPosition}
saveSetting={saveSetting}
availableTaskLists={availableTaskLists}
isFetchingProviderLists={isFetchingProviderLists}
somedayLists={somedayLists}
handleToggleTaskList={handleToggleTaskList}
unsyncConfirm={unsyncConfirm}
onConfirmUnsync={confirmUnsync}
onCancelUnsync={() => setUnsyncConfirm(null)}
handleSyncAll={handleSyncAll}
fetchAvailableTaskLists={fetchAvailableTaskLists}
setCurrentWeekStart={setCurrentWeekStart}
projects={projects}
onProjectsChanged={fetchProjects}
kanbanStages={kanbanStages}
saveKanbanStages={saveKanbanStages}
/>
)
}
{
selectedTaskForRecurrence && (
<TaskRecurrenceModal
task={selectedTaskForRecurrence}
onClose={() => setSelectedTaskForRecurrence(null)}
onSave={handleRecurrenceSave}
language={language}
/>
)
}
{
selectedTaskForNotes && (
<NotesSidebar
task={selectedTaskForNotes}
onClose={() => setSelectedTaskForNotes(null)}
updateTaskNotes={updateTaskNotes}
/>
)
}
<ImportListModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
onImport={handleConfirmImport}
provider={importProvider}
lists={importLists}
isLoading={isFetchingLists}
/>
{/* Mobile: Floating Action Button for quick task creation */}
{isMobile && !showMobileFabSheet && !showSettings && !showFocusMode && (
<button
className="mobile-fab"
onClick={() => setShowMobileFabSheet(true)}
title="Add task"
>
<Plus size={28} />
</button>
)}
{/* Mobile: Bottom Sheet for task creation */}
{isMobile && showMobileFabSheet && (
<>
<div className="bottom-sheet-backdrop" onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }} />
<div className="bottom-sheet">
<div className="bottom-sheet-handle" />
<textarea
ref={fabTextareaRef}
value={fabTaskTitle}
onChange={(e) => setFabTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (fabTaskTitle.trim()) {
// Find today's date and add task
const today = new Date();
const todayStr = formatDateToISO(today);
addTask(today, fabTaskTitle.trim());
setFabTaskTitle("");
setShowMobileFabSheet(false);
}
}
}}
placeholder="What do you need to do?"
rows={2}
style={{
width: "100%",
border: `1px solid ${darkMode ? "#374151" : "#e5e7eb"}`,
borderRadius: "12px",
padding: "12px 16px",
fontSize: "1rem",
background: darkMode ? "#111827" : "#f9fafb",
color: darkMode ? "#e5e7eb" : "#333",
outline: "none",
resize: "none",
fontFamily: "inherit",
}}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "12px", gap: "8px" }}>
<button
onClick={() => { setShowMobileFabSheet(false); setFabTaskTitle(""); }}
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: darkMode ? "#374151" : "#e5e7eb", color: darkMode ? "#e5e7eb" : "#333", fontSize: "0.85rem", cursor: "pointer" }}
>
Cancel
</button>
<button
onClick={() => {
if (fabTaskTitle.trim()) {
const today = new Date();
addTask(today, fabTaskTitle.trim());
setFabTaskTitle("");
setShowMobileFabSheet(false);
}
}}
style={{ padding: "8px 16px", borderRadius: "8px", border: "none", background: "#0ea5e9", color: "white", fontSize: "0.85rem", fontWeight: 600, cursor: "pointer" }}
>
Add Task
</button>
</div>
</div>
</>
)}
{/* Mobile: Date Picker as centered modal overlay */}
{isMobile && showDatePicker && (
<div className="mobile-date-picker-overlay" onClick={() => setShowDatePicker(false)}>
<div onClick={(e) => e.stopPropagation()}>
<SimpleDatePicker
selected={currentWeekStart}
onSelect={(date) => {
setCurrentWeekStart(getStartOfWeek(date));
setShowDatePicker(false);
}}
onClose={() => setShowDatePicker(false)}
language={language}
/>
</div>
</div>
)}
</div >
);
}
// Task Input Component
interface TaskInputProps {
onAddTask: (title: string) => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
}
function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
const [newTaskTitle, setNewTaskTitle] = useState("");
const handleAddTask = () => {
if (newTaskTitle.trim()) {
onAddTask(newTaskTitle);
setNewTaskTitle("");
}
};
return (
<div
className="weekly-task-input"
onDragOver={onDragOver}
onDrop={onDrop}
>
<textarea
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleAddTask();
}
}}
placeholder="Type a to-do..."
rows={newTaskTitle.split("\n").length || 1}
style={{
resize: "none",
overflow: "hidden",
fontFamily: "inherit",
lineHeight: "inherit",
width: "100%",
border: "none",
background: "transparent",
outline: "none",
padding: "inherit",
fontSize: "inherit",
}}
/>
</div>
);
}
function SomedayAddTask({
listId,
onAdd,
onCancel,
slotIdx,
}: {
listId: string;
onAdd: (title: string) => void;
onCancel?: () => void;
slotIdx?: number;
}) {
const [title, setTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
if (title.trim()) {
onAdd(title.trim());
setTitle("");
} else if (onCancel) {
onCancel();
}
};
useEffect(() => {
if (slotIdx !== undefined && inputRef.current) {
inputRef.current.focus();
}
}, [slotIdx]);
return (
<li
className="weekly-task-item minimal"
style={{
margin: slotIdx !== undefined ? "0" : "0 0.5rem",
listStyle: "none",
width: "100%"
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
style={{ width: "100%" }}
>
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSubmit}
onKeyDown={(e) => {
if (e.key === "Escape") {
setTitle("");
if (onCancel) onCancel();
else e.currentTarget.blur();
}
}}
className="weekly-task-text"
style={{
width: "100%",
border: "none",
background: "transparent",
padding: "0 0",
fontSize: "0.9375rem",
outline: "none",
height: slotIdx !== undefined ? "auto" : "24px",
display: "block",
}}
placeholder={slotIdx !== undefined ? "" : "Add task..."}
data-someday-add-input={slotIdx !== undefined ? undefined : listId}
/>
</form>
</li>
);
}
// Task Item Component
interface TaskItemProps {
task: Task;
isEditing: boolean;
onToggle: () => void;
onEdit: () => void;
onUpdate: (title: string) => void;
onDelete: () => void;
onNotes: (notes: string) => void;
onRollToggle: () => void;
onRecurrence: () => void;
onDragStart: (e: DragEvent, task: Task) => void;
onDragEnd: () => void;
variant?: "default" | "minimal";
isSomeday?: boolean;
onAddSubTask?: (parentId: string, title: string) => void;
onToggleSubTask?: (subTaskId: string) => void;
onDeleteSubTask?: (subTaskId: string) => void;
onUpdateSubTask?: (subTaskId: string, title: string) => void;
editingTaskId?: string | null;
onSetEditingTaskId?: (id: string | null) => void;
isSubTask?: boolean;
showTaskCheckboxes?: boolean;
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
}
function TaskItem({
task,
isEditing,
onToggle,
onEdit,
onUpdate,
onDelete,
onNotes,
onRollToggle,
onRecurrence,
onDragStart,
onDragEnd,
variant = "default",
isSomeday = false,
onAddSubTask,
onToggleSubTask,
onDeleteSubTask,
onUpdateSubTask,
editingTaskId,
onSetEditingTaskId,
isSubTask = false,
showTaskCheckboxes = false,
projects = [],
onProjectAssign,
kanbanStages = [],
}: TaskItemProps) {
const [editValue, setEditValue] = useState(task.title);
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [isSubTasksOpen, setIsSubTasksOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const [showProjectPicker, setShowProjectPicker] = useState(false);
const projectPickerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
const taskItemRef = useRef<HTMLLIElement>(null);
// Touch: swipe gesture state
const [swipeX, setSwipeX] = useState(0);
const swipeTouchStart = useRef({ x: 0, y: 0, swiping: false });
// Close touch-active on outside click
useEffect(() => {
if (!touchActive) return;
const handler = (e: Event) => {
if (taskItemRef.current && !taskItemRef.current.contains(e.target as Node)) {
setTouchActive(false);
}
};
document.addEventListener("touchstart", handler);
document.addEventListener("mousedown", handler);
return () => {
document.removeEventListener("touchstart", handler);
document.removeEventListener("mousedown", handler);
};
}, [touchActive]);
// Close project picker on outside click
useEffect(() => {
if (!showProjectPicker) return;
const handler = (e: Event) => {
if (projectPickerRef.current && !projectPickerRef.current.contains(e.target as Node)) {
setShowProjectPicker(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showProjectPicker]);
const needsSync = task.externalProvider && (
!task.externalId ||
!task.lastSyncedAt ||
new Date(task.updatedAt) > new Date(task.lastSyncedAt)
);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
// Focus notes when opened
useEffect(() => {
if (isNotesOpen && notesRef.current) {
notesRef.current.focus();
}
}, [isNotesOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onUpdate(editValue);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
(e.target as HTMLElement).blur();
}
if (e.key === "Escape") {
setEditValue(task.title);
onUpdate(task.title);
}
};
const handleNotesBlur = () => {
if (notesValue !== task.markdownContent) {
onNotes(notesValue);
}
};
// Markdown insertion helper
const insertMarkdown = (prefix: string, suffix: string = "") => {
if (!notesRef.current) return;
const start = notesRef.current.selectionStart;
const end = notesRef.current.selectionEnd;
const text = notesValue;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}${prefix}${selection}${suffix}${after}`;
setNotesValue(newText);
setTimeout(() => {
if (notesRef.current) {
notesRef.current.focus();
const newCursorPos =
start + prefix.length + selection.length + suffix.length;
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0);
};
return (
<li
ref={taskItemRef}
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
style={(() => {
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
if (stageColor) return { borderLeft: `4px solid ${stageColor}`, paddingLeft: "6px" };
if (task.project?.color) return { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" };
return undefined;
})()}
draggable={!isEditing && !isNotesOpen && swipeX === 0}
onDragStart={(e) => {
// If dragging a subtask, don't drag the parent
const target = e.target as HTMLElement;
if (target.closest('.subtask-list')) {
e.stopPropagation();
return;
}
onDragStart(e as unknown as DragEvent, task);
}}
onDragEnd={onDragEnd}
onClick={(e) => {
// Touch: toggle action toolbar on tap
if (window.matchMedia("(pointer: coarse)").matches && !isEditing) {
const target = e.target as HTMLElement;
if (target.closest(".task-actions") || target.closest("button")) return;
setTouchActive(!touchActive);
return;
}
if ((variant === "minimal" || isSomeday) && !isEditing) {
const target = e.target as HTMLElement;
if (
target.tagName === "BUTTON" ||
target.tagName === "INPUT" ||
target.closest("button")
)
return;
onEdit();
}
}}
onTouchStart={(e) => {
const touch = e.touches[0];
swipeTouchStart.current = { x: touch.clientX, y: touch.clientY, swiping: false };
setSwipeX(0);
}}
onTouchMove={(e) => {
const touch = e.touches[0];
const dx = touch.clientX - swipeTouchStart.current.x;
const dy = touch.clientY - swipeTouchStart.current.y;
// Only swipe if horizontal dominant and past 10px threshold
if (!swipeTouchStart.current.swiping && Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.5) {
swipeTouchStart.current.swiping = true;
}
if (swipeTouchStart.current.swiping) {
e.preventDefault();
setSwipeX(dx);
}
}}
onTouchEnd={() => {
if (Math.abs(swipeX) > 80) {
if (swipeX > 0) {
// Swipe right: toggle complete
onToggle();
} else {
// Swipe left: delete
onDelete();
}
}
setSwipeX(0);
swipeTouchStart.current.swiping = false;
}}
>
{/* Swipe indicators */}
{swipeX > 20 && (
<div className="task-swipe-indicator complete" style={{ width: Math.abs(swipeX) }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
</div>
)}
{swipeX < -20 && (
<div className="task-swipe-indicator delete" style={{ width: Math.abs(swipeX) }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</div>
)}
<div style={{ width: "100%", position: "relative", transform: swipeX !== 0 ? `translateX(${swipeX}px)` : undefined, transition: swipeX === 0 ? "transform 0.2s ease" : "none", background: "inherit" }}>
{/* Visual Indicator for Rolling Tasks */}
{task.isRolling && !task.completed && !isSomeday && (
<div className="rolling-icon-indicator" title="Auto-rolling task">
<svg
viewBox="0 0 24 24"
width="10"
height="10"
stroke="currentColor"
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</div>
)}
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: "0.5rem",
width: "100%",
}}
>
{isEditing ? (
<form onSubmit={handleSubmit} style={{ flex: 1, display: "flex" }}>
{variant === "minimal" ? (
<textarea
ref={inputRef}
className="weekly-task-text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onUpdate(editValue)}
rows={editValue.split("\n").length || 1}
style={{
border: "none",
background: "transparent",
outline: "none",
width: "100%",
padding: "0",
resize: "none",
overflow: "hidden",
fontFamily: "inherit",
fontSize: "inherit",
fontWeight: "inherit",
lineHeight: "inherit",
}}
/>
) : (
<textarea
ref={inputRef}
className="weekly-task-text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onUpdate(editValue)}
rows={editValue.split("\n").length || 1}
style={{
resize: "none",
overflow: "hidden",
fontFamily: "inherit",
lineHeight: "inherit",
}}
/>
)}
</form>
) : (
<>
<span
className={`weekly-task-text flex-1 ${task.completed ? "completed" : ""}`}
onClick={(e) => {
if (variant === "default" && !showTaskCheckboxes) onToggle();
// For minimal/someday, parent onClick handles edit
}}
onDoubleClick={variant === "default" ? onEdit : undefined}
style={
variant === "minimal" || isSomeday
? { display: "flex", alignItems: "center", gap: "4px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
: { display: "flex", alignItems: "center", gap: "6px", ...(task.completed && showTaskCheckboxes ? { opacity: 0.5 } : {}) }
}
>
{showTaskCheckboxes && (
<input
type="checkbox"
checked={task.completed}
onChange={(e) => { e.stopPropagation(); onToggle(); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
style={{
width: "16px",
height: "16px",
margin: 0,
cursor: "pointer",
position: "relative",
top: "3px",
left: "-2px",
accentColor: "var(--weekly-teal, #009a9a)",
WebkitAppearance: "checkbox"
}}
/>
)}
<span
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflow: "visible",
flex: 1,
}}
>
{task.title}
</span>
{(() => {
const provider = task.externalProvider
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
if (!provider) return null;
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
apple: { icon: faApple, color: "#555", label: "Apple" },
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
};
const info = iconMap[provider];
if (!info) return null;
return (
<span
className="flex-shrink-0"
title={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 12, height: 12, color: info.color }} />
</span>
);
})()}
</span>
{/* Subtask indicator - toggles subtask list */}
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (() => {
const completed = task.subTasks!.filter(s => s.completed).length;
const total = task.subTasks!.length;
const allDone = completed === total;
const expanded = isSubTasksOpen || isSubTaskInputOpen;
return (
<button
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="subtask-indicator-badge"
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "3px",
padding: "2px 7px",
borderRadius: "10px",
background: expanded ? "rgba(99, 102, 241, 0.15)" : allDone ? "rgba(34, 197, 94, 0.15)" : "rgba(0,0,0,0.08)",
color: expanded ? "#6366f1" : allDone ? "#22c55e" : "#555",
border: expanded ? "1px solid rgba(99, 102, 241, 0.3)" : allDone ? "1px solid rgba(34, 197, 94, 0.3)" : "1px solid rgba(0,0,0,0.12)",
cursor: "pointer",
fontSize: "0.7rem",
fontWeight: 600,
lineHeight: 1,
flexShrink: 0,
whiteSpace: "nowrap",
marginTop: "2px",
}}
>
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polyline points="9 18 15 12 9 6" />
</svg>
{completed}/{total}
</button>
);
})()}
{/* Note indicator - toggles inline notes */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
className="focus:outline-none flex-shrink-0"
title={isNotesOpen ? "Collapse note" : "Expand note"}
style={{
display: "inline-flex",
alignItems: "center",
padding: "2px 4px",
borderRadius: "3px",
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.05)",
color: isNotesOpen ? "#f59e0b" : "#888",
border: "none",
cursor: "pointer",
lineHeight: 1,
marginTop: "2px",
}}
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</button>
)}
<div className="task-actions z-20">
{/* Complete */}
<button
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
title={task.completed ? "Mark incomplete" : "Mark complete"}
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
{/* Edit */}
<button
className="task-action-btn"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="Edit"
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
{/* Add Sub-task */}
{!isSubTask && onAddSubTask && (
<button
className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setIsSubTaskInputOpen(!isSubTaskInputOpen);
if (!isSubTaskInputOpen) {
setTimeout(() => subTaskInputRef.current?.focus(), 50);
}
}}
title="Add sub-task"
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
)}
{/* Recurrence (hidden for someday tasks) */}
{!isSomeday && (
<button
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onRecurrence();
}}
title={
task.isRecurring ? "Edit recurrence" : "Make recurring"
}
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
)}
{/* Notes */}
<button
className={`task-action-btn ${isNotesOpen || (task.markdownContent && task.markdownContent.trim().length > 0) ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
title="Notes"
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
{/* Project Assignment */}
{!isSubTask && projects.length > 0 && onProjectAssign && (
<div className="relative" ref={projectPickerRef}>
<button
className={`task-action-btn ${task.project ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
setShowProjectPicker(!showProjectPicker);
}}
title={task.project ? task.project.name : "Assign project"}
>
<Circle
size={12}
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
/>
</button>
{showProjectPicker && (
<div className="absolute z-50 top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-[140px]" style={{ whiteSpace: "nowrap" }}>
{task.projectId && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500"
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, null);
setShowProjectPicker(false);
}}
>
<X size={10} /> Remove
</button>
)}
{projects.map((p) => (
<button
key={p.id}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 ${task.projectId === p.id ? "font-bold" : ""}`}
onClick={(e) => {
e.stopPropagation();
onProjectAssign(task.id, task.projectId === p.id ? null : p.id);
setShowProjectPicker(false);
}}
>
<Circle size={10} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
{p.name}
</button>
))}
</div>
)}
</div>
)}
{/* Roll Toggle - Active State Colored (hidden for someday tasks) */}
{!task.completed && !isSomeday && (
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
onRollToggle();
}}
title={
task.isRolling ? "Disable rolling" : "Enable rolling"
}
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="1 4 1 10 7 10"></polyline>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
</svg>
</button>
)}
{/* Delete */}
<button
className="task-action-btn delete text-red-500 hover:text-red-700 hover:bg-red-100/50 dark:hover:bg-red-900/30 rounded"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
title="Delete"
>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
stroke="currentColor"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</>
)}
</div>
{/* Inline Notes Editor with Toolbar */}
{
isNotesOpen && (
<div
className="weekly-notes-inline"
onClick={(e) => e.stopPropagation()}
>
<div className="notes-toolbar">
<button
className="notes-toolbar-btn"
onClick={() => insertMarkdown("**", "**")}
title="Bold"
>
B
</button>
<button
className="notes-toolbar-btn"
onClick={() => insertMarkdown("*", "*")}
title="Italic"
>
i
</button>
<button
className="notes-toolbar-btn"
onClick={() => insertMarkdown("[", "](url)")}
title="Link"
>
🔗
</button>
<button
className="notes-toolbar-btn"
onClick={() => insertMarkdown("- ")}
title="List"
>
</button>
<button
className="notes-toolbar-btn"
onClick={() => insertMarkdown("![alt text](", ")")}
title="Image"
>
🖼
</button>
<span
style={{
marginLeft: "auto",
fontSize: "0.75rem",
color: "#999",
}}
>
Markdown supported
</span>
</div>
<textarea
ref={notesRef}
className="weekly-notes-editor-inline"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={handleNotesBlur}
placeholder="Add notes..."
/>
</div>
)
}
{/* Sub-tasks section */}
{
!isSubTask && (isSubTasksOpen || isSubTaskInputOpen) && task.subTasks && task.subTasks.length > 0 && (
<ul className="subtask-list" onClick={(e) => e.stopPropagation()} style={task.project?.color ? { borderLeft: `2px solid ${task.project.color}`, marginLeft: "2px" } : undefined}>
{task.subTasks.map((subTask) => (
<li
key={subTask.id}
className={`subtask-item ${subTask.completed ? "completed" : ""}`}
draggable
onDragStart={(e) => {
e.stopPropagation();
if (onDragStart) {
onDragStart(e as any, { ...subTask, parentTaskId: task.id, scheduledDate: task.scheduledDate } as any);
}
}}
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
>
<button
className="subtask-checkbox"
onClick={() => onToggleSubTask?.(subTask.id)}
aria-label={subTask.completed ? "Mark incomplete" : "Mark complete"}
>
{subTask.completed ? (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
</svg>
)}
</button>
{editingTaskId === subTask.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector("input");
if (input) {
onUpdateSubTask?.(subTask.id, input.value);
onSetEditingTaskId?.(null);
}
}}
style={{ flex: 1 }}
>
<input
type="text"
defaultValue={subTask.title}
autoFocus
className="subtask-edit-input"
onBlur={(e) => {
onUpdateSubTask?.(subTask.id, e.target.value);
onSetEditingTaskId?.(null);
}}
onKeyDown={(e) => {
if (e.key === "Escape") onSetEditingTaskId?.(null);
}}
/>
</form>
) : (
<span
className={`subtask-title ${subTask.completed ? "completed" : ""}`}
onClick={() => onSetEditingTaskId?.(subTask.id)}
>
{subTask.title}
</span>
)}
<button
className="subtask-delete-btn"
onClick={() => onDeleteSubTask?.(subTask.id)}
title="Remove sub-task"
>
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</li>
))}
</ul>
)
}
{/* Add sub-task input */}
{
!isSubTask && isSubTaskInputOpen && (
<div className="subtask-add-row" onClick={(e) => e.stopPropagation()}>
<form
onSubmit={(e) => {
e.preventDefault();
if (newSubTaskTitle.trim()) {
onAddSubTask?.(task.id, newSubTaskTitle.trim());
setNewSubTaskTitle("");
}
}}
style={{ display: "flex", alignItems: "center", gap: "0.25rem", flex: 1 }}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
</svg>
<input
ref={subTaskInputRef}
type="text"
value={newSubTaskTitle}
onChange={(e) => setNewSubTaskTitle(e.target.value)}
onBlur={() => {
if (!newSubTaskTitle.trim()) {
setIsSubTaskInputOpen(false);
}
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setNewSubTaskTitle("");
setIsSubTaskInputOpen(false);
}
}}
placeholder="Add sub-task..."
className="subtask-add-input"
autoFocus
/>
</form>
</div>
)
}
</div >
</li >
);
}
// Settings Modal Component
interface SettingsSidebarProps {
onClose: () => void;
onSettingsChanged?: (newSettings: {
showTimeGrid: boolean;
cellDuration: CellDuration;
viewStyle: ViewStyle;
language: string;
dateFormat: string;
timeFormat: string;
startHour: number;
endHour: number;
fontSize: "S" | "M" | "L";
showNextTask: boolean;
showSomeday: boolean;
showAllDayEvents: boolean;
showSchedule: boolean;
headlineFont: string;
headlineFontSize: string;
headlineFontWeight: string;
goalFontWeight: string;
dateFontFamily: string;
dateFontSize: string;
dateFontWeight: string;
timeTaskFontFamily: string;
timeTaskFontSize: string;
timeTaskFontWeight: string;
bodyFont: string;
taskFontFamily: string;
taskFontSize: string;
taskFontWeight: string;
fontWeight: string;
weekendColorSat: string;
weekendColorSun: string;
weekdayColor?: string;
dateColor?: string;
taskColor?: string;
todayHighlightColor?: string;
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
dateAlignment?: "left" | "center" | "right" | "tight";
hourLabelFormat?: "short" | "full";
showSubHourSlots?: boolean;
allDayPosition?: "above" | "below";
cwFontFamily?: string;
cwFontSize?: string;
cwFontWeight?: string;
cwColor?: string;
yearFontFamily?: string;
yearFontSize?: string;
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
showTaskCheckboxes?: boolean;
startDayOffset?: number;
quoteSourceUrls: string[];
quoteLanguages: string[];
}) => void;
setCurrentWeekStart: (d: Date) => void;
quoteSourceUrls?: string[];
quoteLanguages?: string[];
goal: string;
setGoal: (goal: string) => void;
saveGoal: (goal: string) => void;
connections: any[];
onUpdateConnections: (connections: any[]) => void;
onRemoveConnection: (id: string) => void | Promise<void>;
focusTimerDuration: number;
setFocusTimerDuration: (duration: number) => void;
focusBreakDuration: number;
setFocusBreakDuration: (duration: number) => void;
showNextTask: boolean;
setShowNextTask: (show: boolean) => void;
protectEventTimes: boolean;
setProtectEventTimes: (protect: boolean) => void;
goalDefaultSentence?: string;
goalFallbackType?: string;
importingTasksState: boolean;
executeImport: (provider: "google" | "apple" | "outlook") => Promise<void>;
onImportLists: (lists: { id: string; title: string }[]) => Promise<void>;
importStatusMsg: { type: "success" | "error"; text: string } | null;
showTimeGrid: boolean;
setShowTimeGrid: (show: boolean) => void;
cellDuration: CellDuration;
setCellDuration: (duration: CellDuration) => void;
weekStartDay: number;
setWeekStartDay: (day: number) => void;
fontSize: "S" | "M" | "L";
setFontSize: (size: "S" | "M" | "L") => void;
headlineFont: string;
headlineFontSize: string;
headlineFontWeight: string;
goalFontWeight: string;
dateFontFamily: string;
dateFontSize: string;
dateFontWeight: string;
timeTaskFontFamily: string;
timeTaskFontSize: string;
timeTaskFontWeight: string;
bodyFont: string;
taskFontFamily: string;
taskFontSize: string;
taskFontWeight: string;
fontWeight: string;
weekendColorSat: string;
weekendColorSun: string;
viewStyle: ViewStyle;
setViewStyle: (style: ViewStyle) => void;
showSomeday: boolean;
setShowSomeday: (show: boolean) => void;
showAllDay: boolean;
setShowAllDay: (show: boolean) => void;
showSchedule: boolean;
setShowSchedule: (show: boolean) => void;
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
weekdayFormat?: "long" | "short" | "narrow" | "custom";
weekdayCase?: "normal" | "capitalize" | "uppercase";
customWeekdayNames?: string;
dateAlignment?: "left" | "center" | "right" | "tight";
hourLabelFormat: "short" | "full";
setHourLabelFormat: (fmt: "short" | "full") => void;
showSubHourSlots: boolean;
setShowSubHourSlots: (show: boolean) => void;
allDayPosition: "above" | "below";
setAllDayPosition: (pos: "above" | "below") => void;
saveSetting: (key: string, value: any) => void;
availableTaskLists: {
[key in "google" | "apple" | "outlook" | "synology"]?: { id: string; title: string }[];
};
isFetchingProviderLists: Record<string, boolean>;
somedayLists: SomedayList[];
handleToggleTaskList: (
provider: "google" | "apple" | "outlook" | "synology",
list: { id: string; title: string },
) => Promise<void>;
unsyncConfirm: {
provider: "google" | "apple" | "outlook" | "synology";
list: { id: string; title: string };
} | null;
onConfirmUnsync: () => Promise<void>;
onCancelUnsync: () => void;
handleSyncAll: (
provider: "google" | "outlook" | "synology",
lists: { id: string; title: string }[],
syncOn: boolean,
) => Promise<void>;
fetchAvailableTaskLists: (
provider: "google" | "apple" | "outlook" | "synology",
) => Promise<void>;
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
onProjectsChanged: () => void;
kanbanStages: KanbanStage[];
saveKanbanStages: (stages: KanbanStage[]) => Promise<void>;
}
// Notes Sidebar Component
interface NotesSidebarProps {
task: Task;
onClose: () => void;
updateTaskNotes: (id: string, notes: string) => void;
}
function NotesSidebar({ task, onClose, updateTaskNotes }: NotesSidebarProps) {
const [isVisible, setIsVisible] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [sidebarWidth, setSidebarWidth] = useState(500);
const isResizing = useRef(false);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing.current) return;
const newWidth = window.innerWidth - e.clientX;
setSidebarWidth(Math.max(320, Math.min(newWidth, window.innerWidth * 0.9)));
};
const handleMouseUp = () => {
if (isResizing.current) {
isResizing.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 300);
};
const handleToolbarClick = (before: string, after: string, selectOffsetStart?: number, selectOffsetEnd?: number) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const beforeText = text.substring(0, start);
const selection = text.substring(start, end);
const afterText = text.substring(end);
let newText = `${beforeText}${before}${selection}${after}${afterText}`;
if (before === "![" && after === "](url)") {
// Special case for image to match original logic precisely
newText = `${beforeText}![alt text](url)${afterText}`;
}
updateTaskNotes(task.id, newText);
textarea.value = newText;
textarea.focus();
if (before === "![" && after === "](url)") {
textarea.setSelectionRange(start + 2, start + 10);
} else {
textarea.setSelectionRange(
start + before.length,
start + before.length + selection.length
);
}
};
return (
<>
<div
className={`weekly-modal-overlay ${isVisible ? "show" : ""}`}
onClick={handleClose}
style={{ zIndex: 1999 }}
/>
<div className={`weekly-notes-sidebar ${isVisible ? "open" : ""}`} style={{ width: `${sidebarWidth}px` }}>
{/* Resize handle */}
<div
onMouseDown={(e) => {
e.preventDefault();
isResizing.current = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '6px',
cursor: 'col-resize',
zIndex: 10,
}}
title="Drag to resize"
/>
<header className="weekly-notes-sidebar-header">
<h2 className="weekly-notes-sidebar-title">Notes: {task.title}</h2>
<button className="weekly-notes-sidebar-close" onClick={handleClose}>
×
</button>
</header>
<div className="weekly-notes-sidebar-content">
<div className="notes-toolbar">
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("**", "**")} title="Bold">B</button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("*", "*")} title="Italic">i</button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("[", "](url)")} title="Link">🔗</button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("- ", "")} title="List"></button>
<button className="notes-toolbar-btn" onClick={() => handleToolbarClick("![", "](url)")} title="Image">🖼</button>
</div>
<textarea
ref={textareaRef}
className="weekly-notes-editor"
defaultValue={task.markdownContent || ""}
autoFocus
placeholder="Add details, notes, or links..."
onBlur={(e) => updateTaskNotes(task.id, e.target.value)}
/>
<div className="weekly-modal-actions" style={{ marginTop: '24px' }}>
<button
className="weekly-btn weekly-btn-secondary"
onClick={handleClose}
style={{ width: '100%' }}
>
Close
</button>
</div>
</div>
</div>
</>
);
}
function SettingsSidebar({
onClose,
onSettingsChanged,
viewStyle,
setViewStyle,
showSomeday,
setShowSomeday,
showAllDay,
setShowAllDay,
showSchedule,
setShowSchedule,
goal,
setGoal,
saveGoal,
connections,
onUpdateConnections,
onRemoveConnection,
focusTimerDuration,
setFocusTimerDuration,
focusBreakDuration,
setFocusBreakDuration,
showNextTask,
setShowNextTask,
protectEventTimes,
setProtectEventTimes,
goalFallbackType,
goalDefaultSentence,
importingTasksState,
executeImport,
onImportLists,
importStatusMsg,
showTimeGrid,
setShowTimeGrid,
cellDuration,
setCellDuration,
weekStartDay,
setWeekStartDay,
fontSize,
setFontSize,
headlineFont,
headlineFontSize,
headlineFontWeight,
goalFontWeight,
dateFontFamily,
dateFontSize,
dateFontWeight,
timeTaskFontFamily,
timeTaskFontSize,
timeTaskFontWeight,
bodyFont,
taskFontFamily,
taskFontSize,
taskFontWeight,
fontWeight,
weekendColorSat,
weekendColorSun,
hourLabelFormat,
setHourLabelFormat,
showSubHourSlots,
setShowSubHourSlots,
allDayPosition,
setAllDayPosition,
saveSetting,
availableTaskLists,
isFetchingProviderLists,
somedayLists,
handleToggleTaskList,
unsyncConfirm,
onConfirmUnsync,
onCancelUnsync,
handleSyncAll,
fetchAvailableTaskLists,
initialTab,
setCurrentWeekStart,
projects,
onProjectsChanged,
kanbanStages,
saveKanbanStages,
}: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
>(initialTab || "general");
const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [exportStartDate, setExportStartDate] = useState("");
const [exportEndDate, setExportEndDate] = useState("");
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
const [importFile, setImportFile] = useState<File | null>(null);
const [importMsg, setImportMsg] = useState("");
const [isImporting, setIsImporting] = useState(false);
const [isExportingAll, setIsExportingAll] = useState(false);
const [passwords, setPasswords] = useState({ new: "", confirm: "" });
const [accountMsg, setAccountMsg] = useState("");
const [isVisible, setIsVisible] = useState(false);
// Apple Calendar (CalDAV) State
const [showAppleCalendarModal, setShowAppleCalendarModal] = useState(false);
const [appleCalEmail, setAppleCalEmail] = useState("");
const [appleCalPassword, setAppleCalPassword] = useState("");
const [isConnectingAppleCal, setIsConnectingAppleCal] = useState(false);
const [appleCalError, setAppleCalError] = useState("");
// Synology Calendar State
const [showSynologyCalendarModal, setShowSynologyCalendarModal] = useState(false);
const [synologyCalServerUrl, setSynologyCalServerUrl] = useState("");
const [synologyCalUsername, setSynologyCalUsername] = useState("");
const [synologyCalPassword, setSynologyCalPassword] = useState("");
const [isConnectingSynologyCal, setIsConnectingSynologyCal] = useState(false);
const [synologyCalError, setSynologyCalError] = useState("");
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(
null,
);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectColor, setNewProjectColor] = useState("#3b82f6");
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editProjectName, setEditProjectName] = useState("");
const [editProjectColor, setEditProjectColor] = useState("");
// Fetch lists when the calendar tab is selected
useEffect(() => {
if (activeTab === "calendar") {
const providersWithAccounts = connections.map((c) => c.provider);
if (providersWithAccounts.includes("google"))
fetchAvailableTaskLists("google");
if (providersWithAccounts.includes("outlook"))
fetchAvailableTaskLists("outlook");
if (providersWithAccounts.includes("synology"))
fetchAvailableTaskLists("synology");
}
}, [activeTab, connections, fetchAvailableTaskLists]);
const [connMsg, setConnMsg] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const showConnMsg = (type: "success" | "error", text: string) => {
setConnMsg({ type, text });
setTimeout(() => setConnMsg(null), 5000);
};
const [profile, setProfile] = useState<{
name: string;
email: string;
timezone: string;
autoRolling?: boolean;
protectEventTimes?: boolean;
language?: string;
dateFormat?: string;
timeFormat?: string;
startHour?: number;
endHour?: number;
focusTimerDuration?: number;
focusBreakDuration?: number;
showTimeGrid?: boolean;
cellDuration?: number;
viewStyle?: string;
fontSize?: "S" | "M" | "L";
showNextTask?: boolean;
showSomeday?: boolean;
showAllDayEvents?: boolean;
showSchedule?: boolean;
headlineFont?: string;
headlineFontSize?: string;
headlineFontWeight?: string;
dateFontFamily?: string;
dateFontSize?: string;
dateFontWeight?: string;
timeTaskFontFamily?: string;
timeTaskFontSize?: string;
timeTaskFontWeight?: string;
bodyFont?: string;
taskFontFamily?: string;
taskFontSize?: string;
taskFontWeight?: string;
eventFontFamily?: string;
eventFontSize?: string;
eventFontWeight?: string;
fontWeight?: string;
weekendColorSat?: string;
weekendColorSun?: string;
weekdayColor?: string;
dateColor?: string;
taskColor?: string;
todayHighlightColor?: string;
pastDayColor?: string;
goalFallbackType?: "quote" | "next_todo" | "default";
quoteSourceUrl?: string;
goalDefaultSentence?: string;
goalFontFamily?: string;
goalFontSize?: string;
goalFontWeight?: string;
goalScope?: "week" | "day";
dateLayout?: "above" | "below" | "left" | "right" | "hidden";
mobileDateLayout?: "above" | "below" | "left" | "right" | "hidden";
dateAlignment?: "left" | "center" | "right" | "tight";
hourLabelFormat?: "short" | "full";
showSubHourSlots?: boolean;
allDayPosition?: "above" | "below";
cwFontFamily?: string;
cwFontSize?: string;
cwFontWeight?: string;
cwColor?: string;
yearFontFamily?: string;
yearFontSize?: string;
yearFontWeight?: string;
yearColor?: string;
dayHeaderGap?: string;
showTaskCheckboxes?: boolean;
quoteSourceUrls?: string[];
quoteLanguages?: string[];
startDayOffset?: number;
id?: string;
accountNumber?: number;
weekdayFormat?: "long" | "short" | "narrow" | "custom";
weekdayCase?: "normal" | "capitalize" | "uppercase";
customWeekdayNames?: string;
dateVerticalAlign?: "top" | "middle" | "bottom";
}>({
name: "",
email: "",
timezone: "Europe/Berlin",
autoRolling: false,
protectEventTimes: false,
language: "de",
dateFormat: "yyyy-MM-dd",
timeFormat: "24h",
startHour: 8,
endHour: 18,
focusTimerDuration: 25,
focusBreakDuration: 5,
showTimeGrid: true,
cellDuration: 30,
viewStyle: "list",
fontSize: "M",
showNextTask: false,
showSomeday: true,
showAllDayEvents: true,
showSchedule: true,
hourLabelFormat: "short",
dateVerticalAlign: "middle",
showSubHourSlots: true,
allDayPosition: "below",
goalFallbackType: "quote",
quoteSourceUrl: "",
headlineFont: "Inter",
headlineFontSize: "1.25rem",
headlineFontWeight: "900",
dateFontFamily: "Inter",
dateFontSize: "0.65rem",
dateFontWeight: "400",
timeTaskFontFamily: "Inter",
timeTaskFontSize: "0.75rem",
timeTaskFontWeight: "500",
bodyFont: "Inter",
taskFontFamily: "Inter",
taskFontSize: "0.9rem",
taskFontWeight: "400",
fontWeight: "400",
goalFontFamily: "Inter",
goalFontSize: "0.9rem",
goalFontWeight: "500",
goalScope: "week",
dateLayout: "right",
mobileDateLayout: "below",
dateAlignment: "center",
weekendColorSat: "#666666",
weekendColorSun: "#dc2626",
weekdayColor: "#888888",
dateColor: "#888888",
taskColor: "#333333",
todayHighlightColor: "#f0fafa",
pastDayColor: "#a6a6a7",
weekdayFormat: "long",
weekdayCase: "capitalize",
customWeekdayNames: "",
});
const t = translations[profile.language || "en"] || translations["en"];
// Load fonts for preview
// Font loading moved to top level WeeklyView component
useEffect(() => {
try {
fetchProfile();
// Trigger slide-in after mount
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
} catch (e) {
console.error("Error mounting SettingsSidebar:", e);
}
}, []);
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 300);
};
// Live preview: propagate styling changes immediately without Save
// Auto-save: debounce profile changes to the database
const profileLoadedRef = useRef(false);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!profileLoadedRef.current) return; // Skip initial load from API
// Debounce: save after 800ms of no changes
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]);
useEffect(() => {
if (!profileLoadedRef.current) return;
if (!onSettingsChanged) return;
onSettingsChanged({
showTimeGrid: showTimeGrid,
cellDuration: cellDuration,
viewStyle: viewStyle,
language: profile.language || "de",
dateFormat: profile.dateFormat || "MM/dd/yyyy",
timeFormat: profile.timeFormat || "12h",
startHour: profile.startHour || 8,
endHour: profile.endHour || 18,
fontSize: fontSize,
showNextTask: showNextTask,
showSomeday: showSomeday,
showAllDayEvents: showAllDay,
showSchedule: showSchedule,
headlineFont: profile.headlineFont || headlineFont,
headlineFontSize: profile.headlineFontSize || headlineFontSize,
headlineFontWeight: profile.headlineFontWeight || headlineFontWeight,
dateFontFamily: profile.dateFontFamily || dateFontFamily,
dateFontSize: profile.dateFontSize || dateFontSize,
dateFontWeight: profile.dateFontWeight || dateFontWeight,
timeTaskFontFamily: profile.timeTaskFontFamily || timeTaskFontFamily,
timeTaskFontSize: profile.timeTaskFontSize || timeTaskFontSize,
timeTaskFontWeight: profile.timeTaskFontWeight || timeTaskFontWeight,
bodyFont: profile.bodyFont || bodyFont,
taskFontFamily: profile.taskFontFamily || taskFontFamily,
taskFontSize: profile.taskFontSize || taskFontSize,
taskFontWeight: profile.taskFontWeight || taskFontWeight,
fontWeight: profile.fontWeight || fontWeight,
weekendColorSat: profile.weekendColorSat || weekendColorSat,
weekendColorSun: profile.weekendColorSun || weekendColorSun,
weekdayColor: profile.weekdayColor,
dateColor: profile.dateColor,
taskColor: profile.taskColor,
todayHighlightColor: profile.todayHighlightColor,
eventFontFamily: profile.eventFontFamily,
eventFontSize: profile.eventFontSize,
eventFontWeight: profile.eventFontWeight,
autoRolling: profile.autoRolling,
protectEventTimes: profile.protectEventTimes || protectEventTimes,
pastDayColor: profile.pastDayColor,
goalScope: profile.goalScope,
dateLayout: profile.dateLayout,
mobileDateLayout: profile.mobileDateLayout,
dateAlignment: profile.dateAlignment,
goalFontFamily: profile.goalFontFamily,
goalFontSize: profile.goalFontSize,
goalFontWeight: profile.goalFontWeight,
cwColor: profile.cwColor,
cwFontFamily: profile.cwFontFamily,
cwFontSize: profile.cwFontSize,
cwFontWeight: profile.cwFontWeight,
yearColor: profile.yearColor,
yearFontFamily: profile.yearFontFamily,
yearFontSize: profile.yearFontSize,
yearFontWeight: profile.yearFontWeight,
dayHeaderGap: profile.dayHeaderGap,
showTaskCheckboxes: profile.showTaskCheckboxes,
startDayOffset: profile.startDayOffset,
weekdayFormat: profile.weekdayFormat,
weekdayCase: profile.weekdayCase,
customWeekdayNames: profile.customWeekdayNames,
} as any);
}, [profile, showTimeGrid, cellDuration, viewStyle, fontSize, showNextTask, showSomeday, showAllDay, showSchedule]);
const handleUpdateConnections = async (updatedConnections: any[]) => {
onUpdateConnections(updatedConnections);
};
const handleRemoveConnection = async (connectionId: string) => {
await onRemoveConnection(connectionId);
};
async function fetchProfile() {
try {
const res = await fetch("/api/user/profile");
if (res.ok) {
const data = await res.json();
if (data.user) {
setProfile({
id: data.user.id || "",
name: data.user.name || "",
email: data.user.email || "",
timezone: data.user.timezone || "Europe/Berlin",
autoRolling: data.user.autoRolling || false,
protectEventTimes: data.user.protectEventTimes || false,
language: data.user.language || "de",
dateFormat: data.user.dateFormat || "yyyy-MM-dd",
timeFormat: data.user.timeFormat || "24h",
startHour:
data.user.startHour !== undefined ? data.user.startHour : 8,
endHour: data.user.endHour !== undefined ? data.user.endHour : 18,
focusTimerDuration: data.user.focusTimerDuration || 25,
showTimeGrid:
data.user.showTimeGrid !== undefined
? data.user.showTimeGrid
: true,
cellDuration: data.user.cellDuration || 30,
weekdayFormat: data.user.weekdayFormat || "long",
weekdayCase: data.user.weekdayCase || "capitalize",
customWeekdayNames: data.user.customWeekdayNames || "",
viewStyle: data.user.viewStyle || "list",
fontSize: data.user.fontSize || "M",
headlineFont: data.user.headlineFont || "Inter",
bodyFont: data.user.bodyFont || "Inter",
fontWeight: data.user.fontWeight || "400",
showSchedule:
data.user.showSchedule !== undefined
? data.user.showSchedule
: true,
focusBreakDuration: data.user.focusBreakDuration || 5,
headlineFontSize: data.user.headlineFontSize || "1.25rem",
headlineFontWeight: data.user.headlineFontWeight || "900",
goalFontWeight: data.user.goalFontWeight || "500",
dateFontFamily: data.user.dateFontFamily || "Inter",
dateFontSize: data.user.dateFontSize || "0.65rem",
dateFontWeight: data.user.dateFontWeight || "400",
timeTaskFontFamily: data.user.timeTaskFontFamily || "Inter",
timeTaskFontSize: data.user.timeTaskFontSize || "0.75rem",
timeTaskFontWeight: data.user.timeTaskFontWeight || "500",
taskFontFamily: data.user.taskFontFamily || "Inter",
taskFontSize: data.user.taskFontSize || "0.9rem",
taskFontWeight: data.user.taskFontWeight || "400",
eventFontFamily: data.user.eventFontFamily || "Inter",
eventFontSize: data.user.eventFontSize || "0.85rem",
eventFontWeight: data.user.eventFontWeight || "400",
goalFontFamily: data.user.goalFontFamily || "Inter",
goalFontSize: data.user.goalFontSize || "0.9rem",
goalScope: data.user.goalScope || "week",
dateLayout: data.user.dateLayout || "right",
mobileDateLayout: data.user.mobileDateLayout || "below",
dateAlignment: data.user.dateAlignment || "center",
weekendColorSat: data.user.weekendColorSat || "#666666",
weekendColorSun: data.user.weekendColorSun || "#dc2626",
weekdayColor: data.user.weekdayColor || "#888888",
dateColor: data.user.dateColor || "#888888",
taskColor: data.user.taskColor || "#333333",
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
hourLabelFormat: data.user.hourLabelFormat || "short",
showSubHourSlots: data.user.showSubHourSlots !== undefined ? data.user.showSubHourSlots : true,
allDayPosition: data.user.allDayPosition || "below",
showTaskCheckboxes: data.user.showTaskCheckboxes || false,
startDayOffset: data.user.startDayOffset !== undefined ? data.user.startDayOffset : 0,
});
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);
if (data.user.fontSize)
setFontSize(data.user.fontSize as "S" | "M" | "L");
if (data.user.focusTimerDuration)
setFocusTimerDuration(data.user.focusTimerDuration);
if (data.user.showSchedule !== undefined)
setShowSchedule(data.user.showSchedule);
if (data.user.focusBreakDuration)
setFocusBreakDuration(data.user.focusBreakDuration);
// Apply the initial start date offset if set
if (data.user.startDayOffset !== undefined && data.user.startDayOffset !== 0) {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + data.user.startDayOffset);
setCurrentWeekStart(d);
}
}
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
// Enable live preview after initial load
setTimeout(() => { profileLoadedRef.current = true; }, 100);
}
}
const handleGoogleConnect = () => {
window.location.href = "/api/calendar/google/start";
};
// --- Apple Calendar (CalDAV) handlers ---
const handleAppleCalendarConnect = () => {
setShowAppleCalendarModal(true);
setAppleCalError("");
setAppleCalEmail("");
setAppleCalPassword("");
};
const submitAppleCalendarConnection = async () => {
if (!appleCalEmail || !appleCalPassword) {
setAppleCalError("Please enter both email and app-specific password.");
return;
}
setIsConnectingAppleCal(true);
setAppleCalError("");
try {
const response = await fetch("/api/calendar/apple/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: appleCalEmail,
password: appleCalPassword,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to connect Apple Calendar");
}
setShowAppleCalendarModal(false);
showConnMsg("success", "Apple Calendar connected successfully!");
setTimeout(() => { window.location.href = window.location.pathname + "?calendar=apple_connected&openSettings=calendars"; }, 1200);
} catch (err: any) {
setAppleCalError(err.message || "Connection failed");
} finally {
setIsConnectingAppleCal(false);
}
};
// --- Synology Calendar handlers ---
const handleSynologyCalendarConnect = () => {
setShowSynologyCalendarModal(true);
setSynologyCalError("");
setSynologyCalServerUrl("");
setSynologyCalUsername("");
setSynologyCalPassword("");
};
const submitSynologyCalendarConnection = async () => {
if (!synologyCalServerUrl || !synologyCalUsername || !synologyCalPassword) {
setSynologyCalError("Please enter Server URL, username, and password.");
return;
}
setIsConnectingSynologyCal(true);
setSynologyCalError("");
try {
const response = await fetch("/api/calendar/synology/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serverUrl: synologyCalServerUrl,
username: synologyCalUsername,
password: synologyCalPassword,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to connect Synology Calendar");
}
setShowSynologyCalendarModal(false);
showConnMsg("success", "Synology Calendar connected successfully!");
setTimeout(() => { window.location.href = window.location.pathname + "?calendar=synology_connected&openSettings=calendars"; }, 1200);
} catch (err: any) {
setSynologyCalError(err.message || "Connection failed");
} finally {
setIsConnectingSynologyCal(false);
}
};
const handleOutlookConnect = () => {
window.location.href = "/api/calendar/outlook/start";
};
const handleUpdateCalendar = async (
connectionId: string,
calendarId: string,
updates: { selected?: boolean; editable?: boolean },
) => {
// Optimistic Update
const updatedConnections = connections.map((conn) => {
if (conn.id === connectionId && conn.calendars) {
return {
...conn,
calendars: conn.calendars.map((c: any) =>
c.id === calendarId ? { ...c, ...updates } : c,
),
};
}
return conn;
});
onUpdateConnections(updatedConnections); // used props instead of setConnections
// API Call
try {
const conn = updatedConnections.find((c) => c.id === connectionId);
if (conn) {
await fetch("/api/calendar/connections", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: connectionId,
calendars: conn.calendars,
}),
});
}
} catch (error) {
console.error("Failed to update calendar selection", error);
// Revert on error - tough to do without refetching from parent or keeping prev state
}
};
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
// Only validate password if in Account tab and password field is filled
if (
activeTab === "account" &&
passwords.new &&
passwords.new !== passwords.confirm
) {
setAccountMsg("Passwords do not match");
return;
}
try {
const res = await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...profile,
dateAlignment: profile.dateAlignment,
showTimeGrid: showTimeGrid,
cellDuration: cellDuration,
viewStyle: viewStyle,
showNextTask: showNextTask,
showSomeday: showSomeday,
showAllDayEvents: showAllDay,
showSchedule: showSchedule,
// The following will be taken from profile if present,
// ensuring edited state is saved.
// Validate numeric fields to avoid NaN
focusBreakDuration: !isNaN(Number(profile.focusBreakDuration))
? Number(profile.focusBreakDuration)
: focusBreakDuration || 5,
focusTimerDuration: !isNaN(Number(profile.focusTimerDuration))
? Number(profile.focusTimerDuration)
: focusTimerDuration || 25,
password:
passwords.new && passwords.new.trim() !== ""
? passwords.new
: undefined,
}),
});
const data = await res.json();
if (res.ok) {
setAccountMsg("Profile updated successfully!");
// Update local app state
if (onSettingsChanged) {
onSettingsChanged({
showTimeGrid: showTimeGrid,
cellDuration: cellDuration,
viewStyle: viewStyle,
language: profile.language || "de",
dateFormat: profile.dateFormat || "MM/dd/yyyy",
timeFormat: profile.timeFormat || "12h",
startHour: profile.startHour || 8,
endHour: profile.endHour || 18,
fontSize: fontSize,
showNextTask: showNextTask,
showSomeday: showSomeday,
showAllDayEvents: showAllDay,
showSchedule: showSchedule,
headlineFont: headlineFont,
headlineFontSize: headlineFontSize,
headlineFontWeight: headlineFontWeight,
dateFontFamily: dateFontFamily,
dateFontSize: dateFontSize,
dateFontWeight: dateFontWeight,
timeTaskFontFamily: timeTaskFontFamily,
timeTaskFontSize: timeTaskFontSize,
timeTaskFontWeight: timeTaskFontWeight,
bodyFont: bodyFont,
taskFontFamily: taskFontFamily,
taskFontSize: taskFontSize,
taskFontWeight: taskFontWeight,
fontWeight: fontWeight,
weekendColorSat: weekendColorSat,
weekendColorSun: weekendColorSun,
weekdayColor: profile.weekdayColor,
dateColor: profile.dateColor,
taskColor: profile.taskColor,
todayHighlightColor: profile.todayHighlightColor,
autoRolling: profile.autoRolling,
protectEventTimes: profile.protectEventTimes || protectEventTimes,
focusTimerDuration:
profile.focusTimerDuration || focusTimerDuration,
focusBreakDuration:
profile.focusBreakDuration || focusBreakDuration,
pastDayColor: profile.pastDayColor,
goalScope: profile.goalScope,
dateLayout: profile.dateLayout,
mobileDateLayout: profile.mobileDateLayout,
dateAlignment: profile.dateAlignment,
startDayOffset: profile.startDayOffset,
} as any);
}
if (profile.focusTimerDuration && setFocusTimerDuration) {
setFocusTimerDuration(profile.focusTimerDuration);
}
if (profile.focusBreakDuration && setFocusBreakDuration) {
setFocusBreakDuration(profile.focusBreakDuration);
}
// Temporary success message
setTimeout(() => setAccountMsg(""), 3000);
} else {
console.error("Failed to update profile:", data);
setAccountMsg(
data.details
? `${data.error}: ${data.details}`
: data.error || "Failed to update profile",
);
}
} catch (e) {
console.error("Error updating profile:", e);
setAccountMsg("Error updating profile");
}
};
const handleDownloadData = () => {
window.open("/api/user/export", "_blank");
};
const handleExportAllData = async () => {
setIsExportingAll(true);
try {
const res = await fetch("/api/user/export-data");
if (!res.ok) throw new Error("Export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `weekly_todo_backup_${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error("Export error:", e);
} finally {
setIsExportingAll(false);
}
};
const handleImportData = async () => {
if (!importFile) return;
if (importMode === "replace") {
const confirmed = confirm(t.importConfirmReplace);
if (!confirmed) return;
}
setIsImporting(true);
setImportMsg("");
try {
const text = await importFile.text();
JSON.parse(text); // validate JSON
const res = await fetch(`/api/user/import-data?mode=${importMode}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: text,
});
const data = await res.json();
if (!res.ok) {
setImportMsg(`${data.error || "Import failed"}`);
return;
}
const { imported } = data;
const parts: string[] = [];
if (imported.tasks > 0) parts.push(`${imported.tasks} ${profile.language === "de" ? "Aufgaben" : "tasks"}`);
if (imported.somedayLists > 0) parts.push(`${imported.somedayLists} ${profile.language === "de" ? "Listen" : "lists"}`);
if (imported.projects > 0) parts.push(`${imported.projects} ${profile.language === "de" ? "Projekte" : "projects"}`);
setImportMsg(`${profile.language === "de" ? "Importiert" : "Imported"}: ${parts.join(", ")}`);
setImportFile(null);
// Reset file input
const fileInput = document.getElementById("import-file-input") as HTMLInputElement;
if (fileInput) fileInput.value = "";
// Reload to reflect imported data
setTimeout(() => window.location.reload(), 1500);
} catch (e) {
setImportMsg(`${profile.language === "de" ? "Ungültige JSON-Datei" : "Invalid JSON file"}`);
} finally {
setIsImporting(false);
}
};
const handleDeleteAccount = async () => {
if (
!confirm(
"Are you sure you want to delete your account? This action cannot be undone.",
)
)
return;
try {
const res = await fetch("/api/user/profile", { method: "DELETE" });
if (res.ok) {
window.location.href = "/";
} else {
alert("Failed to delete account");
}
} catch (e) {
alert("Error deleting account");
}
};
return (
<>
<div
className={`weekly-settings-overlay ${isVisible ? "show" : ""}`}
onClick={handleClose}
style={{ zIndex: 1999 }}
/>
<div className={`weekly-settings-sidebar ${isVisible ? "open" : ""}`}>
<header className="weekly-settings-header">
<h2 className="weekly-settings-title">{t.settings}</h2>
<button className="weekly-settings-close" onClick={handleClose}>
×
</button>
</header>
<div
className="weekly-settings-tabs"
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: "4px",
borderBottom: "1px solid var(--weekly-border, #eee)",
padding: "0 24px",
}}
>
{([
{ key: "general", icon: <Settings size={18} />, label: t.general },
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
{ key: "account", icon: <User size={18} />, label: t.account },
{ key: "styling", icon: <Palette size={18} />, label: t.styling },
{ key: "motivation", icon: <Sparkles size={18} />, label: t.motivation },
{ key: "about", icon: <Info size={18} />, label: t.about },
] as const).map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
title={tab.label}
className="settings-tab-btn"
style={{
padding: "10px 14px",
borderBottom:
activeTab === tab.key
? "2px solid var(--weekly-text, black)"
: "2px solid transparent",
background: "none",
border: "none",
borderBottomStyle: "solid",
borderBottomWidth: "2px",
borderBottomColor:
activeTab === tab.key
? "var(--weekly-text, black)"
: "transparent",
cursor: "pointer",
opacity: activeTab === tab.key ? 1 : 0.5,
color: "var(--weekly-text, #333)",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 0.15s, border-color 0.15s",
position: "relative",
}}
>
{tab.icon}
</button>
))}
</div>
<div
className="weekly-settings-content"
style={{ flex: 1, overflowY: "auto", padding: "24px" }}
>
{activeTab === "general" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="showSomeday"
checked={showSomeday}
onChange={(e) => setShowSomeday(e.target.checked)}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showSomeday"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showSomeday}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="showAllDay"
checked={showAllDay}
onChange={(e) => setShowAllDay(e.target.checked)}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showAllDay"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showAllDay}
</label>
</div>
{showAllDay && (
<div
style={{
marginLeft: "24px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.allDayPosition}
</label>
<select
value={allDayPosition}
onChange={(e) => {
const pos = e.target.value as "above" | "below";
setAllDayPosition(pos);
saveSetting("allDayPosition", pos);
}}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="above">{t.allDayAbove}</option>
<option value="below">{t.allDayBelow}</option>
</select>
</div>
)}
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="showSchedule"
checked={showSchedule}
onChange={(e) => setShowSchedule(e.target.checked)}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showSchedule"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showScheduleCalendar}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="autoRolling"
checked={profile.autoRolling || false}
onChange={(e) =>
setProfile({ ...profile, autoRolling: e.target.checked })
}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="autoRolling"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.runningList}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="showTaskCheckboxes"
checked={profile.showTaskCheckboxes || false}
onChange={(e) =>
setProfile({ ...profile, showTaskCheckboxes: e.target.checked })
}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showTaskCheckboxes"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showTaskCheckboxes}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="protectEventTimes"
checked={profile.protectEventTimes || false}
onChange={(e) =>
setProfile({
...profile,
protectEventTimes: e.target.checked,
})
}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="protectEventTimes"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.protectEventTimes}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="showTimeGrid"
checked={showTimeGrid}
onChange={(e) => setShowTimeGrid(e.target.checked)}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showTimeGrid"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showTimeGrid}
</label>
</div>
{showTimeGrid && (
<div
style={{
marginLeft: "24px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.timeSlotDuration}
</label>
<select
value={cellDuration}
onChange={(e) =>
setCellDuration(Number(e.target.value) as CellDuration)
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>1 hour</option>
<option value={120}>2 hours</option>
</select>
</div>
{/* Configurable Hours */}
<div style={{ display: "flex", gap: "12px" }}>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.startHour}
</label>
<input
type="number"
min="0"
max="23"
value={profile.startHour}
onChange={(e) =>
setProfile((prev) => ({
...prev,
startHour: parseInt(e.target.value) || 0,
}))
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
</div>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.endHour}
</label>
<input
type="number"
min="1"
max="24"
value={profile.endHour}
onChange={(e) =>
setProfile((prev) => ({
...prev,
endHour: parseInt(e.target.value) || 0,
}))
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
</div>
</div>
{/* Hour Label Format */}
<div style={{ marginTop: "4px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.hourLabelFormat}
</label>
<select
value={hourLabelFormat}
onChange={(e) => {
const fmt = e.target.value as "short" | "full";
setHourLabelFormat(fmt);
saveSetting("hourLabelFormat", fmt);
}}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="short">{t.hourLabelShort}</option>
<option value="full">{t.hourLabelFull}</option>
</select>
</div>
{/* Sub-hour Slot Labels */}
<div
style={{ display: "flex", alignItems: "center", gap: "8px", marginTop: "8px" }}
>
<input
type="checkbox"
id="showSubHourSlots"
checked={showSubHourSlots}
onChange={(e) => {
setShowSubHourSlots(e.target.checked);
saveSetting("showSubHourSlots", e.target.checked);
}}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="showSubHourSlots"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{t.showSubhourLabels}
</label>
</div>
</div>
)}
<div style={{ marginTop: "16px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "8px",
}}
>
{t.viewStyle}
</label>
<div
className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1"
style={{ width: "fit-content" }}
>
<button
onClick={() => {
setViewStyle("simple");
setShowTimeGrid(true);
saveSetting("viewStyle", "simple");
saveSetting("showTimeGrid", true);
}}
className={`px-4 py-2 text-sm rounded transition-colors ${showTimeGrid && viewStyle === "simple" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{t.simpleView}
</button>
<button
onClick={() => {
setViewStyle("calendar");
setShowTimeGrid(true);
saveSetting("viewStyle", "calendar");
saveSetting("showTimeGrid", true);
}}
className={`px-4 py-2 text-sm rounded transition-colors ${showTimeGrid && viewStyle === "calendar" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{t.calendarView}
</button>
<button
onClick={() => {
setViewStyle("list");
setShowTimeGrid(false);
saveSetting("viewStyle", "list");
saveSetting("showTimeGrid", false);
}}
className={`px-4 py-2 text-sm rounded transition-colors ${!showTimeGrid && viewStyle === "list" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{t.listView}
</button>
<button
onClick={() => {
setViewStyle("kanban");
saveSetting("viewStyle", "kanban");
}}
className={`px-4 py-2 text-sm rounded transition-colors ${viewStyle === "kanban" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{t.kanbanView}
</button>
</div>
</div>
{/* Kanban Stages Settings */}
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
<LayoutGrid size={16} /> {t.kanbanStages}
</h4>
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.kanbanStagesDesc}</p>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
{kanbanStages.map((stage, idx) => (
<div key={stage.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}>
<input
type="color"
value={stage.color}
onChange={(e) => {
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, color: e.target.value } : s);
saveKanbanStages(updated);
}}
style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }}
/>
<input
type="text"
defaultValue={stage.name}
onBlur={(e) => {
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, name: e.target.value } : s);
saveKanbanStages(updated);
}}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
className="weekly-input"
style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }}
/>
<button
onClick={() => {
const updated = kanbanStages.filter((_, i) => i !== idx);
saveKanbanStages(updated);
}}
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }}
title="Delete"
>
<X size={14} />
</button>
</div>
))}
</div>
<button
onClick={() => {
const id = `stage-${Date.now()}`;
saveKanbanStages([...kanbanStages, { id, name: t.stageName, color: "#6b7280" }]);
}}
style={{
display: "flex", alignItems: "center", gap: "4px",
background: "none", border: "1px dashed #ccc", borderRadius: "6px",
padding: "6px 12px", cursor: "pointer", color: "#888", fontSize: "0.85rem",
}}
>
<Plus size={14} /> {t.addStage}
</button>
</div>
{/* Projects Section */}
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
<FolderOpen size={16} /> {t.projects}
</h4>
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.projectsDesc}</p>
{projects.length === 0 && (
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginBottom: "8px" }}>{t.noProjects}</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
{projects.map((p) => (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}>
{editingProjectId === p.id ? (
<>
<input
type="color"
value={editProjectColor}
onChange={(e) => setEditProjectColor(e.target.value)}
style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }}
/>
<input
type="text"
value={editProjectName}
onChange={(e) => setEditProjectName(e.target.value)}
className="weekly-input"
style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }}
onKeyDown={(e) => {
if (e.key === "Enter") {
fetch("/api/projects", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor }),
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
}
if (e.key === "Escape") setEditingProjectId(null);
}}
autoFocus
/>
<button
onClick={() => {
fetch("/api/projects", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor }),
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
}}
style={{ padding: "2px 6px", fontSize: "0.8rem" }}
className="weekly-btn-primary"
>
<Check size={12} />
</button>
</>
) : (
<>
<Circle size={14} fill={p.color || "#999"} stroke={p.color || "#999"} strokeWidth={0} />
<span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 500 }}>{p.name}</span>
<button
onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); }}
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", background: "none", border: "none" }}
title="Edit"
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button
onClick={() => {
if (confirm(profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`)) {
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
}
}}
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }}
title="Delete"
>
<Trash2 size={12} />
</button>
</>
)}
</div>
))}
</div>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<input
type="color"
value={newProjectColor}
onChange={(e) => setNewProjectColor(e.target.value)}
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0 }}
/>
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder={t.projectName}
className="weekly-input"
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
onKeyDown={(e) => {
if (e.key === "Enter" && newProjectName.trim()) {
fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor }),
}).then(() => { onProjectsChanged(); setNewProjectName(""); });
}
}}
/>
<button
onClick={() => {
if (!newProjectName.trim()) return;
fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor }),
}).then(() => { onProjectsChanged(); setNewProjectName(""); });
}}
className="weekly-btn-primary"
style={{ padding: "6px 12px", fontSize: "0.8rem", whiteSpace: "nowrap" }}
>
<Plus size={14} /> {t.addProject}
</button>
</div>
</div>
<div
style={{
marginTop: "16px",
display: "flex",
alignItems: "center",
gap: "12px",
}}
>
<button
onClick={handleUpdateProfile}
className="weekly-btn-primary"
style={{ padding: "10px 20px" }}
>
{t.saveChanges}
</button>
{accountMsg && (
<span
style={{
fontSize: "0.9rem",
color: accountMsg.toLowerCase().includes("success")
? "#059669"
: "#dc2626",
fontWeight: 600,
}}
>
{accountMsg}
</span>
)}
</div>
</div>
) : activeTab === "localisation" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
>
<h4 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
{t.localisation || "Localisation"}
</h4>
{/* Start Week Setting + Start View On */}
<div style={{ display: "flex", gap: "24px", flexWrap: "wrap" }}>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.weekStartLabel}
</label>
<div style={{ display: "flex", gap: "8px" }}>
<button
className={`px-3 py-2 rounded text-sm ${weekStartDay === 1 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
onClick={() => setWeekStartDay(1)}
>
{t.monday}
</button>
<button
className={`px-3 py-2 rounded text-sm ${weekStartDay === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
onClick={() => setWeekStartDay(0)}
>
{t.sunday}
</button>
</div>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.startViewLabel}
</label>
<div style={{ display: "flex", gap: "8px" }}>
<button
className={`px-3 py-2 rounded text-sm ${(profile.startDayOffset || 0) === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
onClick={() => {
setProfile({ ...profile, startDayOffset: 0 });
saveSetting("startDayOffset", 0);
const d = new Date();
d.setHours(0, 0, 0, 0);
setCurrentWeekStart(d);
}}
>
{t.today}
</button>
<button
className={`px-3 py-2 rounded text-sm ${profile.startDayOffset === -1 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"}`}
onClick={() => {
setProfile({ ...profile, startDayOffset: -1 });
saveSetting("startDayOffset", -1);
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - 1);
setCurrentWeekStart(d);
}}
>
{t.yesterday}
</button>
</div>
</div>
</div>
{/* Weekday Format */}
<div style={{ marginTop: "8px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "8px",
}}
>
{t.weekdayFormat || translations["en"].weekdayFormat}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<select
value={profile.weekdayFormat || "long"}
onChange={(e) =>
setProfile({
...profile,
weekdayFormat: e.target.value as any,
})
}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="long">{t.weekdayFormatFull || translations["en"].weekdayFormatFull}</option>
<option value="short">{t.weekdayFormatShort || translations["en"].weekdayFormatShort}</option>
<option value="narrow">{t.weekdayFormatNarrow || translations["en"].weekdayFormatNarrow}</option>
<option value="custom">{t.weekdayFormatCustom || translations["en"].weekdayFormatCustom}</option>
</select>
{profile.weekdayFormat === "custom" && (
<input
type="text"
value={profile.customWeekdayNames || ""}
onChange={(e) => setProfile({ ...profile, customWeekdayNames: e.target.value })}
placeholder={
weekStartDay === 1
? (t.customWeekdayNamesMon || translations["en"].customWeekdayNamesMon)
: (t.customWeekdayNamesSun || translations["en"].customWeekdayNamesSun)
}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
</div>
</div>
{/* Weekday Case */}
<div style={{ marginTop: "8px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "8px",
}}
>
{t.weekdayCase || "Weekday Case"}
</label>
<select
value={profile.weekdayCase || "capitalize"}
onChange={(e) =>
setProfile({
...profile,
weekdayCase: e.target.value as "normal" | "capitalize" | "uppercase",
})
}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="normal">{t.weekdayCaseNormal || "Normal (monday)"}</option>
<option value="capitalize">{t.weekdayCaseCapitalize || "Capitalize (Monday)"}</option>
<option value="uppercase">{t.weekdayCaseUppercase || "Uppercase (MONDAY)"}</option>
</select>
</div>
<div style={{ marginTop: "8px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.language}
</label>
<select
value={profile.language || "de"}
onChange={(e) =>
setProfile((p) => ({ ...p, language: e.target.value }))
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
<option value="fr">🇫🇷 Français</option>
<option value="es">🇪🇸 Español</option>
<option value="it">🇮🇹 Italiano</option>
</select>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.dateFormat}
</label>
<select
value={profile.dateFormat}
onChange={(e) =>
setProfile({ ...profile, dateFormat: e.target.value })
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="MM/dd/yyyy">MM/DD/YYYY</option>
<option value="dd/MM/yyyy">DD/MM/YYYY</option>
<option value="yyyy-MM-dd">YYYY-MM-DD</option>
</select>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.timeFormat}
</label>
<select
value={profile.timeFormat}
onChange={(e) =>
setProfile({ ...profile, timeFormat: e.target.value })
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="12h">12h AM/PM</option>
<option value="24h">24H</option>
</select>
</div>
<div />
</div>
) : activeTab === "calendar" ? (
isLoading ? (
<p>Loading connections...</p>
) : (
<>
<h3
style={{
marginBottom: "1rem",
fontSize: "1rem",
fontWeight: 600,
}}
>
{t.connectedCalendars}
</h3>
{connMsg && (
<div
style={{
padding: "8px 12px",
borderRadius: "4px",
marginBottom: "12px",
fontSize: "0.875rem",
background:
connMsg.type === "success"
? "rgba(16, 185, 129, 0.1)"
: "rgba(239, 68, 68, 0.1)",
color: connMsg.type === "success" ? "#059669" : "#dc2626",
border: `1px solid ${connMsg.type === "success" ? "#10b981" : "#ef4444"}`,
}}
>
{connMsg.text}
</div>
)}
{connections.length === 0 ? (
<p
style={{
color: "var(--weekly-text-light)",
marginBottom: "1.5rem",
}}
>
{t.noCalendars}
</p>
) : (
<ul
style={{
marginBottom: "1.5rem",
listStyle: "none",
padding: 0,
}}
>
{connections.map((conn) => (
<li
key={conn.id}
style={{
padding: "1rem 0",
borderBottom: "1px solid var(--weekly-border)",
}}
>
<div
style={{
marginBottom: "0.5rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span>
{conn.provider === "google"
? <FontAwesomeIcon icon={faGoogle} />
: conn.provider === "apple"
? <FontAwesomeIcon icon={faApple} />
: conn.provider === "synology"
? <FontAwesomeIcon icon={faServer} />
: <FontAwesomeIcon icon={faMicrosoft} />}
</span>
{conn.provider === "google"
? "Google Calendar"
: conn.provider === "apple"
? "Apple Calendar"
: conn.provider === "synology"
? "Synology Calendar"
: "Outlook Calendar"}
</div>
{confirmDisconnectId === conn.id ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span
style={{
fontSize: "0.8rem",
color: "var(--weekly-text)",
}}
>
Sure?
</span>
<button
type="button"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
setConfirmDisconnectId(null);
setDisconnectingId(conn.id);
try {
await onRemoveConnection(conn.id);
showConnMsg(
"success",
"Calendar disconnected.",
);
} catch (err: any) {
console.error("Failed to disconnect:", err);
showConnMsg(
"error",
err.message ||
"Failed to disconnect calendar",
);
} finally {
setDisconnectingId(null);
}
}}
style={{
padding: "3px 8px",
fontSize: "0.8rem",
background: "#dc2626",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Yes
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setConfirmDisconnectId(null);
}}
style={{
padding: "3px 8px",
fontSize: "0.8rem",
background: "#e5e7eb",
color: "#374151",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
No
</button>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setConfirmDisconnectId(conn.id);
}}
disabled={disconnectingId === conn.id}
style={{
padding: "4px 8px",
fontSize: "0.8rem",
color:
disconnectingId === conn.id
? "#999"
: "#dc2626",
background: "none",
border: `1px solid ${disconnectingId === conn.id ? "#999" : "#dc2626"}`,
borderRadius: "4px",
cursor:
disconnectingId === conn.id
? "not-allowed"
: "pointer",
opacity: disconnectingId === conn.id ? 0.7 : 1,
}}
>
{disconnectingId === conn.id
? "Disconnecting..."
: "Disconnect"}
</button>
)}
</div>
{/* Calendar Event Selection List */}
{conn.calendars &&
Array.isArray(conn.calendars) &&
conn.calendars.length > 0 ? (
<div style={{ paddingLeft: "8px" }}>
{/* Column Headers */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "6px",
paddingBottom: "4px",
borderBottom: "1px solid var(--weekly-border, #eee)",
}}
>
<span style={{ flex: 1, fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Calendar
</span>
<span style={{ width: "50px", textAlign: "center", fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Display
</span>
<span style={{ width: "50px", textAlign: "center", fontSize: "0.75rem", color: "var(--weekly-text-light)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Edit
</span>
</div>
{/* Calendar Rows */}
{conn.calendars.map((cal: any) => {
const isShared = /⚠/.test(cal.title);
const cleanTitle = cal.title
.replace(/\s*⚠️?\s*/g, "")
.trim();
return (
<div
key={cal.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "3px 0",
}}
>
{/* Calendar Name */}
<span
style={{
flex: 1,
fontSize: "0.9rem",
color: "var(--weekly-text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cleanTitle}
{isShared && (
<span title="Shared calendar" style={{ marginLeft: "4px", fontSize: "0.75rem", opacity: 0.5 }}>
🔗
</span>
)}
{cal.isPrimary && (
<span style={{ fontSize: "0.8em", color: "var(--weekly-text-light)", marginLeft: "4px" }}>
(Primary)
</span>
)}
</span>
{/* Display checkbox */}
<span style={{ width: "50px", textAlign: "center" }}>
<input
type="checkbox"
checked={cal.selected !== false}
onChange={(e) =>
handleUpdateCalendar(conn.id, cal.id, {
selected: e.target.checked,
})
}
style={{ cursor: "pointer" }}
/>
</span>
{/* Edit checkbox */}
<span style={{ width: "50px", textAlign: "center" }}>
<input
type="checkbox"
checked={cal.editable === true}
onChange={(e) =>
handleUpdateCalendar(conn.id, cal.id, {
editable: e.target.checked,
})
}
style={{ cursor: "pointer" }}
title="Allow adding/editing events"
/>
</span>
</div>
);
})}
</div>
) : (
<div
style={{
fontSize: "0.85rem",
color: "#888",
paddingLeft: "24px",
}}
>
{conn.provider === "google"
? t.noCalendarsFound
: conn.provider === "apple"
? t.noCalendarsApple
: conn.provider === "synology"
? t.noCalendarsSynology
: t.selectionAfterConnect}
</div>
)}
</li>
))}
</ul>
)}
<h3
style={{
marginBottom: "1rem",
fontSize: "1rem",
fontWeight: 600,
}}
>
{t.connectMore}
</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<button
onClick={handleGoogleConnect}
className="calendar-connect-btn"
>
<FontAwesomeIcon icon={faGoogle} className="mr-2" /> {t.connectGoogle}
</button>
<button
onClick={handleAppleCalendarConnect}
className="calendar-connect-btn"
>
<FontAwesomeIcon icon={faApple} className="mr-2" /> {t.connectApple}
</button>
<button
onClick={handleOutlookConnect}
className="calendar-connect-btn"
>
<FontAwesomeIcon icon={faMicrosoft} className="mr-2" /> {t.connectOutlook}
</button>
<button
onClick={handleSynologyCalendarConnect}
className="calendar-connect-btn"
>
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
</button>
</div>
<h3
style={{
marginBottom: "1rem",
fontSize: "1rem",
fontWeight: 600,
marginTop: "2rem",
}}
>
{t.syncTasks}
</h3>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-text-light)",
marginBottom: "1rem",
}}
>
{t.syncTasksDesc}
</p>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
}}
>
{connections
.filter((c) => ["google", "outlook", "synology"].includes(c.provider))
.map((conn) => {
const providerLists =
availableTaskLists[
conn.provider as "google" | "outlook" | "synology"
] || [];
const isFetching =
isFetchingProviderLists[conn.provider];
return (
<div key={conn.id}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "0.5rem",
fontWeight: 600,
fontSize: "0.9rem",
}}
>
<span>
{conn.provider === "google" ? <FontAwesomeIcon icon={faGoogle} /> : conn.provider === "synology" ? <FontAwesomeIcon icon={faServer} /> : <FontAwesomeIcon icon={faMicrosoft} />}
</span>
{conn.provider === "google"
? "Google Tasks"
: conn.provider === "synology"
? "Synology Tasks"
: "Microsoft To-Do"}
{isFetching && (
<span
style={{
fontSize: "0.75rem",
fontWeight: 400,
color: "#888",
}}
>
(fetching lists...)
</span>
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
{/* Column header with sync all / unsync all */}
{providerLists.length > 0 && (() => {
const allSynced = providerLists.every(
(list: { id: string; title: string }) => somedayLists.some(
(sl: SomedayList) => sl.externalId === list.id && sl.externalProvider === conn.provider,
),
);
const noneSynced = providerLists.every(
(list: { id: string; title: string }) => !somedayLists.some(
(sl: SomedayList) => sl.externalId === list.id && sl.externalProvider === conn.provider,
),
);
return (
<div style={{
display: "flex",
alignItems: "center",
padding: "2px 8px",
fontSize: "0.75rem",
fontWeight: 600,
color: "var(--weekly-text-light, #888)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}>
<span style={{ flex: 1 }}>List</span>
{!allSynced && (
<button
onClick={() => handleSyncAll(
conn.provider as "google" | "outlook" | "synology",
providerLists,
true,
)}
disabled={importingTasksState}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.7rem",
color: "var(--weekly-accent, #6366f1)",
padding: "2px 6px",
fontWeight: 500,
textTransform: "none",
}}
>{t.syncAll}</button>
)}
{!noneSynced && (
<button
onClick={() => handleSyncAll(
conn.provider as "google" | "outlook" | "synology",
providerLists,
false,
)}
disabled={importingTasksState}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.7rem",
color: "#ef4444",
padding: "2px 6px",
fontWeight: 500,
textTransform: "none",
}}
>{t.unsyncAll}</button>
)}
<span style={{ width: "50px", textAlign: "center" }}>Sync</span>
</div>
);
})()}
{/* Inline unsync confirmation */}
{unsyncConfirm && unsyncConfirm.provider === conn.provider && (
<div style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px 10px",
borderRadius: "6px",
background: "#fef2f2",
border: "1px solid #fecaca",
fontSize: "0.82rem",
color: "#991b1b",
}}>
<span style={{ flex: 1 }}>
{t.unsyncConfirmMsg.replace("{title}", unsyncConfirm.list.title)}
</span>
<button
onClick={onConfirmUnsync}
style={{
background: "#ef4444",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 10px",
cursor: "pointer",
fontSize: "0.8rem",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>{t.unsyncConfirm}</button>
<button
onClick={onCancelUnsync}
style={{
background: "none",
border: "1px solid #d1d5db",
borderRadius: "4px",
padding: "4px 10px",
cursor: "pointer",
fontSize: "0.8rem",
color: "#666",
whiteSpace: "nowrap",
}}
>{t.unsyncCancel}</button>
</div>
)}
{providerLists.map((list: { id: string; title: string }) => {
const isSynced = somedayLists.some(
(sl: SomedayList) =>
sl.externalId === list.id &&
sl.externalProvider === conn.provider,
);
return (
<div
key={list.id}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderRadius: "4px",
background: "rgba(0,0,0,0.02)",
}}
>
<span style={{ flex: 1, fontSize: "0.9rem" }}>
{list.title}
</span>
<span style={{ width: "50px", textAlign: "center" }}>
<input
type="checkbox"
checked={isSynced}
onChange={() =>
handleToggleTaskList(
conn.provider as
| "google"
| "outlook"
| "synology",
list,
)
}
disabled={importingTasksState}
/>
</span>
</div>
);
})}
{!isFetching && providerLists.length === 0 && (
<div
style={{
fontSize: "0.85rem",
color: "#888",
paddingLeft: "24px",
}}
>
No task lists found.
</div>
)}
</div>
</div>
);
})}
{connections.filter((c) =>
["google", "outlook", "synology"].includes(c.provider),
).length === 0 && (
<div
style={{
fontSize: "0.9rem",
color: "#888",
fontStyle: "italic",
}}
>
Connect a provider above to sync task lists.
</div>
)}
{importStatusMsg && (
<div
style={{
padding: "8px 12px",
borderRadius: "4px",
fontSize: "0.9rem",
background:
importStatusMsg.type === "success"
? "rgba(16, 185, 129, 0.1)"
: "rgba(239, 68, 68, 0.1)",
color:
importStatusMsg.type === "success"
? "#059669"
: "#dc2626",
border: `1px solid ${importStatusMsg.type === "success" ? "#10b981" : "#ef4444"}`,
}}
>
{importStatusMsg.text}
</div>
)}
</div>
</>
)
) : activeTab === "styling" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "24px" }}
>
{/* Typography Settings */}
<div
style={{
marginBottom: "1.5rem",
borderBottom: "1px solid var(--weekly-border)",
paddingBottom: "1rem",
}}
>
<label
style={{
display: "block",
fontSize: "1rem",
fontWeight: 700,
marginBottom: "12px",
color: "var(--weekly-settings-title)",
}}
>
{t.fontCustomization}
</label>
{/* Date Layout & Alignment side-by-side */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "16px",
marginBottom: "1.5rem",
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
}}
>
<div>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.dateLayout}
</label>
<select
value={profile.dateLayout || "right"}
onChange={(e) =>
setProfile({
...profile,
dateLayout: e.target.value as
| "above"
| "below"
| "left"
| "right"
| "hidden",
})
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
fontSize: "0.9rem",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="right">{t.dateLayoutRight}</option>
<option value="left">{t.dateLayoutLeft}</option>
<option value="above">{t.dateLayoutAbove}</option>
<option value="below">{t.dateLayoutBelow}</option>
<option value="hidden">{t.dateLayoutHidden}</option>
</select>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.dateLayoutMobile}
</label>
<select
value={profile.mobileDateLayout || "below"}
onChange={(e) =>
setProfile({
...profile,
mobileDateLayout: e.target.value as
| "above"
| "below"
| "left"
| "right"
| "hidden",
})
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
fontSize: "0.9rem",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="right">{t.dateLayoutRight}</option>
<option value="left">{t.dateLayoutLeft}</option>
<option value="above">{t.dateLayoutAbove}</option>
<option value="below">{t.dateLayoutBelow}</option>
<option value="hidden">{t.dateLayoutHidden}</option>
</select>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.dateAlignment || "Date Alignment"}
</label>
<select
value={profile.dateAlignment || "center"}
onChange={(e) =>
setProfile({
...profile,
dateAlignment: e.target.value as
| "left"
| "center"
| "right"
| "tight",
})
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
fontSize: "0.9rem",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="left">{t.alignmentLeft || "Left"}</option>
<option value="center">{t.alignmentCenter || "Center"}</option>
<option value="right">{t.alignmentRight || "Right"}</option>
<option value="tight">{t.alignmentTight || "Tight"}</option>
</select>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.dateVerticalAlign || "Date Vertical Alignment"}
</label>
<select
value={profile.dateVerticalAlign || "middle"}
onChange={(e) =>
setProfile({
...profile,
dateVerticalAlign: e.target.value as
| "top"
| "middle"
| "bottom",
})
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
fontSize: "0.9rem",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
>
<option value="top">{t.alignTop || "Top"}</option>
<option value="middle">{t.alignMiddle || "Middle"}</option>
<option value="bottom">{t.alignBottom || "Bottom"}</option>
</select>
</div>
</div>
{/* Day / Weekday Gap */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.dayWeekdayGap}
</label>
<input
type="text"
value={profile.dayHeaderGap || "0.35em"}
onChange={(e) =>
setProfile({ ...profile, dayHeaderGap: e.target.value })
}
placeholder="0.35em"
className="weekly-input"
style={{
width: "100%",
padding: "8px",
fontSize: "0.9rem",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
/>
</div>
{/* Day Names */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.weekdayFont}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="color"
value={profile.weekdayColor || "#888888"}
onChange={(e) => setProfile({ ...profile, weekdayColor: e.target.value })}
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
/>
<select
value={isCustomFont(profile.headlineFont || "") || profile.headlineFont === "__custom__" ? "__custom__" : (profile.headlineFont || "Inter")}
onChange={(e) =>
setProfile({ ...profile, headlineFont: e.target.value === "__custom__" ? "__custom__" : e.target.value })
}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{(isCustomFont(profile.headlineFont || "") || profile.headlineFont === "__custom__") && (
<input
type="text"
value={profile.headlineFont === "__custom__" ? "" : (profile.headlineFont || "")}
onChange={(e) => setProfile({ ...profile, headlineFont: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.headlineFontSize || "1.25rem"}
onChange={(e) => setProfile({ ...profile, headlineFontSize: e.target.value })}
placeholder={t.fontSizePlaceholder}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.headlineFontWeight || "900"}
onChange={(e) => setProfile({ ...profile, headlineFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
<option value="900">{t.weightBlack}</option>
</select>
</div>
</div>
</div>
{/* Dates */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.dateFont}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="color"
value={profile.dateColor || "#888888"}
onChange={(e) => setProfile({ ...profile, dateColor: e.target.value })}
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
/>
<select
value={isCustomFont(profile.dateFontFamily || "") || profile.dateFontFamily === "__custom__" ? "__custom__" : (profile.dateFontFamily || "Inter")}
onChange={(e) => setProfile({ ...profile, dateFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{(isCustomFont(profile.dateFontFamily || "") || profile.dateFontFamily === "__custom__") && (
<input
type="text"
value={profile.dateFontFamily === "__custom__" ? "" : (profile.dateFontFamily || "")}
onChange={(e) => setProfile({ ...profile, dateFontFamily: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.dateFontSize || "0.65rem"}
onChange={(e) => setProfile({ ...profile, dateFontSize: e.target.value })}
placeholder="0.65rem"
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.dateFontWeight || "400"}
onChange={(e) => setProfile({ ...profile, dateFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
</select>
</div>
</div>
</div>
{/* Tasks */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.taskFont}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="color"
value={profile.taskColor || "#333333"}
onChange={(e) => setProfile({ ...profile, taskColor: e.target.value })}
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
/>
<select
value={isCustomFont(profile.taskFontFamily || "") || profile.taskFontFamily === "__custom__" ? "__custom__" : (profile.taskFontFamily || "Inter")}
onChange={(e) => setProfile({ ...profile, taskFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value, timeTaskFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{(isCustomFont(profile.taskFontFamily || "") || profile.taskFontFamily === "__custom__") && (
<input
type="text"
value={profile.taskFontFamily === "__custom__" ? "" : (profile.taskFontFamily || "")}
onChange={(e) => setProfile({ ...profile, taskFontFamily: e.target.value || "__custom__", timeTaskFontFamily: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.taskFontSize || "0.9rem"}
onChange={(e) => setProfile({ ...profile, taskFontSize: e.target.value, timeTaskFontSize: e.target.value })}
placeholder="0.9rem"
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.taskFontWeight || "400"}
onChange={(e) => setProfile({ ...profile, taskFontWeight: e.target.value, timeTaskFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
</select>
</div>
</div>
</div>
{/* Calendar Event Font */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Calendar Event Font
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<select
value={isCustomFont(profile.eventFontFamily || "") || profile.eventFontFamily === "__custom__" ? "__custom__" : (profile.eventFontFamily || "Inter")}
onChange={(e) => setProfile({ ...profile, eventFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
{(isCustomFont(profile.eventFontFamily || "") || profile.eventFontFamily === "__custom__") && (
<input
type="text"
value={profile.eventFontFamily === "__custom__" ? "" : (profile.eventFontFamily || "")}
onChange={(e) => setProfile({ ...profile, eventFontFamily: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.eventFontSize || "0.85rem"}
onChange={(e) => setProfile({ ...profile, eventFontSize: e.target.value })}
placeholder="0.85rem"
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.eventFontWeight || "400"}
onChange={(e) => setProfile({ ...profile, eventFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
</select>
</div>
</div>
</div>
{/* Goal Font */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Goal Font
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<select
value={isCustomFont(profile.goalFontFamily || "") || profile.goalFontFamily === "__custom__" ? "__custom__" : (profile.goalFontFamily || "Inter")}
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
{(isCustomFont(profile.goalFontFamily || "") || profile.goalFontFamily === "__custom__") && (
<input
type="text"
value={profile.goalFontFamily === "__custom__" ? "" : (profile.goalFontFamily || "")}
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.goalFontSize || "1rem"}
onChange={(e) => setProfile({ ...profile, goalFontSize: e.target.value })}
placeholder="1rem"
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.goalFontWeight || "400"}
onChange={(e) => setProfile({ ...profile, goalFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
</select>
</div>
</div>
</div>
{/* Calendar Week Font */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Calendar Week (KW)
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="color"
value={profile.cwColor || "#333333"}
onChange={(e) => setProfile({ ...profile, cwColor: e.target.value })}
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
/>
<select
value={isCustomFont(profile.cwFontFamily || "") || profile.cwFontFamily === "__custom__" ? "__custom__" : (profile.cwFontFamily || "Inter")}
onChange={(e) => setProfile({ ...profile, cwFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{(isCustomFont(profile.cwFontFamily || "") || profile.cwFontFamily === "__custom__") && (
<input
type="text"
value={profile.cwFontFamily === "__custom__" ? "" : (profile.cwFontFamily || "")}
onChange={(e) => setProfile({ ...profile, cwFontFamily: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.cwFontSize || "1.125rem"}
onChange={(e) => setProfile({ ...profile, cwFontSize: e.target.value })}
placeholder="1.125rem"
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.cwFontWeight || "700"}
onChange={(e) => setProfile({ ...profile, cwFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
<option value="900">{t.weightBlack}</option>
</select>
</div>
</div>
</div>
{/* Year Font */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Year
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<input
type="color"
value={profile.yearColor || "#333333"}
onChange={(e) => setProfile({ ...profile, yearColor: e.target.value })}
style={{ width: "28px", height: "28px", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", cursor: "pointer", padding: 0, flexShrink: 0 }}
/>
<select
value={isCustomFont(profile.yearFontFamily || "") || profile.yearFontFamily === "__custom__" ? "__custom__" : (profile.yearFontFamily || "Inter")}
onChange={(e) => setProfile({ ...profile, yearFontFamily: e.target.value === "__custom__" ? "__custom__" : e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
{AVAILABLE_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{(isCustomFont(profile.yearFontFamily || "") || profile.yearFontFamily === "__custom__") && (
<input
type="text"
value={profile.yearFontFamily === "__custom__" ? "" : (profile.yearFontFamily || "")}
onChange={(e) => setProfile({ ...profile, yearFontFamily: e.target.value || "__custom__" })}
placeholder={t.fontPlaceholder}
className="weekly-input"
style={{ width: "100%", padding: "8px", fontSize: "0.85rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
)}
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={profile.yearFontSize || "1.125rem"}
onChange={(e) => setProfile({ ...profile, yearFontSize: e.target.value })}
placeholder="1.125rem"
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
/>
<select
value={profile.yearFontWeight || "700"}
onChange={(e) => setProfile({ ...profile, yearFontWeight: e.target.value })}
className="weekly-input"
style={{ flex: 1, padding: "8px", fontSize: "0.9rem", border: "1px solid var(--weekly-settings-input-border)", borderRadius: "4px", background: "var(--weekly-settings-input-bg)", color: "var(--weekly-settings-text)" }}
>
<option value="300">{t.weightLight}</option>
<option value="400">{t.weightNormal}</option>
<option value="500">{t.weightMedium}</option>
<option value="600">{t.weightSemi}</option>
<option value="700">{t.weightBold}</option>
<option value="900">{t.weightBlack}</option>
</select>
</div>
</div>
</div>
</div>
{/* Element Colors */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Element Colors
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "12px",
}}
>
<div>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--weekly-settings-label)",
marginBottom: "4px",
}}
>
Today Highlight
</label>
<input
type="color"
value={profile.todayHighlightColor || "#f0fafa"}
onChange={(e) =>
setProfile({
...profile,
todayHighlightColor: e.target.value,
})
}
style={{
width: "100%",
height: "30px",
cursor: "pointer",
border: "none",
background: "transparent",
}}
/>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--weekly-settings-label)",
marginBottom: "4px",
}}
>
Past Days
</label>
<input
type="color"
value={profile.pastDayColor || "#a6a6a7"}
onChange={(e) =>
setProfile({ ...profile, pastDayColor: e.target.value })
}
style={{
width: "100%",
height: "30px",
cursor: "pointer",
border: "none",
background: "transparent",
}}
/>
</div>
</div>
</div>
{/* Weekend Colors */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "12px",
borderRadius: "8px",
marginBottom: "12px",
}}
>
<label
style={{
display: "block",
fontSize: "0.85rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Weekend Highlight Colors
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "12px",
}}
>
<div>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--weekly-settings-label)",
marginBottom: "4px",
}}
>
Saturday
</label>
<input
type="color"
value={profile.weekendColorSat || "#666666"}
onChange={(e) =>
setProfile({
...profile,
weekendColorSat: e.target.value,
})
}
style={{
width: "100%",
height: "30px",
cursor: "pointer",
border: "none",
background: "transparent",
}}
/>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--weekly-settings-label)",
marginBottom: "4px",
}}
>
Sunday
</label>
<input
type="color"
value={profile.weekendColorSun || "#dc2626"}
onChange={(e) =>
setProfile({
...profile,
weekendColorSun: e.target.value,
})
}
style={{
width: "100%",
height: "30px",
cursor: "pointer",
border: "none",
background: "transparent",
}}
/>
</div>
</div>
</div>
<div
style={{
marginTop: "auto",
display: "flex",
justifyContent: "flex-start",
}}
>
<button
type="button"
onClick={() =>
handleUpdateProfile({ preventDefault: () => { } } as any)
}
className="weekly-btn-primary"
style={{ padding: "8px 16px" }}
>
{t.saveChanges}
</button>
{accountMsg && (
<span
style={{
marginLeft: "12px",
fontSize: "0.9rem",
color: accountMsg.includes("success")
? "#059669"
: "#dc2626",
alignSelf: "center",
}}
>
{accountMsg}
</span>
)}
</div>
</div>
) : activeTab === "motivation" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "24px" }}
>
{/* Replaced Goal of the Week settings block */}
{/* "Do This Now" Toggle */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "8px",
}}
>
<input
type="checkbox"
id="showNextTaskMotivation"
checked={showNextTask}
onChange={(e) => {
const newVal = e.target.checked;
setShowNextTask(newVal);
}}
style={{ width: "20px", height: "20px", cursor: "pointer" }}
/>
<label
htmlFor="showNextTaskMotivation"
style={{
fontSize: "1rem",
fontWeight: 500,
color: "var(--weekly-settings-title)",
cursor: "pointer",
}}
>
{t.showDoThisNow}
</label>
</div>
{/* Focus Timer Settings moved here */}
<div style={{ display: "flex", gap: "16px" }}>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontSize: "1rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
}}
>
{t.focusTimer}
</label>
<input
type="number"
min="1"
max="120"
value={profile.focusTimerDuration || 25}
onChange={(e) =>
setProfile({
...profile,
focusTimerDuration: parseInt(e.target.value) || 25,
})
}
className="weekly-input"
style={{
width: "100%",
padding: "12px",
border: "1px solid var(--weekly-border)",
borderRadius: "6px",
fontSize: "1rem",
}}
/>
</div>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontSize: "1rem",
fontWeight: 600,
color: "var(--weekly-settings-label)",
}}
>
{t.focusBreak}
</label>
<input
type="number"
min="1"
max="60"
value={profile.focusBreakDuration || 5}
onChange={(e) =>
setProfile({
...profile,
focusBreakDuration: parseInt(e.target.value) || 5,
})
}
className="weekly-input"
style={{
width: "100%",
padding: "12px",
border: "1px solid var(--weekly-border)",
borderRadius: "6px",
fontSize: "1rem",
}}
/>
</div>
</div>
{/* Goal Scope Redesign */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "20px",
borderRadius: "12px",
border: "1px solid var(--weekly-border)",
}}
>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
marginBottom: "16px",
color: "var(--weekly-settings-title)",
}}
>
{t.goalScopeTitle}
</h3>
<div
style={{
display: "flex",
background: "var(--weekly-bg)",
padding: "4px",
borderRadius: "8px",
border: "1px solid var(--weekly-border)",
}}
>
<button
type="button"
onClick={() =>
setProfile((p) => ({ ...p, goalScope: "week" }))
}
style={{
flex: 1,
padding: "10px",
borderRadius: "6px",
border: profile.goalScope === "week" ? "2px solid var(--weekly-teal)" : "none",
background:
profile.goalScope === "week"
? "var(--weekly-settings-toggle-active-bg)"
: "transparent",
color:
profile.goalScope === "week"
? "var(--weekly-settings-toggle-active-text)"
: "var(--weekly-settings-text)",
fontWeight: 700,
boxShadow: profile.goalScope === "week" ? "0 2px 4px rgba(0,0,154,0.1)" : "none",
cursor: "pointer",
transition: "all 0.2s",
}}
>
{t.goalScopeWeek}
</button>
<button
type="button"
onClick={() =>
setProfile((p) => ({ ...p, goalScope: "day" }))
}
style={{
flex: 1,
padding: "10px",
borderRadius: "6px",
border: profile.goalScope === "day" ? "2px solid var(--weekly-teal)" : "none",
background:
profile.goalScope === "day"
? "var(--weekly-settings-toggle-active-bg)"
: "transparent",
color:
profile.goalScope === "day"
? "var(--weekly-settings-toggle-active-text)"
: "var(--weekly-settings-text)",
fontWeight: 700,
boxShadow: profile.goalScope === "day" ? "0 2px 4px rgba(0,154,154,0.1)" : "none",
cursor: "pointer",
transition: "all 0.2s",
}}
>
{t.goalScopeDay}
</button>
</div>
</div>
{/* Fallback Section */}
<div
style={{
background: "var(--weekly-settings-item-bg)",
padding: "20px",
borderRadius: "12px",
border: "1px solid var(--weekly-border)",
}}
>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
marginBottom: "8px",
color: "var(--weekly-settings-title)",
}}
>
{t.goalFallbackTitle}
</h3>
<div style={{ marginBottom: "12px" }}>
<label
style={{
display: "block",
fontSize: "0.85rem",
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
{t.goalFallback}
</label>
<select
value={profile.goalFallbackType || "quote"}
onChange={(e) =>
setProfile((p) => ({
...p,
goalFallbackType: e.target.value as any,
}))
}
className="weekly-input"
style={{
width: "100%",
padding: "12px",
borderRadius: "6px",
border: "1px solid var(--weekly-border)",
background: "var(--weekly-bg)",
fontSize: "1rem",
}}
>
<option value="quote">
{t.motivationalQuote}
</option>
<option value="next_todo">{t.nextTodo}</option>
<option value="default">{t.defaultText}</option>
</select>
</div>
{(!profile.goalFallbackType || profile.goalFallbackType === "quote") && (
<div style={{ marginTop: "12px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
{t.apiDataSources}
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{(profile.quoteSourceUrls || [profile.quoteSourceUrl || ""]).map((url: string, idx: number) => (
<div key={idx} style={{ display: "flex", gap: "8px" }}>
<input
type="text"
value={url}
onChange={(e) => {
const newUrls = [...(profile.quoteSourceUrls || [profile.quoteSourceUrl || ""])];
newUrls[idx] = e.target.value;
setProfile((p) => ({ ...p, quoteSourceUrls: newUrls }));
}}
className="weekly-input"
placeholder="https://..."
style={{
flex: 1,
padding: "10px",
fontSize: "0.95rem",
borderRadius: "6px",
border: "1px solid var(--weekly-border)",
background: "var(--weekly-bg)",
}}
/>
<button
type="button"
onClick={() => {
const newUrls = (profile.quoteSourceUrls || [profile.quoteSourceUrl || ""]).filter((_val: string, i: number) => i !== idx);
setProfile((p) => ({ ...p, quoteSourceUrls: newUrls }));
}}
style={{
padding: "8px",
background: "#fee2e2",
color: "#ef4444",
border: "none",
borderRadius: "6px",
cursor: "pointer"
}}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
type="button"
onClick={() => {
const newUrls = [...(profile.quoteSourceUrls || [profile.quoteSourceUrl || ""]), ""];
setProfile((p) => ({ ...p, quoteSourceUrls: newUrls }));
}}
style={{
alignSelf: "flex-start",
marginTop: "4px",
padding: "6px 12px",
fontSize: "0.85rem",
background: "var(--weekly-teal)",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "4px"
}}
>
<Plus size={14} /> {t.addSource}
</button>
</div>
<p style={{ fontSize: "0.75rem", color: "var(--weekly-text-light)", marginTop: "4px" }}>
{t.urlFormatHelp}
</p>
<p style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", marginTop: "8px", fontStyle: "italic" }}>
{t.quoteFallbackDesc}
</p>
<div style={{ marginTop: "12px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
marginBottom: "6px",
color: "var(--weekly-settings-label)",
}}
>
{t.quoteLanguages}
</label>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
{[
{ code: "en", label: "English" },
{ code: "de", label: "Deutsch" },
{ code: "fr", label: "Français" },
{ code: "es", label: "Español" },
{ code: "it", label: "Italiano" },
].map((lang) => {
const selected = (profile.quoteLanguages || ["en", "de"]).includes(lang.code);
return (
<label
key={lang.code}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
fontSize: "0.85rem",
cursor: "pointer",
padding: "4px 10px",
borderRadius: "6px",
border: selected ? "1px solid var(--weekly-teal)" : "1px solid var(--weekly-border)",
background: selected ? "var(--weekly-teal)" : "transparent",
color: selected ? "white" : "var(--weekly-text)",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={selected}
onChange={() => {
const current = profile.quoteLanguages || ["en", "de"];
const updated = selected
? current.filter((c: string) => c !== lang.code)
: [...current, lang.code];
if (updated.length > 0) {
setProfile((p) => ({ ...p, quoteLanguages: updated }));
}
}}
style={{ display: "none" }}
/>
{lang.label}
</label>
);
})}
</div>
<p style={{ fontSize: "0.75rem", color: "var(--weekly-text-light)", marginTop: "4px" }}>
{t.quoteLanguagesDesc}
</p>
</div>
</div>
)}
{profile.goalFallbackType === "default" && (
<div style={{ marginTop: "12px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
{t.defaultText}
</label>
<input
type="text"
value={profile.goalDefaultSentence || ""}
onChange={(e) =>
setProfile((p) => ({
...p,
goalDefaultSentence: e.target.value,
}))
}
className="weekly-input"
style={{
width: "100%",
padding: "12px",
borderRadius: "6px",
border: "1px solid var(--weekly-border)",
}}
placeholder={t.defaultGoalPlaceholder}
/>
</div>
)}
<div
style={{
marginTop: "auto",
paddingTop: "24px",
display: "flex",
justifyContent: "flex-start",
}}
>
<button
type="button"
onClick={() =>
handleUpdateProfile({ preventDefault: () => { } } as any)
}
className="weekly-btn-primary"
style={{
padding: "12px 24px",
fontWeight: 600,
fontSize: "1rem",
}}
>
{t.saveChanges}
</button>
{accountMsg && (
<span
style={{
marginLeft: "12px",
fontSize: "0.9rem",
color: accountMsg.includes("success")
? "#059669"
: "#dc2626",
alignSelf: "center",
}}
>
{accountMsg}
</span>
)}
</div>
</div>
</div>
) : activeTab === "about" ? (
<div
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
>
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<h3
style={{
fontSize: "1.2rem",
fontWeight: 700,
marginBottom: "8px",
color: "var(--weekly-settings-title)",
}}
>
My Weekly To-Do List
</h3>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-settings-label)",
}}
>
Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"}
</p>
</div>
</div>
) : (
/* Account Tab */
<div
style={{ display: "flex", flexDirection: "column", gap: "20px" }}
>
<form
onSubmit={handleUpdateProfile}
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.name}
</label>
<input
type="text"
value={profile.name}
onChange={(e) =>
setProfile({ ...profile, name: e.target.value })
}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
</div>
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.email}
</label>
<input
type="email"
value={profile.email}
disabled
style={{
width: "100%",
padding: "8px",
border: "1px solid #eee",
borderRadius: "4px",
background: "#f5f5f5",
color: "#555",
}}
/>
</div>
{profile.id && (
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.accountId}
</label>
<input
type="text"
value={profile.id}
readOnly
onClick={(e) => (e.target as HTMLInputElement).select()}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #eee",
borderRadius: "4px",
background: "#f5f5f5",
color: "#555",
fontSize: "0.85rem",
fontFamily: "monospace",
cursor: "text",
}}
/>
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
{t.accountIdDesc}
</span>
</div>
)}
{profile.accountNumber && (
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.accountNumberLabel}
</label>
<input
type="text"
value={`#${profile.accountNumber}`}
readOnly
onClick={(e) => (e.target as HTMLInputElement).select()}
className="weekly-input"
style={{
width: "100%",
padding: "8px",
border: "1px solid #eee",
borderRadius: "4px",
background: "#f5f5f5",
color: "#555",
fontSize: "0.85rem",
fontFamily: "monospace",
cursor: "text",
}}
/>
<span style={{ fontSize: "0.75rem", color: "var(--weekly-settings-label)", opacity: 0.7 }}>
{t.accountNumberDesc}
</span>
</div>
)}
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.timezone}
</label>
<select
value={profile.timezone}
onChange={(e) =>
setProfile({ ...profile, timezone: e.target.value })
}
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
>
<option value="UTC">UTC</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="America/New_York">America/New_York</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
<div
style={{
borderTop: "1px solid #eee",
paddingTop: "12px",
marginTop: "8px",
}}
>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.changePassword}
</label>
<input
type="password"
placeholder={t.newPassword}
value={passwords.new}
onChange={(e) =>
setPasswords({ ...passwords, new: e.target.value })
}
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
marginBottom: "8px",
}}
/>
<input
type="password"
placeholder={t.confirmPassword}
value={passwords.confirm}
onChange={(e) =>
setPasswords({ ...passwords, confirm: e.target.value })
}
style={{
width: "100%",
padding: "8px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
<small
className="help-text"
style={{
fontSize: "0.75rem",
color: "#666",
marginTop: "4px",
display: "block",
}}
>
{translations[profile.language || "en"]?.newPasswordDesc ||
translations["en"].newPasswordDesc}
</small>
</div>
<div
style={{
marginTop: "16px",
display: "flex",
alignItems: "center",
gap: "12px",
}}
>
<button
type="submit"
className="weekly-btn-primary"
style={{ padding: "10px 20px" }}
>
{t.saveChanges}
</button>
{accountMsg && (
<span
style={{
fontSize: "0.9rem",
color: accountMsg.toLowerCase().includes("success")
? "#059669"
: "#dc2626",
fontWeight: 600,
}}
>
{accountMsg}
</span>
)}
</div>
</form>
{/* Data Export Section */}
<div
style={{
marginTop: "20px",
paddingTop: "20px",
borderTop: "1px solid var(--weekly-border)",
}}
>
<h4
style={{
marginBottom: "10px",
fontSize: "1rem",
fontWeight: 600,
color: "var(--weekly-settings-title)",
}}
>
{profile.language === "de" ? "Datenexport" : "Data Export"}
</h4>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-settings-label)",
marginBottom: "15px",
}}
>
{profile.language === "de"
? "Laden Sie eine CSV-Datei Ihrer erledigten Aufgaben herunter."
: "Download a CSV file of your completed tasks."}
</p>
<div
style={{ display: "flex", gap: "10px", marginBottom: "15px" }}
>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
fontSize: "0.8rem",
fontWeight: 600,
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
Start
</label>
<input
type="date"
value={exportStartDate}
onChange={(e) => setExportStartDate(e.target.value)}
className="weekly-input"
style={{
width: "100%",
padding: "6px",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
/>
</div>
<div style={{ flex: 1 }}>
<label
style={{
display: "block",
fontSize: "0.8rem",
fontWeight: 600,
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
End
</label>
<input
type="date"
value={exportEndDate}
onChange={(e) => setExportEndDate(e.target.value)}
className="weekly-input"
style={{
width: "100%",
padding: "6px",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
}}
/>
</div>
</div>
<a
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}`}
target="_blank"
className="weekly-auth-button w-full justify-center"
style={{
display: "inline-flex",
textDecoration: "none",
background: "var(--weekly-settings-item-bg)",
border: "1px solid var(--weekly-settings-input-border)",
color: "var(--weekly-settings-text)",
padding: "10px",
borderRadius: "4px",
fontWeight: 500,
transition: "background-color 0.2s",
}}
>
{profile.language === "de"
? "Erledigte Aufgaben exportieren (CSV)"
: "Export Completed Tasks (CSV)"}
</a>
</div>
{/* Backup & Restore Section */}
<div
style={{
marginTop: "20px",
paddingTop: "20px",
borderTop: "1px solid var(--weekly-border)",
}}
>
<h4
style={{
marginBottom: "10px",
fontSize: "1rem",
fontWeight: 600,
color: "var(--weekly-settings-title)",
}}
>
{t.backupRestore}
</h4>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-settings-label)",
marginBottom: "15px",
}}
>
{t.backupRestoreDesc}
</p>
{/* Export All Data */}
<button
onClick={handleExportAllData}
disabled={isExportingAll}
className="weekly-auth-button w-full justify-center"
style={{
display: "inline-flex",
width: "100%",
padding: "10px",
background: "var(--weekly-settings-item-bg)",
border: "1px solid var(--weekly-settings-input-border)",
color: "var(--weekly-settings-text)",
borderRadius: "4px",
fontWeight: 500,
cursor: isExportingAll ? "wait" : "pointer",
transition: "background-color 0.2s",
marginBottom: "15px",
opacity: isExportingAll ? 0.7 : 1,
}}
>
{isExportingAll ? t.exporting : t.exportAllData}
</button>
{/* Import Section */}
<div
style={{
padding: "12px",
border: "1px solid var(--weekly-border)",
borderRadius: "6px",
background: "var(--weekly-settings-item-bg)",
}}
>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "10px",
color: "var(--weekly-settings-text)",
}}
>
{t.importData}
</label>
{/* Import Mode Toggle */}
<div style={{ marginBottom: "10px" }}>
<label
style={{
display: "block",
fontSize: "0.8rem",
fontWeight: 600,
marginBottom: "6px",
color: "var(--weekly-settings-label)",
}}
>
{t.importMode}
</label>
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={() => setImportMode("merge")}
style={{
flex: 1,
padding: "8px",
borderRadius: "4px",
border: importMode === "merge"
? "2px solid var(--weekly-teal)"
: "1px solid var(--weekly-settings-input-border)",
background: importMode === "merge"
? "rgba(20, 184, 166, 0.1)"
: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: importMode === "merge" ? 600 : 400,
transition: "all 0.2s",
}}
>
<div>{t.importModeMerge}</div>
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
{t.importModeMergeDesc}
</div>
</button>
<button
onClick={() => setImportMode("replace")}
style={{
flex: 1,
padding: "8px",
borderRadius: "4px",
border: importMode === "replace"
? "2px solid #ef4444"
: "1px solid var(--weekly-settings-input-border)",
background: importMode === "replace"
? "rgba(239, 68, 68, 0.1)"
: "var(--weekly-settings-input-bg)",
color: importMode === "replace" ? "#ef4444" : "var(--weekly-settings-text)",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: importMode === "replace" ? 600 : 400,
transition: "all 0.2s",
}}
>
<div>{t.importModeReplace}</div>
<div style={{ fontSize: "0.75rem", opacity: 0.7, marginTop: "2px" }}>
{t.importModeReplaceDesc}
</div>
</button>
</div>
</div>
{importMode === "replace" && (
<div
style={{
padding: "8px 10px",
marginBottom: "10px",
borderRadius: "4px",
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.3)",
fontSize: "0.8rem",
color: "#ef4444",
fontWeight: 500,
}}
>
{t.importReplaceWarning}
</div>
)}
{/* File Input */}
<input
id="import-file-input"
type="file"
accept=".json"
onChange={(e) => {
setImportFile(e.target.files?.[0] || null);
setImportMsg("");
}}
className="weekly-input"
style={{
width: "100%",
padding: "6px",
border: "1px solid var(--weekly-settings-input-border)",
borderRadius: "4px",
background: "var(--weekly-settings-input-bg)",
color: "var(--weekly-settings-text)",
marginBottom: "10px",
fontSize: "0.85rem",
}}
/>
<button
onClick={handleImportData}
disabled={!importFile || isImporting}
className="weekly-auth-button w-full justify-center"
style={{
display: "inline-flex",
width: "100%",
padding: "10px",
background: !importFile || isImporting
? "var(--weekly-settings-input-bg)"
: "var(--weekly-teal)",
border: "1px solid var(--weekly-settings-input-border)",
color: !importFile || isImporting
? "var(--weekly-settings-label)"
: "#fff",
borderRadius: "4px",
fontWeight: 600,
cursor: !importFile || isImporting ? "not-allowed" : "pointer",
transition: "background-color 0.2s",
opacity: !importFile || isImporting ? 0.6 : 1,
}}
>
{isImporting ? t.importing : t.importButton}
</button>
{importMsg && (
<p
style={{
marginTop: "10px",
fontSize: "0.85rem",
fontWeight: 600,
color: importMsg.startsWith("✓")
? "#059669"
: "#dc2626",
}}
>
{importMsg}
</p>
)}
</div>
</div>
<div
className="account-danger-zone"
style={{
marginTop: "20px",
paddingTop: "20px",
borderTop: "1px solid var(--weekly-border)",
}}
>
{/* Sign Out Button - accessible on mobile */}
<button
onClick={() => signOut()}
className="weekly-auth-button w-full justify-center"
style={{
marginBottom: "18px",
padding: "10px",
border: "1px solid var(--weekly-settings-input-border)",
background: "var(--weekly-settings-item-bg)",
color: "var(--weekly-settings-text)",
borderRadius: "4px",
cursor: "pointer",
fontWeight: 500,
transition: "background-color 0.2s",
display: "flex",
alignItems: "center",
gap: "8px",
width: "100%",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
{t.signOut}
</button>
<h3
style={{
fontSize: "1rem",
fontWeight: 600,
marginBottom: "10px",
color: "var(--weekly-settings-title)",
}}
>
{t.dataPrivacy}
</h3>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
}}
>
<button
onClick={handleDownloadData}
className="weekly-auth-button w-full justify-center"
style={{
padding: "10px",
border: "1px solid var(--weekly-settings-input-border)",
background: "var(--weekly-settings-item-bg)",
color: "var(--weekly-settings-text)",
borderRadius: "4px",
cursor: "pointer",
fontWeight: 500,
transition: "background-color 0.2s",
}}
>
{t.downloadData}
</button>
<button
onClick={handleDeleteAccount}
className="weekly-auth-button w-full justify-center"
style={{
padding: "10px",
border: "1px solid #ef4444",
background: "rgba(239, 68, 68, 0.05)",
color: "#ef4444",
borderRadius: "4px",
cursor: "pointer",
fontWeight: 600,
transition: "background-color 0.2s",
}}
onMouseOver={(e) =>
(e.currentTarget.style.background =
"rgba(239, 68, 68, 0.1)")
}
onMouseOut={(e) =>
(e.currentTarget.style.background =
"rgba(239, 68, 68, 0.05)")
}
>
{t.deleteAccount}
</button>
</div>
</div>
</div>
)}
{/* Apple Calendar (CalDAV) Connection Modal */}
{showAppleCalendarModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
<h3 className="text-xl font-bold mb-4">
Connect Apple Calendar
</h3>
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
<p style={{ marginBottom: "6px" }}>
Connect your iCloud Calendar events via CalDAV.
</p>
<p style={{ fontSize: "0.8rem", opacity: 0.85 }}>
This requires an{" "}
<a
href="https://support.apple.com/en-us/102654"
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "underline" }}
>
app-specific password
</a>{" "}
generated at appleid.apple.com.
</p>
</div>
{appleCalError && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
{appleCalError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Apple ID (Email)
</label>
<input
type="email"
value={appleCalEmail}
onChange={(e) => setAppleCalEmail(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="name@icloud.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
App-Specific Password
</label>
<input
type="password"
value={appleCalPassword}
onChange={(e) => setAppleCalPassword(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="xxxx-xxxx-xxxx-xxxx"
onKeyDown={(e) =>
e.key === "Enter" && submitAppleCalendarConnection()
}
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowAppleCalendarModal(false);
setAppleCalError("");
}}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
disabled={isConnectingAppleCal}
>
Cancel
</button>
<button
onClick={submitAppleCalendarConnection}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
disabled={isConnectingAppleCal}
>
{isConnectingAppleCal ? (
<>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
Connecting...
</>
) : (
"Connect"
)}
</button>
</div>
</div>
</div>
)}
{/* Synology Calendar Connection Modal */}
{showSynologyCalendarModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
<h3 className="text-xl font-bold mb-4">
Connect Synology Calendar
</h3>
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
<p style={{ marginBottom: "6px" }}>
Connect your Synology NAS Calendar events.
</p>
<p style={{ fontSize: "0.8rem", opacity: 0.85 }}>
Make sure Synology Calendar is installed and the CalDAV URL is reachable over HTTPS.
</p>
</div>
{synologyCalError && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
{synologyCalError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Server URL (CalDAV)
</label>
<input
type="url"
value={synologyCalServerUrl}
onChange={(e) => setSynologyCalServerUrl(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="https://your-synology-nas:5001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={synologyCalUsername}
onChange={(e) => setSynologyCalUsername(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
value={synologyCalPassword}
onChange={(e) => setSynologyCalPassword(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
onKeyDown={(e) =>
e.key === "Enter" && submitSynologyCalendarConnection()
}
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowSynologyCalendarModal(false);
setSynologyCalError("");
}}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
disabled={isConnectingSynologyCal}
>
Cancel
</button>
<button
onClick={submitSynologyCalendarConnection}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
disabled={isConnectingSynologyCal}
>
{isConnectingSynologyCal ? (
<>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
Connecting...
</>
) : (
"Connect"
)}
</button>
</div>
</div>
</div>
)}
</div>
</div >
</>
);
}