My-Weekly-ToDo-List/src/components/WeeklyView.tsx

10799 lines
551 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 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,
} 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";
// Cookie helpers for per-device settings
const DEVICE_SETTINGS_KEYS = ["viewDays", "cellDuration", "startHour", "endHour"];
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
function setCookie(name: string, value: string, days: number = 365) {
if (typeof document === "undefined") return;
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
}
export type ViewStyle = "simple" | "calendar" | "list" | "grid";
export interface Task {
id: string;
title: string;
completed: boolean;
dayOfWeek?: number | null;
scheduledDate?: string | null;
markdownContent?: string | null;
createdAt?: Date;
updatedAt: Date;
order: number;
completedAt?: Date | null;
externalId?: string | null;
externalProvider?: string | null;
lastSyncedAt?: Date | null;
syncStatus?: string | null;
subTasks?: Task[];
parentId?: string | null;
isRolling?: boolean;
isRecurring?: boolean;
somedayListId?: string | null;
somedaySlotIndex?: number | null;
repeatPattern?: string | null;
repeatEndDate?: string | null;
repeatStartDate?: string | null;
originalRecurringId?: string | null;
baseRecurringTask?: Task | null;
recurringExceptions?: RecurringTaskException[];
startTime?: string | null;
duration?: number | null;
parentTaskId?: string | null;
userId: string;
recurrenceInterval?: number | null;
recurrenceUnit?: string | null;
recurrenceTime?: string | null;
recurrenceEndDate?: Date | null;
externalListId?: string | null;
}
interface CalendarEvent {
id: string;
title: string;
startTime: string;
endTime: string;
source: "google" | "apple" | "outlook";
calendarId?: string;
calendarTitle?: string;
calendarColor?: string;
editable?: boolean;
}
interface SomedayList {
id: string;
title: string;
tasks: Task[];
externalProvider?: string | null;
externalId?: string | null;
externalListId?: string | null;
}
// Time grid configuration options
type CellDuration = 15 | 30 | 60 | 120;
const DEFAULT_SOMEDAY_SLOT_COUNT = 5;
const getSomedaySlotCount = (tasks: Task[]) => {
const maxIdx = tasks.reduce((max, t) => {
if (t.somedaySlotIndex !== null && t.somedaySlotIndex !== undefined) {
return Math.max(max, t.somedaySlotIndex);
}
return max;
}, -1);
// Add 1 extra slot if more than 4 tasks exist, or at least 5 slots total.
// "add 5 rows and then when 4 are taken add another row"
// Let's ensure there's always at least one empty slot at the bottom.
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
};
// Font options
const AVAILABLE_FONTS = [
{ name: "Default (Inter)", value: "Inter" },
{ name: "Roboto", value: "Roboto" },
{ name: "Open Sans", value: "Open Sans" },
{ name: "Lato", value: "Lato" },
{ name: "Montserrat", value: "Montserrat" },
{ name: "Oswald", value: "Oswald" },
{ name: "Raleway", value: "Raleway" },
{ name: "Playfair Display", value: "Playfair Display" },
{ name: "Merriweather", value: "Merriweather" },
{ name: "Nunito", value: "Nunito" },
{ name: "Dancing Script", value: "Dancing Script" },
{ name: "Pacifico", value: "Pacifico" },
];
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",
account: "Account",
runningList: "Running List (Auto-roll tasks to today)",
protectEventTimes: "Protect Event Times",
showTimeGrid: "Show Time Grid",
timeSlotDuration: "Time Slot Duration",
viewStyle: "View Style",
simpleView: "Simple",
calendarView: "Calendar",
listView: "List",
language: "Language",
dateFormat: "Date Format",
timeFormat: "Time Format",
saveChanges: "Save Changes",
connectedCalendars: "Connected Calendars",
connectMore: "Connect More",
connectGoogle: "Connect Google Calendar",
connectApple: "Connect Apple Calendar",
noCalendars: "No calendars connected yet.",
dataPrivacy: "Data & Privacy",
downloadData: "Download My Data",
deleteAccount: "Delete Account",
name: "Name",
email: "Email",
timezone: "Timezone",
changePassword: "Change Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
someday: "SOMEDAY",
lists: "Lists",
loading: "Loading your tasks...",
sycing: "Syncing...",
synced: "Synced",
localization: "Localization",
allDayEvents: "ALL-DAY EVENTS",
syncCalendar: "Sync Calendar",
toggleDarkMode: "Toggle Dark Mode",
signOut: "Sign Out",
startHour: "Start of Day",
endHour: "End of Day",
weekAbbr: "W",
goalOfWeek: "Goal of the Week",
goalScope: "Goal Scope",
goalScopeWeek: "Per Week",
goalScopeDay: "Per Day",
goalFallback: "Goal Fallback Type",
defaultGoal: "Custom Default Goal",
showTaskCheckboxes: "Show Checkboxes on Tasks",
showSomeday: "Show Someday Section",
showAllDay: "Show All-Day Section",
allDayPosition: "All-Day Events Position",
allDayAbove: "Above",
allDayBelow: "Below",
newPasswordDesc: "Leave blank to keep current password.",
dateAlignment: "Date Alignment",
alignmentLeft: "Left",
alignmentCenter: "Center",
alignmentRight: "Right",
alignmentTight: "Tight",
},
de: {
settings: "Einstellungen",
general: "Allgemein",
calendar: "Verbindungen",
account: "Konto",
runningList: "Laufende Liste (Aufgaben automatisch auf heute verschieben)",
protectEventTimes: "Ereigniszeiten schützen",
showTimeGrid: "Zeitplan anzeigen",
timeSlotDuration: "Zeitfensterdauer",
viewStyle: "Ansichtsstil",
simpleView: "Einfach",
calendarView: "Kalender",
listView: "Liste",
notes: "Notizen",
notesSidebar: "Notizen-Seitenleiste",
language: "Sprache",
dateFormat: "Datumsformat",
timeFormat: "Zeitformat",
saveChanges: "Änderungen speichern",
connectedCalendars: "Verbundene Kalender",
connectMore: "Mehr verbinden",
connectGoogle: "Google Kalender verbinden",
connectApple: "Apple Kalender verbinden",
noCalendars: "Keine Kalender verbunden.",
dataPrivacy: "Daten & Datenschutz",
downloadData: "Meine Daten herunterladen",
deleteAccount: "Konto löschen",
name: "Name",
email: "E-Mail",
timezone: "Zeitzone",
changePassword: "Passwort ändern",
newPassword: "Neues Passwort",
confirmPassword: "Passwort bestätigen",
someday: "IRGENDWANN",
lists: "Listen",
loading: "Lade Aufgaben...",
syncing: "Synchronisiere...",
synced: "Synchronisiert",
localization: "Lokalisierung",
allDayEvents: "GANZTÄGIGE EREIGNISSE",
syncCalendar: "Kalender synchronisieren",
toggleDarkMode: "Dunkelmodus umschalten",
signOut: "Abmelden",
startHour: "Tagesbeginn",
endHour: "Tagesende",
weekAbbr: "KW",
goalOfWeek: "Ziel der Woche",
goalScope: "Ziel-Zeitraum",
goalScopeWeek: "Pro Woche",
goalScopeDay: "Pro Tag",
goalFallback: "Ziel-Fallback-Typ",
defaultGoal: "Benutzerdefiniertes Standardziel",
showTaskCheckboxes: "Checkboxen bei Aufgaben anzeigen",
showSomeday: "Irgendwann-Bereich anzeigen",
showAllDay: "Ganztägige Ereignisse anzeigen",
allDayPosition: "Position ganztägiger Ereignisse",
allDayAbove: "Oben",
allDayBelow: "Unten",
newPasswordDesc: "Leer lassen, um das aktuelle Passwort zu behalten.",
dateAlignment: "Datums-Ausrichtung",
alignmentLeft: "Links",
alignmentCenter: "Mitte",
alignmentRight: "Rechts",
alignmentTight: "Eng",
},
};
// Date utilities
function getStartOfWeek(date: Date, startDay: number = 0): Date {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct.
// Wait, let's re-verify:
// Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday).
// Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct.
// Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday).
// Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct.
// What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct.
// Better logic:
// const day = d.getDay();
// const diff = (day < startDay ? 7 : 0) + day - startDay;
// d.setDate(d.getDate() - diff);
//
// Let's stick to a robust one:
const currentDay = d.getDay();
const distance = (currentDay - startDay + 7) % 7;
d.setDate(d.getDate() - distance);
return d;
}
function formatDateHeader(date: Date, locale: string = "en-US"): string {
return date.toLocaleDateString(locale, { day: "numeric", month: "short" }); // e.g. 12. Feb.
}
function getDayName(date: Date, locale: string = "en-US"): string {
return date.toLocaleDateString(locale, { weekday: "long" }).toUpperCase();
}
function isSameDay(d1: Date, d2: Date): boolean {
return d1.toDateString() === d2.toDateString();
}
function formatDateToISO(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatHour(hour: number, format: "short" | "full" = "short", timeFormat: string = "24h"): string {
if (timeFormat === "12h") {
const h = hour % 12 || 12;
const ampm = hour >= 12 ? "PM" : "AM";
return format === "full" ? `${h}:00 ${ampm}` : `${h} ${ampm}`;
}
return format === "full" ? `${hour}:00` : `${hour}`;
}
function getTimeSlots(
cellDuration: CellDuration,
startHour: number,
endHour: number,
): string[] {
const slots: string[] = [];
const slotsPerHour = 60 / cellDuration;
for (let hour = startHour; hour < endHour; hour++) {
for (let slot = 0; slot < slotsPerHour; slot++) {
const minutes = slot * cellDuration;
slots.push(
`${hour.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`,
);
}
}
return slots;
}
function getHourFromSlot(slot: string): number {
return parseInt(slot.split(":")[0], 10);
}
function getWeekNumber(date: Date): number {
const d = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
);
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
// Check if an event is an all-day event
// Defined outside component to avoid stale closure issues in useCallbacks
const isAllDayEvent = (event: CalendarEvent): boolean => {
if (!event.startTime) return false;
// Date-only format (YYYY-MM-DD)
if (!event.startTime.includes("T")) return true;
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
// Check if strictly midnight to midnight in local time
const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0;
// Check if UTC midnight (common for API-converted date strings)
const isUTCMidnight =
start.getUTCHours() === 0 && start.getUTCMinutes() === 0;
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
};
// Helper to invert colors for dark mode
function invertColor(hex: string): string {
if (!hex) return hex;
let color = hex.startsWith("#") ? hex.slice(1) : hex;
if (color.length === 3) {
color = color
.split("")
.map((c) => c + c)
.join("");
}
if (color.length !== 6) return hex;
try {
const r = (255 - parseInt(color.slice(0, 2), 16))
.toString(16)
.padStart(2, "0");
const g = (255 - parseInt(color.slice(2, 4), 16))
.toString(16)
.padStart(2, "0");
const b = (255 - parseInt(color.slice(4, 6), 16))
.toString(16)
.padStart(2, "0");
return `#${r}${g}${b}`;
} catch (e) {
return hex;
}
}
// 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);
d.setDate(d.getDate() - 1);
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 [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [dropTargetListIndex, setDropTargetListIndex] = useState<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 [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importProvider, setImportProvider] = useState<
"google" | "apple" | "outlook" | null
>(null);
const [importLists, setImportLists] = useState<
{ id: string; title: string }[]
>([]);
const [isFetchingLists, setIsFetchingLists] = useState(false);
const [availableTaskLists, setAvailableTaskLists] = useState<{
[key in "google" | "apple" | "outlook"]?: { 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";
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[];
}>({
name: session?.user?.name || "",
email: session?.user?.email || "",
timezone: "UTC",
language: "de",
dateFormat: "yyyy-MM-dd",
timeFormat: "24h",
startHour: 8,
endHour: 18,
autoRolling: false,
protectEventTimes: false,
showTimeGrid: true,
cellDuration: 30,
viewStyle: "simple",
fontSize: "M",
showNextTask: false,
showSomeday: true,
showAllDayEvents: true,
showSchedule: true,
hourLabelFormat: "short",
showSubHourSlots: true,
dayHeaderGap: "0.75em",
allDayPosition: "above",
focusTimerDuration: 25,
focusBreakDuration: 5,
headlineFont: "Oswald",
headlineFontSize: "1.5rem",
headlineFontWeight: "900",
weekdayColor: "#0ea5e9",
dateFontFamily: "Inter",
dateFontSize: "0.65rem",
dateFontWeight: "400",
timeTaskFontFamily: "Inter",
timeTaskFontSize: "0.75rem",
timeTaskFontWeight: "500",
bodyFont: "Inter",
taskFontFamily: "Inter",
taskFontSize: "0.9rem",
taskFontWeight: "400",
eventFontFamily: "Inter",
eventFontSize: "0.85rem",
eventFontWeight: "400",
goalFallbackType: "quote",
goalFontFamily: "Lato",
goalFontSize: "1rem",
goalFontWeight: "500",
goalScope: "week",
dateLayout: "right",
dateAlignment: "center",
weekendColorSat: "#ffc107",
weekendColorSun: "#dc2626",
pastDayColor: "#a6a6a7",
cwFontFamily: "Oswald",
cwFontSize: "1.5rem",
cwFontWeight: "700",
yearFontFamily: "Oswald",
yearFontSize: "1.5rem",
yearFontWeight: "700",
quoteSourceUrl: "https://recite.vercel.app/api/random",
quoteSourceUrls: ["https://recite.vercel.app/api/random"],
});
const [motivationalQuote, setMotivationalQuote] = useState("");
const [showSummary, setShowSummary] = useState(false);
const [isAddingSomedayList, setIsAddingSomedayList] = useState(false);
const [newSomedayListName, setNewSomedayListName] = useState("");
const [selectedSomedayProvider, setSelectedSomedayProvider] = useState<
string | null
>(null);
const [language, setLanguage] = useState("de");
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">(
"idle",
);
const [cellDuration, setCellDuration] = useState<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 [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);
// New UI State
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false);
const [showDatePicker, setShowDatePicker] = useState(false);
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
const [fontSize, setFontSize] = useState<"S" | "M" | "L">("M");
const [headlineFont, setHeadlineFont] = useState("Inter");
const [headlineFontSize, setHeadlineFontSize] = useState("1.25rem");
const [headlineFontWeight, setHeadlineFontWeight] = useState("900");
const [dateFontFamily, setDateFontFamily] = useState("Inter");
const [dateFontSize, setDateFontSize] = useState("0.65rem");
const [dateFontWeight, setDateFontWeight] = useState("400");
const [timeTaskFontFamily, setTimeTaskFontFamily] = useState("Inter");
const [timeTaskFontSize, setTimeTaskFontSize] = useState("0.75rem");
const [timeTaskFontWeight, setTimeTaskFontWeight] = useState("500");
const [bodyFont, setBodyFont] = useState("Inter");
const [taskFontFamily, setTaskFontFamily] = useState("Inter");
const [taskFontSize, setTaskFontSize] = useState("0.9rem");
const [taskFontWeight, setTaskFontWeight] = useState("400");
const [eventFontFamily, setEventFontFamily] = useState("Inter");
const [eventFontSize, setEventFontSize] = useState("0.85rem");
const [eventFontWeight, setEventFontWeight] = useState("400");
const [fontWeight, setFontWeight] = useState("400");
const [weekendColorSat, setWeekendColorSat] = useState("#666666");
const [weekendColorSun, setWeekendColorSun] = useState("#dc2626");
// Load fonts
// Dynamic font loading is handled by the main useGoogleFonts hook call below
// Load ALL available fonts at the top level to ensure they are available
// regardless of whether the settings modal is open or closed, and for
// real-time preview usage.
useGoogleFonts([
...AVAILABLE_FONTS.map((f) => f.value),
"Dancing Script",
"Pacifico",
]);
// Dynamic font loading is handled by useGoogleFonts hook call above
// Calendar Event Modal State
const [calendarEventModal, setCalendarEventModal] = useState<{
isOpen: boolean;
event?: CalendarEvent;
initialDate?: Date;
initialStartTime?: string;
}>({ isOpen: false });
// Dark Mode Persistence & Class Toggle
const [mounted, setMounted] = useState(false);
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);
// 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: currentWeekStart.toISOString(),
timeMax: new Date(
currentWeekStart.getTime() + 7 * 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];
});
}
// Also force-refresh from provider to ensure full sync
fetchCalendarEvents(true);
} catch (error: any) {
console.error("Error saving event:", error);
if (error.name === "AbortError") {
throw new Error("Request timed out. Please try again.");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
};
const handleEventDelete = async (eventId: string, calendarId: string) => {
try {
const res = await fetch(
`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`,
{
method: "DELETE",
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to delete event");
}
// Optimistically remove, then force refresh
setRawCalendarEvents(prev => prev.filter(e => e.id !== eventId));
fetchCalendarEvents(true);
} catch (error) {
console.error("Error deleting event:", error);
throw error;
}
};
const handleRecurrenceSave = async (taskId: string, recurrence: any) => {
try {
const res = await fetch("/api/tasks", {
// Uses PATCH endpoint which handles ID in body
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: taskId,
...recurrence,
}),
});
if (!res.ok) {
throw new Error("Failed to update recurrence");
}
const data = await res.json();
// Update local state and REFRESH all tasks to show virtual instances
setTasks((prev) => prev.map((t) => (t.id === taskId ? data.task : t)));
await fetchTasks();
} catch (error) {
console.error(error);
alert("Failed to save recurrence settings");
}
};
const fetchMotivationalQuote = useCallback(async () => {
if (profile.goalFallbackType !== "quote") return;
const urls = profile.quoteSourceUrls && profile.quoteSourceUrls.length > 0
? profile.quoteSourceUrls
: [profile.quoteSourceUrl || "https://recite.vercel.app/api/random"];
// Strategy: try sources until one works
for (const url of urls) {
try {
const res = await fetch(url);
if (!res.ok) continue;
const data = await res.json();
let quoteText = "";
if (Array.isArray(data) && data.length > 0) {
const item = data[0];
quoteText = item.quote || item.text || item.content || (typeof item === 'string' ? item : "");
if (item.author) quoteText += ` - ${item.author}`;
} else if (data && typeof data === 'object') {
quoteText = data.quote || data.text || data.content || "";
if (data.author) quoteText += ` - ${data.author}`;
} else if (typeof data === 'string') {
quoteText = data;
}
if (quoteText) {
setMotivationalQuote(quoteText);
return; // Success!
}
} catch (error) {
console.error(`Error fetching quote from ${url}:`, error);
}
}
// Final fallback if all failed
setMotivationalQuote("Stay focused and productive.");
}, [profile.goalFallbackType, profile.quoteSourceUrl, profile.quoteSourceUrls]);
// 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);
}
};
useEffect(() => {
const el = somedayGridRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
// Ignore if scrolling horizontally natively (trackpad)
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
// Check if hovering over a vertically scrollable list that isn't at its boundary
let target = e.target as HTMLElement | null;
let canScrollVertically = false;
while (target && target !== el) {
if (target.scrollHeight > target.clientHeight) {
const style = window.getComputedStyle(target);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
const atTop = target.scrollTop <= 0;
const atBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 1;
if (!(e.deltaY < 0 && atTop) && !(e.deltaY > 0 && atBottom)) {
canScrollVertically = true;
break;
}
}
}
target = target.parentElement;
}
if (!canScrollVertically && e.deltaY !== 0) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [showSomeday, somedayExpanded]);
const saveSetting = async (key: string, value: any) => {
// Save per-device settings to cookie as well
if (DEVICE_SETTINGS_KEYS.includes(key)) {
setCookie(`setting_${key}`, String(value));
}
try {
await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
} catch (err) {
console.error(`Failed to save setting ${key}:`, err);
}
};
const handleSettingsChanged = (newSettings: any) => {
setShowTimeGrid(newSettings.showTimeGrid);
setCellDuration(newSettings.cellDuration);
setViewStyle(newSettings.viewStyle);
setLanguage(newSettings.language);
setDateFormat(newSettings.dateFormat);
setTimeFormat(newSettings.timeFormat);
setStartHour(newSettings.startHour);
setEndHour(newSettings.endHour);
setFontSize(newSettings.fontSize);
setShowNextTask(newSettings.showNextTask);
setShowSomeday(newSettings.showSomeday);
setShowAllDay(newSettings.showAllDayEvents);
setShowSchedule(newSettings.showSchedule);
setHeadlineFont(newSettings.headlineFont);
setHeadlineFontSize(newSettings.headlineFontSize);
setHeadlineFontWeight(newSettings.headlineFontWeight);
setDateFontFamily(newSettings.dateFontFamily);
setDateFontSize(newSettings.dateFontSize);
setDateFontWeight(newSettings.dateFontWeight);
setTimeTaskFontFamily(newSettings.timeTaskFontFamily);
setTimeTaskFontSize(newSettings.timeTaskFontSize);
setTimeTaskFontWeight(newSettings.timeTaskFontWeight);
setBodyFont(newSettings.bodyFont);
setTaskFontFamily(newSettings.taskFontFamily);
setTaskFontSize(newSettings.taskFontSize);
setTaskFontWeight(newSettings.taskFontWeight);
if (newSettings.eventFontFamily)
setEventFontFamily(newSettings.eventFontFamily);
if (newSettings.eventFontSize) setEventFontSize(newSettings.eventFontSize);
if (newSettings.eventFontWeight)
setEventFontWeight(newSettings.eventFontWeight);
if (newSettings.fontWeight) setFontWeight(newSettings.fontWeight);
if (newSettings.weekendColorSat)
setWeekendColorSat(newSettings.weekendColorSat);
if (newSettings.weekendColorSun)
setWeekendColorSun(newSettings.weekendColorSun);
setProfile((prev: any) => ({
...prev,
...newSettings,
weekdayColor: newSettings.weekdayColor || prev.weekdayColor,
dateColor: newSettings.dateColor || prev.dateColor,
taskColor: newSettings.taskColor || prev.taskColor,
todayHighlightColor:
newSettings.todayHighlightColor || prev.todayHighlightColor,
eventFontFamily: newSettings.eventFontFamily || prev.eventFontFamily,
eventFontSize: newSettings.eventFontSize || prev.eventFontSize,
eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight,
}));
// Custom start/end hours might affect task placement if we filter strictly
fetchTasks();
};
const fetchUserInfo = async () => {
try {
const res = await fetch("/api/user/profile");
if (res.ok) {
const data = await res.json();
if (data.user) {
setProtectEventTimes(data.user.protectEventTimes || false);
setTimeFormat(data.user.timeFormat || "12h");
setDateFormat(data.user.dateFormat || "MM/dd/yyyy");
setLanguage(data.user.language || "en");
if (data.user.startHour !== undefined)
setStartHour(data.user.startHour);
if (data.user.endHour !== undefined)
setEndHour(data.user.endHour);
if (data.user.viewStyle !== undefined) {
setViewStyle(data.user.viewStyle as ViewStyle);
setShowTimeGrid(data.user.showTimeGrid ?? true);
}
if (data.user.viewDays !== undefined) {
savedViewDaysRef.current = data.user.viewDays;
const width = window.innerWidth;
if (width <= 480) setViewDays(1);
else if (width <= 768) setViewDays(3);
else if (width <= 1024) setViewDays(Math.min(data.user.viewDays, 5));
else setViewDays(data.user.viewDays);
}
if (data.user.cellDuration !== undefined)
setCellDuration(data.user.cellDuration as CellDuration);
// Cookie overrides for per-device settings (always apply, even if DB has no value)
const cookieViewDays = getCookie("setting_viewDays");
if (cookieViewDays) {
const v = Number(cookieViewDays);
savedViewDaysRef.current = v;
const width = window.innerWidth;
if (width <= 480) setViewDays(1);
else if (width <= 768) setViewDays(3);
else if (width <= 1024) setViewDays(Math.min(v, 5));
else setViewDays(v);
}
const cookieCellDuration = getCookie("setting_cellDuration");
if (cookieCellDuration) setCellDuration(Number(cookieCellDuration) as CellDuration);
const cookieStartHour = getCookie("setting_startHour");
if (cookieStartHour) setStartHour(Number(cookieStartHour));
const cookieEndHour = getCookie("setting_endHour");
if (cookieEndHour) setEndHour(Number(cookieEndHour));
setShowNextTask(data.user.showNextTask || false);
setCalendarEditMode(data.user.calendarEditMode || false);
if (data.user.fontSize)
setFontSize(data.user.fontSize as "S" | "M" | "L");
if (data.user.showSomeday !== undefined)
setShowSomeday(data.user.showSomeday);
if (data.user.showAllDayEvents !== undefined)
setShowAllDay(data.user.showAllDayEvents);
if (data.user.showSchedule !== undefined)
setShowSchedule(data.user.showSchedule);
if (data.user.hourLabelFormat)
setHourLabelFormat(data.user.hourLabelFormat as "short" | "full");
if (data.user.showSubHourSlots !== undefined)
setShowSubHourSlots(data.user.showSubHourSlots);
if (data.user.allDayPosition)
setAllDayPosition(data.user.allDayPosition as "above" | "below");
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
if (data.user.headlineFontSize)
setHeadlineFontSize(data.user.headlineFontSize);
if (data.user.headlineFontWeight)
setHeadlineFontWeight(data.user.headlineFontWeight);
if (data.user.dateFontFamily)
setDateFontFamily(data.user.dateFontFamily);
if (data.user.dateFontSize) setDateFontSize(data.user.dateFontSize);
if (data.user.dateFontWeight)
setDateFontWeight(data.user.dateFontWeight);
if (data.user.timeTaskFontFamily)
setTimeTaskFontFamily(data.user.timeTaskFontFamily);
if (data.user.timeTaskFontSize)
setTimeTaskFontSize(data.user.timeTaskFontSize);
if (data.user.timeTaskFontWeight)
setTimeTaskFontWeight(data.user.timeTaskFontWeight);
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
if (data.user.taskFontFamily)
setTaskFontFamily(data.user.taskFontFamily);
if (data.user.taskFontSize) setTaskFontSize(data.user.taskFontSize);
if (data.user.taskFontWeight)
setTaskFontWeight(data.user.taskFontWeight);
if (data.user.eventFontFamily)
setEventFontFamily(data.user.eventFontFamily);
if (data.user.eventFontSize)
setEventFontSize(data.user.eventFontSize);
if (data.user.eventFontWeight)
setEventFontWeight(data.user.eventFontWeight);
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
if (data.user.weekendColorSat)
setWeekendColorSat(data.user.weekendColorSat);
if (data.user.weekendColorSun)
setWeekendColorSun(data.user.weekendColorSun);
setProfile((prev) => ({
...prev,
...data.user,
name: data.user.name || prev.name,
email: data.user.email || prev.email,
weekdayColor: data.user.weekdayColor || "#888888",
dateColor: data.user.dateColor || "#888888",
taskColor: data.user.taskColor || "#333333",
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
}));
if (data.user.focusTimerDuration)
setFocusTimerDuration(data.user.focusTimerDuration);
if (data.user.focusBreakDuration)
setFocusBreakDuration(data.user.focusBreakDuration);
if (data.user.showTimeGrid !== undefined)
setShowTimeGrid(data.user.showTimeGrid);
if (data.user.cellDuration)
setCellDuration(data.user.cellDuration as CellDuration);
if (data.user.viewStyle)
setViewStyle(data.user.viewStyle as ViewStyle);
}
}
} catch (e) {
console.error(e);
}
};
useEffect(() => {
fetchUserInfo();
}, []);
async function fetchSomedayLists() {
try {
const response = await fetch("/api/someday-lists");
if (response.ok) {
const data = await response.json();
// Map tasks is handled in fetchTasks or we can merge here if needed.
// But fetchTasks fetches ALL tasks.
// Optimally we fetch lists, then tasks, then merge.
// For now, let's just set the lists structure.
setSomedayLists(
data.lists.map((l: any) => ({
id: l.id,
title: l.title,
tasks: l.tasks || [], // Tasks will be overwritten/populated by fetchTasks
externalId: l.externalId || null,
externalProvider: l.externalProvider || null,
})),
);
return data.lists;
}
} catch (error) {
console.error("Error fetching someday lists:", error);
return [];
}
}
async function fetchTasks() {
startSync();
try {
const [tasksResponse, listsResponse] = await Promise.all([
fetch("/api/tasks"),
fetch("/api/someday-lists"), // Fetch lists in parallel
]);
let fetchedLists: SomedayList[] = [];
if (listsResponse.ok) {
const listData = await listsResponse.json();
fetchedLists = listData.lists.map((l: any) => ({
id: l.id,
title: l.title,
tasks: [],
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),
}));
// 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),
}));
// 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() - 1);
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") => {
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") => {
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",
list: { id: string; title: string },
) => {
const existing = somedayLists.find(
(l) => l.externalId === list.id && l.externalProvider === provider,
);
if (existing) {
// Unsync/Remove
if (
!confirm(
`Are you sure you want to stop syncing the list "${list.title}"? This will move its tasks to the trash.`,
)
) {
return;
}
try {
const res = await fetch(`/api/someday-lists?id=${existing.id}`, {
method: "DELETE",
});
if (res.ok) {
setSomedayLists((prev) => prev.filter((l) => l.id !== existing.id));
setImportStatusMsg({
type: "success",
text: `Stopped syncing "${list.title}".`,
});
}
} catch (error) {
console.error("Failed to delete list", error);
setImportStatusMsg({
type: "error",
text: "Failed to stop syncing list.",
});
}
} else {
// Sync/Import
await doImport(provider, [list]);
}
};
// Core import logic — accepts provider directly so it works both from modal and sidebar
const doImport = async (
provider: "google" | "apple" | "outlook",
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 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,
) => {
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 was from a someday list, move it to the calendar
if (draggedTask.somedayListId) {
const newScheduledDate = formatDateToISO(targetDateObj);
// Remove from someday list UI
setSomedayLists((prev) =>
prev.map((l) => ({
...l,
tasks: l.tasks.filter((t) => t.id !== draggedTask.id),
})),
);
// Add to calendar tasks
setTasks((prev) => [
...prev,
{
...draggedTask,
somedayListId: null,
somedaySlotIndex: null,
scheduledDate: newScheduledDate,
dayOfWeek,
startTime: targetSlot || "",
},
]);
// Persist
try {
await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: draggedTask.id,
somedayListId: null,
somedaySlotIndex: null,
scheduledDate: newScheduledDate,
dayOfWeek,
startTime: targetSlot || "",
}),
});
// Sync due date to external provider when moving from someday to calendar
if (draggedTask.externalId && draggedTask.externalProvider) {
fetch("/api/tasks/sync", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId: draggedTask.id,
scheduledDate: newScheduledDate,
}),
}).catch((e) => console.error("Sync error:", e));
}
} catch (error) {
console.error("Error moving task from someday to calendar:", error);
}
} else {
moveTaskToSlot(
draggedTask.id,
dayOfWeek,
targetSlot || "",
targetDateObj,
);
}
setDraggedTask(null);
}
setDropPreview(null);
};
const handleDragEnd = () => {
setDraggedTask(null);
setDropPreview(null);
// Remove drag-source class from all elements
document
.querySelectorAll(".drag-source")
.forEach((el) => el.classList.remove("drag-source"));
};
const handleDragLeave = () => {
setDropPreview(null);
};
const handleSomedayDragOver = (e: React.DragEvent, listId: string, slotIdx: number) => {
e.preventDefault();
setDropPreview({ listId, slotIdx });
};
const handleSomedayDrop = async (e: React.DragEvent, listId: string, slotIndex: number) => {
e.preventDefault();
if (draggedTask) {
// 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,
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, remove it from the calendar tasks array
if (!draggedTask.somedayListId) {
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,
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: currentWeekStart.toISOString(),
timeMax: new Date(
currentWeekStart.getTime() + 7 * 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 containerStyle = {
"--weekly-font-headline":
profile.headlineFont || headlineFont
? `"${profile.headlineFont || headlineFont}", sans-serif`
: "var(--font-headline)",
"--weekly-headline-size": profile.headlineFontSize || "1.25rem",
"--weekly-headline-weight": profile.headlineFontWeight || "900",
"--weekly-date-font": profile.dateFontFamily
? `"${profile.dateFontFamily}", sans-serif`
: "var(--weekly-font-headline)",
"--weekly-date-size": profile.dateFontSize || "0.65rem",
"--weekly-date-weight": profile.dateFontWeight || "400",
"--weekly-time-task-font": profile.timeTaskFontFamily
? `"${profile.timeTaskFontFamily}", sans-serif`
: "var(--weekly-font)",
"--weekly-time-task-size": profile.timeTaskFontSize || "0.75rem",
"--weekly-time-task-weight": profile.timeTaskFontWeight || "500",
"--weekly-font":
"var(--font-body)" /* Force default body font as requested */,
"--weekly-task-font": profile.taskFontFamily
? `"${profile.taskFontFamily}", sans-serif`
: "var(--weekly-font)",
"--weekly-task-size": profile.taskFontSize || "0.9rem",
"--weekly-task-weight": profile.taskFontWeight || "400",
"--weekly-event-font":
profile.eventFontFamily || eventFontFamily
? `"${profile.eventFontFamily || eventFontFamily}", sans-serif`
: "var(--weekly-font)",
"--weekly-event-size": profile.eventFontSize || eventFontSize || "0.85rem",
"--weekly-event-weight":
profile.eventFontWeight || eventFontWeight || "400",
"--font-weight-body": profile.fontWeight || fontWeight || "400",
"--weekly-weekend-sat": darkMode
? invertColor(profile.weekendColorSat || "#666666")
: profile.weekendColorSat || "#666666",
"--weekly-weekend-sun": darkMode
? invertColor(profile.weekendColorSun || "#dc2626")
: profile.weekendColorSun || "#dc2626",
"--weekly-weekday-color": darkMode
? invertColor(profile.weekdayColor || "#888888")
: profile.weekdayColor || "#888888",
"--weekly-date-color": darkMode
? invertColor(profile.dateColor || "#888888")
: profile.dateColor || "#888888",
"--weekly-task-color": darkMode
? invertColor(profile.taskColor || "#333333")
: profile.taskColor || "#333333",
"--weekly-today-highlight": darkMode
? invertColor(profile.todayHighlightColor || "#f0fafa")
: profile.todayHighlightColor || "#f0fafa",
"--weekly-past-color": darkMode
? invertColor(profile.pastDayColor || "#a6a6a7")
: profile.pastDayColor || "#a6a6a7",
} as React.CSSProperties;
if (isLoading) {
return (
<div
className="weekly-container"
style={{ alignItems: "center", justifyContent: "center" }}
>
<div style={{ color: "var(--weekly-text-light)" }}>
{translations[language]?.loading || translations["en"].loading}
</div>
</div>
);
}
// 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}
>
{/* 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}
<span>KW {getWeekNumber((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).toString().padStart(2, "0")}</span>
<span className="text-gray-400">|</span>
<span>{(() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })().getFullYear()}</span>
</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 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" style={{ zIndex: 0 }}>
{/* Week & Year */}
<div className="whitespace-nowrap flex items-center gap-2">
{syncError ? (
<div className="flex items-center gap-1 text-red-500" title={syncError}>
<AlertCircle size={14} />
<span className="text-xs">{syncError}</span>
</div>
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
<div className="weekly-spinner" 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"
title="Refresh Calendar & Tasks"
>
<RefreshCcw size={14} />
</button>
)}
<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((() => { const days = getVisibleDays(); const mid = days[Math.floor(days.length / 2)] || currentWeekStart; return mid; })()).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>
{/* Goal */}
<div className="flex items-center text-sm">
<span className="text-gray-300 mx-2">-</span>
{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: "500px",
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>
)}
<span className="text-gray-300 mx-2">-</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>
{/* Date Picker Toggle */}
<div className="relative">
<button
className={`p-1.5 hover:bg-gray-100 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50" : "text-gray-500 hover:text-black"}`}
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}
/>
)}
</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)}
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}
{/* Main Grid with Time Column */}
<div className="time-grid-wrapper">
{/* 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",
alignItems:
profile.dateLayout === "above" ||
profile.dateLayout === "below"
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
: "baseline",
justifyContent:
profile.dateAlignment === "left"
? "flex-start"
: profile.dateAlignment === "right"
? "flex-end"
: "center",
flexDirection:
profile.dateLayout === "above"
? "column-reverse"
: profile.dateLayout === "below"
? "column"
: "row",
gap: profile.dateAlignment === "tight" ? "2px" : "4px",
}}
>
{profile.dateLayout === "left" && (
<span className="weekly-day-date">W</span>
)}
<h3 className="weekly-day-name" style={{ marginBottom: 0 }}>
X
</h3>
{(profile.dateLayout === "right" ||
profile.dateLayout === "above" ||
profile.dateLayout === "below" ||
profile.dateLayout === 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",
alignItems:
profile.dateLayout === "above" ||
profile.dateLayout === "below"
? (profile.dateAlignment === "left" ? "flex-start" : profile.dateAlignment === "right" ? "flex-end" : "center")
: "baseline",
justifyContent:
profile.dateAlignment === "left"
? "flex-start"
: profile.dateAlignment === "right"
? "flex-end"
: "center",
flexDirection:
profile.dateLayout === "above"
? "column-reverse"
: profile.dateLayout === "below"
? "column"
: "row",
gap: profile.dateAlignment === "tight" ? "2px" : (profile.dayHeaderGap || "0.35em"),
}}
>
{profile.dateLayout === "left" && (
<span className="weekly-day-date">
{formatDateHeader(date, language)}
</span>
)}
<h3
className={`weekly-day-name ${isSameDay(date, new Date()) ? "is-today" : ""}`}
style={{ marginBottom: 0 }}
>
{getDayName(date, language)}
</h3>
{(profile.dateLayout === "right" ||
profile.dateLayout === "above" ||
profile.dateLayout === "below" ||
profile.dateLayout === undefined) && (
<span className="weekly-day-date">
{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}
/>
))}
{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}
/>
))}
</ol>
</div>
)}
</div>
);
})}
</main>
</div>
{/* All-Day Events Section (below position) */}
{allDayPosition === "below" && allDaySection}
{/* Someday Section */}
{showSomeday && (
<section
className={`weekly-someday ${somedayExpanded ? "expanded" : "collapsed"} transition-colors duration-200`}
>
<div style={{ display: "flex", flexDirection: "row", maxWidth: "100%", width: "100%" }}>
{showTimeGrid && (
<div
className="someday-label-column"
style={{
width: "50px",
flexShrink: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
borderRight: "1px solid var(--weekly-border)",
padding: "4px 4px",
gap: "4px",
position: "relative",
}}
>
<div
onClick={() => setSomedayExpanded(!somedayExpanded)}
style={{
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0px",
}}
title={somedayExpanded ? "Collapse" : "Expand"}
>
<span
style={{
fontSize: "0.6rem",
fontWeight: 600,
color: "var(--weekly-text-light)",
textTransform: "uppercase",
letterSpacing: "0.05em",
lineHeight: 1.1,
textAlign: "center",
}}
>
any day
</span>
<span
style={{
fontSize: "0.55rem",
color: "#888",
marginTop: "1px",
}}
>
{somedayLists.length}{" "}
{translations[language]?.lists || translations["en"].lists}
</span>
</div>
<button
className="someday-add-btn dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:border-white transition-colors"
onClick={(e) => {
e.stopPropagation();
handleStartAddSomedayList();
}}
title="Add new list"
style={{
background: "none",
border: "1px dashed #ccc",
borderRadius: "50%",
width: "18px",
height: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "#888",
fontSize: "0.8rem",
lineHeight: 1,
padding: 0,
}}
>
+
</button>
</div>
)}
{!showTimeGrid && (
<div
className="someday-label-column"
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
gap: "6px",
}}
>
<div
onClick={() => setSomedayExpanded(!somedayExpanded)}
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
}}
title={somedayExpanded ? "Collapse" : "Expand"}
>
<span
style={{
fontSize: "0.6rem",
fontWeight: 600,
color: "var(--weekly-text-light)",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
any day
</span>
<span style={{ fontSize: "0.55rem", color: "#888" }}>
{somedayLists.length}{" "}
{translations[language]?.lists || translations["en"].lists}
</span>
</div>
<button
className="someday-add-btn dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:border-white transition-colors"
onClick={(e) => {
e.stopPropagation();
handleStartAddSomedayList();
}}
title="Add new list"
style={{
background: "none",
border: "1px dashed #ccc",
borderRadius: "50%",
width: "18px",
height: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "#888",
fontSize: "0.8rem",
lineHeight: 1,
padding: 0,
}}
>
+
</button>
</div>
)}
<div style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
{somedayExpanded && (
<div
ref={somedayGridRef}
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
style={{ display: "flex", flexDirection: "row", flexWrap: "nowrap" }}
>
{(() => {
const baseLists = somedayLists.length > 0
? somedayLists
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
const sliced = baseLists.slice(0, Math.max(somedayLists.length, viewDays));
// Compute visual order during drag
if (draggingListId && dropTargetListIndex !== null) {
const dragIdx = sliced.findIndex(l => l.id === draggingListId);
if (dragIdx !== -1 && dragIdx !== dropTargetListIndex) {
const reordered = [...sliced];
const [moved] = reordered.splice(dragIdx, 1);
reordered.splice(dropTargetListIndex, 0, moved);
return reordered;
}
}
return sliced;
})()
.map((list) => (
<div
key={list.id}
className={`weekly-someday-list ${draggingListId === list.id ? "is-dragging" : ""} p-2 transition-colors duration-200`}
style={{
minHeight: "200px",
cursor: "text", // Indicate actionable area
display: "flex",
flexDirection: "column",
}}
onClick={(e) => {
// Focus the add input if clicking empty area or the list container
// Only if not clicking a task item or other interactive element
const target = e.target as HTMLElement;
if (
target.closest(".weekly-task-item") ||
target.tagName === "INPUT" ||
target.tagName === "BUTTON"
) {
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;
// Allow task items to be dragged freely
if (target.closest(".weekly-task-item")) {
return; // Let the TaskItem handle its own drag
}
// Only allow list drag if started from the handle
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); }}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!draggingListId) return;
const container = somedayGridRef.current;
if (!container) return;
const children = Array.from(container.children) as HTMLElement[];
let targetIdx = children.length - 1;
for (let i = 0; i < children.length; i++) {
const rect = children[i].getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) {
targetIdx = i;
break;
}
}
setDropTargetListIndex(targetIdx);
}}
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 draggedIndex = somedayLists.findIndex(
(l) => l.id === droppedListId,
);
if (draggedIndex === -1) {
setDraggingListId(null);
setDropTargetListIndex(null);
return;
}
const newLists = [...somedayLists];
const [draggedItem] = newLists.splice(draggedIndex, 1);
newLists.splice(dropTargetListIndex, 0, draggedItem);
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",
}}
>
<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 && (
<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: "4px", opacity: 0.5, flexShrink: 0 }}
>
{list.externalProvider === "outlook" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="18" rx="2" /><path d="M7 3v18" /><path d="M7 8h10" /><path d="M7 13h10" /></svg>
) : list.externalProvider === "google" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 8v8" /><path d="M8 12h8" /></svg>
) : list.externalProvider === "apple" ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83" /><path d="M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11" /></svg>
) : (
<RefreshCcw size={12} />
)}
</span>
)}
<button
className="someday-list-delete-btn"
onClick={async (e) => {
e.stopPropagation();
if (confirm("Delete this list?")) {
try {
await fetch(
`/api/someday-lists?id=${list.id}`,
{ method: "DELETE" },
);
setSomedayLists((prev) =>
prev.filter((l) => l.id !== list.id),
);
} catch (err) {
console.error(err);
}
}
}}
style={{
border: "none",
background: "none",
cursor: "pointer",
fontSize: "1rem",
color: "#ccc",
marginLeft: "auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
title="Delete List"
>
×
</button>
</div>
<div
className="weekly-task-list"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
position: "relative",
}}
>
{Array.from({ length: getSomedaySlotCount(list.tasks) }).map((_, slotIdx) => {
const taskInSlot = list.tasks.find(t => t.somedaySlotIndex === slotIdx);
const isTarget = dropPreview?.listId === list.id && dropPreview?.slotIdx === slotIdx;
return (
<div
key={slotIdx}
className={`task-list-slot ${isTarget ? 'drop-target' : ''}`}
onDragOver={(e) => handleSomedayDragOver(e, list.id, slotIdx)}
onDrop={(e) => handleSomedayDrop(e, list.id, slotIdx)}
onDragLeave={() => setDropPreview(null)}
>
{taskInSlot && (
<TaskItem
key={taskInSlot.id}
task={taskInSlot}
isEditing={editingTaskId === taskInSlot.id}
onToggle={() => toggleTask(taskInSlot.id)}
onEdit={() => setEditingTaskId(taskInSlot.id)}
onUpdate={(title) => updateTask(taskInSlot.id, title)}
onDelete={() => deleteTask(taskInSlot.id)}
onNotes={() => setSelectedTaskForNotes(taskInSlot)}
onRollToggle={() => toggleTaskRolling(taskInSlot.id)}
onRecurrence={() => setSelectedTaskForRecurrence(taskInSlot)}
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}
/>
)}
</div>
);
})}
{/* Legacy / unindexed tasks */}
{list.tasks.filter(t => t.somedaySlotIndex === null || t.somedaySlotIndex === undefined || t.somedaySlotIndex >= getSomedaySlotCount(list.tasks)).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}
/>
</div>
))}
<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);
}
}}
/>
</div>
</div>
))}
{/* 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 grid-cols-4 gap-2">
<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>
<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">
<svg
viewBox="0 0 24 24"
className="w-4 h-4"
fill="currentColor"
>
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.9 3.32-2.18 4.36-1.52 1.2-3.8 2.36-7.66 2.36-6.4 0-11.64-5.16-11.64-11.56S3.96 5.8 10.36 5.8c3.48 0 6.08 1.36 8 3.16l2.32-2.32C18.4 4.56 14.88 3 10.36 3 4.2 3 0 7.12 0 12.32S4.2 21.64 10.36 21.64c3.28 0 5.84-1.08 7.84-3.16 2.08-2.08 2.76-4.96 2.76-7.36 0-.72-.04-1.4-.16-2.2h-8.32z" />
</svg>
</div>
<span className="text-[10px] font-black uppercase">
Google
</span>
</button>
<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">
<svg
viewBox="0 0 24 24"
className="w-4 h-4"
fill="currentColor"
>
<path
d="M1.5 6L11.5 1L21.5 6V18L11.5 23L1.5 18V6Z"
fill="#0078D4"
/>
<path
d="M11.5 12.5V23L21.5 18V6L11.5 12.5Z"
fill="#005A9E"
/>
<path
d="M11.5 12.5L1.5 6V18L11.5 23V12.5Z"
fill="#0078D4"
/>
<path
d="M16.5 7.5L6.5 2.5V14.5L16.5 19.5V7.5Z"
fill="#28A8EA"
/>
</svg>
</div>
<span className="text-[10px] font-black uppercase">
Outlook
</span>
</button>
<button
onClick={() =>
setSelectedSomedayProvider("apple")
}
className={`flex flex-col items-center justify-center p-3 rounded-xl border-2 transition-all ${selectedSomedayProvider === "apple" ? "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">
<svg
viewBox="0 0 24 24"
className="w-4 h-4"
fill="currentColor"
>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.69 3.33 1.66-3.04 1.77-2.54 5.92.51 7.14-.58 1.51-1.32 3.03-2.45 4.21zM11.95 7.21c-.08-2.67 2.2-4.96 4.79-5.11.31 2.94-2.81 5.34-4.79 5.11z" />
</svg>
</div>
<span className="text-[10px] font-black uppercase">
iCloud
</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}
fetchAvailableTaskLists={fetchAvailableTaskLists}
/>
)
}
{
selectedTaskForRecurrence && (
<TaskRecurrenceModal
task={selectedTaskForRecurrence}
onClose={() => setSelectedTaskForRecurrence(null)}
onSave={handleRecurrenceSave}
/>
)
}
{
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,
}: {
listId: string;
onAdd: (title: string) => void;
}) {
const [title, setTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
if (title.trim()) {
onAdd(title.trim());
setTitle("");
}
};
return (
<li
className="weekly-task-item minimal"
style={{
margin: "0 0.5rem",
}}
>
<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("");
e.currentTarget.blur();
}
}}
className="weekly-task-text"
style={{
width: "100%",
border: "none",
background: "transparent",
padding: "0 0",
fontSize: "0.8rem",
outline: "none",
height: "24px",
display: "block", // Height match filler
}}
placeholder=""
data-someday-add-input={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;
}
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,
}: 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 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]);
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 && !showTaskCheckboxes ? "completed" : ""} ${isSomeday ? "relative mx-2" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
draggable={!isEditing && !isNotesOpen && swipeX === 0}
onDragStart={(e) => 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 && (
<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",
fontSize: "0.9375rem",
fontWeight: 500,
resize: "none",
overflow: "hidden",
fontFamily: "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 && !showTaskCheckboxes ? "completed" : ""}`}
onClick={(e) => {
if (variant === "default" && !showTaskCheckboxes) onToggle();
// For minimal/someday, parent onClick handles edit
}}
onDoubleClick={variant === "default" ? onEdit : undefined}
style={
variant === "minimal" || isSomeday
? { fontSize: "0.9375rem", 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: "14px", height: "14px", margin: 0, cursor: "pointer", position: "relative", top: "4px", left: "-2px", accentColor: "#333" }}
/>
)}
{needsSync && (
<span title="Needs to be synced" className="text-yellow-500 flex-shrink-0">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38" />
</svg>
</span>
)}
<span
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflow: "visible",
flex: 1,
}}
>
{task.title}
</span>
{task.subTasks && task.subTasks.length > 0 && !isSubTask && (
<button
onClick={(e) => {
e.stopPropagation();
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="focus:outline-none flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
title={isSubTasksOpen ? "Collapse subtasks" : "Expand subtasks"}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isSubTasksOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
)}
{task.markdownContent && (
<span className="task-note-icon flex-shrink-0" data-note={task.markdownContent}>
<svg viewBox="0 0 24 24" width="12" height="12" 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>
</span>
)}
</span>
<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 */}
<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>
{/* 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 && task.subTasks && task.subTasks.length > 0 && (
<ul className="subtask-list" onClick={(e) => e.stopPropagation()}>
{task.subTasks.map((subTask) => (
<li
key={subTask.id}
className={`subtask-item ${subTask.completed ? "completed" : ""}`}
>
<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";
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[];
}) => void;
quoteSourceUrls?: 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";
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"]?: { id: string; title: string }[];
};
isFetchingProviderLists: Record<string, boolean>;
somedayLists: SomedayList[];
handleToggleTaskList: (
provider: "google" | "apple" | "outlook",
list: { id: string; title: string },
) => Promise<void>;
fetchAvailableTaskLists: (
provider: "google" | "apple" | "outlook",
) => Promise<void>;
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
}
// 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);
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" : ""}`}>
<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,
fetchAvailableTaskLists,
initialTab,
}: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState<
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
>(initialTab || "general");
const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [exportStartDate, setExportStartDate] = useState("");
const [exportEndDate, setExportEndDate] = useState("");
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("");
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(
null,
);
// 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");
}
}, [activeTab, connections]);
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";
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[];
}>({
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",
showSubHourSlots: true,
allDayPosition: "below",
goalFallbackType: "quote",
quoteSourceUrl: "https://recite.vercel.app/api/random",
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",
dateAlignment: "center",
weekendColorSat: "#666666",
weekendColorSun: "#dc2626",
weekdayColor: "#888888",
dateColor: "#888888",
taskColor: "#333333",
todayHighlightColor: "#f0fafa",
});
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 || "en",
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,
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,
} 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({
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,
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",
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,
});
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);
}
}
} 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.reload(), 1200);
} catch (err: any) {
setAppleCalError(err.message || "Connection failed");
} finally {
setIsConnectingAppleCal(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 || "en",
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,
dateAlignment: profile.dateAlignment,
} 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 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",
gap: "4px",
borderBottom: "1px solid var(--weekly-border, #eee)",
padding: "0 24px",
}}
>
{([
{ key: "general", icon: <Settings size={18} />, label: t.general },
{ key: "calendar", icon: <Calendar size={18} />, label: t.calendar },
{ key: "account", icon: <User size={18} />, label: t.account },
{ key: "styling", icon: <Palette size={18} />, label: "Styling" },
{ key: "motivation", icon: <Sparkles size={18} />, label: "Motivation" },
{ key: "about", icon: <Info size={18} />, label: "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" }}
>
{/* Visibility Toggles */}
<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 }}
>
Show Schedule / Calendar
</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>
</div>
)}
<div
style={{
borderTop: "1px solid #eee",
marginTop: "16px",
paddingTop: "16px",
}}
></div>
<h4 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
{t.localization}
</h4>
{/* Start Week Setting */}
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
Start week on
</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)}
>
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)}
>
Sunday
</button>
</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>
</div>
</div>
<div style={{ marginTop: "16px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
{t.language}
</label>
<select
value={profile.language}
onChange={(e) =>
setProfile({ ...profile, 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">German</option>
<option value="fr">French</option>
<option value="es">Spanish</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>
{/* Hour Label Format */}
<div>
<label
style={{
display: "block",
fontSize: "0.9rem",
fontWeight: 600,
marginBottom: "4px",
}}
>
Hour Label Format
</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">Short (8, 9, 10)</option>
<option value="full">Full (8:00, 9:00, 10:00)</option>
</select>
</div>
{/* Sub-hour Slot Labels */}
<div
style={{ display: "flex", alignItems: "center", gap: "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 }}
>
Show Sub-hour Labels (:15, :30, :45)
</label>
</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 === "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"
? "📅"
: conn.provider === "apple"
? "🍎"
: "📧"}
</span>
{conn.provider === "google"
? "Google Calendar"
: conn.provider === "apple"
? "Apple 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"
? "No calendars found or permission denied."
: conn.provider === "apple"
? "No calendars loaded. Please disconnect and reconnect Apple Calendar to load your calendars."
: "Selection available after connect."}
</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"
>
<span>📅</span> {t.connectGoogle}
</button>
<button
onClick={handleAppleCalendarConnect}
className="calendar-connect-btn"
>
<span>🍎</span> {t.connectApple}
</button>
<button
onClick={handleOutlookConnect}
className="calendar-connect-btn"
>
<span>📧</span> Connect Outlook
</button>
</div>
<h3
style={{
marginBottom: "1rem",
fontSize: "1rem",
fontWeight: 600,
marginTop: "2rem",
}}
>
Sync Tasks
</h3>
<p
style={{
fontSize: "0.9rem",
color: "var(--weekly-text-light)",
marginBottom: "1rem",
}}
>
Sync tasks with Google Tasks or Microsoft To-Do. Selected
lists will be kept in sync automatically.
</p>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
}}
>
{connections
.filter((c) => ["google", "outlook"].includes(c.provider))
.map((conn) => {
const providerLists =
availableTaskLists[
conn.provider as "google" | "outlook"
] || [];
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" ? "📅" : "📧"}
</span>
{conn.provider === "google"
? "Google 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 */}
{providerLists.length > 0 && (
<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>
<span style={{ width: "50px", textAlign: "center" }}>Sync</span>
</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",
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"].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)",
}}
>
Font Customization
</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",
}}
>
Date Layout
</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">Date Right of Weekday</option>
<option value="left">Date Left of Weekday</option>
<option value="above">Date Above Weekday</option>
<option value="below">Date Below Weekday</option>
<option value="hidden">Date Hidden</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>
{/* 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",
}}
>
Weekday Font (e.g. MONTAG)
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
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 }}
/>
<select
value={profile.headlineFont || "Inter"}
onChange={(e) =>
setProfile({ ...profile, headlineFont: 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>
<input
type="text"
value={profile.headlineFontSize || "1.25rem"}
onChange={(e) =>
setProfile({
...profile,
headlineFontSize: e.target.value,
})
}
placeholder="1.25rem"
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)",
}}
/>
<select
value={profile.headlineFontWeight || "900"}
onChange={(e) =>
setProfile({
...profile,
headlineFontWeight: 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)",
}}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
<option value="900">Black</option>
</select>
</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",
}}
>
Date Font (e.g. 12. Feb.)
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
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 }}
/>
<select
value={profile.dateFontFamily || "Inter"}
onChange={(e) =>
setProfile({
...profile,
dateFontFamily: 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>
<input
type="text"
value={profile.dateFontSize || "0.65rem"}
onChange={(e) =>
setProfile({ ...profile, dateFontSize: e.target.value })
}
placeholder="0.65rem"
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)",
}}
/>
<select
value={profile.dateFontWeight || "400"}
onChange={(e) =>
setProfile({
...profile,
dateFontWeight: 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)",
}}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
</select>
</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",
}}
>
Task Font
</label>
<div
style={{
display: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
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 }}
/>
<select
value={profile.taskFontFamily || "Inter"}
onChange={(e) =>
setProfile({
...profile,
taskFontFamily: e.target.value,
timeTaskFontFamily: 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>
<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={{
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)",
}}
/>
<select
value={profile.taskFontWeight || "400"}
onChange={(e) =>
setProfile({
...profile,
taskFontWeight: e.target.value,
timeTaskFontWeight: 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)",
}}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
</select>
</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: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
gap: "8px",
alignItems: "center",
}}
>
<div style={{ width: "28px" }} />
<select
value={profile.eventFontFamily || "Inter"}
onChange={(e) =>
setProfile({
...profile,
eventFontFamily: 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>
<input
type="text"
value={profile.eventFontSize || "0.85rem"}
onChange={(e) =>
setProfile({
...profile,
eventFontSize: e.target.value,
})
}
placeholder="0.85rem"
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)",
}}
/>
<select
value={profile.eventFontWeight || "400"}
onChange={(e) =>
setProfile({
...profile,
eventFontWeight: 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)",
}}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
</select>
</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: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
gap: "8px",
alignItems: "center",
}}
>
<div style={{ width: "28px" }} />
<select
value={profile.goalFontFamily || "Inter"}
onChange={(e) =>
setProfile({
...profile,
goalFontFamily: 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>
<input
type="text"
value={profile.goalFontSize || "1rem"}
onChange={(e) =>
setProfile({ ...profile, goalFontSize: e.target.value })
}
placeholder="1rem"
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)",
}}
/>
<select
value={profile.goalFontWeight || "400"}
onChange={(e) =>
setProfile({
...profile,
goalFontWeight: 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)",
}}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</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",
}}
>
Day / Weekday Gap
</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>
{/* 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: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
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 }}
/>
<select
value={profile.cwFontFamily || "Inter"}
onChange={(e) =>
setProfile({ ...profile, cwFontFamily: 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>
<input
type="text"
value={profile.cwFontSize || "1.125rem"}
onChange={(e) =>
setProfile({ ...profile, cwFontSize: e.target.value })
}
placeholder="1.125rem"
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)" }}
/>
<select
value={profile.cwFontWeight || "700"}
onChange={(e) =>
setProfile({ ...profile, cwFontWeight: 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)" }}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
<option value="900">Black</option>
</select>
</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: "grid",
gridTemplateColumns: "auto 2fr 1fr 1fr",
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 }}
/>
<select
value={profile.yearFontFamily || "Inter"}
onChange={(e) =>
setProfile({ ...profile, yearFontFamily: 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>
<input
type="text"
value={profile.yearFontSize || "1.125rem"}
onChange={(e) =>
setProfile({ ...profile, yearFontSize: e.target.value })
}
placeholder="1.125rem"
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)" }}
/>
<select
value={profile.yearFontWeight || "700"}
onChange={(e) =>
setProfile({ ...profile, yearFontWeight: 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)" }}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
<option value="900">Black</option>
</select>
</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",
}}
>
Show &quot;Do This Now&quot; instead of Motto
</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)",
}}
>
Focus Timer (min)
</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)",
}}
>
Focus Break (min)
</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)",
}}
>
Ziel-Zeitraum
</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",
}}
>
Pro Woche
</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",
}}
>
Pro Tag
</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)",
}}
>
Ziel der Woche Fallback
</h3>
<div style={{ marginBottom: "12px" }}>
<label
style={{
display: "block",
fontSize: "0.85rem",
color: "var(--weekly-settings-label)",
marginBottom: "8px",
}}
>
Ziel-Fallback-Typ
</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">
Motivational Quote / Holiday Hint
</option>
<option value="next_todo">Nächstes To-Do</option>
<option value="default">Standardtext</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)",
}}
>
API-Datenquellen (URLs)
</label>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{(profile.quoteSourceUrls || [profile.quoteSourceUrl || "https://recite.vercel.app/api/random"]).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 || "https://recite.vercel.app/api/random"])];
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 || "https://recite.vercel.app/api/random"]).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 || "https://recite.vercel.app/api/random"]), ""];
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} /> Add Source
</button>
</div>
<p style={{ fontSize: "0.75rem", color: "var(--weekly-text-light)", marginTop: "4px" }}>
URL that returns a JSON list or object of quotes (e.g. {'[{"quote":"...","author":"..."}]'} or {'{"quote":"..."}'}).
</p>
</div>
)}
{profile.goalFallbackType === "default" && (
<div style={{ marginTop: "12px" }}>
<label
style={{
display: "block",
fontSize: "0.9rem",
marginBottom: "4px",
color: "var(--weekly-settings-label)",
}}
>
Standardtext
</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="Ihr Ziel hier eingeben..."
/>
</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>
<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>
<div
className="account-danger-zone"
style={{
marginTop: "20px",
paddingTop: "20px",
borderTop: "1px solid var(--weekly-border)",
}}
>
<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>
)}
</div>
</div >
</>
);
}