My-Weekly-ToDo-List/src/components/WeeklyView.tsx
mARTin ccb34d12d5 feat: Add soft delete, remove Apple Reminders, improve Google Tasks sync
Add soft-delete support for tasks with deletedAt field and migration.
Remove Apple Reminders iCloud integration entirely (API routes, lib,
UI modal, and Python script) in favor of CalDAV approach. Add periodic
pull-sync from Google Tasks every 2 minutes with deletion detection.
Fix orphaned someday task rescue and cross-list task toggle/edit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:04:49 +01:00

5832 lines
318 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 FocusModeOverlay from './FocusModeOverlay';
import {
LayoutGrid,
Calendar,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
Settings,
User,
Clock,
Menu,
Target,
Sun,
Moon,
Repeat,
GripVertical,
Play,
Zap,
Plus
} from 'lucide-react';
// Types
import UserMenu from './UserMenu';
import SearchModal from './SearchModal';
import SimpleDatePicker from './SimpleDatePicker';
import RecurringTasksManager from './RecurringTasksManager';
import { ImportListModal } from './ImportListModal';
export type ViewStyle = 'simple' | 'calendar' | 'list';
interface Task {
id: string;
title: string;
markdownContent?: string;
completed: boolean;
dayOfWeek?: number | null;
scheduledDate?: string | null;
somedayListId?: string | null;
order: number;
startTime?: string | null;
endTime?: string | null;
userId: string;
isRolling?: boolean;
isRecurring?: boolean;
recurrenceInterval?: number | null;
recurrenceUnit?: string | null;
recurrenceTime?: string | null;
recurrenceEndDate?: Date | null;
createdAt: Date;
updatedAt: Date;
externalId?: string | null;
externalProvider?: string | null;
externalListId?: string | null;
lastSyncedAt?: Date | null;
}
interface CalendarEvent {
id: string;
title: string;
startTime: string;
endTime: string;
source: 'google' | 'apple';
calendarId?: string;
calendarTitle?: string;
calendarColor?: string;
editable?: boolean;
}
interface SomedayList {
id: string;
title: string;
tasks: Task[];
}
// Time grid configuration options
type CellDuration = 15 | 30 | 60 | 120;
// 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',
showSomeday: 'Show Someday Section',
showAllDay: 'Show All-Day Section',
newPasswordDesc: 'Leave blank to keep current password.'
},
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',
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',
showSomeday: 'Irgendwann-Bereich anzeigen',
showAllDay: 'Ganztägige Ereignisse anzeigen',
newPasswordDesc: 'Leer lassen, um das aktuelle Passwort zu behalten.'
}
};
// 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): string {
return `${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;
}
}
// 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 [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const [timeFormat, setTimeFormat] = useState('24h');
const [dateFormat, setDateFormat] = useState('yyyy-MM-dd');
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);
// Moved state definitions to the top
const [showSettings, setShowSettings] = useState(false);
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('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' | null>(null);
const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]);
const [isFetchingLists, setIsFetchingLists] = useState(false);
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';
goalDefaultSentence?: string;
goalFontFamily?: string;
goalFontSize?: string;
goalFontWeight?: string;
goalScope?: 'week' | 'day';
}>({
name: session?.user?.name || '',
email: session?.user?.email || '',
timezone: 'UTC',
language: 'de',
dateFormat: 'yyyy-MM-dd',
timeFormat: '24h',
startHour: 8,
endHour: 22,
autoRolling: true,
protectEventTimes: true,
showTimeGrid: true,
cellDuration: 60,
viewStyle: 'list',
fontSize: 'M',
showNextTask: false,
showSomeday: true,
showAllDayEvents: true,
showSchedule: true,
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',
eventFontFamily: 'Inter',
eventFontSize: '0.85rem',
eventFontWeight: '400',
fontWeight: '400',
goalFontFamily: 'Inter',
goalFontSize: '0.9rem',
goalFontWeight: '500',
weekendColorSat: '#666666',
weekendColorSun: '#dc2626',
focusTimerDuration: 25,
focusBreakDuration: 5,
pastDayColor: '#a6a6a7'
});
const [showSummary, setShowSummary] = useState(false);
const [isAddingSomedayList, setIsAddingSomedayList] = useState(false);
const [newSomedayListName, setNewSomedayListName] = useState('');
const [language, setLanguage] = useState('de');
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle');
const [cellDuration, setCellDuration] = useState<CellDuration>(60);
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 } | null>(null);
const [viewStyle, setViewStyle] = useState<ViewStyle>('simple');
const [protectEventTimes, setProtectEventTimes] = useState(true);
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
const [startHour, setStartHour] = useState(8);
const [endHour, setEndHour] = useState(22);
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);
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));
}
}, []);
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);
// 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: CellDuration) => {
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 40;
case 120: return 40;
default: return 40;
}
};
// Working hours range (configurable)
const workingHoursStart = startHour;
const workingHoursEnd = endHour;
// Fetch calendar events
const fetchCalendarEvents = useCallback(async () => {
setIsSyncing(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(),
}),
});
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 {
setIsSyncing(false);
}
}, [currentWeekStart]);
// 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');
}
// Refresh events
await fetchCalendarEvents();
} 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');
}
// Refresh events
await fetchCalendarEvents();
} 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
setTasks(prev => prev.map(t => t.id === taskId ? data.task : t));
} catch (error) {
console.error(error);
alert('Failed to save recurrence settings');
}
};
// Fetch tasks on mount
useEffect(() => {
if (session) {
fetchTasks();
fetchConnections();
fetchCalendarEvents();
}
}, [session]);
// 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) {
console.log(`[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions from Google Tasks`);
fetchTasks(); // Reload to reflect changes
}
}
} catch (e) {
// Silent fail for background sync
}
}, 2 * 60 * 1000);
return () => clearInterval(interval);
}, [session]);
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 minute for the "Now" line
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 60000); // Update every minute
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 {
await fetch('/api/goal', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
weekStart: goalDateKey,
text: newGoal,
}),
});
} catch (err) {
console.error('Failed to save goal:', err);
}
};
const handleSomedayWheel = (e: React.WheelEvent) => {
if (e.currentTarget) {
e.currentTarget.scrollLeft += e.deltaY;
}
};
const saveSetting = async (key: string, value: any) => {
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) setViewDays(data.user.viewDays);
if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration);
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.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
})));
return data.lists;
}
} catch (error) {
console.error('Error fetching someday lists:', error);
return [];
}
}
async function fetchTasks() {
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: []
}));
}
// 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);
}
}
// 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;
// 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;
// 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 && task.startTime === slot;
});
}, [tasks]);
// 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]);
// Navigation handlers with proper slide animation
// Simplified: Immediate state update with slide-in animation to prevent blank flash
const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => {
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
const doc = document as any;
doc.documentElement.dataset.slideDirection = direction === 'left' ? 'next' : 'prev';
doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week
doc.startViewTransition(() => {
setCurrentWeekStart(newDate);
// setSlideDirection state is not strictly needed for global transition but keeping it clean
setSlideDirection(direction === 'left' ? 'next' : 'prev');
});
} else {
setCurrentWeekStart(newDate);
}
};
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);
};
const executeImport = async (provider: 'google' | 'apple') => {
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);
}
};
// Core import logic — accepts provider directly so it works both from modal and sidebar
const doImport = async (provider: 'google' | 'apple', 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: `Imported ${data.count} new tasks into ${data.listsCreated || 1} list(s).` });
await fetchTasks();
} else {
setImportStatusMsg({ type: 'error', text: data.error || 'Import failed.' });
}
} catch (error) {
console.error('Import error:', error);
setImportStatusMsg({ type: 'error', text: 'An error occurred during import.' });
} 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);
};
// Task CRUD operations
const addTask = async (date: Date, title: string, startTime?: string) => {
if (!title.trim()) return;
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) => {
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) => {
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);
}
};
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) => {
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, 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 }),
});
// 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) => {
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) {
const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE.");
if (deleteSeries) {
setTasks(prev => prev.filter(t => {
if (t.id === originalId) return false;
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
if (t.id === taskId) return false;
return true;
}));
setEditingTaskId(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);
}
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);
}
};
// 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) {
const targetSlotTasks = getTasksForSlot(targetDateObj, targetSlot);
if (targetSlotTasks.length > 0 && !targetSlotTasks.some(t => t.id === 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];
const nextSlotTasks = getTasksForSlot(targetDateObj, nextSlot);
if (nextSlotTasks.length === 0) {
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, 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,
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);
};
// Sync calendar
const handleSync = async () => {
setSyncStatus('syncing');
try {
// Pull changes from Google Tasks, then reload everything
await fetch('/api/tasks/sync').catch(e => console.error('Task pull sync error:', e));
await Promise.all([fetchCalendarEvents(), fetchTasks()]);
setSyncStatus('synced');
setTimeout(() => setSyncStatus('idle'), 3000);
} catch (error) {
console.error('Error syncing:', error);
setSyncStatus('idle');
}
};
// 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('');
return;
}
try {
const response = await fetch('/api/someday-lists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newSomedayListName.trim() }),
});
if (response.ok) {
const data = await response.json();
setSomedayLists(prev => [...prev, {
...data.list,
tasks: [] // Initially empty
}]);
setNewSomedayListName('');
setIsAddingSomedayList(false);
}
} catch (error) {
console.error('Error adding someday list:', error);
}
};
// 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>
);
}
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('');
})()
}} />
{/* Refactored 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">
{/* LEFT SECTION: Slot Duration & Days to Show */}
<div className="flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
{/* 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);
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>
)}
{/* View Style Toggles */}
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1" title="View Style">
<button
onClick={() => {
setViewStyle('simple');
setShowTimeGrid(true);
saveSetting('viewStyle', 'simple');
saveSetting('showTimeGrid', true);
}}
className={`px-2 py-0.5 text-xs 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'}`}
>
Simple
</button>
<button
onClick={() => {
setViewStyle('calendar');
setShowTimeGrid(true);
saveSetting('viewStyle', 'calendar');
saveSetting('showTimeGrid', true);
}}
className={`px-2 py-0.5 text-xs 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'}`}
>
Calendar
</button>
<button
onClick={() => {
setViewStyle('list');
setShowTimeGrid(false);
saveSetting('viewStyle', 'list');
saveSetting('showTimeGrid', false);
}}
className={`px-2 py-0.5 text-xs 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'}`}
>
List
</button>
</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">
{/* Week & Year */}
<div className="text-lg font-bold whitespace-nowrap flex items-center gap-2">
{(isLoading || isSyncing) && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-600" title="Syncing..."></div>
)}
KW {getWeekNumber(currentWeekStart).toString().padStart(2, '0')} <span className="text-gray-400">|</span> {currentWeekStart.getFullYear()}
</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 text-gray-600 hover:text-black transition-colors ${showNextTask ? 'cursor-default' : ''}`}
title={showNextTask ? "Next task" : "Edit goal"}
style={{
fontFamily: profile.goalFontFamily ? `"${profile.goalFontFamily}", sans-serif` : undefined,
fontSize: profile.goalFontSize || undefined,
fontWeight: profile.goalFontWeight || undefined,
}}
>
{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];
return nextTask ? `Do this now: ${nextTask.title}` : goal;
})() : goal}
</span>
)}
<span className="text-gray-300 mx-2">-</span>
</div>
</div>
{/* RIGHT SECTION: Navigation & Tools */}
<div className="flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
{/* 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>
{/* Main Grid with Time Column */}
<div className="time-grid-wrapper">
{/* Time Column */}
{showTimeGrid && (
<div className="time-column">
<div className="time-column-header" style={{ minHeight: `${getHeaderHeight(cellDuration)}px` }}></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';
return (
<div
key={slot}
className={`time-slot-label ${isHourStart ? 'hour-start' : ''}`}
style={{ height: `${getSlotHeight(cellDuration)}px` }}
>
{isHourStart && <span>{formatHour(hour)}</span>}
</div>
);
})}
</div>
</div>
)}
{/* Day Columns */}
<main
className={`weekly-days-grid cols-${viewDays}`}
data-slide-direction={slideDirection}
data-nav-type={viewDays > 1 ? 'week' : 'day'}
style={{ viewTransitionName: viewDays > 1 ? 'week-grid' : 'none' } as React.CSSProperties}
>
{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' : ''}`}
style={{ viewTransitionName: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` } as any}
>
{/* Day Header */}
<header className="weekly-day-header">
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<h3 className={`weekly-day-name ${isSameDay(date, new Date()) ? 'is-today' : ''}`} style={{ marginBottom: 0 }}>
{getDayName(date, language)} <span className="weekly-day-date">{formatDateHeader(date, language)}</span>
</h3>
</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' }}
>
{/* 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;
return <div className="now-line" 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>
);
})}
{visibleSlots.map((slot) => {
const hour = getHourFromSlot(slot);
const minutes = slot.split(':')[1];
const isHourStart = minutes === '00';
const slotTasks = getTasksForSlot(date, slot);
const slotEvents = getEventsForSlot(date, slot);
const isActive = activeSlot?.day === date.getDay() && activeSlot?.slot === slot;
const isProtected = isSlotProtected(date, slot);
const handleSlotClick = (e: React.MouseEvent) => {
if (isProtected) return; // Don't allow adding tasks to protected 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) return; // Don't allow dropping on protected 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 ? 'drop-target' : ''} ${isActive ? 'active' : ''}`}
style={{ height: `${getSlotHeight(cellDuration)}px`, position: 'relative', cursor: isProtected ? 'not-allowed' : 'text' }}
onClick={handleSlotClick}
onDragOver={(e) => !isProtected && handleDragOver(e, date.getDay(), slot)}
onDrop={handleSlotDrop}
>
{/* Drop preview indicator */}
{isDropTarget && !isProtected && <div className="drop-preview" />}
{slotTasks.map(task => (
<div
key={task.id}
className={`time-slot-task ${task.completed ? 'completed' : ''} ${draggedTask?.id === task.id ? 'dragging' : ''}`}
draggable={!editingTaskId}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
onClick={(e) => {
e.stopPropagation();
if (editingTaskId !== task.id) {
toggleTask(task.id);
}
}}
>
{editingTaskId === task.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement;
updateTask(task.id, input.value);
}}
onClick={(e) => e.stopPropagation()}
style={{ width: '100%', paddingRight: '20px' }}
>
<input
name="title"
autoFocus
defaultValue={task.title}
onBlur={(e) => updateTask(task.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setEditingTaskId(null);
if (e.key === 'Enter') e.currentTarget.blur();
}}
className="weekly-task-text"
style={{ width: '100%', background: 'transparent', border: 'none', borderBottom: '1px solid var(--weekly-border)', outline: 'none' }}
/>
{/* Duration Presets */}
<div className="duration-presets" style={{ display: 'flex', gap: '4px', marginTop: '6px', flexWrap: 'wrap' }}>
{[15, 30, 45, 60, 90, 120].map(m => (
<button
key={m}
type="button"
onClick={(e) => { e.stopPropagation(); updateTaskDuration(task.id, m); }}
title={`Set duration to ${m} minutes`}
style={{ fontSize: '0.7rem', padding: '2px 6px', background: '#f5f5f5', border: '1px solid #ccc', borderRadius: '3px', cursor: 'pointer', color: '#333' }}
>
{m < 60 ? `${m}m` : `${m / 60}h`}
</button>
))}
</div>
</form>
) : (
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'pre-wrap', wordBreak: 'break-word', flex: 1 }} onDoubleClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}>
{task.title}
</span>
)}
<div className="task-actions" style={{
display: 'flex',
alignItems: 'center',
gap: '2px',
marginLeft: '4px',
flexShrink: 0,
flexWrap: 'nowrap',
background: darkMode ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.8)',
padding: '1px 3px',
borderRadius: '4px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
{/* Edit button */}
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }} 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>
{/* Notes button */}
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setSelectedTaskForNotes(task); }} 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 button */}
{!task.completed && (
<button className={`task-action-btn ${task.isRolling ? 'active' : ''}`} onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }} 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="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
</button>
)}
{/* Recurrence button */}
<button
className={`task-action-btn ${task.isRecurring ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
>
<Repeat size={12} strokeWidth={2.5} />
</button>
{/* Delete button */}
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
</div>
</div>
))}
{/* 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%',
height: '100%',
border: 'none',
background: 'transparent',
outline: 'none',
minHeight: '24px',
paddingLeft: '0'
}}
/>
</form>
)}
</div>
);
})}
{/* All Day Events Section */}
{/* Untimed Tasks List below grid */}
<div className="weekly-task-list" style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '0.5rem' }}>
{/* Filter for untimed tasks */}
{getTasksForDate(date)
.filter(task => !task.startTime)
.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={(notes) => updateTaskNotes(task.id, notes)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
/>
))}
</div>
</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={(notes) => updateTaskNotes(task.id, notes)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
/>
))}
</ol>
</div>
)
}
</div>
);
})}
</main >
</div >
{/* All-Day Events Section */}
{
(() => {
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>
);
})()
}
{/* Someday Section */}
{
showSomeday && (
<section className={`weekly-someday ${somedayExpanded ? 'expanded' : 'collapsed'} dark:bg-gray-900 dark:text-white transition-colors duration-200`}>
<div style={{ display: 'flex', flexDirection: 'row' }}>
{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, display: 'flex', flexDirection: 'column' }}>
{somedayExpanded && (
<div
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
onWheel={handleSomedayWheel}
>
{(somedayLists.length > 0 ? somedayLists : [{ id: 'default', title: 'LISTE', tasks: [] }]).slice(0, Math.max(somedayLists.length, viewDays)).map(list => (
<div
key={list.id}
className={`weekly-someday-list ${draggingListId === list.id ? 'is-dragging' : ''} dark:bg-gray-800 dark:border-gray-700 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 clicking the handle
if (!target.closest('.someday-drag-handle')) {
e.preventDefault();
return;
}
setDraggingListId(list.id);
e.dataTransfer.setData('text/list-id', list.id);
e.dataTransfer.effectAllowed = 'move';
}}
onDragEnd={() => setDraggingListId(null)}
onDragOver={(e) => {
e.preventDefault(); // Allow drop
e.dataTransfer.dropEffect = 'move';
}}
onDrop={async (e) => {
e.preventDefault();
setDraggingListId(null);
const draggedListId = e.dataTransfer.getData('text/list-id');
const draggedTaskId = e.dataTransfer.getData('text/plain');
if (draggedListId === list.id) return;
// Check if a calendar task is being dropped into this someday list
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
// Move calendar task to this someday list
const taskToMove = draggedTask;
// Remove from calendar tasks
setTasks(prev => prev.filter(t => t.id !== taskToMove.id));
// Add to target someday list and remove from source/other someday lists
const movedTask = { ...taskToMove, somedayListId: list.id, scheduledDate: undefined, dayOfWeek: null, startTime: '' };
setSomedayLists(prev => prev.map(l => {
// Filter out the task from all lists first (handles source removal and prevents target duplicates)
const filteredTasks = l.tasks.filter(t => t.id !== taskToMove.id);
if (l.id === list.id) {
return { ...l, tasks: [...filteredTasks, movedTask] };
}
return { ...l, tasks: filteredTasks };
}));
setDraggedTask(null);
// Persist
try {
await fetch('/api/tasks', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: taskToMove.id,
somedayListId: list.id,
scheduledDate: null,
dayOfWeek: null,
startTime: null
}),
});
// Clear due date in external provider when moving to someday
if (taskToMove.externalId && taskToMove.externalProvider) {
fetch('/api/tasks/sync', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: taskToMove.id, scheduledDate: null }),
}).catch(e => console.error('Sync error:', e));
}
} catch (error) {
console.error('Error moving task to someday list:', error);
}
return;
}
// Reorder logic (list drag)
if (!draggedListId) return;
const draggedIndex = somedayLists.findIndex(l => l.id === draggedListId);
const targetIndex = somedayLists.findIndex(l => l.id === list.id);
if (draggedIndex === -1 || targetIndex === -1) return;
const newLists = [...somedayLists];
const [draggedItem] = newLists.splice(draggedIndex, 1);
newLists.splice(targetIndex, 0, draggedItem);
setSomedayLists(newLists);
// 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">
<GripVertical size={14} />
</div>
{/* Editable Title */}
<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();
}}
/>
<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>
<ol className="weekly-task-list" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{list.tasks.map(task => (
<TaskItem
key={task.id}
task={task}
isEditing={editingTaskId === task.id}
onToggle={() => toggleTask(task.id)}
onEdit={() => setEditingTaskId(task.id)}
onUpdate={(title) => updateTask(task.id, title)}
onDelete={() => deleteTask(task.id)}
onNotes={() => setSelectedTaskForNotes(task)}
onRollToggle={() => toggleTaskRolling(task.id)}
onRecurrence={() => setSelectedTaskForRecurrence(task)}
onDragStart={(e, t) => handleDragStart(e, t)}
onDragEnd={handleDragEnd}
variant="minimal"
isSomeday={true}
/>
))}
<SomedayAddTask listId={list.id} onAdd={async (title) => {
try {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, somedayListId: list.id }),
});
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);
}
}} />
{Array.from({ length: Math.max(0, 5 - list.tasks.length) }).map((_, i) => (
<li key={`filler-${i}`} className="weekly-task-item minimal filler" style={{
borderBottom: '1px solid var(--weekly-border)',
height: '32px',
margin: '0 0.5rem',
pointerEvents: 'none'
}}></li>
))}
</ol>
</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" onClick={(e) => { e.stopPropagation(); setIsAddingSomedayList(false); }}>
<div className="bg-white p-6 rounded-lg shadow-lg w-96 max-w-full m-4" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold mb-4">New List</h3>
<input
autoFocus
type="text"
placeholder="LIST NAME..."
value={newSomedayListName}
onChange={(e) => setNewSomedayListName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveSomedayList();
if (e.key === 'Escape') {
setIsAddingSomedayList(false);
setNewSomedayListName('');
}
}}
className="w-full p-2 border border-gray-300 rounded mb-4"
/>
<div className="flex justify-end gap-2">
<button onClick={() => setIsAddingSomedayList(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded">Cancel</button>
<button onClick={saveSomedayList} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</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 => prev.map(t => {
const isMatch = t.title === task.title &&
t.userId === task.userId &&
t.recurrenceInterval === task.recurrenceInterval &&
t.recurrenceUnit === task.recurrenceUnit;
if (isMatch) {
return { ...t, recurrenceEndDate: new Date(dateStr) };
}
return t;
}));
// 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);
}
}}
/>
{/* Recurrence Modal */}
{
selectedTaskForRecurrence && (
<RecurrenceModal
task={selectedTaskForRecurrence}
onClose={() => setSelectedTaskForRecurrence(null)}
onSave={async (recurrence) => {
await updateTaskFields(selectedTaskForRecurrence.id, recurrence);
setSelectedTaskForRecurrence(null);
}}
/>
)
}
{/* 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
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}
goalFallbackType={profile.goalFallbackType}
goalDefaultSentence={profile.goalDefaultSentence}
importingTasksState={importingTasksState}
executeImport={executeImport}
onImportLists={(lists) => doImport('apple', lists)}
importStatusMsg={importStatusMsg}
/>
)
}
{
selectedTaskForRecurrence && (
<TaskRecurrenceModal
task={selectedTaskForRecurrence}
onClose={() => setSelectedTaskForRecurrence(null)}
onSave={handleRecurrenceSave}
/>
)
}
{
selectedTaskForNotes && (
<div className="weekly-modal-overlay" onClick={() => setSelectedTaskForNotes(null)}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()}>
<h3>Notes: {selectedTaskForNotes.title}</h3>
{/* Toolbar for Modal */}
<div className="notes-toolbar" style={{ marginTop: '1rem' }}>
<button className="notes-toolbar-btn" onClick={() => {
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}**${selection}**${after}`;
updateTaskNotes(selectedTaskForNotes.id, newText);
// Hacky re-focus and update value visually since it's uncontrolled-ish/onBlur driven
textarea.value = newText;
textarea.focus();
textarea.setSelectionRange(start + 2, start + 2 + selection.length);
}} title="Bold">B</button>
<button className="notes-toolbar-btn" onClick={() => {
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}*${selection}*${after}`;
updateTaskNotes(selectedTaskForNotes.id, newText);
textarea.value = newText;
textarea.focus();
textarea.setSelectionRange(start + 1, start + 1 + selection.length);
}} title="Italic">i</button>
<button className="notes-toolbar-btn" onClick={() => {
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}[${selection}](url)${after}`;
updateTaskNotes(selectedTaskForNotes.id, newText);
textarea.value = newText;
textarea.focus();
textarea.setSelectionRange(start + 1, start + 1 + selection.length);
}} title="Link">🔗</button>
<button className="notes-toolbar-btn" onClick={() => {
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}- ${selection}${after}`;
updateTaskNotes(selectedTaskForNotes.id, newText);
textarea.value = newText;
textarea.focus();
textarea.setSelectionRange(start + 2, start + 2 + selection.length);
}} title="List"></button>
<button className="notes-toolbar-btn" onClick={() => {
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const before = text.substring(0, start);
const selection = text.substring(start, end);
const after = text.substring(end);
const newText = `${before}![alt text](url)${after}`;
updateTaskNotes(selectedTaskForNotes.id, newText);
textarea.value = newText;
textarea.focus();
textarea.setSelectionRange(start + 2, start + 10); // select "alt text"
}} title="Image">🖼</button>
</div>
<textarea
className="weekly-notes-editor"
defaultValue={selectedTaskForNotes.markdownContent || ''}
autoFocus
placeholder="Add details, notes, or links..."
onBlur={(e) => updateTaskNotes(selectedTaskForNotes.id, e.target.value)}
/>
<div className="weekly-modal-actions">
<button className="weekly-btn weekly-btn-secondary" onClick={() => setSelectedTaskForNotes(null)}>Close</button>
</div>
</div>
</div>
)
}
<ImportListModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
onImport={handleConfirmImport}
provider={importProvider}
lists={importLists}
isLoading={isFetchingLists}
/>
</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 = (e: React.FormEvent) => {
e.preventDefault();
if (newTaskTitle.trim()) {
onAddTask(newTaskTitle);
setNewTaskTitle('');
}
};
return (
<form
onSubmit={handleAddTask}
className="weekly-task-input"
onDragOver={onDragOver}
onDrop={onDrop}
>
<input
type="text"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder="Type a to-do..."
/>
</form>
);
}
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={{ borderBottom: '1px solid var(--weekly-border)', 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;
}
function TaskItem({ task, isEditing, onToggle, onEdit, onUpdate, onDelete, onNotes, onRollToggle, onRecurrence, onDragStart, onDragEnd, variant = 'default', isSomeday = false }: TaskItemProps) {
const [editValue, setEditValue] = useState(task.title);
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || '');
const inputRef = useRef<HTMLInputElement>(null);
const notesRef = useRef<HTMLTextAreaElement>(null);
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 === '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
className={`weekly-task-item ${variant} ${task.completed ? 'completed' : ''} ${isSomeday ? 'relative mx-2' : ''}`}
draggable={!isEditing && !isNotesOpen} // Disable drag when editing
onDragStart={(e) => onDragStart(e as unknown as DragEvent, task)}
onDragEnd={onDragEnd}
onClick={(e) => {
if ((variant === 'minimal' || isSomeday) && !isEditing) {
const target = e.target as HTMLElement;
if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) return;
onEdit();
}
}}
>
<div style={{ width: '100%', position: 'relative' }}>
{/* 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' ? (
<input
ref={inputRef}
type="text"
className="weekly-task-text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onUpdate(editValue)}
style={{
border: 'none', background: 'transparent',
outline: 'none', width: '100%', padding: '0',
fontSize: '0.9375rem', fontWeight: 500
}}
/>
) : (
<input
ref={inputRef}
type="text"
className="weekly-task-text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onUpdate(editValue)}
/>
)}
</form>
) : (
<>
<span
className={`weekly-task-text flex-1 ${task.completed ? 'completed' : ''}`}
onClick={(e) => {
if (variant === 'default') onToggle();
// For minimal/someday, parent onClick handles edit
}}
onDoubleClick={variant === 'default' ? onEdit : undefined}
style={variant === 'minimal' || isSomeday ? { fontSize: '0.9375rem' } : undefined}
>
{task.title}
</span>
<div className="task-actions absolute right-0 top-0 bottom-0 flex items-center bg-white/95 dark:bg-gray-800/95 pl-2 pr-1 shadow-[-12px_0_12px_-4px_rgba(255,255,255,0.95)] dark:shadow-[-12px_0_12px_-4px_rgba(31,41,55,0.95)] z-20">
{/* 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>
{/* 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" 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">
<line x1="5" y1="12" x2="19" y2="12"></line>
</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>
)}
</div>
</li>
);
}
// Recurrence Modal Component
interface RecurrenceModalProps {
task: Task;
onClose: () => void;
onSave: (recurrence: { isRecurring: boolean, interval: number, unit: string, time: string, endDate: Date | null }) => void;
}
function RecurrenceModal({ task, onClose, onSave }: RecurrenceModalProps) {
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
const [interval, setInterval] = useState(task.recurrenceInterval || 1);
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks');
const [time, setTime] = useState(task.recurrenceTime || task.startTime || '09:00');
const [endDate, setEndDate] = useState<string>(task.recurrenceEndDate ? formatDateToISO(new Date(task.recurrenceEndDate)) : '');
const handleSave = () => {
onSave({
isRecurring,
interval,
unit,
time,
endDate: endDate ? new Date(endDate) : null
});
onClose();
};
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>Recurring Task</h3>
<div style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={isRecurring}
onChange={(e) => setIsRecurring(e.target.checked)}
style={{ width: 'auto', marginRight: '4px' }}
/>
Enable Recurrence
</label>
</div>
{isRecurring && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '0.9rem', color: '#666' }}>Repeat every</span>
<input
type="number"
min="1"
value={interval}
onChange={(e) => setInterval(parseInt(e.target.value) || 1)}
style={{ width: '60px', padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '0.9rem', color: '#666' }}>At time</span>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '0.9rem', color: '#666' }}>End date (optional)</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
</div>
)}
<div className="weekly-modal-actions" style={{ marginTop: '1.5rem' }}>
<button className="weekly-btn weekly-btn-secondary" onClick={onClose}>Cancel</button>
<button className="weekly-btn weekly-btn-primary" onClick={handleSave}>Save</button>
</div>
</div>
</div>
);
}
// Settings Modal Component
interface SettingsSidebarProps {
onClose: () => void;
onSettingsChanged?: (newSettings: {
showTimeGrid: boolean;
cellDuration: CellDuration;
viewStyle: 'grid' | 'list';
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;
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;
eventFontFamily?: string;
eventFontSize?: string;
eventFontWeight?: string;
weekdayColor?: string;
dateColor?: string;
taskColor?: string;
todayHighlightColor?: string;
}) => void;
showTimeGrid: boolean;
setShowTimeGrid: (show: boolean) => void;
cellDuration: CellDuration;
setCellDuration: (duration: CellDuration) => void;
weekStartDay: number;
setWeekStartDay: (day: number) => void;
viewStyle: ViewStyle;
setViewStyle: (style: ViewStyle) => void;
showSomeday: boolean;
setShowSomeday: (show: boolean) => void;
showAllDay: boolean;
setShowAllDay: (show: boolean) => void;
showSchedule: boolean;
setShowSchedule: (show: boolean) => void;
goal: string;
setGoal: (goal: string) => void;
saveGoal: (goal: string) => void;
connections: any[];
onUpdateConnections: (connections: any[]) => void;
focusTimerDuration: number;
setFocusTimerDuration: (duration: number) => void;
focusBreakDuration: number;
setFocusBreakDuration: (duration: number) => void;
fontSize: 'S' | 'M' | 'L';
setFontSize: (size: 'S' | 'M' | 'L') => void;
showNextTask: boolean;
setShowNextTask: (show: boolean) => void;
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;
protectEventTimes: boolean;
setProtectEventTimes: (protect: boolean) => void;
onRemoveConnection: (id: string) => void | Promise<void>;
goalDefaultSentence?: string;
goalFallbackType?: string;
importingTasksState: boolean;
executeImport: (provider: 'google' | 'apple') => Promise<void>;
onImportLists: (lists: { id: string, title: string }[]) => Promise<void>;
importStatusMsg: { type: 'success' | 'error', text: string } | null;
}
function SettingsSidebar({
onClose,
onSettingsChanged,
showTimeGrid,
setShowTimeGrid,
cellDuration,
setCellDuration,
weekStartDay,
setWeekStartDay,
viewStyle,
setViewStyle,
showSomeday,
setShowSomeday,
showAllDay,
setShowAllDay,
showSchedule,
setShowSchedule,
goal,
setGoal,
saveGoal,
connections,
onUpdateConnections,
onRemoveConnection,
focusTimerDuration,
setFocusTimerDuration,
focusBreakDuration,
setFocusBreakDuration,
fontSize,
setFontSize,
showNextTask,
setShowNextTask,
headlineFont,
headlineFontSize,
headlineFontWeight,
dateFontFamily,
dateFontSize,
dateFontWeight,
timeTaskFontFamily,
timeTaskFontSize,
timeTaskFontWeight,
bodyFont,
taskFontFamily,
taskFontSize,
taskFontWeight,
eventFontFamily,
eventFontSize,
eventFontWeight,
fontWeight,
weekendColorSat,
weekendColorSun,
protectEventTimes,
setProtectEventTimes,
goalFallbackType,
goalDefaultSentence,
importingTasksState,
executeImport,
onImportLists,
importStatusMsg
}: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('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);
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';
goalDefaultSentence?: string;
goalFontFamily?: string;
goalFontSize?: string;
goalFontWeight?: string;
goalScope?: 'week' | 'day';
}>({
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,
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',
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);
};
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',
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',
goalFontWeight: data.user.goalFontWeight || '500',
goalScope: data.user.goalScope || 'week',
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',
});
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);
}
}
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,
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: profile.taskFontWeight || taskFontWeight,
eventFontFamily: profile.eventFontFamily || eventFontFamily,
eventFontSize: profile.eventFontSize || eventFontSize,
eventFontWeight: profile.eventFontWeight || eventFontWeight,
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
} 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', borderBottom: '1px solid #eee', padding: '0 24px' }}>
<button
onClick={() => setActiveTab('general')}
style={{ padding: '12px 16px', borderBottom: activeTab === 'general' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'general' ? 1 : 0.6 }}
>
{t.general}
</button>
<button
onClick={() => setActiveTab('calendar')}
style={{ padding: '12px 16px', borderBottom: activeTab === 'calendar' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'calendar' ? 1 : 0.6 }}
>
{t.calendar}
</button>
<button
onClick={() => setActiveTab('account')}
style={{ padding: '12px 16px', borderBottom: activeTab === 'account' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'account' ? 1 : 0.6 }}
>
{t.account}
</button>
</div>
<div className="weekly-settings-content" style={{ flex: 1, overflowY: 'auto', padding: '24px' }}>
{activeTab === 'general' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Goal */}
<div>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.goalOfWeek}</label>
<input
type="text"
value={goal}
onChange={(e) => setGoal(e.target.value)}
onBlur={() => saveGoal(goal)}
onKeyDown={(e) => e.key === 'Enter' && (e.currentTarget.blur())}
className="weekly-input"
placeholder={t.goalOfWeek}
style={{ width: '100%' }}
/>
</div>
{/* 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>
<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="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>
<div>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.viewStyle}</label>
<select
value={viewStyle}
onChange={(e) => setViewStyle(e.target.value as ViewStyle)}
className="weekly-input"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="simple">{t.simpleView}</option>
<option value="calendar">{t.calendarView}</option>
<option value="list">{t.listView}</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>
<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>
{/* Text Size setting */}
<div className="settings-item" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<label style={{ fontWeight: 500, color: 'var(--weekly-settings-label)' }}>Text size</label>
<div className="weekly-toggle-group" style={{ display: 'flex', background: 'var(--weekly-settings-toggle-bg)', borderRadius: '6px', padding: '2px' }}>
{(['S', 'M', 'L'] as const).map((size) => (
<button
key={size}
type="button"
onClick={() => {
setProfile({ ...profile, fontSize: size });
setFontSize(size);
}}
style={{
padding: '6px 12px',
border: 'none',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer',
background: (profile.fontSize || fontSize) === size ? 'var(--weekly-settings-toggle-active-bg)' : 'transparent',
color: (profile.fontSize || fontSize) === size ? 'var(--weekly-settings-toggle-active-text)' : 'var(--weekly-settings-toggle-text)',
boxShadow: (profile.fontSize || fontSize) === size ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',
transition: 'all 0.2s ease'
}}
>
{size}
</button>
))}
</div>
</div>
{/* 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>
{/* 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: '2fr 1fr 1fr', gap: '8px' }}>
<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>
<select
value={profile.headlineFontSize || '1.25rem'}
onChange={(e) => setProfile({ ...profile, headlineFontSize: 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="1rem">Small 16px</option>
<option value="1.25rem">Normal 20px</option>
<option value="1.5rem">Large 24px</option>
<option value="1.75rem">XL 28px</option>
<option value="2rem">Huge 32px</option>
</select>
<select
value={profile.headlineFontWeight || '900'}
onChange={(e) => setProfile({ ...profile, headlineFontWeight: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
<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: '2fr 1fr 1fr', gap: '8px' }}>
<select
value={profile.dateFontFamily || 'Inter'}
onChange={(e) => setProfile({ ...profile, dateFontFamily: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
value={profile.dateFontSize || '0.65rem'}
onChange={(e) => setProfile({ ...profile, dateFontSize: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
<option value="0.55rem">XS 9px</option>
<option value="0.65rem">Normal 10px</option>
<option value="0.75rem">Small 12px</option>
<option value="0.85rem">Medium 14px</option>
<option value="1rem">Large 16px</option>
</select>
<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: '2fr 1fr 1fr', gap: '8px' }}>
<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' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
value={profile.taskFontSize || '0.9rem'}
onChange={(e) => setProfile({ ...profile, taskFontSize: e.target.value, timeTaskFontSize: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
<option value="0.75rem">Small 12px</option>
<option value="0.9rem">Normal 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.1rem">Large 18px</option>
<option value="1.25rem">XL 20px</option>
</select>
<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: '2fr 1fr 1fr', gap: '8px' }}>
<select
value={profile.eventFontFamily || 'Inter'}
onChange={(e) => setProfile({ ...profile, eventFontFamily: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
value={profile.eventFontSize || '0.85rem'}
onChange={(e) => setProfile({ ...profile, eventFontSize: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
<option value="0.75rem">Small 12px</option>
<option value="0.85rem">Normal 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.1rem">Large 18px</option>
</select>
<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: '2fr 1fr 1fr', gap: '8px' }}>
<select
value={profile.goalFontFamily || 'Inter'}
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
<select
value={profile.goalFontSize || '0.9rem'}
onChange={(e) => setProfile({ ...profile, goalFontSize: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
>
<option value="0.75rem">Small 12px</option>
<option value="0.85rem">Normal 14px</option>
<option value="0.9rem">Default 14px</option>
<option value="1rem">Medium 16px</option>
<option value="1.1rem">Large 18px</option>
<option value="1.25rem">XL 20px</option>
</select>
<select
value={profile.goalFontWeight || '500'}
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>
{/* 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' }}>Weekday Name</label>
<input
type="color"
value={profile.weekdayColor || '#888888'}
onChange={(e) => setProfile({ ...profile, weekdayColor: 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' }}>Date</label>
<input
type="color"
value={profile.dateColor || '#888888'}
onChange={(e) => setProfile({ ...profile, dateColor: 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' }}>Task Text</label>
<input
type="color"
value={profile.taskColor || '#333333'}
onChange={(e) => setProfile({ ...profile, taskColor: 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' }}>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>
<div style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
id="showNextTask"
checked={showNextTask || false}
onChange={(e) => setShowNextTask(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<label htmlFor="showNextTask" style={{ cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 }}>
Show &quot;Do This Now&quot; instead of Motto
</label>
</div>
<div style={{ marginBottom: '1rem', display: 'flex', gap: '16px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500, 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 })}
style={{ width: '100%' }}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500, 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 })}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Goal Scope Setting */}
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '16px', borderRadius: '8px', marginBottom: '1.5rem', border: '1px solid var(--weekly-border)' }}>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '12px' }}>{t.goalScope}</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setProfile({ ...profile, goalScope: 'week' })}
style={{
flex: 1, padding: '8px 12px', borderRadius: '6px',
border: profile.goalScope === 'week' || !profile.goalScope ? '2px solid var(--weekly-accent, #4A90D9)' : '1px solid #ddd',
background: profile.goalScope === 'week' || !profile.goalScope ? 'var(--weekly-accent-light, #e8f0fe)' : 'white',
fontWeight: profile.goalScope === 'week' || !profile.goalScope ? 600 : 400,
cursor: 'pointer', fontSize: '0.85rem'
}}
>{t.goalScopeWeek}</button>
<button
onClick={() => setProfile({ ...profile, goalScope: 'day' })}
style={{
flex: 1, padding: '8px 12px', borderRadius: '6px',
border: profile.goalScope === 'day' ? '2px solid var(--weekly-accent, #4A90D9)' : '1px solid #ddd',
background: profile.goalScope === 'day' ? 'var(--weekly-accent-light, #e8f0fe)' : 'white',
fontWeight: profile.goalScope === 'day' ? 600 : 400,
cursor: 'pointer', fontSize: '0.85rem'
}}
>{t.goalScopeDay}</button>
</div>
</div>
{/* Goal Fallback Settings */}
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '16px', borderRadius: '8px', marginBottom: '1.5rem', border: '1px solid var(--weekly-border)' }}>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '12px' }}>{t.goalOfWeek} Fallback</label>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>{t.goalFallback}</label>
<select
value={profile.goalFallbackType || 'quote'}
onChange={(e) => setProfile({ ...profile, goalFallbackType: e.target.value as any })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd', background: 'white' }}
>
<option value="quote">Motivational Quote / Holiday Hint</option>
<option value="next_todo">Next Pending Task</option>
<option value="default">Custom Default Sentence</option>
</select>
</div>
{profile.goalFallbackType === 'default' && (
<div>
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>{t.defaultGoal}</label>
<input
type="text"
value={profile.goalDefaultSentence || ''}
onChange={(e) => setProfile({ ...profile, goalDefaultSentence: e.target.value })}
placeholder="e.g. goal of the week"
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd', background: 'white' }}
/>
</div>
)}
</div>
<div style={{ marginTop: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={handleUpdateProfile}
className="weekly-btn-primary"
style={{ padding: '10px 20px' }}
>
{t.saveChanges}
</button>
{accountMsg && (
<span style={{
fontSize: '0.9rem',
color: accountMsg.toLowerCase().includes('success') ? '#059669' : '#dc2626',
fontWeight: 600
}}>
{accountMsg}
</span>
)}
</div>
</div>
) : activeTab === '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 ? (
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
{conn.calendars.map((cal: any) => {
const isShared = /⚠/.test(cal.title);
const cleanTitle = cal.title.replace(/\s*⚠️?\s*/g, '').trim();
return (
<li key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={cal.selected !== false}
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { selected: e.target.checked })}
style={{ cursor: 'pointer' }}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text)' }}>
{cleanTitle}
{isShared && <span title="Shared calendar" style={{ marginLeft: '5px', fontSize: '0.75rem', opacity: 0.5 }}>🔗</span>}
{cal.isPrimary && <span style={{ fontSize: '0.8em', color: 'var(--weekly-text-light)', marginLeft: '4px' }}>(Primary)</span>}
</span>
</div>
{/* Editable toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', opacity: 0.8 }}>
<input
type="checkbox"
checked={cal.editable === true}
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { editable: e.target.checked })}
style={{ cursor: 'pointer', width: '12px', height: '12px' }}
title="Allow adding/editing events"
/>
<span style={{ fontSize: '0.8rem', color: '#888' }} title="Allow adding/editing events">Editable</span>
</div>
</li>
);
})}
</ul>
) : (
<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' }}>Import Tasks</h3>
<p style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', marginBottom: '1rem' }}>
Import tasks from Google Tasks into a Someday list.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => executeImport('google')}
className="calendar-connect-btn"
disabled={!connections.some(c => c.provider === 'google') || importingTasksState}
style={{ opacity: (!connections.some(c => c.provider === 'google') || importingTasksState) ? 0.5 : 1 }}
>
<span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'}
</button>
</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>
</>
)
) : (
/* 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: '30px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
<h4 style={{ marginBottom: '10px', fontSize: '1.1rem' }}>{(profile.language === 'de') ? 'Datenexport' : 'Data Export'}</h4>
<p style={{ fontSize: '0.9rem', color: '#666', 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' }}>Start</label>
<input
type="date"
value={exportStartDate}
onChange={(e) => setExportStartDate(e.target.value)}
className="weekly-input"
style={{ width: '100%', padding: '6px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, marginBottom: '4px' }}>End</label>
<input
type="date"
value={exportEndDate}
onChange={(e) => setExportEndDate(e.target.value)}
className="weekly-input"
style={{ width: '100%', padding: '6px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
</div>
<a
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}`}
target="_blank"
className="weekly-auth-button"
style={{ display: 'inline-flex', textDecoration: 'none', background: '#f8fafc', border: '1px solid #e2e8f0', color: '#0f172a', justifyContent: 'center' }}
>
{(profile.language === 'de') ? 'Erledigte Aufgaben exportieren (CSV)' : 'Export Completed Tasks (CSV)'}
</a>
</div>
<div className="account-danger-zone" style={{ marginTop: '30px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '10px' }}>{t.dataPrivacy}</h3>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleDownloadData}
style={{ padding: '8px 12px', border: '1px solid #ddd', background: 'white', borderRadius: '4px', cursor: 'pointer' }}
>
{t.downloadData}
</button>
<button
onClick={handleDeleteAccount}
style={{ padding: '8px 12px', border: '1px solid #d32f2f', background: 'white', color: '#d32f2f', borderRadius: '4px', cursor: 'pointer' }}
>
{t.deleteAccount}
</button>
</div>
</div>
</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 ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Connecting...
</>
) : 'Connect'}
</button>
</div>
</div>
</div>
)}
</>
);
}