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

4188 lines
220 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
} from 'lucide-react';
// Types
import UserMenu from './UserMenu';
import SearchModal from './SearchModal';
import SimpleDatePicker from './SimpleDatePicker';
import RecurringTasksManager from './RecurringTasksManager';
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;
}
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: 'Calendar',
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',
gridView: 'Grid View',
listView: 'List View',
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',
mottoOfWeek: 'Motto of the Week',
showSomeday: 'Show Someday Section',
showAllDay: 'Show All-Day Section'
},
de: {
settings: 'Einstellungen',
general: 'Allgemein',
calendar: 'Kalender',
account: 'Konto',
runningList: 'Laufende Liste (Aufgaben automatisch auf heute verschieben)',
protectEventTimes: 'Ereigniszeiten schützen',
showTimeGrid: 'Zeitplan anzeigen',
timeSlotDuration: 'Zeitfensterdauer',
viewStyle: 'Ansichtsstil',
gridView: 'Rasteransicht',
listView: 'Listenansicht',
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',
mottoOfWeek: 'Motto der Woche',
showSomeday: 'Irgendwann-Bereich anzeigen',
showAllDay: 'Ganztägige Ereignisse anzeigen'
}
};
// 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);
};
// 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(getStartOfWeek(new Date(), 1)); // Default align to Monday initially
const [viewDays, setViewDays] = useState(7);
const [isLoading, setIsLoading] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [timeFormat, setTimeFormat] = useState('24h');
const [dateFormat, setDateFormat] = useState('yyyy-MM-dd');
const [somedayExpanded, setSomedayExpanded] = useState(true);
const [draggingListId, setDraggingListId] = useState<string | null>(null);
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
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<'left' | 'right' | 'out-left' | 'out-right' | 'in-left' | 'in-right' | 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<'grid' | 'list'>('list');
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 [motto, setMotto] = useState('Focus and Execute');
const [isEditingMotto, setIsEditingMotto] = 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 [focusTimerDuration, setFocusTimerDuration] = useState(25);
const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M');
const [headlineFont, setHeadlineFont] = useState('Inter');
const [bodyFont, setBodyFont] = useState('Inter');
const [fontWeight, setFontWeight] = useState('400');
// Load fonts
useGoogleFonts([headlineFont, bodyFont, '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));
// Re-align current week start when start day changes
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 () => {
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 data = await response.json();
if (data.events) {
setRawCalendarEvents(data.events);
}
}
} catch (error) {
console.error('Error fetching calendar events:', error);
}
}, [currentWeekStart]);
// Calendar Event Handlers
const handleEventSave = async (eventData: any) => {
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)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to save event');
}
// Refresh events
await fetchCalendarEvents();
} catch (error) {
console.error('Error saving event:', error);
throw error;
}
};
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(); // Fetch connections
fetchCalendarEvents();
}
}, [session]);
async function fetchConnections() {
try {
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);
}
}
// 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);
}, []);
const handleSettingsChanged = (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;
bodyFont: string;
fontWeight: string;
}) => {
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);
setBodyFont(newSettings.bodyFont);
setFontWeight(newSettings.fontWeight);
// 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);
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.bodyFont) setBodyFont(data.user.bodyFont);
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
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 'grid' | 'list');
}
}
} 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),
}));
const dayTasks = fetchedTasks.filter((t: Task) => t.dayOfWeek !== null && !t.somedayListId);
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
setTasks(dayTasks);
// Populate lists with tasks
const populatedLists = fetchedLists.map(list => ({
...list,
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id)
}));
// Fallback: If there are someday tasks with IDs that don't match any list (orphans),
// or if we rely on the old "default" list for legacy data.
// The old code had a default list.
// Let's ensure we use the fetched lists.
setSomedayLists(populatedLists);
}
} 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;
const taskDateStr = 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;
const taskDateStr = 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]);
// 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.transitionDirection = direction === 'left' ? 'next' : 'prev';
doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week
doc.startViewTransition(() => {
setCurrentWeekStart(newDate);
setSlideDirection(null);
});
} 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 = () => setCurrentWeekStart(getStartOfWeek(new Date(), weekStartDay));
// 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);
}
};
const toggleTask = async (taskId: string) => {
const task = tasks.find(t => t.id === taskId);
if (!task) return;
const updatedCompleted = !task.completed;
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 }),
});
} catch (error) {
console.error('Error toggling task:', error);
}
};
const updateTask = async (taskId: string, newTitle: string) => {
if (!newTitle.trim()) {
await deleteTask(taskId);
return;
}
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() }),
});
} 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) => {
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 }),
});
} catch (error) {
console.error('Error updating task notes:', error);
}
};
const toggleTaskRolling = async (taskId: string) => {
const task = tasks.find(t => t.id === taskId);
if (!task) return;
const newRollingState = !task.isRolling;
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);
// Revert on error
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;
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 }),
});
} catch (error) {
console.error('Error moving task:', error);
}
};
const deleteTask = async (taskId: string) => {
const taskToDelete = tasks.find(t => t.id === taskId);
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) {
// Confirm deletion type
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) {
// DELETE SERIES
// Remove all tasks related to this series from the UI immediately
setTasks(prev => prev.filter(t => {
// Check if t is the original task
if (t.id === originalId) return false;
// Check if t is a virtual task of this series
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
// Check if t is the specific task being clicked (if logic above didn't catch it)
if (t.id === taskId) return false;
return true;
}));
setEditingTaskId(null);
try {
// Deleting the original ID stops the series
await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' });
} catch (error) {
console.error('Error deleting series:', error);
// Optionally revert UI state here if needed, but for now assuming success
}
return;
}
}
// NORMAL DELETE (Single instance)
setTasks(prev => prev.filter(t => t.id !== taskId));
setEditingTaskId(null);
try {
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
}),
});
} 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 || ''
}),
});
} 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 {
await fetchCalendarEvents();
setSyncStatus('synced');
// Reset status after 3 seconds
setTimeout(() => setSyncStatus('idle'), 3000);
} catch (error) {
console.error('Error syncing calendar:', 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 = {
'--font-headline': `"${headlineFont}", sans-serif`,
'--font-body': `"${bodyFont}", sans-serif`,
'--font-weight-body': fontWeight || '400',
} 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`} 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="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">
{/* 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)}
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)}
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) => setStartHour(Math.max(0, Math.min(parseInt(e.target.value) || 0, endHour - 1)))}
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) => setEndHour(Math.max(startHour + 1, Math.min(parseInt(e.target.value) || 24, 24)))}
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={() => setShowTimeGrid(!showTimeGrid)}
className={`px-2 py-0.5 text-xs rounded transition-colors ${showTimeGrid ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
>
Time Grid
</button>
<button
onClick={() => setViewStyle('grid')}
className={`px-2 py-0.5 text-xs rounded transition-colors ${!showTimeGrid && viewStyle === 'grid' ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
>
Grid
</button>
<button
onClick={() => setViewStyle('list')}
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, Motto, Focus Mode */}
<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">
KW {getWeekNumber(currentWeekStart).toString().padStart(2, '0')} <span className="text-gray-400">|</span> {currentWeekStart.getFullYear()}
</div>
{/* Motto */}
<div className="flex items-center text-sm">
<span className="text-gray-300 mx-2">-</span>
{isEditingMotto ? (
<input
type="text"
value={motto}
onChange={(e) => setMotto(e.target.value)}
onBlur={() => setIsEditingMotto(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, motto.length)}ch` }}
/>
) : (
<span
onClick={() => !showNextTask && setIsEditingMotto(true)}
className={`cursor-pointer font-medium italic text-gray-600 hover:text-black transition-colors ${showNextTask ? 'cursor-default' : ''}`}
title={showNextTask ? "Next task" : "Edit motto"}
>
{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}` : motto;
})() : motto}
</span>
)}
<span className="text-gray-300 mx-2">-</span>
</div>
{/* Focus Mode Toggle */}
<button
onClick={() => setShowFocusMode(true)}
className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-gray-100 hover:bg-gray-200 text-gray-600 transition-colors text-xs font-medium"
title="Enter Focus Mode"
>
<Target size={14} />
<span>Focus Mode</span>
</button>
</div>
{/* RIGHT SECTION: Navigation & Tools */}
<div className="flex items-center gap-3">
{/* Day/Night Mode Switch */}
<button
onClick={() => setDarkMode(!darkMode)}
className="p-1.5 rounded-md hover:bg-gray-100 transition-colors"
title={darkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
{darkMode ? <Sun size={18} className="text-yellow-500" /> : <Moon size={18} className="text-gray-500" />}
</button>
{/* Navigation Controls */}
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToPrevWeek} title="Previous Week">
<ChevronsLeft size={16} />
</button>
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToPrevDay} title="Previous Day">
<ChevronLeft size={16} />
</button>
<button className="px-3 py-1 text-xs font-bold text-gray-600 hover:text-black hover:bg-white hover:shadow-sm rounded transition-all" onClick={goToToday} title="Go to Today">
Today
</button>
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToNextDay} title="Next Day">
<ChevronLeft size={16} className="rotate-180" />
</button>
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToNextWeek} title="Next Week">
<ChevronsLeft size={16} className="rotate-180" />
</button>
</div>
{/* Search */}
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
onClick={() => setIsSearchOpen(true)}
title="Search"
>
<Search size={18} />
</button>
{/* Recurring Tasks */}
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
onClick={() => setIsRecurringTasksOpen(true)}
title="Recurring Tasks"
>
<Repeat size={18} />
</button>
<button
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
onClick={() => setShowSettings(true)}
title="Settings"
>
<Settings size={18} />
</button>
{/* User Menu */}
<UserMenu
userEmail={session?.user?.email}
onOpenSettings={() => setShowSettings(true)}
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} ${slideDirection ? `slide-${slideDirection}` : ''}`}>
{getVisibleDays().map((date, colIndex) => (
<div
key={date.toISOString()}
className="weekly-day-column"
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 style={{ fontWeight: 400 }}>{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 height = durationMinutes * pixelsPerMinute;
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 #777', 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: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '2px',
marginLeft: '4px',
flexShrink: 0
}}>
{/* Edit button */}
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }} title="Edit">
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" 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="14" height="14" stroke="currentColor" strokeWidth="2" 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="14" height="14" stroke="currentColor" strokeWidth="2" 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"}
>
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" 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>
{/* Delete button */}
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" strokeWidth="2" 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: `${eventHeight}px`,
minHeight: `${eventHeight}px`,
position: 'absolute',
top: `${topOffset}px`,
left: '4px',
right: '4px',
zIndex: 5,
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 () => {
// Only save if still active (not already submitted)
if (activeSlot && newSlotTask.trim()) {
const taskTitle = newSlotTask.trim();
setActiveSlot(null);
setNewSlotTask('');
await addTask(date, taskTitle, slot);
} else {
setActiveSlot(null);
setNewSlotTask('');
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setActiveSlot(null);
setNewSlotTask('');
}
}}
autoFocus
className="slot-input"
/>
</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}
/>
))}
</div>
</div>
) : (
<>
{/* 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 >
))}
</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 className="all-day-events-header" onClick={() => setIsAllDayExpanded(!isAllDayExpanded)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', position: 'relative', paddingRight: '2.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span className="all-day-events-title">📆 {t.allDayEvents}</span>
<span className="all-day-events-count">{allDayEvents.length}</span>
</div>
<button className="all-day-chevron" style={{ position: 'absolute', top: '0.25rem', right: '1rem', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.75rem', color: '#666', padding: '0.25rem' }}>
{isAllDayExpanded ? '▼' : '▶'}
</button>
</div>
{isAllDayExpanded && (
<div
className={`all-day-events-grid cols-${viewDays}`}
style={{ marginLeft: showTimeGrid ? '50px' : '0' }}
>
{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>
)}
</section>
);
})()
}
{/* Someday Section */}
{
showSomeday && (
<section className={`weekly-someday ${somedayExpanded ? 'expanded' : 'collapsed'} dark:bg-gray-900 dark:text-white transition-colors duration-200`}>
<div className="weekly-someday-header" onClick={() => setSomedayExpanded(!somedayExpanded)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', position: 'relative', padding: '0.5rem 2.5rem' }}>
<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={{ position: 'absolute', left: '1rem', top: '50%', transform: 'translateY(-50%)', background: 'none', border: '1px dashed #ccc', borderRadius: '50%', width: '24px', height: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#888', fontSize: '1rem', lineHeight: 1, padding: 0 }}
>
+
</button>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span className="weekly-someday-title dark:text-white" style={{ fontWeight: 'bold' }}>{translations[language]?.someday || translations['en'].someday}</span>
<span className="weekly-someday-count dark:text-gray-400" style={{ marginLeft: '12px', fontSize: '0.9rem', color: '#888' }}>
{somedayLists.length} {translations[language]?.lists || translations['en'].lists}
</span>
</div>
<button
className="someday-chevron dark:text-gray-400"
style={{ position: 'absolute', right: '1rem', top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.75rem', color: '#666', padding: '0.25rem' }}
>
{somedayExpanded ? '▼' : '▶'}
</button>
</div>
{somedayExpanded && (
<div className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}>
{(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 rounded-lg 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) => {
// Only drag if clicking the header
const target = e.target as HTMLElement;
if (!target.closest('.weekly-someday-list-title-header')) {
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
}),
});
} 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: 'space-between', alignItems: 'center', cursor: 'grab' }} title="Drag to reorder">
{/* 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
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' }}
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 #eee',
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>
)}
</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
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}
motto={motto}
setMotto={setMotto}
connections={connections}
onUpdateConnections={setConnections}
focusTimerDuration={focusTimerDuration}
setFocusTimerDuration={setFocusTimerDuration}
focusBreakDuration={focusBreakDuration}
setFocusBreakDuration={setFocusBreakDuration}
fontSize={fontSize}
setFontSize={setFontSize}
showNextTask={showNextTask}
setShowNextTask={setShowNextTask}
headlineFont={headlineFont}
setHeadlineFont={setHeadlineFont}
bodyFont={bodyFont}
setBodyFont={setBodyFont}
fontWeight={fontWeight}
setFontWeight={setFontWeight}
/>
)
}
{
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>
)
}
</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 #eee', 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;
bodyFont: string;
fontWeight: string;
}) => void;
showTimeGrid: boolean;
setShowTimeGrid: (show: boolean) => void;
cellDuration: CellDuration;
setCellDuration: (duration: CellDuration) => void;
weekStartDay: number;
setWeekStartDay: (day: number) => void;
viewStyle: 'grid' | 'list';
setViewStyle: (style: 'grid' | 'list') => void;
showSomeday: boolean;
setShowSomeday: (show: boolean) => void;
showAllDay: boolean;
setShowAllDay: (show: boolean) => void;
showSchedule: boolean;
setShowSchedule: (show: boolean) => void;
motto: string;
setMotto: (motto: 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;
setHeadlineFont: (font: string) => void;
bodyFont: string;
setBodyFont: (font: string) => void;
fontWeight: string;
setFontWeight: (weight: string) => void;
}
function SettingsSidebar({
onClose,
onSettingsChanged,
showTimeGrid,
setShowTimeGrid,
cellDuration,
setCellDuration,
weekStartDay,
setWeekStartDay,
viewStyle,
setViewStyle,
showSomeday,
setShowSomeday,
showAllDay,
setShowAllDay,
showSchedule,
setShowSchedule,
motto,
setMotto,
connections,
onUpdateConnections,
focusTimerDuration,
setFocusTimerDuration,
focusBreakDuration,
setFocusBreakDuration,
fontSize,
setFontSize,
showNextTask,
setShowNextTask,
headlineFont,
setHeadlineFont,
bodyFont,
setBodyFont,
fontWeight,
setFontWeight
}: SettingsSidebarProps) {
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general');
const [isLoading, setIsLoading] = useState(true);
const [exportStartDate, setExportStartDate] = useState('');
const [exportEndDate, setExportEndDate] = useState('');
const [passwords, setPasswords] = useState({ new: '', confirm: '' });
const [accountMsg, setAccountMsg] = useState('');
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;
bodyFont?: string;
fontWeight?: string;
}>({
name: '',
email: '',
timezone: 'Europe/Berlin',
autoRolling: false,
protectEventTimes: false,
language: 'de',
dateFormat: 'yyyy-MM-dd',
timeFormat: '24h',
startHour: 8,
endHour: 18,
focusTimerDuration: 25,
focusBreakDuration: 5,
showTimeGrid: true,
cellDuration: 30,
viewStyle: 'list',
fontSize: 'M',
showNextTask: false,
showSomeday: true,
showAllDayEvents: true,
showSchedule: true,
headlineFont: 'Inter',
bodyFont: 'Inter',
fontWeight: '400'
});
const t = translations[profile.language || 'en'] || translations['en'];
// Load fonts for preview
useGoogleFonts([profile.headlineFont || '', profile.bodyFont || '']);
useEffect(() => {
fetchProfile();
// Trigger slide-in after mount
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
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,
});
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 'grid' | 'list');
if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L');
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
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';
};
const handleAppleConnect = () => {
alert('Apple Calendar integration coming soon! For now, you can import .ics files.');
};
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,
headlineFont: headlineFont,
bodyFont: bodyFont,
fontWeight: fontWeight,
focusBreakDuration: focusBreakDuration,
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,
bodyFont: bodyFont,
fontWeight: fontWeight
});
}
if (profile.focusTimerDuration && setFocusTimerDuration) {
setFocusTimerDuration(profile.focusTimerDuration);
}
if (profile.focusBreakDuration && setFocusBreakDuration) {
setFocusBreakDuration(profile.focusBreakDuration);
}
// Temporary success message
setTimeout(() => setAccountMsg(''), 3000);
} else {
setAccountMsg(data.error || 'Failed to update profile');
if (data.details) console.error('Update profile details:', data.details);
}
} 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' }}>
{accountMsg && (
<div className={`weekly-auth-message ${accountMsg.toLowerCase().includes('success') ? 'success' : 'error'}`}
style={{ marginBottom: '20px', padding: '10px', borderRadius: '4px', textAlign: 'center', fontWeight: 600 }}>
{accountMsg}
</div>
)}
{activeTab === 'general' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Motto */}
<div>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.mottoOfWeek}</label>
<input
type="text"
value={motto}
onChange={(e) => setMotto(e.target.value)}
className="weekly-input"
placeholder={t.mottoOfWeek}
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</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 'grid' | 'list')}
className="weekly-input"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="grid">{t.gridView}</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) }))}
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) }))}
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: '#4b5563' }}>Text size</label>
<div className="weekly-toggle-group" style={{ display: 'flex', background: '#f3f4f6', 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 ? 'white' : 'transparent',
color: (profile.fontSize || fontSize) === size ? '#111827' : '#6b7280',
boxShadow: (profile.fontSize || fontSize) === size ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',
transition: 'all 0.2s ease'
}}
>
{size}
</button>
))}
</div>
</div>
{/* Font Settings */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '8px' }}>Typography</label>
{/* Headline Font */}
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: '#666', marginBottom: '4px' }}>Headline Font</label>
<select
value={profile.headlineFont || 'Inter'}
onChange={(e) => setProfile({ ...profile, headlineFont: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', fontFamily: profile.headlineFont || 'Inter' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{/* Body Font */}
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', color: '#666', marginBottom: '4px' }}>Body Font</label>
<select
value={profile.bodyFont || 'Inter'}
onChange={(e) => setProfile({ ...profile, bodyFont: e.target.value })}
className="weekly-input"
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', fontFamily: profile.bodyFont || 'Inter' }}
>
{AVAILABLE_FONTS.map(font => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
))}
</select>
</div>
{/* Font Weight */}
<div>
<label style={{ display: 'block', fontSize: '0.85rem', color: '#666', marginBottom: '4px' }}>Body Font Weight</label>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{FONT_WEIGHTS.map(weight => (
<div key={weight.value} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={profile.fontWeight === weight.value}
onChange={() => setProfile({ ...profile, fontWeight: weight.value })}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<label onClick={() => setProfile({ ...profile, fontWeight: weight.value })} style={{ cursor: 'pointer', fontSize: '0.9rem' }}>
{weight.name}
</label>
</div>
))}
</div>
</div>
{/* Font Preview */}
<div style={{
marginTop: '1rem',
padding: '1rem',
border: '1px solid #e5e7eb',
borderRadius: '8px',
background: '#f9fafb'
}}>
<div style={{
fontFamily: profile.headlineFont || 'Inter',
fontSize: '1.25rem',
fontWeight: 700,
marginBottom: '0.5rem',
color: '#111827'
}}>
Headline Preview
</div>
<div style={{
fontFamily: profile.bodyFont || 'Inter',
fontWeight: parseInt(profile.fontWeight || '400'),
fontSize: '0.95rem',
color: '#374151',
lineHeight: 1.5
}}>
Body text preview. The quick brown fox jumps over the lazy dog. 1234567890.
</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 "Do This Now" 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 }}>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%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>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%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
</div>
<button
onClick={handleUpdateProfile}
className="weekly-btn-primary"
style={{ marginTop: '8px', padding: '10px', alignSelf: 'flex-start' }}
>
{t.saveChanges}
</button>
</div>
) : activeTab === 'calendar' ? (
isLoading ? (
<p>Loading connections...</p>
) : (
<>
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>{t.connectedCalendars}</h3>
{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={{ fontWeight: 600, marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{conn.provider === 'google' ? '📅' : '🍎'}</span>
{conn.provider === 'google' ? 'Google Calendar' : 'Apple Calendar'}
</div>
{/* Calendar Selection List */}
{conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
{conn.calendars.map((cal: any) => (
<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: '#555' }}>
{cal.title} {cal.isPrimary && <span style={{ fontSize: '0.8em', color: '#888' }}>(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.' : 'Selection available after connect.'}
</div>
)}
</li>
))}
</ul>
)}
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>{t.connectMore}</h3>
<div style={{ display: 'flex', gap: '1rem' }}>
<button onClick={handleGoogleConnect} className="calendar-connect-btn">
<span>📅</span> {t.connectGoogle}
</button>
<button onClick={handleAppleConnect} className="calendar-connect-btn">
<span>🍎</span> {t.connectApple}
</button>
<button onClick={handleOutlookConnect} className="calendar-connect-btn">
<span>📧</span> Connect Outlook
</button>
</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>
<button
type="submit"
className="weekly-auth-button primary"
style={{ marginTop: '8px' }}
>
{t.saveChanges}
</button>
</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>
</>
);
}