Add soft-delete support for tasks with deletedAt field and migration. Remove Apple Reminders iCloud integration entirely (API routes, lib, UI modal, and Python script) in favor of CalDAV approach. Add periodic pull-sync from Google Tasks every 2 minutes with deletion detection. Fix orphaned someday task rescue and cross-list task toggle/edit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5832 lines
318 KiB
TypeScript
5832 lines
318 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useRef, useCallback, useMemo, DragEvent } from 'react';
|
||
import { useSession, signOut } from 'next-auth/react';
|
||
import CalendarEventModal from './CalendarEventModal';
|
||
import TaskRecurrenceModal from './RecurrenceModal';
|
||
|
||
import FocusModeOverlay from './FocusModeOverlay';
|
||
import {
|
||
LayoutGrid,
|
||
Calendar,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
Search,
|
||
Settings,
|
||
User,
|
||
Clock,
|
||
Menu,
|
||
Target,
|
||
Sun,
|
||
Moon,
|
||
Repeat,
|
||
GripVertical,
|
||
Play,
|
||
Zap,
|
||
Plus
|
||
} from 'lucide-react';
|
||
|
||
// Types
|
||
import UserMenu from './UserMenu';
|
||
import SearchModal from './SearchModal';
|
||
import SimpleDatePicker from './SimpleDatePicker';
|
||
import RecurringTasksManager from './RecurringTasksManager';
|
||
import { ImportListModal } from './ImportListModal';
|
||
|
||
export type ViewStyle = 'simple' | 'calendar' | 'list';
|
||
|
||
interface Task {
|
||
id: string;
|
||
title: string;
|
||
markdownContent?: string;
|
||
completed: boolean;
|
||
dayOfWeek?: number | null;
|
||
scheduledDate?: string | null;
|
||
somedayListId?: string | null;
|
||
order: number;
|
||
startTime?: string | null;
|
||
endTime?: string | null;
|
||
userId: string;
|
||
isRolling?: boolean;
|
||
isRecurring?: boolean;
|
||
recurrenceInterval?: number | null;
|
||
recurrenceUnit?: string | null;
|
||
recurrenceTime?: string | null;
|
||
recurrenceEndDate?: Date | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
externalId?: string | null;
|
||
externalProvider?: string | null;
|
||
externalListId?: string | null;
|
||
lastSyncedAt?: Date | null;
|
||
}
|
||
|
||
interface CalendarEvent {
|
||
id: string;
|
||
title: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
source: 'google' | 'apple';
|
||
calendarId?: string;
|
||
calendarTitle?: string;
|
||
calendarColor?: string;
|
||
editable?: boolean;
|
||
}
|
||
|
||
interface SomedayList {
|
||
id: string;
|
||
title: string;
|
||
tasks: Task[];
|
||
}
|
||
|
||
// Time grid configuration options
|
||
type CellDuration = 15 | 30 | 60 | 120;
|
||
|
||
// Font options
|
||
const AVAILABLE_FONTS = [
|
||
{ name: 'Default (Inter)', value: 'Inter' },
|
||
{ name: 'Roboto', value: 'Roboto' },
|
||
{ name: 'Open Sans', value: 'Open Sans' },
|
||
{ name: 'Lato', value: 'Lato' },
|
||
{ name: 'Montserrat', value: 'Montserrat' },
|
||
{ name: 'Oswald', value: 'Oswald' },
|
||
{ name: 'Raleway', value: 'Raleway' },
|
||
{ name: 'Playfair Display', value: 'Playfair Display' },
|
||
{ name: 'Merriweather', value: 'Merriweather' },
|
||
{ name: 'Nunito', value: 'Nunito' },
|
||
{ name: 'Dancing Script', value: 'Dancing Script' },
|
||
{ name: 'Pacifico', value: 'Pacifico' },
|
||
];
|
||
|
||
const FONT_WEIGHTS = [
|
||
{ name: 'Light', value: '300' },
|
||
{ name: 'Normal', value: '400' },
|
||
{ name: 'Medium', value: '500' },
|
||
{ name: 'Bold', value: '700' },
|
||
];
|
||
|
||
// Helper to load Google Fonts
|
||
const useGoogleFonts = (fonts: string[]) => {
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
const fontsToLoad = fonts.filter(f => f && f !== 'Inter');
|
||
if (fontsToLoad.length === 0) return;
|
||
|
||
const linkId = 'google-fonts-link';
|
||
let link = document.getElementById(linkId) as HTMLLinkElement;
|
||
|
||
const fontQuery = fontsToLoad.map(f => f.replace(' ', '+')).join('|');
|
||
const href = `https://fonts.googleapis.com/css2?family=${fontsToLoad.map(f => `${f.replace(' ', '+')}:wght@300;400;500;700`).join('&family=')}&display=swap`;
|
||
|
||
if (!link) {
|
||
link = document.createElement('link');
|
||
link.id = linkId;
|
||
link.rel = 'stylesheet';
|
||
document.head.appendChild(link);
|
||
}
|
||
link.href = href;
|
||
}, [fonts]);
|
||
};
|
||
|
||
// Translations
|
||
const translations: Record<string, any> = {
|
||
en: {
|
||
settings: 'Settings',
|
||
general: 'General',
|
||
calendar: 'Connections',
|
||
account: 'Account',
|
||
runningList: 'Running List (Auto-roll tasks to today)',
|
||
protectEventTimes: 'Protect Event Times',
|
||
showTimeGrid: 'Show Time Grid',
|
||
timeSlotDuration: 'Time Slot Duration',
|
||
viewStyle: 'View Style',
|
||
simpleView: 'Simple',
|
||
calendarView: 'Calendar',
|
||
listView: 'List',
|
||
language: 'Language',
|
||
dateFormat: 'Date Format',
|
||
timeFormat: 'Time Format',
|
||
saveChanges: 'Save Changes',
|
||
connectedCalendars: 'Connected Calendars',
|
||
connectMore: 'Connect More',
|
||
connectGoogle: 'Connect Google Calendar',
|
||
connectApple: 'Connect Apple Calendar',
|
||
noCalendars: 'No calendars connected yet.',
|
||
dataPrivacy: 'Data & Privacy',
|
||
downloadData: 'Download My Data',
|
||
deleteAccount: 'Delete Account',
|
||
name: 'Name',
|
||
email: 'Email',
|
||
timezone: 'Timezone',
|
||
changePassword: 'Change Password',
|
||
newPassword: 'New Password',
|
||
confirmPassword: 'Confirm Password',
|
||
someday: 'SOMEDAY',
|
||
lists: 'Lists',
|
||
loading: 'Loading your tasks...',
|
||
sycing: 'Syncing...',
|
||
synced: 'Synced',
|
||
localization: 'Localization',
|
||
allDayEvents: 'ALL-DAY EVENTS',
|
||
syncCalendar: 'Sync Calendar',
|
||
toggleDarkMode: 'Toggle Dark Mode',
|
||
signOut: 'Sign Out',
|
||
startHour: 'Start of Day',
|
||
endHour: 'End of Day',
|
||
weekAbbr: 'W',
|
||
goalOfWeek: 'Goal of the Week',
|
||
goalScope: 'Goal Scope',
|
||
goalScopeWeek: 'Per Week',
|
||
goalScopeDay: 'Per Day',
|
||
goalFallback: 'Goal Fallback Type',
|
||
defaultGoal: 'Custom Default Goal',
|
||
showSomeday: 'Show Someday Section',
|
||
showAllDay: 'Show All-Day Section',
|
||
newPasswordDesc: 'Leave blank to keep current password.'
|
||
},
|
||
de: {
|
||
settings: 'Einstellungen',
|
||
general: 'Allgemein',
|
||
calendar: 'Verbindungen',
|
||
account: 'Konto',
|
||
runningList: 'Laufende Liste (Aufgaben automatisch auf heute verschieben)',
|
||
protectEventTimes: 'Ereigniszeiten schützen',
|
||
showTimeGrid: 'Zeitplan anzeigen',
|
||
timeSlotDuration: 'Zeitfensterdauer',
|
||
viewStyle: 'Ansichtsstil',
|
||
simpleView: 'Einfach',
|
||
calendarView: 'Kalender',
|
||
listView: 'Liste',
|
||
language: 'Sprache',
|
||
dateFormat: 'Datumsformat',
|
||
timeFormat: 'Zeitformat',
|
||
saveChanges: 'Änderungen speichern',
|
||
connectedCalendars: 'Verbundene Kalender',
|
||
connectMore: 'Mehr verbinden',
|
||
connectGoogle: 'Google Kalender verbinden',
|
||
connectApple: 'Apple Kalender verbinden',
|
||
noCalendars: 'Keine Kalender verbunden.',
|
||
dataPrivacy: 'Daten & Datenschutz',
|
||
downloadData: 'Meine Daten herunterladen',
|
||
deleteAccount: 'Konto löschen',
|
||
name: 'Name',
|
||
email: 'E-Mail',
|
||
timezone: 'Zeitzone',
|
||
changePassword: 'Passwort ändern',
|
||
newPassword: 'Neues Passwort',
|
||
confirmPassword: 'Passwort bestätigen',
|
||
someday: 'IRGENDWANN',
|
||
lists: 'Listen',
|
||
loading: 'Lade Aufgaben...',
|
||
syncing: 'Synchronisiere...',
|
||
synced: 'Synchronisiert',
|
||
localization: 'Lokalisierung',
|
||
allDayEvents: 'GANZTÄGIGE EREIGNISSE',
|
||
syncCalendar: 'Kalender synchronisieren',
|
||
toggleDarkMode: 'Dunkelmodus umschalten',
|
||
signOut: 'Abmelden',
|
||
startHour: 'Tagesbeginn',
|
||
endHour: 'Tagesende',
|
||
weekAbbr: 'KW',
|
||
goalOfWeek: 'Ziel der Woche',
|
||
goalScope: 'Ziel-Zeitraum',
|
||
goalScopeWeek: 'Pro Woche',
|
||
goalScopeDay: 'Pro Tag',
|
||
goalFallback: 'Ziel-Fallback-Typ',
|
||
defaultGoal: 'Benutzerdefiniertes Standardziel',
|
||
showSomeday: 'Irgendwann-Bereich anzeigen',
|
||
showAllDay: 'Ganztägige Ereignisse anzeigen',
|
||
newPasswordDesc: 'Leer lassen, um das aktuelle Passwort zu behalten.'
|
||
}
|
||
};
|
||
|
||
// Date utilities
|
||
function getStartOfWeek(date: Date, startDay: number = 0): Date {
|
||
const d = new Date(date);
|
||
const day = d.getDay();
|
||
const diff = d.getDate() - day + (day < startDay ? -7 : 0) + startDay; // if today is sun(0) and start is mon(1), day < start (0 < 1) -> -7 + 1 = -6. 0 - 6 = -6. Correct.
|
||
// Wait, let's re-verify:
|
||
// Start Mon(1). Today Sun(0). day=0. diff = date - 0 + (-7) + 1 = date - 6. Correct (last Monday).
|
||
// Start Mon(1). Today Mon(1). day=1. diff = date - 1 + (0) + 1 = date. Correct.
|
||
// Start Sun(0). Today Mon(1). day=1. diff = date - 1 + (0) + 0 = date - 1. Correct (last Sunday).
|
||
// Start Sun(0). Today Sun(0). day=0. diff = date - 0 + (0) + 0 = date. Correct.
|
||
// What if Start Mon(1), Today Tue(2). day=2. diff = date - 2 + 0 + 1 = date - 1. Correct.
|
||
|
||
// Better logic:
|
||
// const day = d.getDay();
|
||
// const diff = (day < startDay ? 7 : 0) + day - startDay;
|
||
// d.setDate(d.getDate() - diff);
|
||
//
|
||
// Let's stick to a robust one:
|
||
const currentDay = d.getDay();
|
||
const distance = (currentDay - startDay + 7) % 7;
|
||
d.setDate(d.getDate() - distance);
|
||
return d;
|
||
}
|
||
|
||
function formatDateHeader(date: Date, locale: string = 'en-US'): string {
|
||
return date.toLocaleDateString(locale, { day: 'numeric', month: 'short' }); // e.g. 12. Feb.
|
||
}
|
||
|
||
function getDayName(date: Date, locale: string = 'en-US'): string {
|
||
return date.toLocaleDateString(locale, { weekday: 'long' }).toUpperCase();
|
||
}
|
||
|
||
function isSameDay(d1: Date, d2: Date): boolean {
|
||
return d1.toDateString() === d2.toDateString();
|
||
}
|
||
|
||
function formatDateToISO(date: Date): string {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function formatHour(hour: number): string {
|
||
return `${hour}`;
|
||
}
|
||
|
||
function getTimeSlots(cellDuration: CellDuration, startHour: number, endHour: number): string[] {
|
||
const slots: string[] = [];
|
||
const slotsPerHour = 60 / cellDuration;
|
||
for (let hour = startHour; hour < endHour; hour++) {
|
||
for (let slot = 0; slot < slotsPerHour; slot++) {
|
||
const minutes = slot * cellDuration;
|
||
slots.push(`${hour.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||
}
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
function getHourFromSlot(slot: string): number {
|
||
return parseInt(slot.split(':')[0], 10);
|
||
}
|
||
|
||
function getWeekNumber(date: Date): number {
|
||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||
const dayNum = d.getUTCDay() || 7;
|
||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||
}
|
||
|
||
// Check if an event is an all-day event
|
||
// Defined outside component to avoid stale closure issues in useCallbacks
|
||
const isAllDayEvent = (event: CalendarEvent): boolean => {
|
||
if (!event.startTime) return false;
|
||
|
||
// Date-only format (YYYY-MM-DD)
|
||
if (!event.startTime.includes('T')) return true;
|
||
|
||
const start = new Date(event.startTime);
|
||
const end = new Date(event.endTime);
|
||
const durationHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||
|
||
// Check if strictly midnight to midnight in local time
|
||
const isLocalMidnight = start.getHours() === 0 && start.getMinutes() === 0;
|
||
|
||
// Check if UTC midnight (common for API-converted date strings)
|
||
const isUTCMidnight = start.getUTCHours() === 0 && start.getUTCMinutes() === 0;
|
||
|
||
// If it's effectively 24h+ and starts at midnight (local or UTC), treat as all-day
|
||
return durationHours >= 24 && (isLocalMidnight || isUTCMidnight);
|
||
};
|
||
|
||
// Helper to invert colors for dark mode
|
||
function invertColor(hex: string): string {
|
||
if (!hex) return hex;
|
||
let color = hex.startsWith('#') ? hex.slice(1) : hex;
|
||
if (color.length === 3) {
|
||
color = color.split('').map(c => c + c).join('');
|
||
}
|
||
if (color.length !== 6) return hex;
|
||
|
||
try {
|
||
const r = (255 - parseInt(color.slice(0, 2), 16)).toString(16).padStart(2, '0');
|
||
const g = (255 - parseInt(color.slice(2, 4), 16)).toString(16).padStart(2, '0');
|
||
const b = (255 - parseInt(color.slice(4, 6), 16)).toString(16).padStart(2, '0');
|
||
return `#${r}${g}${b}`;
|
||
} catch (e) {
|
||
return hex;
|
||
}
|
||
}
|
||
|
||
// Main Component
|
||
export default function WeeklyView() {
|
||
const { data: session } = useSession();
|
||
const [tasks, setTasks] = useState<Task[]>([]);
|
||
const [connections, setConnections] = useState<any[]>([]); // Lifted state
|
||
const [rawCalendarEvents, setRawCalendarEvents] = useState<CalendarEvent[]>([]);
|
||
|
||
// Extend events with editable flag from connections
|
||
const calendarEvents = useMemo(() => {
|
||
return rawCalendarEvents.map(event => {
|
||
let isEditable = false;
|
||
if (event.calendarId) {
|
||
for (const conn of connections) {
|
||
if (conn.calendars && Array.isArray(conn.calendars)) {
|
||
const cal = conn.calendars.find((c: any) => c.id === event.calendarId);
|
||
if (cal && cal.editable) {
|
||
isEditable = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { ...event, editable: isEditable };
|
||
});
|
||
}, [rawCalendarEvents, connections]);
|
||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() - 1);
|
||
return d;
|
||
});
|
||
const [viewDays, setViewDays] = useState(7);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [darkMode, setDarkMode] = useState(false);
|
||
const [timeFormat, setTimeFormat] = useState('24h');
|
||
const [dateFormat, setDateFormat] = useState('yyyy-MM-dd');
|
||
|
||
const [somedayExpanded, setSomedayExpanded] = useState(true);
|
||
const [isAllDayExpanded, setIsAllDayExpanded] = useState(true);
|
||
const [somedayLists, setSomedayLists] = useState<SomedayList[]>([]);
|
||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||
const [draggingListId, setDraggingListId] = useState<string | null>(null);
|
||
|
||
// Moved state definitions to the top
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general');
|
||
const [exportStartDate, setExportStartDate] = useState('');
|
||
const [exportEndDate, setExportEndDate] = useState('');
|
||
const [passwords, setPasswords] = useState({ new: '', confirm: '' });
|
||
const [accountMsg, setAccountMsg] = useState<string>('');
|
||
const [importingTasksState, setImportingTasksState] = useState<boolean>(false);
|
||
const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||
const [importProvider, setImportProvider] = useState<'google' | 'apple' | null>(null);
|
||
const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]);
|
||
const [isFetchingLists, setIsFetchingLists] = useState(false);
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const [profile, setProfile] = useState<{
|
||
name: string;
|
||
email: string;
|
||
timezone: string;
|
||
autoRolling?: boolean;
|
||
protectEventTimes?: boolean;
|
||
language?: string;
|
||
dateFormat?: string;
|
||
timeFormat?: string;
|
||
startHour?: number;
|
||
endHour?: number;
|
||
focusTimerDuration?: number;
|
||
focusBreakDuration?: number;
|
||
showTimeGrid?: boolean;
|
||
cellDuration?: number;
|
||
viewStyle?: string;
|
||
fontSize?: 'S' | 'M' | 'L';
|
||
showNextTask?: boolean;
|
||
showSomeday?: boolean;
|
||
showAllDayEvents?: boolean;
|
||
showSchedule?: boolean;
|
||
headlineFont?: string;
|
||
headlineFontSize?: string;
|
||
headlineFontWeight?: string;
|
||
dateFontFamily?: string;
|
||
dateFontSize?: string;
|
||
dateFontWeight?: string;
|
||
timeTaskFontFamily?: string;
|
||
timeTaskFontSize?: string;
|
||
timeTaskFontWeight?: string;
|
||
bodyFont?: string;
|
||
taskFontFamily?: string;
|
||
taskFontSize?: string;
|
||
taskFontWeight?: string;
|
||
eventFontFamily?: string;
|
||
eventFontSize?: string;
|
||
eventFontWeight?: string;
|
||
fontWeight?: string;
|
||
weekendColorSat?: string;
|
||
weekendColorSun?: string;
|
||
weekdayColor?: string;
|
||
dateColor?: string;
|
||
taskColor?: string;
|
||
todayHighlightColor?: string;
|
||
pastDayColor?: string;
|
||
goalFallbackType?: 'quote' | 'next_todo' | 'default';
|
||
goalDefaultSentence?: string;
|
||
goalFontFamily?: string;
|
||
goalFontSize?: string;
|
||
goalFontWeight?: string;
|
||
goalScope?: 'week' | 'day';
|
||
}>({
|
||
name: session?.user?.name || '',
|
||
email: session?.user?.email || '',
|
||
timezone: 'UTC',
|
||
language: 'de',
|
||
dateFormat: 'yyyy-MM-dd',
|
||
timeFormat: '24h',
|
||
startHour: 8,
|
||
endHour: 22,
|
||
autoRolling: true,
|
||
protectEventTimes: true,
|
||
showTimeGrid: true,
|
||
cellDuration: 60,
|
||
viewStyle: 'list',
|
||
fontSize: 'M',
|
||
showNextTask: false,
|
||
showSomeday: true,
|
||
showAllDayEvents: true,
|
||
showSchedule: true,
|
||
headlineFont: 'Inter',
|
||
headlineFontSize: '1.25rem',
|
||
headlineFontWeight: '900',
|
||
dateFontFamily: 'Inter',
|
||
dateFontSize: '0.65rem',
|
||
dateFontWeight: '400',
|
||
timeTaskFontFamily: 'Inter',
|
||
timeTaskFontSize: '0.75rem',
|
||
timeTaskFontWeight: '500',
|
||
bodyFont: 'Inter',
|
||
taskFontFamily: 'Inter',
|
||
taskFontSize: '0.9rem',
|
||
taskFontWeight: '400',
|
||
eventFontFamily: 'Inter',
|
||
eventFontSize: '0.85rem',
|
||
eventFontWeight: '400',
|
||
fontWeight: '400',
|
||
goalFontFamily: 'Inter',
|
||
goalFontSize: '0.9rem',
|
||
goalFontWeight: '500',
|
||
weekendColorSat: '#666666',
|
||
weekendColorSun: '#dc2626',
|
||
focusTimerDuration: 25,
|
||
focusBreakDuration: 5,
|
||
pastDayColor: '#a6a6a7'
|
||
});
|
||
const [showSummary, setShowSummary] = useState(false);
|
||
|
||
const [isAddingSomedayList, setIsAddingSomedayList] = useState(false);
|
||
const [newSomedayListName, setNewSomedayListName] = useState('');
|
||
const [language, setLanguage] = useState('de');
|
||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'synced'>('idle');
|
||
const [cellDuration, setCellDuration] = useState<CellDuration>(60);
|
||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||
const [showTimeGrid, setShowTimeGrid] = useState(true);
|
||
const [slideDirection, setSlideDirection] = useState<'next' | 'prev' | null>(null);
|
||
const [activeSlot, setActiveSlot] = useState<{ day: number; slot: string } | null>(null);
|
||
const [newSlotTask, setNewSlotTask] = useState('');
|
||
const [selectedTaskForNotes, setSelectedTaskForNotes] = useState<Task | null>(null);
|
||
const [currentTime, setCurrentTime] = useState(new Date());
|
||
const [dropPreview, setDropPreview] = useState<{ day: number; slot: string } | null>(null);
|
||
const [viewStyle, setViewStyle] = useState<ViewStyle>('simple');
|
||
const [protectEventTimes, setProtectEventTimes] = useState(true);
|
||
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
||
|
||
const [startHour, setStartHour] = useState(8);
|
||
const [endHour, setEndHour] = useState(22);
|
||
const [weekStartDay, setWeekStartDay] = useState(1); // 1 = Monday, 0 = Sunday
|
||
const [showSomeday, setShowSomeday] = useState(true);
|
||
const [showAllDay, setShowAllDay] = useState(true);
|
||
const [goal, setGoal] = useState('your goal of this week');
|
||
const [isEditingGoal, setIsEditingGoal] = useState(false);
|
||
const [showNextTask, setShowNextTask] = useState(false);
|
||
const [calendarEditMode, setCalendarEditMode] = useState(false);
|
||
const [selectedTaskForRecurrence, setSelectedTaskForRecurrence] = useState<Task | null>(null);
|
||
const [showFocusMode, setShowFocusMode] = useState(false);
|
||
const [showSchedule, setShowSchedule] = useState(true);
|
||
const [focusBreakDuration, setFocusBreakDuration] = useState(5);
|
||
|
||
// New UI State
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||
const [isRecurringTasksOpen, setIsRecurringTasksOpen] = useState(false);
|
||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||
|
||
const [focusTimerDuration, setFocusTimerDuration] = useState(25);
|
||
const [fontSize, setFontSize] = useState<'S' | 'M' | 'L'>('M');
|
||
const [headlineFont, setHeadlineFont] = useState('Inter');
|
||
const [headlineFontSize, setHeadlineFontSize] = useState('1.25rem');
|
||
const [headlineFontWeight, setHeadlineFontWeight] = useState('900');
|
||
const [dateFontFamily, setDateFontFamily] = useState('Inter');
|
||
const [dateFontSize, setDateFontSize] = useState('0.65rem');
|
||
const [dateFontWeight, setDateFontWeight] = useState('400');
|
||
const [timeTaskFontFamily, setTimeTaskFontFamily] = useState('Inter');
|
||
const [timeTaskFontSize, setTimeTaskFontSize] = useState('0.75rem');
|
||
const [timeTaskFontWeight, setTimeTaskFontWeight] = useState('500');
|
||
const [bodyFont, setBodyFont] = useState('Inter');
|
||
const [taskFontFamily, setTaskFontFamily] = useState('Inter');
|
||
const [taskFontSize, setTaskFontSize] = useState('0.9rem');
|
||
const [taskFontWeight, setTaskFontWeight] = useState('400');
|
||
const [eventFontFamily, setEventFontFamily] = useState('Inter');
|
||
const [eventFontSize, setEventFontSize] = useState('0.85rem');
|
||
const [eventFontWeight, setEventFontWeight] = useState('400');
|
||
const [fontWeight, setFontWeight] = useState('400');
|
||
const [weekendColorSat, setWeekendColorSat] = useState('#666666');
|
||
const [weekendColorSun, setWeekendColorSun] = useState('#dc2626');
|
||
|
||
// Load fonts
|
||
// Dynamic font loading is handled by the main useGoogleFonts hook call below
|
||
|
||
// Load ALL available fonts at the top level to ensure they are available
|
||
// regardless of whether the settings modal is open or closed, and for
|
||
// real-time preview usage.
|
||
useGoogleFonts([
|
||
...AVAILABLE_FONTS.map(f => f.value),
|
||
'Dancing Script',
|
||
'Pacifico'
|
||
]);
|
||
|
||
// Dynamic font loading is handled by useGoogleFonts hook call above
|
||
|
||
// Calendar Event Modal State
|
||
const [calendarEventModal, setCalendarEventModal] = useState<{
|
||
isOpen: boolean;
|
||
event?: CalendarEvent;
|
||
initialDate?: Date;
|
||
initialStartTime?: string;
|
||
}>({ isOpen: false });
|
||
|
||
// Dark Mode Persistence & Class Toggle
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
const savedDarkMode = localStorage.getItem('weekly-dark-mode');
|
||
if (savedDarkMode) {
|
||
setDarkMode(JSON.parse(savedDarkMode));
|
||
}
|
||
const savedWeekStart = localStorage.getItem('weekly-week-start');
|
||
if (savedWeekStart) {
|
||
setWeekStartDay(Number(savedWeekStart));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
localStorage.setItem('weekly-dark-mode', JSON.stringify(darkMode));
|
||
if (darkMode) {
|
||
document.documentElement.classList.add('dark');
|
||
} else {
|
||
document.documentElement.classList.remove('dark');
|
||
}
|
||
}, [darkMode, mounted]);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
localStorage.setItem('weekly-week-start', String(weekStartDay));
|
||
// REMOVED: Re-align current week start when start day changes
|
||
// This was forcing the view to snap to Monday, breaking the "Yesterday as first column" setting.
|
||
// setCurrentWeekStart(prev => getStartOfWeek(prev, weekStartDay));
|
||
}, [weekStartDay, mounted]);
|
||
|
||
// Translation helper
|
||
const t = translations[language] || translations['en'];
|
||
|
||
// Refs for scroll synchronization
|
||
const timeColumnRef = useRef<HTMLDivElement>(null);
|
||
const dayColumnsRef = useRef<HTMLDivElement[]>([]);
|
||
const isScrollSyncing = useRef(false);
|
||
|
||
// Scroll sync handler
|
||
const handleTimeColumnScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||
if (isScrollSyncing.current) return;
|
||
isScrollSyncing.current = true;
|
||
const scrollTop = e.currentTarget.scrollTop;
|
||
dayColumnsRef.current.forEach(col => {
|
||
if (col) col.scrollTop = scrollTop;
|
||
});
|
||
setTimeout(() => { isScrollSyncing.current = false; }, 10);
|
||
};
|
||
|
||
const handleDayColumnScroll = (e: React.UIEvent<HTMLDivElement>, index: number) => {
|
||
if (isScrollSyncing.current) return;
|
||
isScrollSyncing.current = true;
|
||
const scrollTop = e.currentTarget.scrollTop;
|
||
if (timeColumnRef.current) timeColumnRef.current.scrollTop = scrollTop;
|
||
dayColumnsRef.current.forEach((col, i) => {
|
||
if (col && i !== index) col.scrollTop = scrollTop;
|
||
});
|
||
setTimeout(() => { isScrollSyncing.current = false; }, 10);
|
||
};
|
||
|
||
// Slot height based on cell duration
|
||
const getSlotHeight = (duration: CellDuration) => {
|
||
switch (duration) {
|
||
case 15: return 25;
|
||
case 30: return 35;
|
||
case 60: return 50;
|
||
case 120: return 80;
|
||
default: return 50;
|
||
}
|
||
};
|
||
|
||
// Header height based on cell duration for alignment
|
||
const getHeaderHeight = (duration: CellDuration) => {
|
||
switch (duration) {
|
||
case 15: return 65;
|
||
case 30: return 55;
|
||
case 60: return 40;
|
||
case 120: return 40;
|
||
default: return 40;
|
||
}
|
||
};
|
||
|
||
// Working hours range (configurable)
|
||
const workingHoursStart = startHour;
|
||
const workingHoursEnd = endHour;
|
||
|
||
// Fetch calendar events
|
||
const fetchCalendarEvents = useCallback(async () => {
|
||
setIsSyncing(true);
|
||
try {
|
||
const response = await fetch('/api/calendar/sync', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
timeMin: currentWeekStart.toISOString(),
|
||
timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const text = await response.text();
|
||
try {
|
||
const data = JSON.parse(text);
|
||
if (data.events) {
|
||
setRawCalendarEvents(data.events);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to parse calendar sync response:', text.substring(0, 100));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching calendar events:', error);
|
||
} finally {
|
||
setIsSyncing(false);
|
||
}
|
||
}, [currentWeekStart]);
|
||
|
||
// Calendar Event Handlers
|
||
const handleEventSave = async (eventData: any) => {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout
|
||
|
||
try {
|
||
const method = eventData.id ? 'PATCH' : 'POST';
|
||
const body = {
|
||
...eventData,
|
||
eventId: eventData.id // For PATCH
|
||
};
|
||
|
||
const res = await fetch('/api/calendar/events', {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Failed to save event');
|
||
}
|
||
|
||
// Refresh events
|
||
await fetchCalendarEvents();
|
||
} catch (error: any) {
|
||
console.error('Error saving event:', error);
|
||
if (error.name === 'AbortError') {
|
||
throw new Error('Request timed out. Please try again.');
|
||
}
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
|
||
const handleEventDelete = async (eventId: string, calendarId: string) => {
|
||
try {
|
||
const res = await fetch(`/api/calendar/events?calendarId=${calendarId}&eventId=${eventId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Failed to delete event');
|
||
}
|
||
|
||
// Refresh events
|
||
await fetchCalendarEvents();
|
||
} catch (error) {
|
||
console.error('Error deleting event:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const handleRecurrenceSave = async (taskId: string, recurrence: any) => {
|
||
try {
|
||
const res = await fetch('/api/tasks', { // Uses PATCH endpoint which handles ID in body
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
...recurrence
|
||
})
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Failed to update recurrence');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
// Update local state
|
||
setTasks(prev => prev.map(t => t.id === taskId ? data.task : t));
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert('Failed to save recurrence settings');
|
||
}
|
||
};
|
||
|
||
// Fetch tasks on mount
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchTasks();
|
||
fetchConnections();
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [session]);
|
||
|
||
// Periodic pull-sync from Google Tasks (every 2 minutes)
|
||
useEffect(() => {
|
||
if (!session) return;
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const res = await fetch('/api/tasks/sync');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.updated > 0 || data.deleted > 0) {
|
||
console.log(`[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions from Google Tasks`);
|
||
fetchTasks(); // Reload to reflect changes
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Silent fail for background sync
|
||
}
|
||
}, 2 * 60 * 1000);
|
||
return () => clearInterval(interval);
|
||
}, [session]);
|
||
|
||
async function fetchConnections() {
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await fetch('/api/calendar/connections');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setConnections(data.connections || []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching connections:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
const handleRemoveConnection = async (connectionId: string) => {
|
||
console.log('Disconnecting connection:', connectionId);
|
||
const res = await fetch(`/api/calendar/connections?id=${connectionId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (res.ok) {
|
||
// Update state immediately
|
||
setConnections(prev => prev.filter(c => c.id !== connectionId));
|
||
// Refresh connections to be sure
|
||
fetchConnections();
|
||
// Optionally refresh events too as they might be gone
|
||
fetchCalendarEvents();
|
||
} else {
|
||
const err = await res.json();
|
||
console.error('Failed to disconnect calendar', err);
|
||
throw new Error(err.error || 'Unknown error');
|
||
}
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
// Refetch calendar events when week changes
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [currentWeekStart, session, fetchCalendarEvents]);
|
||
|
||
// Update current time every minute for the "Now" line
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
setCurrentTime(new Date());
|
||
}, 60000); // Update every minute
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
// Compute the goal date key: for "week" scope, normalize to Monday of that week; for "day", use the exact date
|
||
const getGoalDateKey = useCallback((date: Date): string => {
|
||
const scope = profile.goalScope || 'week';
|
||
if (scope === 'day') {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d.toISOString();
|
||
}
|
||
// Normalize to Monday of the week containing this date
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
const day = d.getDay(); // 0=Sun, 1=Mon, ...
|
||
const diff = day === 0 ? -6 : 1 - day; // Monday offset
|
||
d.setDate(d.getDate() + diff);
|
||
return d.toISOString();
|
||
}, [profile.goalScope]);
|
||
|
||
const goalDateKey = useMemo(() => getGoalDateKey(currentWeekStart), [currentWeekStart, getGoalDateKey]);
|
||
|
||
// Fetch goal for current week/day
|
||
useEffect(() => {
|
||
const fetchGoal = async () => {
|
||
try {
|
||
const res = await fetch(`/api/goal?weekStart=${goalDateKey}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setGoal(data.goal);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch goal:', err);
|
||
}
|
||
};
|
||
fetchGoal();
|
||
}, [goalDateKey]);
|
||
|
||
const saveGoal = async (newGoal: string) => {
|
||
setGoal(newGoal);
|
||
try {
|
||
await fetch('/api/goal', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
weekStart: goalDateKey,
|
||
text: newGoal,
|
||
}),
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to save goal:', err);
|
||
}
|
||
};
|
||
|
||
const handleSomedayWheel = (e: React.WheelEvent) => {
|
||
if (e.currentTarget) {
|
||
e.currentTarget.scrollLeft += e.deltaY;
|
||
}
|
||
};
|
||
const saveSetting = async (key: string, value: any) => {
|
||
try {
|
||
await fetch('/api/user/profile', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ [key]: value })
|
||
});
|
||
} catch (err) {
|
||
console.error(`Failed to save setting ${key}:`, err);
|
||
}
|
||
};
|
||
|
||
const handleSettingsChanged = (newSettings: any) => {
|
||
setShowTimeGrid(newSettings.showTimeGrid);
|
||
setCellDuration(newSettings.cellDuration);
|
||
setViewStyle(newSettings.viewStyle);
|
||
setLanguage(newSettings.language);
|
||
setDateFormat(newSettings.dateFormat);
|
||
setTimeFormat(newSettings.timeFormat);
|
||
setStartHour(newSettings.startHour);
|
||
setEndHour(newSettings.endHour);
|
||
setFontSize(newSettings.fontSize);
|
||
setShowNextTask(newSettings.showNextTask);
|
||
setShowSomeday(newSettings.showSomeday);
|
||
setShowAllDay(newSettings.showAllDayEvents);
|
||
setShowSchedule(newSettings.showSchedule);
|
||
setHeadlineFont(newSettings.headlineFont);
|
||
setHeadlineFontSize(newSettings.headlineFontSize);
|
||
setHeadlineFontWeight(newSettings.headlineFontWeight);
|
||
setDateFontFamily(newSettings.dateFontFamily);
|
||
setDateFontSize(newSettings.dateFontSize);
|
||
setDateFontWeight(newSettings.dateFontWeight);
|
||
setTimeTaskFontFamily(newSettings.timeTaskFontFamily);
|
||
setTimeTaskFontSize(newSettings.timeTaskFontSize);
|
||
setTimeTaskFontWeight(newSettings.timeTaskFontWeight);
|
||
setBodyFont(newSettings.bodyFont);
|
||
setTaskFontFamily(newSettings.taskFontFamily);
|
||
setTaskFontSize(newSettings.taskFontSize);
|
||
setTaskFontWeight(newSettings.taskFontWeight);
|
||
if (newSettings.eventFontFamily) setEventFontFamily(newSettings.eventFontFamily);
|
||
if (newSettings.eventFontSize) setEventFontSize(newSettings.eventFontSize);
|
||
if (newSettings.eventFontWeight) setEventFontWeight(newSettings.eventFontWeight);
|
||
if (newSettings.fontWeight) setFontWeight(newSettings.fontWeight);
|
||
if (newSettings.weekendColorSat) setWeekendColorSat(newSettings.weekendColorSat);
|
||
if (newSettings.weekendColorSun) setWeekendColorSun(newSettings.weekendColorSun);
|
||
|
||
setProfile((prev: any) => ({
|
||
...prev,
|
||
...newSettings,
|
||
weekdayColor: newSettings.weekdayColor || prev.weekdayColor,
|
||
dateColor: newSettings.dateColor || prev.dateColor,
|
||
taskColor: newSettings.taskColor || prev.taskColor,
|
||
todayHighlightColor: newSettings.todayHighlightColor || prev.todayHighlightColor,
|
||
eventFontFamily: newSettings.eventFontFamily || prev.eventFontFamily,
|
||
eventFontSize: newSettings.eventFontSize || prev.eventFontSize,
|
||
eventFontWeight: newSettings.eventFontWeight || prev.eventFontWeight
|
||
}));
|
||
|
||
// Custom start/end hours might affect task placement if we filter strictly
|
||
fetchTasks();
|
||
};
|
||
|
||
const fetchUserInfo = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/profile');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.user) {
|
||
setProtectEventTimes(data.user.protectEventTimes || false);
|
||
setTimeFormat(data.user.timeFormat || '12h');
|
||
setDateFormat(data.user.dateFormat || 'MM/dd/yyyy');
|
||
setLanguage(data.user.language || 'en');
|
||
if (data.user.startHour !== undefined) setStartHour(data.user.startHour);
|
||
if (data.user.endHour !== undefined) setEndHour(data.user.endHour);
|
||
if (data.user.viewStyle !== undefined) {
|
||
setViewStyle(data.user.viewStyle as ViewStyle);
|
||
setShowTimeGrid(data.user.showTimeGrid ?? true);
|
||
}
|
||
if (data.user.viewDays !== undefined) setViewDays(data.user.viewDays);
|
||
if (data.user.cellDuration !== undefined) setCellDuration(data.user.cellDuration as CellDuration);
|
||
setShowNextTask(data.user.showNextTask || false);
|
||
setCalendarEditMode(data.user.calendarEditMode || false);
|
||
if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L');
|
||
|
||
if (data.user.showSomeday !== undefined) setShowSomeday(data.user.showSomeday);
|
||
if (data.user.showAllDayEvents !== undefined) setShowAllDay(data.user.showAllDayEvents);
|
||
if (data.user.showSchedule !== undefined) setShowSchedule(data.user.showSchedule);
|
||
if (data.user.headlineFont) setHeadlineFont(data.user.headlineFont);
|
||
if (data.user.headlineFontSize) setHeadlineFontSize(data.user.headlineFontSize);
|
||
if (data.user.headlineFontWeight) setHeadlineFontWeight(data.user.headlineFontWeight);
|
||
if (data.user.dateFontFamily) setDateFontFamily(data.user.dateFontFamily);
|
||
if (data.user.dateFontSize) setDateFontSize(data.user.dateFontSize);
|
||
if (data.user.dateFontWeight) setDateFontWeight(data.user.dateFontWeight);
|
||
if (data.user.timeTaskFontFamily) setTimeTaskFontFamily(data.user.timeTaskFontFamily);
|
||
if (data.user.timeTaskFontSize) setTimeTaskFontSize(data.user.timeTaskFontSize);
|
||
if (data.user.timeTaskFontWeight) setTimeTaskFontWeight(data.user.timeTaskFontWeight);
|
||
if (data.user.bodyFont) setBodyFont(data.user.bodyFont);
|
||
if (data.user.taskFontFamily) setTaskFontFamily(data.user.taskFontFamily);
|
||
if (data.user.taskFontSize) setTaskFontSize(data.user.taskFontSize);
|
||
if (data.user.taskFontWeight) setTaskFontWeight(data.user.taskFontWeight);
|
||
if (data.user.eventFontFamily) setEventFontFamily(data.user.eventFontFamily);
|
||
if (data.user.eventFontSize) setEventFontSize(data.user.eventFontSize);
|
||
if (data.user.eventFontWeight) setEventFontWeight(data.user.eventFontWeight);
|
||
if (data.user.fontWeight) setFontWeight(data.user.fontWeight);
|
||
if (data.user.weekendColorSat) setWeekendColorSat(data.user.weekendColorSat);
|
||
if (data.user.weekendColorSun) setWeekendColorSun(data.user.weekendColorSun);
|
||
|
||
setProfile(prev => ({
|
||
...prev,
|
||
...data.user,
|
||
name: data.user.name || prev.name,
|
||
email: data.user.email || prev.email,
|
||
weekdayColor: data.user.weekdayColor || '#888888',
|
||
dateColor: data.user.dateColor || '#888888',
|
||
taskColor: data.user.taskColor || '#333333',
|
||
todayHighlightColor: data.user.todayHighlightColor || '#f0fafa',
|
||
}));
|
||
|
||
if (data.user.focusTimerDuration) setFocusTimerDuration(data.user.focusTimerDuration);
|
||
if (data.user.focusBreakDuration) setFocusBreakDuration(data.user.focusBreakDuration);
|
||
if (data.user.showTimeGrid !== undefined) setShowTimeGrid(data.user.showTimeGrid);
|
||
if (data.user.cellDuration) setCellDuration(data.user.cellDuration as CellDuration);
|
||
if (data.user.viewStyle) setViewStyle(data.user.viewStyle as ViewStyle);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchUserInfo();
|
||
}, []);
|
||
|
||
async function fetchSomedayLists() {
|
||
try {
|
||
const response = await fetch('/api/someday-lists');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
// Map tasks is handled in fetchTasks or we can merge here if needed.
|
||
// But fetchTasks fetches ALL tasks.
|
||
// Optimally we fetch lists, then tasks, then merge.
|
||
// For now, let's just set the lists structure.
|
||
setSomedayLists(data.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tasks: l.tasks || [] // Tasks will be overwritten/populated by fetchTasks
|
||
})));
|
||
return data.lists;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching someday lists:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function fetchTasks() {
|
||
try {
|
||
const [tasksResponse, listsResponse] = await Promise.all([
|
||
fetch('/api/tasks'),
|
||
fetch('/api/someday-lists') // Fetch lists in parallel
|
||
]);
|
||
|
||
let fetchedLists: SomedayList[] = [];
|
||
if (listsResponse.ok) {
|
||
const listData = await listsResponse.json();
|
||
fetchedLists = listData.lists.map((l: any) => ({
|
||
id: l.id,
|
||
title: l.title,
|
||
tasks: []
|
||
}));
|
||
}
|
||
|
||
// If no lists exist, maybe create default 'Someday'?
|
||
// TeuxDeux usually starts with one.
|
||
// If DB is empty, maybe create one?
|
||
// For now, if empty, we might show empty.
|
||
if (fetchedLists.length === 0) {
|
||
// Optionally create default list if none exist?
|
||
// Let's stick to what's in DB.
|
||
}
|
||
|
||
if (tasksResponse.ok) {
|
||
const data = await tasksResponse.json();
|
||
const fetchedTasks = data.tasks.map((t: any) => ({
|
||
...t,
|
||
createdAt: new Date(t.createdAt),
|
||
updatedAt: new Date(t.updatedAt),
|
||
}));
|
||
|
||
// Calendar tasks: anything NOT in a someday list (includes tasks with scheduledDate OR dayOfWeek)
|
||
const dayTasks = fetchedTasks.filter((t: Task) => !t.somedayListId);
|
||
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
|
||
|
||
setTasks(dayTasks);
|
||
|
||
// Populate lists with tasks
|
||
const listIds = new Set(fetchedLists.map((l: SomedayList) => l.id));
|
||
const orphanedSomedayTasks = somedayTasks.filter((t: Task) => !listIds.has(t.somedayListId || ''));
|
||
|
||
const populatedLists = fetchedLists.map(list => ({
|
||
...list,
|
||
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id)
|
||
}));
|
||
|
||
// Rescue orphaned someday tasks: if their list was deleted, move them to calendar
|
||
if (orphanedSomedayTasks.length > 0) {
|
||
console.warn(`[RESCUE] Found ${orphanedSomedayTasks.length} orphaned someday tasks, recovering to calendar`);
|
||
const rescuedTasks = orphanedSomedayTasks.map((t: Task) => ({
|
||
...t,
|
||
somedayListId: null,
|
||
scheduledDate: t.scheduledDate || new Date().toISOString(),
|
||
}));
|
||
setTasks(prev => [...prev, ...rescuedTasks]);
|
||
// Persist the rescue to DB
|
||
for (const t of orphanedSomedayTasks) {
|
||
fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: t.id, somedayListId: null, scheduledDate: new Date().toISOString() }),
|
||
}).catch(e => console.error('Failed to rescue orphaned task:', e));
|
||
}
|
||
}
|
||
|
||
setSomedayLists(populatedLists);
|
||
|
||
// Roll overdue tasks
|
||
if (dayTasks.length > 0) {
|
||
rollOverdueTasks(dayTasks);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching data:', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
|
||
// Get visible days based on current view setting
|
||
const getVisibleDays = useCallback(() => {
|
||
const days: Date[] = [];
|
||
for (let i = 0; i < viewDays; i++) {
|
||
days.push(new Date(currentWeekStart.getTime() + i * 24 * 60 * 60 * 1000));
|
||
}
|
||
return days;
|
||
}, [currentWeekStart, viewDays]);
|
||
|
||
// Get tasks for a specific date
|
||
const getTasksForDate = useCallback((date: Date): Task[] => {
|
||
const dateStr = formatDateToISO(date); // Use local date formatting
|
||
return tasks
|
||
.filter(task => {
|
||
if (!task.scheduledDate) return false;
|
||
// Use string comparison to avoid timezone shifts
|
||
const taskDateStr = typeof task.scheduledDate === 'string'
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
return taskDateStr === dateStr;
|
||
})
|
||
.sort((a, b) => {
|
||
// Sort by time if available
|
||
if (a.startTime && b.startTime) {
|
||
return a.startTime.localeCompare(b.startTime);
|
||
}
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
}, [tasks]);
|
||
|
||
// Get tasks for a specific time slot
|
||
const getTasksForSlot = useCallback((date: Date, slot: string): Task[] => {
|
||
const dateStr = formatDateToISO(date);
|
||
return tasks.filter(task => {
|
||
if (!task.scheduledDate) return false;
|
||
// Use string comparison to avoid timezone shifts
|
||
const taskDateStr = typeof task.scheduledDate === 'string'
|
||
? task.scheduledDate.substring(0, 10)
|
||
: formatDateToISO(new Date(task.scheduledDate));
|
||
return taskDateStr === dateStr && task.startTime === slot;
|
||
});
|
||
}, [tasks]);
|
||
|
||
// Get calendar events for a specific date
|
||
const getEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||
return calendarEvents.filter(event => {
|
||
// Skip all-day events (handled separately)
|
||
if (isAllDayEvent(event)) return false;
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
return isSameDay(eventDate, date);
|
||
});
|
||
}, [calendarEvents]);
|
||
|
||
// Get calendar events for a specific time slot
|
||
const getEventsForSlot = useCallback((date: Date, slot: string): CalendarEvent[] => {
|
||
return calendarEvents.filter(event => {
|
||
// Skip all-day events (handled separately)
|
||
const isAllDay = isAllDayEvent(event);
|
||
if (isAllDay) return false;
|
||
|
||
if (event.title.includes('Valentinstag')) {
|
||
// Debug removed
|
||
}
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
if (!isSameDay(eventDate, date)) return false;
|
||
|
||
// Extract hour:minute from event start time and compare with slot
|
||
const eventHour = eventDate.getHours();
|
||
const eventMinute = eventDate.getMinutes();
|
||
|
||
// Match if event starts within this slot
|
||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
const slotEnd = slotStart + cellDuration;
|
||
const eventStart = eventHour * 60 + eventMinute;
|
||
|
||
return eventStart >= slotStart && eventStart < slotEnd;
|
||
});
|
||
}, [calendarEvents, cellDuration]);
|
||
|
||
// Calculate event duration in pixels for proper height display
|
||
const getEventDuration = (event: CalendarEvent): number => {
|
||
if (isAllDayEvent(event)) return 0; // All-day events handled separately
|
||
|
||
const start = new Date(event.startTime);
|
||
const end = new Date(event.endTime);
|
||
const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60);
|
||
|
||
// Calculate height based on duration and slot height
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
return Math.max(durationMinutes * pixelsPerMinute, getSlotHeight(cellDuration));
|
||
};
|
||
|
||
// Get all-day events for a specific date
|
||
const getAllDayEventsForDate = useCallback((date: Date): CalendarEvent[] => {
|
||
return calendarEvents.filter(event => {
|
||
if (!isAllDayEvent(event)) return false;
|
||
|
||
// Parse date from startTime
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = event.endTime ? new Date(event.endTime) : new Date(eventStart);
|
||
|
||
// Normalize dates to start of day for comparison
|
||
const targetDate = new Date(date);
|
||
targetDate.setHours(0, 0, 0, 0);
|
||
|
||
const start = new Date(eventStart);
|
||
start.setHours(0, 0, 0, 0);
|
||
|
||
const end = new Date(eventEnd);
|
||
end.setHours(0, 0, 0, 0);
|
||
|
||
// If strictly dates, often end date is exclusive or same day?
|
||
// Google Calendar all-day events: end date is exclusive (e.g. starts 2023-01-01, ends 2023-01-02 for 1 day).
|
||
// If start == end, it's 1 day (but usually GCal sends next day).
|
||
// Let's assume inclusive start, exclusive end logic or "overlaps" logic.
|
||
// Check if targetDate is >= start AND targetDate < end
|
||
|
||
// Handle single day case where start == end or end is not provided
|
||
if (!event.endTime || start.getTime() === end.getTime()) {
|
||
return start.getTime() === targetDate.getTime();
|
||
}
|
||
|
||
return targetDate.getTime() >= start.getTime() && targetDate.getTime() < end.getTime();
|
||
});
|
||
}, [calendarEvents]);
|
||
|
||
// Get all all-day events for the visible week
|
||
const getAllDayEventsForWeek = useCallback((): Map<string, CalendarEvent[]> => {
|
||
const eventsByDay = new Map<string, CalendarEvent[]>();
|
||
const visibleDays = getVisibleDays();
|
||
|
||
visibleDays.forEach(date => {
|
||
const dateKey = formatDateToISO(date);
|
||
eventsByDay.set(dateKey, getAllDayEventsForDate(date));
|
||
});
|
||
|
||
return eventsByDay;
|
||
}, [calendarEvents, currentWeekStart, viewDays]);
|
||
|
||
const rollOverdueTasks = useCallback(async (currentTasks: Task[]) => {
|
||
const autoRolling = profile.autoRolling ?? false;
|
||
if (!autoRolling) return;
|
||
|
||
const now = new Date();
|
||
const todayStr = formatDateToISO(now);
|
||
const today = new Date(todayStr);
|
||
|
||
const overdue = currentTasks.filter(t =>
|
||
!t.completed &&
|
||
t.isRolling &&
|
||
t.scheduledDate &&
|
||
formatDateToISO(new Date(t.scheduledDate)) < todayStr
|
||
);
|
||
|
||
if (overdue.length === 0) return;
|
||
|
||
console.log(`[ROLLING] Found ${overdue.length} overdue tasks to roll to today. autoRolling=${autoRolling}`);
|
||
|
||
const updatedTasks = [...currentTasks];
|
||
let hasChanges = false;
|
||
|
||
const dailyEvents = getEventsForDate(today);
|
||
|
||
for (const task of overdue) {
|
||
const targetSlot = task.startTime || '09:00'; // Default to 9am if no time
|
||
|
||
// Collision detection
|
||
const isBlocked = (date: Date, slot: string, tasksToCheck: Task[]) => {
|
||
// Check other tasks in the updated list
|
||
const taskConflict = tasksToCheck.find(t =>
|
||
t.id !== task.id &&
|
||
t.scheduledDate &&
|
||
formatDateToISO(new Date(t.scheduledDate)) === formatDateToISO(date) &&
|
||
t.startTime === slot
|
||
);
|
||
if (taskConflict) return true;
|
||
|
||
// Check calendar events
|
||
const [h, m] = slot.split(':').map(Number);
|
||
const slotStart = new Date(date);
|
||
slotStart.setHours(h, m, 0, 0);
|
||
const slotEnd = new Date(slotStart);
|
||
slotEnd.setMinutes(slotEnd.getMinutes() + cellDuration);
|
||
|
||
return dailyEvents.some(event => {
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
return slotStart < eventEnd && slotEnd > eventStart;
|
||
});
|
||
};
|
||
|
||
const findFreeSlot = (date: Date, preferred: string, tasksToCheck: Task[]) => {
|
||
let current = preferred;
|
||
let [h, m] = current.split(':').map(Number);
|
||
|
||
while (isBlocked(date, current, tasksToCheck)) {
|
||
m += cellDuration;
|
||
if (m >= 60) {
|
||
h += 1;
|
||
m = 0;
|
||
}
|
||
if (h >= endHour) break;
|
||
current = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||
}
|
||
return current;
|
||
};
|
||
|
||
const nextSlot = findFreeSlot(today, targetSlot, updatedTasks);
|
||
|
||
// Update in DB
|
||
try {
|
||
const res = await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: task.id,
|
||
scheduledDate: todayStr,
|
||
startTime: nextSlot
|
||
})
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const taskIndex = updatedTasks.findIndex(t => t.id === task.id);
|
||
if (taskIndex !== -1) {
|
||
updatedTasks[taskIndex] = {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt)
|
||
};
|
||
hasChanges = true;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`Failed to roll task ${task.id}:`, err);
|
||
}
|
||
}
|
||
|
||
if (hasChanges) {
|
||
setTasks(updatedTasks.filter(t => !t.somedayListId));
|
||
}
|
||
}, [profile.autoRolling, cellDuration, endHour, getEventsForDate]);
|
||
|
||
// Check if a slot is protected by calendar events (only if slot starts within event time range)
|
||
const isSlotProtected = useCallback((date: Date, slot: string): boolean => {
|
||
if (!protectEventTimes) return false;
|
||
|
||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||
const slotStart = slotHour * 60 + slotMinute;
|
||
|
||
return calendarEvents.some(event => {
|
||
if (isAllDayEvent(event)) return false;
|
||
// Skip events that have been unlocked by the user
|
||
if (unlockedEvents.has(event.id)) return false;
|
||
|
||
const eventDate = new Date(event.startTime);
|
||
if (!isSameDay(eventDate, date)) return false;
|
||
|
||
const eventStart = eventDate.getHours() * 60 + eventDate.getMinutes();
|
||
const eventEndDate = new Date(event.endTime);
|
||
const eventEndMinutes = eventEndDate.getHours() * 60 + eventEndDate.getMinutes();
|
||
|
||
// Only protect if the slot start time falls within the event's actual duration
|
||
// This ensures protection matches exactly what the event covers
|
||
return slotStart >= eventStart && slotStart < eventEndMinutes;
|
||
});
|
||
}, [protectEventTimes, calendarEvents, unlockedEvents]);
|
||
|
||
// Navigation handlers with proper slide animation
|
||
// Simplified: Immediate state update with slide-in animation to prevent blank flash
|
||
const navigate = (newDate: Date, direction: 'left' | 'right', type: 'day' | 'week') => {
|
||
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
|
||
const doc = document as any;
|
||
doc.documentElement.dataset.slideDirection = direction === 'left' ? 'next' : 'prev';
|
||
doc.documentElement.dataset.navType = 'week'; // Always use full-grid slide for both day and week
|
||
|
||
doc.startViewTransition(() => {
|
||
setCurrentWeekStart(newDate);
|
||
// setSlideDirection state is not strictly needed for global transition but keeping it clean
|
||
setSlideDirection(direction === 'left' ? 'next' : 'prev');
|
||
});
|
||
} else {
|
||
setCurrentWeekStart(newDate);
|
||
}
|
||
};
|
||
|
||
const goToPrevWeek = () => navigate(new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000), 'right', 'week');
|
||
const goToNextWeek = () => navigate(new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000), 'left', 'week');
|
||
const goToPrevDay = () => navigate(new Date(currentWeekStart.getTime() - 24 * 60 * 60 * 1000), 'right', 'day');
|
||
const goToNextDay = () => navigate(new Date(currentWeekStart.getTime() + 24 * 60 * 60 * 1000), 'left', 'day');
|
||
const goToToday = () => {
|
||
const d = new Date();
|
||
d.setHours(0, 0, 0, 0);
|
||
d.setDate(d.getDate() - 1);
|
||
setCurrentWeekStart(d);
|
||
};
|
||
|
||
|
||
const executeImport = async (provider: 'google' | 'apple') => {
|
||
setImportProvider(provider);
|
||
setIsImportModalOpen(true);
|
||
setIsFetchingLists(true);
|
||
setImportLists([]);
|
||
setImportStatusMsg(null);
|
||
|
||
try {
|
||
const res = await fetch(`/api/tasks/lists?provider=${provider}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setImportLists(data.lists || []);
|
||
} else {
|
||
const errData = await res.json();
|
||
console.error('Failed to fetch lists', errData);
|
||
setIsImportModalOpen(false);
|
||
setImportStatusMsg({ type: 'error', text: errData.error || 'Failed to fetch task lists.' });
|
||
}
|
||
} catch (e) {
|
||
console.error('Error fetching lists:', e);
|
||
setIsImportModalOpen(false);
|
||
setImportStatusMsg({ type: 'error', text: 'Error fetching task lists.' });
|
||
} finally {
|
||
setIsFetchingLists(false);
|
||
}
|
||
};
|
||
|
||
// Core import logic — accepts provider directly so it works both from modal and sidebar
|
||
const doImport = async (provider: 'google' | 'apple', selectedLists: { id: string, title: string }[]) => {
|
||
setImportingTasksState(true);
|
||
setImportStatusMsg(null);
|
||
|
||
try {
|
||
const response = await fetch('/api/tasks/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ provider, sourceLists: selectedLists })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok) {
|
||
setImportStatusMsg({ type: 'success', text: `Imported ${data.count} new tasks into ${data.listsCreated || 1} list(s).` });
|
||
await fetchTasks();
|
||
} else {
|
||
setImportStatusMsg({ type: 'error', text: data.error || 'Import failed.' });
|
||
}
|
||
} catch (error) {
|
||
console.error('Import error:', error);
|
||
setImportStatusMsg({ type: 'error', text: 'An error occurred during import.' });
|
||
} finally {
|
||
setImportingTasksState(false);
|
||
}
|
||
};
|
||
|
||
// Called from the Google Tasks modal
|
||
const handleConfirmImport = async (selectedLists: { id: string, title: string }[]) => {
|
||
if (!importProvider) return;
|
||
setIsImportModalOpen(false);
|
||
await doImport(importProvider, selectedLists);
|
||
setImportProvider(null);
|
||
};
|
||
|
||
// Task CRUD operations
|
||
const addTask = async (date: Date, title: string, startTime?: string) => {
|
||
if (!title.trim()) return;
|
||
|
||
const scheduledDate = formatDateToISO(date); // Use local date formatting
|
||
|
||
if (!session?.user) {
|
||
// Local-only demo mode when not authenticated
|
||
const tempId = `temp-${Date.now()}`;
|
||
setTasks(prevTasks => [...prevTasks, {
|
||
id: tempId,
|
||
title: title.trim(),
|
||
dayOfWeek: date.getDay(),
|
||
scheduledDate,
|
||
order: prevTasks.filter(t => t.scheduledDate === scheduledDate).length,
|
||
completed: false,
|
||
userId: 'temp',
|
||
startTime,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}]);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: title.trim(),
|
||
dayOfWeek: date.getDay(),
|
||
scheduledDate,
|
||
order: 0,
|
||
startTime
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setTasks(prevTasks => [...prevTasks, {
|
||
...data.task,
|
||
createdAt: new Date(data.task.createdAt),
|
||
updatedAt: new Date(data.task.updatedAt),
|
||
}]);
|
||
} else {
|
||
console.error('Failed to add task:', await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding task:', error);
|
||
}
|
||
};
|
||
|
||
// Helper to find a task in both calendar tasks and someday lists
|
||
const findTaskAnywhere = (taskId: string): Task | undefined => {
|
||
const calTask = tasks.find(t => t.id === taskId);
|
||
if (calTask) return calTask;
|
||
for (const list of somedayLists) {
|
||
const found = list.tasks.find(t => t.id === taskId);
|
||
if (found) return found;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
const toggleTask = async (taskId: string) => {
|
||
const task = findTaskAnywhere(taskId);
|
||
if (!task) return;
|
||
|
||
const updatedCompleted = !task.completed;
|
||
const isSomeday = !!task.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, completed: updatedCompleted, updatedAt: new Date() } : t)
|
||
})));
|
||
} else {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
}
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, completed: updatedCompleted }),
|
||
});
|
||
|
||
if (task.externalId && task.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId: taskId, completed: updatedCompleted }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error toggling task:', error);
|
||
}
|
||
};
|
||
|
||
const updateTask = async (taskId: string, newTitle: string) => {
|
||
if (!newTitle.trim()) {
|
||
await deleteTask(taskId);
|
||
return;
|
||
}
|
||
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, title: newTitle.trim(), updatedAt: new Date() } : t)
|
||
})));
|
||
} else {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||
: t
|
||
));
|
||
}
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, title: newTitle.trim() }),
|
||
});
|
||
|
||
if (task?.externalId && task?.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId, title: newTitle.trim() }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating task:', error);
|
||
}
|
||
};
|
||
|
||
const updateTaskFields = async (taskId: string, fields: Partial<Task>) => {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, ...fields, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
// Also update someday lists if the task is there
|
||
setSomedayLists(lists => lists.map(list => ({
|
||
...list,
|
||
tasks: list.tasks.map(t => t.id === taskId ? { ...t, ...fields, updatedAt: new Date() } : t)
|
||
})));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, ...fields }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task fields:', error);
|
||
}
|
||
};
|
||
|
||
const updateTaskDuration = async (taskId: string, durationMinutes: number) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task || !task.startTime) return;
|
||
|
||
try {
|
||
// Parse start time (HH:mm)
|
||
const [startHour, startMinute] = task.startTime.split(':').map(Number);
|
||
|
||
// Calculate end time
|
||
const totalStartMinutes = startHour * 60 + startMinute;
|
||
const totalEndMinutes = totalStartMinutes + durationMinutes;
|
||
|
||
const endHour = Math.floor(totalEndMinutes / 60) % 24; // Wrap around 24h
|
||
const endMinute = totalEndMinutes % 60;
|
||
|
||
const endTimeStr = `${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}`;
|
||
|
||
// Optimistic update
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, endTime: endTimeStr, updatedAt: new Date() } : t
|
||
));
|
||
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, endTime: endTimeStr }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task duration:', error);
|
||
}
|
||
};
|
||
|
||
const updateTaskNotes = async (taskId: string, notes: string) => {
|
||
const task = findTaskAnywhere(taskId);
|
||
const isSomeday = !!task?.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t)
|
||
})));
|
||
} else {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t
|
||
));
|
||
}
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, markdownContent: notes }),
|
||
});
|
||
|
||
if (task?.externalId && task?.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId, notes }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating task notes:', error);
|
||
}
|
||
};
|
||
|
||
const toggleTaskRolling = async (taskId: string) => {
|
||
const task = findTaskAnywhere(taskId);
|
||
if (!task) return;
|
||
|
||
const newRollingState = !task.isRolling;
|
||
const isSomeday = !!task.somedayListId;
|
||
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t)
|
||
})));
|
||
} else {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t
|
||
));
|
||
}
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, isRolling: newRollingState }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task rolling state:', error);
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, isRolling: !newRollingState } : t)
|
||
})));
|
||
} else {
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t
|
||
));
|
||
}
|
||
}
|
||
};
|
||
|
||
const moveTaskToSlot = async (taskId: string, dayOfWeek: number, startTime: string, scheduledDate?: Date) => {
|
||
const newScheduledDate = scheduledDate ? formatDateToISO(scheduledDate) : undefined;
|
||
const task = tasks.find(t => t.id === taskId);
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, dayOfWeek, startTime, scheduledDate: newScheduledDate || t.scheduledDate, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, dayOfWeek, startTime, scheduledDate: newScheduledDate }),
|
||
});
|
||
|
||
// Sync due date change to external provider
|
||
if (task?.externalId && task?.externalProvider && newScheduledDate) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error moving task:', error);
|
||
}
|
||
};
|
||
|
||
const deleteTask = async (taskId: string) => {
|
||
const taskToDelete = findTaskAnywhere(taskId);
|
||
const isSomeday = !!taskToDelete?.somedayListId;
|
||
const isVirtual = taskId.startsWith('virtual-');
|
||
|
||
let originalId = taskId;
|
||
if (isVirtual) {
|
||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||
if (match) {
|
||
originalId = match[1];
|
||
}
|
||
}
|
||
|
||
// Check if it's a series (virtual or real recurring)
|
||
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
|
||
|
||
if (isSeries) {
|
||
const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE.");
|
||
|
||
if (deleteSeries) {
|
||
setTasks(prev => prev.filter(t => {
|
||
if (t.id === originalId) return false;
|
||
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
|
||
if (t.id === taskId) return false;
|
||
return true;
|
||
}));
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
const origTask = findTaskAnywhere(originalId);
|
||
if (origTask?.externalId && origTask?.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId: originalId, action: 'delete' }),
|
||
}).catch(e => console.error('Sync delete error:', e));
|
||
}
|
||
|
||
await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Error deleting series:', error);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// NORMAL DELETE (Single instance)
|
||
if (isSomeday) {
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.filter(t => t.id !== taskId)
|
||
})));
|
||
} else {
|
||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||
}
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId, action: 'delete' }),
|
||
}).catch(e => console.error('Sync delete error:', e));
|
||
}
|
||
|
||
await fetch(`/api/tasks?id=${taskId}`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Error deleting task:', error);
|
||
}
|
||
};
|
||
|
||
// Toggle rolling status
|
||
const toggleRolling = async (taskId: string) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const updatedIsRolling = !task.isRolling;
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, isRolling: updatedIsRolling, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, isRolling: updatedIsRolling }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error toggling rolling status:', error);
|
||
}
|
||
};
|
||
|
||
// Roll task to tomorrow or next week
|
||
const rollTask = async (taskId: string, rollType: 'tomorrow' | 'nextWeek') => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task || task.completed) return;
|
||
|
||
// Get current task date
|
||
const currentDate = task.scheduledDate ? new Date(task.scheduledDate) : new Date();
|
||
|
||
// Calculate new date
|
||
const newDate = new Date(currentDate);
|
||
if (rollType === 'tomorrow') {
|
||
newDate.setDate(newDate.getDate() + 1);
|
||
} else {
|
||
newDate.setDate(newDate.getDate() + 7);
|
||
}
|
||
|
||
const newScheduledDate = formatDateToISO(newDate);
|
||
|
||
// Preserve startTime — if the preferred slot is taken, find next free one
|
||
let resolvedStartTime = task.startTime || undefined;
|
||
if (resolvedStartTime) {
|
||
const targetSlotTasks = tasks.filter(t => {
|
||
if (t.id === taskId || !t.scheduledDate) return false;
|
||
const tDate = formatDateToISO(new Date(t.scheduledDate));
|
||
return tDate === newScheduledDate && t.startTime === resolvedStartTime;
|
||
});
|
||
if (targetSlotTasks.length > 0) {
|
||
// Slot is taken — find next free slot
|
||
const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
const startIndex = allSlots.indexOf(resolvedStartTime);
|
||
if (startIndex !== -1) {
|
||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||
const candidate = allSlots[i];
|
||
const candidateTasks = tasks.filter(t => {
|
||
if (t.id === taskId || !t.scheduledDate) return false;
|
||
const tDate = formatDateToISO(new Date(t.scheduledDate));
|
||
return tDate === newScheduledDate && t.startTime === candidate;
|
||
});
|
||
if (candidateTasks.length === 0) {
|
||
resolvedStartTime = candidate;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, scheduledDate: newScheduledDate, dayOfWeek: newDate.getDay(), startTime: resolvedStartTime || t.startTime, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: taskId,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek: newDate.getDay(),
|
||
startTime: resolvedStartTime
|
||
}),
|
||
});
|
||
|
||
// Sync due date change to external provider
|
||
if (task.externalId && task.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId, scheduledDate: newScheduledDate }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error rolling task:', error);
|
||
}
|
||
};
|
||
|
||
// Drag and drop handlers
|
||
const handleDragStart = (e: DragEvent, task: Task) => {
|
||
setDraggedTask(task);
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', task.id);
|
||
}
|
||
// Add drag-source class for styling
|
||
if (e.currentTarget instanceof HTMLElement) {
|
||
e.currentTarget.classList.add('drag-source');
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: DragEvent | React.DragEvent, dayOfWeek?: number, slot?: string) => {
|
||
e.preventDefault();
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}
|
||
// Update drop preview if we have day and slot info
|
||
if (dayOfWeek !== undefined && slot) {
|
||
setDropPreview({ day: dayOfWeek, slot });
|
||
}
|
||
};
|
||
|
||
const handleDrop = async (e: DragEvent, dayOfWeek: number, slot?: string) => {
|
||
e.preventDefault();
|
||
if (draggedTask) {
|
||
const visibleDays = getVisibleDays();
|
||
const targetDateObj = visibleDays.find(d => d.getDay() === dayOfWeek) || new Date();
|
||
|
||
let targetSlot = slot;
|
||
|
||
// If no slot provided (dropped on header/background), try to keep original time
|
||
if (!targetSlot && draggedTask.startTime) {
|
||
targetSlot = draggedTask.startTime;
|
||
}
|
||
|
||
// Collision detection / Find next free slot
|
||
if (targetSlot) {
|
||
const targetSlotTasks = getTasksForSlot(targetDateObj, targetSlot);
|
||
if (targetSlotTasks.length > 0 && !targetSlotTasks.some(t => t.id === draggedTask.id)) {
|
||
const allSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
const startIndex = allSlots.indexOf(targetSlot);
|
||
if (startIndex !== -1) {
|
||
for (let i = startIndex + 1; i < allSlots.length; i++) {
|
||
const nextSlot = allSlots[i];
|
||
const nextSlotTasks = getTasksForSlot(targetDateObj, nextSlot);
|
||
if (nextSlotTasks.length === 0) {
|
||
targetSlot = nextSlot;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If the task was from a someday list, move it to the calendar
|
||
if (draggedTask.somedayListId) {
|
||
const newScheduledDate = formatDateToISO(targetDateObj);
|
||
// Remove from someday list UI
|
||
setSomedayLists(prev => prev.map(l => ({
|
||
...l,
|
||
tasks: l.tasks.filter(t => t.id !== draggedTask.id)
|
||
})));
|
||
// Add to calendar tasks
|
||
setTasks(prev => [...prev, { ...draggedTask, somedayListId: null, scheduledDate: newScheduledDate, dayOfWeek, startTime: targetSlot || '' }]);
|
||
// Persist
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: draggedTask.id,
|
||
somedayListId: null,
|
||
scheduledDate: newScheduledDate,
|
||
dayOfWeek,
|
||
startTime: targetSlot || ''
|
||
}),
|
||
});
|
||
|
||
// Sync due date to external provider when moving from someday to calendar
|
||
if (draggedTask.externalId && draggedTask.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId: draggedTask.id, scheduledDate: newScheduledDate }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error moving task from someday to calendar:', error);
|
||
}
|
||
} else {
|
||
moveTaskToSlot(draggedTask.id, dayOfWeek, targetSlot || '', targetDateObj);
|
||
}
|
||
setDraggedTask(null);
|
||
}
|
||
setDropPreview(null);
|
||
};
|
||
|
||
const handleDragEnd = () => {
|
||
setDraggedTask(null);
|
||
setDropPreview(null);
|
||
// Remove drag-source class from all elements
|
||
document.querySelectorAll('.drag-source').forEach(el => el.classList.remove('drag-source'));
|
||
};
|
||
|
||
const handleDragLeave = () => {
|
||
setDropPreview(null);
|
||
};
|
||
|
||
// Sync calendar
|
||
const handleSync = async () => {
|
||
setSyncStatus('syncing');
|
||
try {
|
||
// Pull changes from Google Tasks, then reload everything
|
||
await fetch('/api/tasks/sync').catch(e => console.error('Task pull sync error:', e));
|
||
await Promise.all([fetchCalendarEvents(), fetchTasks()]);
|
||
setSyncStatus('synced');
|
||
setTimeout(() => setSyncStatus('idle'), 3000);
|
||
} catch (error) {
|
||
console.error('Error syncing:', error);
|
||
setSyncStatus('idle');
|
||
}
|
||
};
|
||
|
||
// Start adding someday list UI
|
||
const handleStartAddSomedayList = () => {
|
||
setIsAddingSomedayList(true);
|
||
// Focus will happen in render logic if possible or via ref, but let's render conditional input first
|
||
};
|
||
|
||
const saveSomedayList = async () => {
|
||
if (!newSomedayListName.trim()) {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName('');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/someday-lists', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: newSomedayListName.trim() }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setSomedayLists(prev => [...prev, {
|
||
...data.list,
|
||
tasks: [] // Initially empty
|
||
}]);
|
||
setNewSomedayListName('');
|
||
setIsAddingSomedayList(false);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding someday list:', error);
|
||
}
|
||
};
|
||
|
||
// Get time slots to display
|
||
// Get time slots to display
|
||
const visibleSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
|
||
const containerStyle = {
|
||
'--weekly-font-headline': (profile.headlineFont || headlineFont) ? `"${profile.headlineFont || headlineFont}", sans-serif` : 'var(--font-headline)',
|
||
'--weekly-headline-size': profile.headlineFontSize || '1.25rem',
|
||
'--weekly-headline-weight': profile.headlineFontWeight || '900',
|
||
'--weekly-date-font': (profile.dateFontFamily) ? `"${profile.dateFontFamily}", sans-serif` : 'var(--weekly-font-headline)',
|
||
'--weekly-date-size': profile.dateFontSize || '0.65rem',
|
||
'--weekly-date-weight': profile.dateFontWeight || '400',
|
||
'--weekly-time-task-font': (profile.timeTaskFontFamily) ? `"${profile.timeTaskFontFamily}", sans-serif` : 'var(--weekly-font)',
|
||
'--weekly-time-task-size': profile.timeTaskFontSize || '0.75rem',
|
||
'--weekly-time-task-weight': profile.timeTaskFontWeight || '500',
|
||
'--weekly-font': 'var(--font-body)', /* Force default body font as requested */
|
||
'--weekly-task-font': (profile.taskFontFamily) ? `"${profile.taskFontFamily}", sans-serif` : 'var(--weekly-font)',
|
||
'--weekly-task-size': profile.taskFontSize || '0.9rem',
|
||
'--weekly-task-weight': profile.taskFontWeight || '400',
|
||
'--weekly-event-font': (profile.eventFontFamily || eventFontFamily) ? `"${profile.eventFontFamily || eventFontFamily}", sans-serif` : 'var(--weekly-font)',
|
||
'--weekly-event-size': profile.eventFontSize || eventFontSize || '0.85rem',
|
||
'--weekly-event-weight': profile.eventFontWeight || eventFontWeight || '400',
|
||
'--font-weight-body': profile.fontWeight || fontWeight || '400',
|
||
'--weekly-weekend-sat': darkMode ? invertColor(profile.weekendColorSat || '#666666') : (profile.weekendColorSat || '#666666'),
|
||
'--weekly-weekend-sun': darkMode ? invertColor(profile.weekendColorSun || '#dc2626') : (profile.weekendColorSun || '#dc2626'),
|
||
'--weekly-weekday-color': darkMode ? invertColor(profile.weekdayColor || '#888888') : (profile.weekdayColor || '#888888'),
|
||
'--weekly-date-color': darkMode ? invertColor(profile.dateColor || '#888888') : (profile.dateColor || '#888888'),
|
||
'--weekly-task-color': darkMode ? invertColor(profile.taskColor || '#333333') : (profile.taskColor || '#333333'),
|
||
'--weekly-today-highlight': darkMode ? invertColor(profile.todayHighlightColor || '#f0fafa') : (profile.todayHighlightColor || '#f0fafa'),
|
||
'--weekly-past-color': darkMode ? invertColor(profile.pastDayColor || '#a6a6a7') : (profile.pastDayColor || '#a6a6a7'),
|
||
} as React.CSSProperties;
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="weekly-container" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ color: 'var(--weekly-text-light)' }}>{translations[language]?.loading || translations['en'].loading}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`weekly-container ${darkMode ? 'dark-mode' : ''} font-size-${fontSize.toLowerCase()} ${viewStyle}-view ${showTimeGrid ? 'time-grid-on' : 'time-grid-off'}`} style={containerStyle}>
|
||
{/* View Transitions Style Block */}
|
||
<style dangerouslySetInnerHTML={{
|
||
__html: (() => {
|
||
// Generate View Transition styles for a wide range of days around current view
|
||
// to ensure both entering and exiting days have the 500ms duration.
|
||
const center = currentWeekStart;
|
||
const validNames = [];
|
||
// Cover +/- 2 weeks just to be safe (exiting days need styles too)
|
||
for (let i = -14; i <= 21; i++) {
|
||
const d = new Date(center);
|
||
d.setDate(d.getDate() + i);
|
||
validNames.push(`day-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`);
|
||
}
|
||
|
||
return validNames.map(name => `
|
||
::view-transition-group(${name}) {
|
||
animation-duration: 0.5s;
|
||
animation-timing-function: ease-in-out;
|
||
}
|
||
`).join('');
|
||
})()
|
||
}} />
|
||
|
||
{/* Refactored Header: Left, Center, Right */}
|
||
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200">
|
||
{/* LEFT SECTION: Slot Duration & Days to Show */}
|
||
<div className="flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||
{/* Slot Duration */}
|
||
{showTimeGrid && (
|
||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1" title="Slot Duration">
|
||
<Clock size={16} className="text-gray-500 mr-1" />
|
||
{[15, 30, 60].map(duration => (
|
||
<button
|
||
key={duration}
|
||
onClick={() => {
|
||
setCellDuration(duration as CellDuration);
|
||
saveSetting('cellDuration', duration);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${cellDuration === duration ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||
>
|
||
{duration}m
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Days to Show */}
|
||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1" title="Days to show">
|
||
<LayoutGrid size={16} className="text-gray-500 mr-1" />
|
||
{[1, 3, 5, 7].map(num => (
|
||
<button
|
||
key={num}
|
||
onClick={() => {
|
||
setViewDays(num);
|
||
saveSetting('viewDays', num);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${viewDays === num ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||
>
|
||
{num}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Time Range */}
|
||
{showTimeGrid && (
|
||
<div className="flex items-center gap-2 text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 rounded p-1 px-2" title="Visible Hours">
|
||
<Clock size={16} className="text-gray-500" />
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max={endHour - 1}
|
||
value={startHour}
|
||
onChange={(e) => {
|
||
const val = Math.max(0, Math.min(parseInt(e.target.value) || 0, endHour - 1));
|
||
setStartHour(val);
|
||
saveSetting('startHour', val);
|
||
}}
|
||
className="w-10 p-0.5 border border-gray-200 dark:border-gray-700 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
|
||
/>
|
||
<span>-</span>
|
||
<input
|
||
type="number"
|
||
min={startHour + 1}
|
||
max="24"
|
||
value={endHour}
|
||
onChange={(e) => {
|
||
const val = Math.max(startHour + 1, Math.min(parseInt(e.target.value) || 24, 24));
|
||
setEndHour(val);
|
||
saveSetting('endHour', val);
|
||
}}
|
||
className="w-10 p-0.5 border border-gray-200 dark:border-gray-700 rounded text-center bg-transparent focus:outline-none focus:border-teal-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* View Style Toggles */}
|
||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded p-1" title="View Style">
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle('simple');
|
||
setShowTimeGrid(true);
|
||
saveSetting('viewStyle', 'simple');
|
||
saveSetting('showTimeGrid', true);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${showTimeGrid && viewStyle === 'simple' ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||
>
|
||
Simple
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle('calendar');
|
||
setShowTimeGrid(true);
|
||
saveSetting('viewStyle', 'calendar');
|
||
saveSetting('showTimeGrid', true);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${showTimeGrid && viewStyle === 'calendar' ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||
>
|
||
Calendar
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setViewStyle('list');
|
||
setShowTimeGrid(false);
|
||
saveSetting('viewStyle', 'list');
|
||
saveSetting('showTimeGrid', false);
|
||
}}
|
||
className={`px-2 py-0.5 text-xs rounded transition-colors ${!showTimeGrid && viewStyle === 'list' ? 'bg-white shadow-sm font-bold text-black' : 'text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700'}`}
|
||
>
|
||
List
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
|
||
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2">
|
||
{/* Week & Year */}
|
||
<div className="text-lg font-bold whitespace-nowrap flex items-center gap-2">
|
||
{(isLoading || isSyncing) && (
|
||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-600" title="Syncing..."></div>
|
||
)}
|
||
KW {getWeekNumber(currentWeekStart).toString().padStart(2, '0')} <span className="text-gray-400">|</span> {currentWeekStart.getFullYear()}
|
||
</div>
|
||
|
||
{/* Goal */}
|
||
<div className="flex items-center text-sm">
|
||
<span className="text-gray-300 mx-2">-</span>
|
||
{isEditingGoal ? (
|
||
<input
|
||
type="text"
|
||
value={goal}
|
||
onChange={(e) => setGoal(e.target.value)}
|
||
onBlur={() => { saveGoal(goal); setIsEditingGoal(false); }}
|
||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||
autoFocus
|
||
className="border-b border-gray-300 focus:outline-none focus:border-black px-1 text-center font-medium italic"
|
||
style={{
|
||
width: `${Math.max(10, goal.length)}ch`,
|
||
fontFamily: profile.goalFontFamily ? `"${profile.goalFontFamily}", sans-serif` : undefined,
|
||
fontSize: profile.goalFontSize || undefined,
|
||
fontWeight: profile.goalFontWeight || undefined,
|
||
}}
|
||
/>
|
||
) : (
|
||
<span
|
||
onClick={() => !showNextTask && setIsEditingGoal(true)}
|
||
className={`cursor-pointer font-medium italic text-gray-600 hover:text-black transition-colors ${showNextTask ? 'cursor-default' : ''}`}
|
||
title={showNextTask ? "Next task" : "Edit goal"}
|
||
style={{
|
||
fontFamily: profile.goalFontFamily ? `"${profile.goalFontFamily}", sans-serif` : undefined,
|
||
fontSize: profile.goalFontSize || undefined,
|
||
fontWeight: profile.goalFontWeight || undefined,
|
||
}}
|
||
>
|
||
{showNextTask ? (() => {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const todayStr = formatDateToISO(today);
|
||
const todayDay = today.getDay();
|
||
const todaysTasks = tasks.filter(t => {
|
||
if (t.completed || t.somedayListId) return false;
|
||
if (t.scheduledDate) return formatDateToISO(new Date(t.scheduledDate)) === todayStr;
|
||
if (t.dayOfWeek === todayDay && !t.scheduledDate) return true;
|
||
return false;
|
||
}).sort((a, b) => {
|
||
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
const nextTask = todaysTasks[0];
|
||
return nextTask ? `Do this now: ${nextTask.title}` : goal;
|
||
})() : goal}
|
||
</span>
|
||
)}
|
||
<span className="text-gray-300 mx-2">-</span>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* RIGHT SECTION: Navigation & Tools */}
|
||
<div className="flex items-center gap-3 transition-opacity duration-300 opacity-0 group-hover:opacity-100">
|
||
{/* Add Event Button */}
|
||
<button
|
||
onClick={() => {
|
||
const now = new Date();
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: undefined,
|
||
initialDate: now,
|
||
initialStartTime: `${String(now.getHours()).padStart(2, '0')}:00`
|
||
});
|
||
}}
|
||
className="weekly-btn-icon"
|
||
title="Add Calendar Event"
|
||
>
|
||
<Plus size={18} className="text-gray-600 hover:text-black transition-colors" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const newVal = !showNextTask;
|
||
setShowNextTask(newVal);
|
||
saveSetting('showNextTask', newVal);
|
||
}}
|
||
className={`weekly-btn-icon ${showNextTask ? 'active' : ''}`}
|
||
title={showNextTask ? "Showing Next Task" : "Showing Goal"}
|
||
>
|
||
{showNextTask ? <Play size={18} className="text-teal-600" /> : <Target size={18} className="text-gray-400" />}
|
||
</button>
|
||
|
||
{/* Focus Mode Toggle */}
|
||
<button
|
||
onClick={() => setShowFocusMode(true)}
|
||
className="weekly-btn-icon"
|
||
title="Enter Focus Mode"
|
||
>
|
||
<Zap size={18} className="text-gray-600 hover:text-yellow-500 transition-colors" />
|
||
</button>
|
||
|
||
{/* Day/Night Mode Switch */}
|
||
<button
|
||
onClick={() => setDarkMode(!darkMode)}
|
||
className="p-1.5 rounded-md hover:bg-gray-100 transition-colors"
|
||
title={darkMode ? "Switch to Light Mode" : "Switch to Dark Mode"}
|
||
>
|
||
{darkMode ? <Sun size={18} className="text-yellow-500" /> : <Moon size={18} className="text-gray-500" />}
|
||
</button>
|
||
|
||
{/* Navigation Controls */}
|
||
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToPrevWeek} title="Previous Week">
|
||
<ChevronsLeft size={16} />
|
||
</button>
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToPrevDay} title="Previous Day">
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<button className="px-3 py-1 text-xs font-bold text-gray-600 hover:text-black hover:bg-white hover:shadow-sm rounded transition-all" onClick={goToToday} title="Go to Today">
|
||
Today
|
||
</button>
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToNextDay} title="Next Day">
|
||
<ChevronLeft size={16} className="rotate-180" />
|
||
</button>
|
||
<button className="p-1 hover:bg-white hover:shadow-sm rounded text-gray-500 hover:text-black transition-all" onClick={goToNextWeek} title="Next Week">
|
||
<ChevronsLeft size={16} className="rotate-180" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Date Picker Toggle */}
|
||
<div className="relative">
|
||
<button
|
||
className={`p-1.5 hover:bg-gray-100 rounded-md transition-colors ${showDatePicker ? 'text-teal-600 bg-teal-50' : 'text-gray-500 hover:text-black'}`}
|
||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||
title="Jump to date"
|
||
>
|
||
<Calendar size={18} />
|
||
</button>
|
||
{showDatePicker && (
|
||
<SimpleDatePicker
|
||
selected={currentWeekStart}
|
||
onSelect={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
setShowDatePicker(false);
|
||
}}
|
||
onClose={() => setShowDatePicker(false)}
|
||
language={language}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setIsSearchOpen(true)}
|
||
title="Search"
|
||
>
|
||
<Search size={18} />
|
||
</button>
|
||
|
||
{/* Recurring Tasks */}
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setIsRecurringTasksOpen(true)}
|
||
title="Recurring Tasks"
|
||
>
|
||
<Repeat size={18} />
|
||
</button>
|
||
|
||
<button
|
||
className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors"
|
||
onClick={() => setShowSettings(true)}
|
||
title="Settings"
|
||
>
|
||
<Settings size={18} />
|
||
</button>
|
||
|
||
{/* User Menu */}
|
||
<UserMenu
|
||
userEmail={session?.user?.email}
|
||
onOpenSettings={() => setShowSettings(true)}
|
||
trigger={
|
||
<button className="p-1.5 hover:bg-gray-100 rounded-md text-gray-500 hover:text-black transition-colors" title="User Menu">
|
||
<User size={18} />
|
||
</button>
|
||
}
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main Grid with Time Column */}
|
||
<div className="time-grid-wrapper">
|
||
{/* Time Column */}
|
||
{showTimeGrid && (
|
||
<div className="time-column">
|
||
<div className="time-column-header" style={{ minHeight: `${getHeaderHeight(cellDuration)}px` }}></div>
|
||
<div
|
||
className="time-column-slots"
|
||
ref={timeColumnRef}
|
||
onScroll={handleTimeColumnScroll}
|
||
>
|
||
{visibleSlots.map((slot, index) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(':')[1];
|
||
const isHourStart = minutes === '00';
|
||
return (
|
||
<div
|
||
key={slot}
|
||
className={`time-slot-label ${isHourStart ? 'hour-start' : ''}`}
|
||
style={{ height: `${getSlotHeight(cellDuration)}px` }}
|
||
>
|
||
{isHourStart && <span>{formatHour(hour)}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Day Columns */}
|
||
<main
|
||
className={`weekly-days-grid cols-${viewDays}`}
|
||
data-slide-direction={slideDirection}
|
||
data-nav-type={viewDays > 1 ? 'week' : 'day'}
|
||
style={{ viewTransitionName: viewDays > 1 ? 'week-grid' : 'none' } as React.CSSProperties}
|
||
>
|
||
{getVisibleDays().map((date, colIndex) => {
|
||
const todayMidnight = new Date();
|
||
todayMidnight.setHours(0, 0, 0, 0);
|
||
const isToday = isSameDay(date, todayMidnight);
|
||
const isPast = date < todayMidnight && !isToday;
|
||
|
||
return (
|
||
<div
|
||
key={date.toISOString()}
|
||
className={`weekly-day-column ${date.getDay() === 6 ? 'is-sat' : ''} ${date.getDay() === 0 ? 'is-sun' : ''} ${isToday ? 'is-today' : ''} ${isPast ? 'is-past' : ''}`}
|
||
style={{ viewTransitionName: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` } as any}
|
||
>
|
||
{/* Day Header */}
|
||
<header className="weekly-day-header">
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
||
<h3 className={`weekly-day-name ${isSameDay(date, new Date()) ? 'is-today' : ''}`} style={{ marginBottom: 0 }}>
|
||
{getDayName(date, language)} <span className="weekly-day-date">{formatDateHeader(date, language)}</span>
|
||
</h3>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Time Grid or Simple List */}
|
||
{showTimeGrid ? (
|
||
<div
|
||
className="time-slots-container"
|
||
ref={el => { if (el) dayColumnsRef.current[colIndex] = el; }}
|
||
onScroll={(e) => handleDayColumnScroll(e, colIndex)}
|
||
onDragLeave={handleDragLeave}
|
||
style={{ position: 'relative' }}
|
||
>
|
||
{/* Now Line - only show on today's column */}
|
||
{isSameDay(date, new Date()) && (() => {
|
||
const now = currentTime;
|
||
const nowHour = now.getHours();
|
||
const nowMinute = now.getMinutes();
|
||
// Only show if within visible time range
|
||
if (nowHour >= workingHoursStart && nowHour < workingHoursEnd) {
|
||
const minutesSinceStart = (nowHour - workingHoursStart) * 60 + nowMinute;
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
const topPosition = minutesSinceStart * pixelsPerMinute;
|
||
return <div className="now-line" style={{ top: `${topPosition}px` }} />;
|
||
}
|
||
return null;
|
||
})()}
|
||
{/* Protection overlays - render at exact event positions */}
|
||
{protectEventTimes && getEventsForDate(date).filter(e => !isAllDayEvent(e)).map(event => {
|
||
const eventStart = new Date(event.startTime);
|
||
const eventEnd = new Date(event.endTime);
|
||
const eventStartHour = eventStart.getHours();
|
||
const eventStartMinute = eventStart.getMinutes();
|
||
|
||
|
||
// Only show if event is within visible time range
|
||
if (eventStartHour < workingHoursStart || eventStartHour >= workingHoursEnd) return null;
|
||
|
||
const minutesSinceStart = (eventStartHour - workingHoursStart) * 60 + eventStartMinute;
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
const topPosition = minutesSinceStart * pixelsPerMinute;
|
||
|
||
// Calculate height based on event duration
|
||
const durationMinutes = (eventEnd.getTime() - eventStart.getTime()) / (1000 * 60);
|
||
const calculatedHeight = durationMinutes * pixelsPerMinute;
|
||
// Ensure minimum height of 15px for visibility
|
||
const height = Math.max(calculatedHeight, 15);
|
||
|
||
const isUnlocked = unlockedEvents.has(event.id);
|
||
|
||
return (
|
||
<div
|
||
key={`protection-${event.id}`}
|
||
className="event-protection-overlay"
|
||
style={{
|
||
position: 'absolute',
|
||
top: `${topPosition}px`,
|
||
left: 0,
|
||
right: 0,
|
||
height: `${height}px`,
|
||
zIndex: 1,
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<button
|
||
className="event-unlock-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setUnlockedEvents(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(event.id)) {
|
||
next.delete(event.id);
|
||
} else {
|
||
next.add(event.id);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
title={isUnlocked ? 'Lock this time slot' : 'Unlock this time slot'}
|
||
style={{ pointerEvents: 'auto' }}
|
||
>
|
||
{isUnlocked ? '🔓' : '🔒'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{visibleSlots.map((slot) => {
|
||
const hour = getHourFromSlot(slot);
|
||
const minutes = slot.split(':')[1];
|
||
const isHourStart = minutes === '00';
|
||
const slotTasks = getTasksForSlot(date, slot);
|
||
const slotEvents = getEventsForSlot(date, slot);
|
||
const isActive = activeSlot?.day === date.getDay() && activeSlot?.slot === slot;
|
||
const isProtected = isSlotProtected(date, slot);
|
||
|
||
const handleSlotClick = (e: React.MouseEvent) => {
|
||
if (isProtected) return; // Don't allow adding tasks to protected slots
|
||
|
||
// Alt+Click to Create Calendar Event
|
||
if (e.altKey) {
|
||
e.stopPropagation();
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
initialDate: date,
|
||
initialStartTime: slot
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!isActive) {
|
||
setActiveSlot({ day: date.getDay(), slot });
|
||
setNewSlotTask('');
|
||
}
|
||
};
|
||
|
||
const handleSlotSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const taskTitle = newSlotTask.trim();
|
||
// Clear state immediately to prevent double submit
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
if (taskTitle) {
|
||
await addTask(date, taskTitle, slot);
|
||
}
|
||
};
|
||
|
||
const handleSlotDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
if (isProtected) return; // Don't allow dropping on protected slots
|
||
handleDrop(e, date.getDay(), slot);
|
||
};
|
||
|
||
const isDropTarget = dropPreview?.day === date.getDay() && dropPreview?.slot === slot;
|
||
|
||
return (
|
||
<div
|
||
key={slot}
|
||
className={`time-slot ${isHourStart ? 'hour-start' : ''} ${draggedTask && !isProtected ? 'drop-target' : ''} ${isActive ? 'active' : ''}`}
|
||
style={{ height: `${getSlotHeight(cellDuration)}px`, position: 'relative', cursor: isProtected ? 'not-allowed' : 'text' }}
|
||
onClick={handleSlotClick}
|
||
onDragOver={(e) => !isProtected && handleDragOver(e, date.getDay(), slot)}
|
||
onDrop={handleSlotDrop}
|
||
>
|
||
{/* Drop preview indicator */}
|
||
{isDropTarget && !isProtected && <div className="drop-preview" />}
|
||
{slotTasks.map(task => (
|
||
<div
|
||
key={task.id}
|
||
className={`time-slot-task ${task.completed ? 'completed' : ''} ${draggedTask?.id === task.id ? 'dragging' : ''}`}
|
||
draggable={!editingTaskId}
|
||
onDragStart={(e) => handleDragStart(e, task)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (editingTaskId !== task.id) {
|
||
toggleTask(task.id);
|
||
}
|
||
}}
|
||
>
|
||
{editingTaskId === task.id ? (
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement;
|
||
updateTask(task.id, input.value);
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{ width: '100%', paddingRight: '20px' }}
|
||
>
|
||
<input
|
||
name="title"
|
||
autoFocus
|
||
defaultValue={task.title}
|
||
onBlur={(e) => updateTask(task.id, e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') setEditingTaskId(null);
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
className="weekly-task-text"
|
||
style={{ width: '100%', background: 'transparent', border: 'none', borderBottom: '1px solid var(--weekly-border)', outline: 'none' }}
|
||
/>
|
||
{/* Duration Presets */}
|
||
<div className="duration-presets" style={{ display: 'flex', gap: '4px', marginTop: '6px', flexWrap: 'wrap' }}>
|
||
{[15, 30, 45, 60, 90, 120].map(m => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); updateTaskDuration(task.id, m); }}
|
||
title={`Set duration to ${m} minutes`}
|
||
style={{ fontSize: '0.7rem', padding: '2px 6px', background: '#f5f5f5', border: '1px solid #ccc', borderRadius: '3px', cursor: 'pointer', color: '#333' }}
|
||
>
|
||
{m < 60 ? `${m}m` : `${m / 60}h`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'pre-wrap', wordBreak: 'break-word', flex: 1 }} onDoubleClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}>
|
||
{task.title}
|
||
</span>
|
||
)}
|
||
<div className="task-actions" style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '2px',
|
||
marginLeft: '4px',
|
||
flexShrink: 0,
|
||
flexWrap: 'nowrap',
|
||
background: darkMode ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.8)',
|
||
padding: '1px 3px',
|
||
borderRadius: '4px',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
||
}}>
|
||
{/* Edit button */}
|
||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }} title="Edit">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||
</button>
|
||
{/* Notes button */}
|
||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); setSelectedTaskForNotes(task); }} title="Notes">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
||
</button>
|
||
{/* Roll button */}
|
||
{!task.completed && (
|
||
<button className={`task-action-btn ${task.isRolling ? 'active' : ''}`} onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }} title={task.isRolling ? "Disable rolling" : "Enable rolling"}>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
|
||
</button>
|
||
)}
|
||
{/* Recurrence button */}
|
||
<button
|
||
className={`task-action-btn ${task.isRecurring ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
|
||
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
|
||
>
|
||
<Repeat size={12} strokeWidth={2.5} />
|
||
</button>
|
||
{/* Delete button */}
|
||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{/* Calendar Events in time slot */}
|
||
{slotEvents.map(event => {
|
||
const eventHeight = getEventDuration(event);
|
||
const startTime = new Date(event.startTime);
|
||
const endTime = new Date(event.endTime);
|
||
const timeStr = `${startTime.getHours().toString().padStart(2, '0')}:${startTime.getMinutes().toString().padStart(2, '0')} - ${endTime.getHours().toString().padStart(2, '0')}:${endTime.getMinutes().toString().padStart(2, '0')}`;
|
||
|
||
// Calculate offset within the slot based on event start time
|
||
const [slotHour, slotMinute] = slot.split(':').map(Number);
|
||
const slotStartMinutes = slotHour * 60 + slotMinute;
|
||
const eventStartMinutes = startTime.getHours() * 60 + startTime.getMinutes();
|
||
const offsetMinutes = eventStartMinutes - slotStartMinutes;
|
||
const pixelsPerMinute = getSlotHeight(cellDuration) / cellDuration;
|
||
const topOffset = offsetMinutes * pixelsPerMinute;
|
||
|
||
// Convert hex to rgba for background, or use default
|
||
const eventColor = event.calendarColor || '#009a9a';
|
||
const bgColor = eventColor.startsWith('#')
|
||
? `${eventColor}20` // Add alpha for transparency
|
||
: eventColor;
|
||
const borderColor = eventColor.startsWith('#')
|
||
? eventColor
|
||
: 'var(--weekly-teal)';
|
||
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
className="time-slot-event"
|
||
title={`${event.calendarTitle}: ${event.title}\n${timeStr}`}
|
||
style={{
|
||
height: `${Math.max(eventHeight, 15)}px`,
|
||
minHeight: `15px`,
|
||
position: 'absolute',
|
||
top: `${topOffset}px`,
|
||
left: '-10px',
|
||
right: '-15px',
|
||
zIndex: 1,
|
||
flexDirection: 'column',
|
||
alignItems: 'flex-start',
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable ? 'pointer' : 'default'
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<div className="event-title-row">
|
||
<span className="event-indicator">📅</span>
|
||
<span className="event-title">{event.title}</span>
|
||
</div>
|
||
<div className="event-time-row">{timeStr}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{isActive && (
|
||
<form onSubmit={handleSlotSubmit} className="slot-input-form">
|
||
<input
|
||
type="text"
|
||
value={newSlotTask}
|
||
onChange={(e) => setNewSlotTask(e.target.value)}
|
||
onBlur={async (e) => {
|
||
// Prevent double submission if form was submitted
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
// Delay slightly to let onSubmit fire if that was the cause
|
||
setTimeout(async () => {
|
||
if (activeSlot && newSlotTask.trim()) {
|
||
const taskTitle = newSlotTask.trim();
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
await addTask(date, taskTitle, slot);
|
||
}
|
||
}, 100);
|
||
} else {
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
setActiveSlot(null);
|
||
setNewSlotTask('');
|
||
}
|
||
}}
|
||
autoFocus
|
||
className="weekly-task-input"
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
border: 'none',
|
||
background: 'transparent',
|
||
outline: 'none',
|
||
minHeight: '24px',
|
||
paddingLeft: '0'
|
||
}}
|
||
/>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{/* All Day Events Section */}
|
||
|
||
{/* Untimed Tasks List below grid */}
|
||
<div className="weekly-task-list" style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '0.5rem' }}>
|
||
{/* Filter for untimed tasks */}
|
||
{getTasksForDate(date)
|
||
.filter(task => !task.startTime)
|
||
.map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
variant="minimal"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onDragOver={(e) => handleDragOver(e, date.getDay())}
|
||
onDrop={(e) => handleDrop(e, date.getDay())}
|
||
onDragLeave={handleDragLeave}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{/* Calendar Events */}
|
||
{getEventsForDate(date).map(event => {
|
||
const eventColor = event.calendarColor || '#009a9a';
|
||
const bgColor = eventColor.startsWith('#') ? `${eventColor}20` : eventColor;
|
||
const borderColor = eventColor.startsWith('#') ? eventColor : 'var(--weekly-teal)';
|
||
|
||
return (
|
||
<div key={event.id} className="weekly-calendar-event"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event
|
||
});
|
||
}
|
||
}}
|
||
style={{
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable ? 'pointer' : 'default'
|
||
}}>
|
||
<div className="weekly-calendar-event-time" style={{ color: 'inherit', opacity: 0.8 }}>
|
||
{new Date(event.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||
</div>
|
||
<div className="weekly-calendar-event-title" style={{ color: 'inherit' }}>{event.title}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Tasks */}
|
||
<ol className="weekly-task-list">
|
||
{getTasksForDate(date).map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
/>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
</div>
|
||
);
|
||
})}
|
||
</main >
|
||
</div >
|
||
|
||
{/* All-Day Events Section */}
|
||
{
|
||
(() => {
|
||
if (!showAllDay) return null;
|
||
const allDayEvents = calendarEvents.filter(event => isAllDayEvent(event));
|
||
if (allDayEvents.length === 0) return null;
|
||
|
||
return (
|
||
<section className={`all-day-events-section ${isAllDayExpanded ? 'expanded' : 'collapsed'}`}>
|
||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||
{showTimeGrid && (
|
||
<div
|
||
className="all-day-label-column"
|
||
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
|
||
style={{
|
||
width: '50px',
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
borderRight: '1px solid var(--weekly-border)',
|
||
padding: '2px 4px',
|
||
gap: '0px',
|
||
position: 'relative'
|
||
}}
|
||
title={isAllDayExpanded ? 'Collapse' : 'Expand'}
|
||
>
|
||
<span style={{ fontSize: '0.6rem', fontWeight: 600, color: 'var(--weekly-text-light)', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center' }}>all day</span>
|
||
<span className="all-day-events-count" style={{ fontSize: '0.55rem', padding: '0px 3px', marginTop: '1px' }}>{allDayEvents.length}</span>
|
||
</div>
|
||
)}
|
||
{!showTimeGrid && (
|
||
<div
|
||
className="all-day-label-column"
|
||
onClick={() => setIsAllDayExpanded(!isAllDayExpanded)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
cursor: 'pointer',
|
||
padding: '2px 8px',
|
||
gap: '6px'
|
||
}}
|
||
title={isAllDayExpanded ? 'Collapse' : 'Expand'}
|
||
>
|
||
<span style={{ fontSize: '0.6rem', fontWeight: 600, color: 'var(--weekly-text-light)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>all day</span>
|
||
<span className="all-day-events-count" style={{ fontSize: '0.55rem', padding: '0px 3px' }}>{allDayEvents.length}</span>
|
||
</div>
|
||
)}
|
||
{isAllDayExpanded && (
|
||
<div
|
||
className={`all-day-events-grid cols-${viewDays}`}
|
||
style={{ flex: 1 }}
|
||
>
|
||
{getVisibleDays().map((date) => {
|
||
const dayEvents = getAllDayEventsForDate(date);
|
||
return (
|
||
<div key={date.toISOString()} className="all-day-events-column">
|
||
{dayEvents.length > 0 ? (
|
||
dayEvents.map(event => (
|
||
<div
|
||
key={event.id}
|
||
className="all-day-event"
|
||
title={`${event.calendarTitle}: ${event.title}`}
|
||
style={{
|
||
backgroundColor: event.calendarColor || '#3b82f6',
|
||
color: 'white',
|
||
borderLeft: 'none',
|
||
padding: '2px 4px',
|
||
borderRadius: '3px',
|
||
fontSize: '0.75rem',
|
||
marginBottom: '2px',
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px'
|
||
}}
|
||
>
|
||
<span className="event-indicator">📅</span>
|
||
<span className="all-day-event-title">{event.title}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="all-day-empty"></div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
})()
|
||
}
|
||
|
||
{/* Someday Section */}
|
||
{
|
||
showSomeday && (
|
||
<section className={`weekly-someday ${somedayExpanded ? 'expanded' : 'collapsed'} dark:bg-gray-900 dark:text-white transition-colors duration-200`}>
|
||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||
{showTimeGrid && (
|
||
<div
|
||
className="someday-label-column"
|
||
style={{
|
||
width: '50px',
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'flex-start',
|
||
borderRight: '1px solid var(--weekly-border)',
|
||
padding: '4px 4px',
|
||
gap: '4px',
|
||
position: 'relative'
|
||
}}
|
||
>
|
||
<div
|
||
onClick={() => setSomedayExpanded(!somedayExpanded)}
|
||
style={{ cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0px' }}
|
||
title={somedayExpanded ? 'Collapse' : 'Expand'}
|
||
>
|
||
<span style={{ fontSize: '0.6rem', fontWeight: 600, color: 'var(--weekly-text-light)', textTransform: 'uppercase', letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center' }}>any day</span>
|
||
<span style={{ fontSize: '0.55rem', color: '#888', marginTop: '1px' }}>{somedayLists.length} {translations[language]?.lists || translations['en'].lists}</span>
|
||
</div>
|
||
<button
|
||
className="someday-add-btn dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:border-white transition-colors"
|
||
onClick={(e) => { e.stopPropagation(); handleStartAddSomedayList(); }}
|
||
title="Add new list"
|
||
style={{ background: 'none', border: '1px dashed #ccc', borderRadius: '50%', width: '18px', height: '18px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#888', fontSize: '0.8rem', lineHeight: 1, padding: 0 }}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!showTimeGrid && (
|
||
<div
|
||
className="someday-label-column"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
padding: '4px 8px',
|
||
gap: '6px'
|
||
}}
|
||
>
|
||
<div
|
||
onClick={() => setSomedayExpanded(!somedayExpanded)}
|
||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px' }}
|
||
title={somedayExpanded ? 'Collapse' : 'Expand'}
|
||
>
|
||
<span style={{ fontSize: '0.6rem', fontWeight: 600, color: 'var(--weekly-text-light)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>any day</span>
|
||
<span style={{ fontSize: '0.55rem', color: '#888' }}>{somedayLists.length} {translations[language]?.lists || translations['en'].lists}</span>
|
||
</div>
|
||
<button
|
||
className="someday-add-btn dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:border-white transition-colors"
|
||
onClick={(e) => { e.stopPropagation(); handleStartAddSomedayList(); }}
|
||
title="Add new list"
|
||
style={{ background: 'none', border: '1px dashed #ccc', borderRadius: '50%', width: '18px', height: '18px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#888', fontSize: '0.8rem', lineHeight: 1, padding: 0 }}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
{somedayExpanded && (
|
||
<div
|
||
className={`weekly-someday-lists-grid cols-${Math.min(7, Math.max(1, viewDays))}`}
|
||
onWheel={handleSomedayWheel}
|
||
>
|
||
{(somedayLists.length > 0 ? somedayLists : [{ id: 'default', title: 'LISTE', tasks: [] }]).slice(0, Math.max(somedayLists.length, viewDays)).map(list => (
|
||
<div
|
||
key={list.id}
|
||
className={`weekly-someday-list ${draggingListId === list.id ? 'is-dragging' : ''} dark:bg-gray-800 dark:border-gray-700 p-2 transition-colors duration-200`}
|
||
style={{
|
||
minHeight: '200px',
|
||
cursor: 'text', // Indicate actionable area
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}
|
||
onClick={(e) => {
|
||
// Focus the add input if clicking empty area or the list container
|
||
// Only if not clicking a task item or other interactive element
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest('.weekly-task-item') || target.tagName === 'INPUT' || target.tagName === 'BUTTON') {
|
||
return;
|
||
}
|
||
|
||
const input = e.currentTarget.querySelector(`[data-someday-add-input="${list.id}"]`) as HTMLInputElement;
|
||
if (input) {
|
||
input.focus();
|
||
}
|
||
}}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
const target = e.target as HTMLElement;
|
||
// Allow task items to be dragged freely
|
||
if (target.closest('.weekly-task-item')) {
|
||
return; // Let the TaskItem handle its own drag
|
||
}
|
||
// Only allow list drag if clicking the handle
|
||
if (!target.closest('.someday-drag-handle')) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
setDraggingListId(list.id);
|
||
e.dataTransfer.setData('text/list-id', list.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}}
|
||
onDragEnd={() => setDraggingListId(null)}
|
||
onDragOver={(e) => {
|
||
e.preventDefault(); // Allow drop
|
||
e.dataTransfer.dropEffect = 'move';
|
||
}}
|
||
onDrop={async (e) => {
|
||
e.preventDefault();
|
||
setDraggingListId(null);
|
||
const draggedListId = e.dataTransfer.getData('text/list-id');
|
||
const draggedTaskId = e.dataTransfer.getData('text/plain');
|
||
|
||
if (draggedListId === list.id) return;
|
||
|
||
// Check if a calendar task is being dropped into this someday list
|
||
if (draggedTaskId && draggedTask && draggedTask.id === draggedTaskId) {
|
||
// Move calendar task to this someday list
|
||
const taskToMove = draggedTask;
|
||
// Remove from calendar tasks
|
||
setTasks(prev => prev.filter(t => t.id !== taskToMove.id));
|
||
|
||
// Add to target someday list and remove from source/other someday lists
|
||
const movedTask = { ...taskToMove, somedayListId: list.id, scheduledDate: undefined, dayOfWeek: null, startTime: '' };
|
||
setSomedayLists(prev => prev.map(l => {
|
||
// Filter out the task from all lists first (handles source removal and prevents target duplicates)
|
||
const filteredTasks = l.tasks.filter(t => t.id !== taskToMove.id);
|
||
if (l.id === list.id) {
|
||
return { ...l, tasks: [...filteredTasks, movedTask] };
|
||
}
|
||
return { ...l, tasks: filteredTasks };
|
||
}));
|
||
setDraggedTask(null);
|
||
// Persist
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: taskToMove.id,
|
||
somedayListId: list.id,
|
||
scheduledDate: null,
|
||
dayOfWeek: null,
|
||
startTime: null
|
||
}),
|
||
});
|
||
|
||
// Clear due date in external provider when moving to someday
|
||
if (taskToMove.externalId && taskToMove.externalProvider) {
|
||
fetch('/api/tasks/sync', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ taskId: taskToMove.id, scheduledDate: null }),
|
||
}).catch(e => console.error('Sync error:', e));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error moving task to someday list:', error);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Reorder logic (list drag)
|
||
if (!draggedListId) return;
|
||
const draggedIndex = somedayLists.findIndex(l => l.id === draggedListId);
|
||
const targetIndex = somedayLists.findIndex(l => l.id === list.id);
|
||
|
||
if (draggedIndex === -1 || targetIndex === -1) return;
|
||
|
||
const newLists = [...somedayLists];
|
||
const [draggedItem] = newLists.splice(draggedIndex, 1);
|
||
newLists.splice(targetIndex, 0, draggedItem);
|
||
|
||
setSomedayLists(newLists);
|
||
|
||
// Persist order
|
||
const orderUpdates = newLists.map((l, index) => ({ id: l.id, order: index }));
|
||
try {
|
||
await fetch('/api/someday-lists', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(orderUpdates)
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to update list order', err);
|
||
}
|
||
}}
|
||
>
|
||
<div className="weekly-someday-list-title-header" style={{ display: 'flex', justifyContent: 'flex-start', alignItems: 'center' }}>
|
||
<div className="someday-drag-handle" title="Drag to reorder">
|
||
<GripVertical size={14} />
|
||
</div>
|
||
{/* Editable Title */}
|
||
<input
|
||
type="text"
|
||
defaultValue={list.title}
|
||
className="weekly-someday-list-title-input dark:bg-transparent dark:text-white"
|
||
onBlur={async (e) => {
|
||
const newTitle = e.target.value.trim();
|
||
if (newTitle && newTitle !== list.title) {
|
||
try {
|
||
await fetch('/api/someday-lists', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: list.id, title: newTitle }),
|
||
});
|
||
setSomedayLists(prev => prev.map(l => l.id === list.id ? { ...l, title: newTitle } : l));
|
||
} catch (err) {
|
||
console.error(err);
|
||
e.target.value = list.title;
|
||
}
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
<button
|
||
className="someday-list-delete-btn"
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
if (confirm('Delete this list?')) {
|
||
try {
|
||
await fetch(`/api/someday-lists?id=${list.id}`, { method: 'DELETE' });
|
||
setSomedayLists(prev => prev.filter(l => l.id !== list.id));
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}}
|
||
style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '1rem', color: '#ccc', marginLeft: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||
title="Delete List"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<ol className="weekly-task-list" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
{list.tasks.map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(title) => updateTask(task.id, title)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={() => setSelectedTaskForNotes(task)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
variant="minimal"
|
||
isSomeday={true}
|
||
/>
|
||
))}
|
||
<SomedayAddTask listId={list.id} onAdd={async (title) => {
|
||
try {
|
||
const res = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title, somedayListId: list.id }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
const newTask = { ...data.task, createdAt: new Date(data.task.createdAt), updatedAt: new Date(data.task.updatedAt) };
|
||
setSomedayLists(prev => prev.map(l =>
|
||
l.id === list.id ? { ...l, tasks: [...l.tasks, newTask] } : l
|
||
));
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}} />
|
||
{Array.from({ length: Math.max(0, 5 - list.tasks.length) }).map((_, i) => (
|
||
<li key={`filler-${i}`} className="weekly-task-item minimal filler" style={{
|
||
borderBottom: '1px solid var(--weekly-border)',
|
||
height: '32px',
|
||
margin: '0 0.5rem',
|
||
pointerEvents: 'none'
|
||
}}></li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
))}
|
||
|
||
{/* Modal for adding lists if needed, or just rely on placeholders */}
|
||
{isAddingSomedayList && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={(e) => { e.stopPropagation(); setIsAddingSomedayList(false); }}>
|
||
<div className="bg-white p-6 rounded-lg shadow-lg w-96 max-w-full m-4" onClick={e => e.stopPropagation()}>
|
||
<h3 className="text-lg font-bold mb-4">New List</h3>
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
placeholder="LIST NAME..."
|
||
value={newSomedayListName}
|
||
onChange={(e) => setNewSomedayListName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') saveSomedayList();
|
||
if (e.key === 'Escape') {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName('');
|
||
}
|
||
}}
|
||
className="w-full p-2 border border-gray-300 rounded mb-4"
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<button onClick={() => setIsAddingSomedayList(false)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded">Cancel</button>
|
||
<button onClick={saveSomedayList} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Create</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>{/* close flex row */}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
{/* Search Modal */}
|
||
<SearchModal
|
||
isOpen={isSearchOpen}
|
||
onClose={() => setIsSearchOpen(false)}
|
||
tasks={tasks}
|
||
events={calendarEvents}
|
||
onSelectTask={(date) => {
|
||
setCurrentWeekStart(getStartOfWeek(date));
|
||
}}
|
||
/>
|
||
|
||
{/* Recurring Tasks Manager */}
|
||
<RecurringTasksManager
|
||
isOpen={isRecurringTasksOpen}
|
||
onClose={() => setIsRecurringTasksOpen(false)}
|
||
tasks={tasks}
|
||
onStopRecurring={async (task) => {
|
||
const idToUpdate = task.id.startsWith('virtual-') ? task.id.split('-')[1] : task.id;
|
||
const dateStr = new Date().toISOString();
|
||
|
||
// Update locally
|
||
setTasks(prev => prev.map(t => {
|
||
const isMatch = t.title === task.title &&
|
||
t.userId === task.userId &&
|
||
t.recurrenceInterval === task.recurrenceInterval &&
|
||
t.recurrenceUnit === task.recurrenceUnit;
|
||
if (isMatch) {
|
||
return { ...t, recurrenceEndDate: new Date(dateStr) };
|
||
}
|
||
return t;
|
||
}));
|
||
|
||
// Update DB
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: idToUpdate,
|
||
recurrenceEndDate: dateStr
|
||
})
|
||
});
|
||
fetchTasks();
|
||
} catch (error) {
|
||
console.error('Failed to stop recurring series:', error);
|
||
}
|
||
}}
|
||
/>
|
||
{/* Recurrence Modal */}
|
||
{
|
||
selectedTaskForRecurrence && (
|
||
<RecurrenceModal
|
||
task={selectedTaskForRecurrence}
|
||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||
onSave={async (recurrence) => {
|
||
await updateTaskFields(selectedTaskForRecurrence.id, recurrence);
|
||
setSelectedTaskForRecurrence(null);
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Calendar Event Modal */}
|
||
{
|
||
calendarEventModal.isOpen && (
|
||
<CalendarEventModal
|
||
event={calendarEventModal.event}
|
||
initialDate={calendarEventModal.initialDate}
|
||
initialStartTime={calendarEventModal.initialStartTime}
|
||
connections={connections}
|
||
onClose={() => setCalendarEventModal({ ...calendarEventModal, isOpen: false })}
|
||
onSave={handleEventSave}
|
||
onDelete={handleEventDelete}
|
||
/>
|
||
)
|
||
}
|
||
{/* Focus Mode Overlay */}
|
||
{
|
||
showFocusMode && (
|
||
<FocusModeOverlay
|
||
task={(() => {
|
||
// Logic to find the "Next Task"
|
||
// 1. Tasks for today with start time, sorted by time
|
||
// 2. Tasks for today without start time, sorted by order
|
||
// 3. Tasks rolling over from previous days
|
||
|
||
const now = new Date();
|
||
const todayStr = now.toISOString().split('T')[0];
|
||
|
||
// Get all tasks relevant for "Now"
|
||
const activeTasks = tasks.filter(t =>
|
||
!t.completed &&
|
||
!t.somedayListId &&
|
||
(
|
||
// Scheduled for today
|
||
(t.scheduledDate && new Date(t.scheduledDate).toISOString().split('T')[0] === todayStr) ||
|
||
// Or rolling and overdue (simplified, assuming rolling means show on today if not done)
|
||
(t.isRolling && (!t.scheduledDate || new Date(t.scheduledDate) <= now)) ||
|
||
// Or implicitly today if within current view logic (e.g. dayOfWeek match in current week)
|
||
// But let's stick to explicit date or rolling for Focus Mode to be precise.
|
||
(!t.scheduledDate && t.dayOfWeek === now.getDay() && isSameDay(currentWeekStart, getStartOfWeek(now, weekStartDay)))
|
||
)
|
||
);
|
||
|
||
// Sort: Time-based first, then Order
|
||
activeTasks.sort((a, b) => {
|
||
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
|
||
if (a.startTime) return -1;
|
||
if (b.startTime) return 1;
|
||
return a.order - b.order;
|
||
});
|
||
|
||
return activeTasks.length > 0 ? activeTasks[0] : null;
|
||
})()}
|
||
duration={focusTimerDuration}
|
||
onClose={() => setShowFocusMode(false)}
|
||
onComplete={(taskId) => toggleTask(taskId)}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{/* Settings Sidebar */}
|
||
{
|
||
showSettings && (
|
||
<SettingsSidebar
|
||
onRemoveConnection={handleRemoveConnection}
|
||
onClose={() => setShowSettings(false)}
|
||
onSettingsChanged={handleSettingsChanged}
|
||
showTimeGrid={showTimeGrid}
|
||
setShowTimeGrid={setShowTimeGrid}
|
||
cellDuration={cellDuration}
|
||
setCellDuration={setCellDuration}
|
||
weekStartDay={weekStartDay}
|
||
setWeekStartDay={setWeekStartDay}
|
||
viewStyle={viewStyle}
|
||
setViewStyle={setViewStyle}
|
||
showSomeday={showSomeday}
|
||
setShowSomeday={setShowSomeday}
|
||
showAllDay={showAllDay}
|
||
setShowAllDay={setShowAllDay}
|
||
showSchedule={showSchedule}
|
||
setShowSchedule={setShowSchedule}
|
||
goal={goal}
|
||
setGoal={setGoal}
|
||
saveGoal={saveGoal}
|
||
connections={connections}
|
||
onUpdateConnections={setConnections}
|
||
focusTimerDuration={focusTimerDuration}
|
||
setFocusTimerDuration={setFocusTimerDuration}
|
||
focusBreakDuration={focusBreakDuration}
|
||
setFocusBreakDuration={setFocusBreakDuration}
|
||
fontSize={fontSize}
|
||
setFontSize={setFontSize}
|
||
showNextTask={showNextTask}
|
||
setShowNextTask={setShowNextTask}
|
||
headlineFont={headlineFont}
|
||
headlineFontSize={headlineFontSize}
|
||
headlineFontWeight={headlineFontWeight}
|
||
dateFontFamily={dateFontFamily}
|
||
dateFontSize={dateFontSize}
|
||
dateFontWeight={dateFontWeight}
|
||
timeTaskFontFamily={timeTaskFontFamily}
|
||
timeTaskFontSize={timeTaskFontSize}
|
||
timeTaskFontWeight={timeTaskFontWeight}
|
||
bodyFont={bodyFont}
|
||
taskFontFamily={taskFontFamily}
|
||
taskFontSize={taskFontSize}
|
||
taskFontWeight={taskFontWeight}
|
||
fontWeight={fontWeight}
|
||
weekendColorSat={weekendColorSat}
|
||
weekendColorSun={weekendColorSun}
|
||
protectEventTimes={protectEventTimes}
|
||
setProtectEventTimes={setProtectEventTimes}
|
||
goalFallbackType={profile.goalFallbackType}
|
||
goalDefaultSentence={profile.goalDefaultSentence}
|
||
importingTasksState={importingTasksState}
|
||
executeImport={executeImport}
|
||
onImportLists={(lists) => doImport('apple', lists)}
|
||
importStatusMsg={importStatusMsg}
|
||
/>
|
||
)
|
||
}
|
||
|
||
{
|
||
selectedTaskForRecurrence && (
|
||
<TaskRecurrenceModal
|
||
task={selectedTaskForRecurrence}
|
||
onClose={() => setSelectedTaskForRecurrence(null)}
|
||
onSave={handleRecurrenceSave}
|
||
/>
|
||
)
|
||
}
|
||
|
||
|
||
{
|
||
selectedTaskForNotes && (
|
||
<div className="weekly-modal-overlay" onClick={() => setSelectedTaskForNotes(null)}>
|
||
<div className="weekly-modal-content" onClick={e => e.stopPropagation()}>
|
||
<h3>Notes: {selectedTaskForNotes.title}</h3>
|
||
{/* Toolbar for Modal */}
|
||
<div className="notes-toolbar" style={{ marginTop: '1rem' }}>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}**${selection}**${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
// Hacky re-focus and update value visually since it's uncontrolled-ish/onBlur driven
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 2, start + 2 + selection.length);
|
||
}} title="Bold">B</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}*${selection}*${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 1, start + 1 + selection.length);
|
||
}} title="Italic">i</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}[${selection}](url)${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 1, start + 1 + selection.length);
|
||
}} title="Link">🔗</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}- ${selection}${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 2, start + 2 + selection.length);
|
||
}} title="List">☑</button>
|
||
<button className="notes-toolbar-btn" onClick={() => {
|
||
const textarea = document.querySelector('.weekly-notes-editor') as HTMLTextAreaElement;
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const text = textarea.value;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
const newText = `${before}${after}`;
|
||
updateTaskNotes(selectedTaskForNotes.id, newText);
|
||
textarea.value = newText;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(start + 2, start + 10); // select "alt text"
|
||
}} title="Image">🖼️</button>
|
||
</div>
|
||
|
||
<textarea
|
||
className="weekly-notes-editor"
|
||
defaultValue={selectedTaskForNotes.markdownContent || ''}
|
||
autoFocus
|
||
placeholder="Add details, notes, or links..."
|
||
onBlur={(e) => updateTaskNotes(selectedTaskForNotes.id, e.target.value)}
|
||
/>
|
||
<div className="weekly-modal-actions">
|
||
<button className="weekly-btn weekly-btn-secondary" onClick={() => setSelectedTaskForNotes(null)}>Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
<ImportListModal
|
||
isOpen={isImportModalOpen}
|
||
onClose={() => setIsImportModalOpen(false)}
|
||
onImport={handleConfirmImport}
|
||
provider={importProvider}
|
||
lists={importLists}
|
||
isLoading={isFetchingLists}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Task Input Component
|
||
interface TaskInputProps {
|
||
onAddTask: (title: string) => void;
|
||
onDragOver: (e: React.DragEvent) => void;
|
||
onDrop: (e: React.DragEvent) => void;
|
||
}
|
||
|
||
function TaskInput({ onAddTask, onDragOver, onDrop }: TaskInputProps) {
|
||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||
|
||
const handleAddTask = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (newTaskTitle.trim()) {
|
||
onAddTask(newTaskTitle);
|
||
setNewTaskTitle('');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<form
|
||
onSubmit={handleAddTask}
|
||
className="weekly-task-input"
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={newTaskTitle}
|
||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||
placeholder="Type a to-do..."
|
||
/>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
function SomedayAddTask({ listId, onAdd }: { listId: string; onAdd: (title: string) => void }) {
|
||
const [title, setTitle] = useState('');
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleSubmit = () => {
|
||
if (title.trim()) {
|
||
onAdd(title.trim());
|
||
setTitle('');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<li className="weekly-task-item minimal" style={{ borderBottom: '1px solid var(--weekly-border)', margin: '0 0.5rem' }}>
|
||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} style={{ width: '100%' }}>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onBlur={handleSubmit}
|
||
onKeyDown={(e) => { if (e.key === 'Escape') { setTitle(''); e.currentTarget.blur(); } }}
|
||
className="weekly-task-text"
|
||
style={{
|
||
width: '100%', border: 'none',
|
||
background: 'transparent', padding: '0 0', fontSize: '0.8rem', outline: 'none',
|
||
height: '24px', display: 'block' // Height match filler
|
||
}}
|
||
placeholder=""
|
||
data-someday-add-input={listId}
|
||
/>
|
||
</form>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
// Task Item Component
|
||
interface TaskItemProps {
|
||
task: Task;
|
||
isEditing: boolean;
|
||
onToggle: () => void;
|
||
onEdit: () => void;
|
||
onUpdate: (title: string) => void;
|
||
onDelete: () => void;
|
||
onNotes: (notes: string) => void;
|
||
onRollToggle: () => void;
|
||
onRecurrence: () => void;
|
||
onDragStart: (e: DragEvent, task: Task) => void;
|
||
onDragEnd: () => void;
|
||
variant?: 'default' | 'minimal';
|
||
isSomeday?: boolean;
|
||
}
|
||
|
||
function TaskItem({ task, isEditing, onToggle, onEdit, onUpdate, onDelete, onNotes, onRollToggle, onRecurrence, onDragStart, onDragEnd, variant = 'default', isSomeday = false }: TaskItemProps) {
|
||
const [editValue, setEditValue] = useState(task.title);
|
||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||
const [notesValue, setNotesValue] = useState(task.markdownContent || '');
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (isEditing && inputRef.current) {
|
||
inputRef.current.focus();
|
||
inputRef.current.select();
|
||
}
|
||
}, [isEditing]);
|
||
|
||
// Focus notes when opened
|
||
useEffect(() => {
|
||
if (isNotesOpen && notesRef.current) {
|
||
notesRef.current.focus();
|
||
}
|
||
}, [isNotesOpen]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
onUpdate(editValue);
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
setEditValue(task.title);
|
||
onUpdate(task.title);
|
||
}
|
||
};
|
||
|
||
const handleNotesBlur = () => {
|
||
if (notesValue !== task.markdownContent) {
|
||
onNotes(notesValue);
|
||
}
|
||
};
|
||
|
||
// Markdown insertion helper
|
||
const insertMarkdown = (prefix: string, suffix: string = '') => {
|
||
if (!notesRef.current) return;
|
||
|
||
const start = notesRef.current.selectionStart;
|
||
const end = notesRef.current.selectionEnd;
|
||
const text = notesValue;
|
||
const before = text.substring(0, start);
|
||
const selection = text.substring(start, end);
|
||
const after = text.substring(end);
|
||
|
||
const newText = `${before}${prefix}${selection}${suffix}${after}`;
|
||
setNotesValue(newText);
|
||
|
||
setTimeout(() => {
|
||
if (notesRef.current) {
|
||
notesRef.current.focus();
|
||
const newCursorPos = start + prefix.length + selection.length + suffix.length;
|
||
notesRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
return (
|
||
<li
|
||
className={`weekly-task-item ${variant} ${task.completed ? 'completed' : ''} ${isSomeday ? 'relative mx-2' : ''}`}
|
||
draggable={!isEditing && !isNotesOpen} // Disable drag when editing
|
||
onDragStart={(e) => onDragStart(e as unknown as DragEvent, task)}
|
||
onDragEnd={onDragEnd}
|
||
onClick={(e) => {
|
||
if ((variant === 'minimal' || isSomeday) && !isEditing) {
|
||
const target = e.target as HTMLElement;
|
||
if (target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.closest('button')) return;
|
||
onEdit();
|
||
}
|
||
}}
|
||
>
|
||
<div style={{ width: '100%', position: 'relative' }}>
|
||
{/* Visual Indicator for Rolling Tasks */}
|
||
{task.isRolling && !task.completed && (
|
||
<div className="rolling-icon-indicator" title="Auto-rolling task">
|
||
<svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10"></polyline>
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', width: '100%' }}>
|
||
{isEditing ? (
|
||
<form onSubmit={handleSubmit} style={{ flex: 1, display: 'flex' }}>
|
||
{variant === 'minimal' ? (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className="weekly-task-text"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => onUpdate(editValue)}
|
||
style={{
|
||
border: 'none', background: 'transparent',
|
||
outline: 'none', width: '100%', padding: '0',
|
||
fontSize: '0.9375rem', fontWeight: 500
|
||
}}
|
||
/>
|
||
) : (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
className="weekly-task-text"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={() => onUpdate(editValue)}
|
||
/>
|
||
)}
|
||
</form>
|
||
) : (
|
||
<>
|
||
<span
|
||
className={`weekly-task-text flex-1 ${task.completed ? 'completed' : ''}`}
|
||
onClick={(e) => {
|
||
if (variant === 'default') onToggle();
|
||
// For minimal/someday, parent onClick handles edit
|
||
}}
|
||
onDoubleClick={variant === 'default' ? onEdit : undefined}
|
||
style={variant === 'minimal' || isSomeday ? { fontSize: '0.9375rem' } : undefined}
|
||
>
|
||
{task.title}
|
||
</span>
|
||
|
||
<div className="task-actions absolute right-0 top-0 bottom-0 flex items-center bg-white/95 dark:bg-gray-800/95 pl-2 pr-1 shadow-[-12px_0_12px_-4px_rgba(255,255,255,0.95)] dark:shadow-[-12px_0_12px_-4px_rgba(31,41,55,0.95)] z-20">
|
||
{/* Edit */}
|
||
<button className="task-action-btn" onClick={(e) => { e.stopPropagation(); onEdit(); }} title="Edit">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||
</button>
|
||
|
||
{/* Recurrence */}
|
||
<button
|
||
className={`task-action-btn ${task.isRecurring ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); onRecurrence(); }}
|
||
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="23 4 23 10 17 10"></polyline>
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Notes */}
|
||
<button
|
||
className={`task-action-btn ${isNotesOpen || (task.markdownContent && task.markdownContent.trim().length > 0) ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }}
|
||
title="Notes"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Roll Toggle - Active State Colored (hidden for someday tasks) */}
|
||
{!task.completed && !isSomeday && (
|
||
<button
|
||
className={`task-action-btn ${task.isRolling ? 'active' : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); onRollToggle(); }}
|
||
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="1 4 1 10 7 10"></polyline>
|
||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Delete */}
|
||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); onDelete(); }} title="Delete">
|
||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Inline Notes Editor with Toolbar */}
|
||
{isNotesOpen && (
|
||
<div className="weekly-notes-inline" onClick={(e) => e.stopPropagation()}>
|
||
<div className="notes-toolbar">
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('**', '**')} title="Bold">B</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('*', '*')} title="Italic">i</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('[', '](url)')} title="Link">🔗</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('- ')} title="List">☑</button>
|
||
<button className="notes-toolbar-btn" onClick={() => insertMarkdown('')} title="Image">🖼️</button>
|
||
<span style={{ marginLeft: 'auto', fontSize: '0.75rem', color: '#999' }}>Markdown supported</span>
|
||
</div>
|
||
<textarea
|
||
ref={notesRef}
|
||
className="weekly-notes-editor-inline"
|
||
value={notesValue}
|
||
onChange={(e) => setNotesValue(e.target.value)}
|
||
onBlur={handleNotesBlur}
|
||
placeholder="Add notes..."
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</li>
|
||
);
|
||
}
|
||
|
||
// Recurrence Modal Component
|
||
interface RecurrenceModalProps {
|
||
task: Task;
|
||
onClose: () => void;
|
||
onSave: (recurrence: { isRecurring: boolean, interval: number, unit: string, time: string, endDate: Date | null }) => void;
|
||
}
|
||
|
||
function RecurrenceModal({ task, onClose, onSave }: RecurrenceModalProps) {
|
||
const [isRecurring, setIsRecurring] = useState(task.isRecurring || false);
|
||
const [interval, setInterval] = useState(task.recurrenceInterval || 1);
|
||
const [unit, setUnit] = useState(task.recurrenceUnit || 'weeks');
|
||
const [time, setTime] = useState(task.recurrenceTime || task.startTime || '09:00');
|
||
const [endDate, setEndDate] = useState<string>(task.recurrenceEndDate ? formatDateToISO(new Date(task.recurrenceEndDate)) : '');
|
||
|
||
const handleSave = () => {
|
||
onSave({
|
||
isRecurring,
|
||
interval,
|
||
unit,
|
||
time,
|
||
endDate: endDate ? new Date(endDate) : null
|
||
});
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<div className="weekly-modal-overlay" onClick={onClose}>
|
||
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
|
||
<h3 style={{ marginBottom: '1.5rem' }}>Recurring Task</h3>
|
||
|
||
<div style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={isRecurring}
|
||
onChange={(e) => setIsRecurring(e.target.checked)}
|
||
style={{ width: 'auto', marginRight: '4px' }}
|
||
/>
|
||
Enable Recurrence
|
||
</label>
|
||
</div>
|
||
|
||
{isRecurring && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '0.9rem', color: '#666' }}>Repeat every</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={interval}
|
||
onChange={(e) => setInterval(parseInt(e.target.value) || 1)}
|
||
style={{ width: '60px', padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
<select
|
||
value={unit}
|
||
onChange={(e) => setUnit(e.target.value)}
|
||
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="days">Days</option>
|
||
<option value="weeks">Weeks</option>
|
||
<option value="months">Months</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '0.9rem', color: '#666' }}>At time</span>
|
||
<input
|
||
type="time"
|
||
value={time}
|
||
onChange={(e) => setTime(e.target.value)}
|
||
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontSize: '0.9rem', color: '#666' }}>End date (optional)</span>
|
||
<input
|
||
type="date"
|
||
value={endDate}
|
||
onChange={(e) => setEndDate(e.target.value)}
|
||
style={{ padding: '4px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="weekly-modal-actions" style={{ marginTop: '1.5rem' }}>
|
||
<button className="weekly-btn weekly-btn-secondary" onClick={onClose}>Cancel</button>
|
||
<button className="weekly-btn weekly-btn-primary" onClick={handleSave}>Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
);
|
||
}
|
||
|
||
// Settings Modal Component
|
||
interface SettingsSidebarProps {
|
||
onClose: () => void;
|
||
onSettingsChanged?: (newSettings: {
|
||
showTimeGrid: boolean;
|
||
cellDuration: CellDuration;
|
||
viewStyle: 'grid' | 'list';
|
||
language: string;
|
||
dateFormat: string;
|
||
timeFormat: string;
|
||
startHour: number;
|
||
endHour: number;
|
||
fontSize: 'S' | 'M' | 'L';
|
||
showNextTask: boolean;
|
||
showSomeday: boolean;
|
||
showAllDayEvents: boolean;
|
||
showSchedule: boolean;
|
||
headlineFont: string;
|
||
headlineFontSize: string;
|
||
headlineFontWeight: string;
|
||
dateFontFamily: string;
|
||
dateFontSize: string;
|
||
dateFontWeight: string;
|
||
timeTaskFontFamily: string;
|
||
timeTaskFontSize: string;
|
||
timeTaskFontWeight: string;
|
||
bodyFont: string;
|
||
taskFontFamily: string;
|
||
taskFontSize: string;
|
||
taskFontWeight: string;
|
||
fontWeight: string;
|
||
weekendColorSat: string;
|
||
weekendColorSun: string;
|
||
eventFontFamily?: string;
|
||
eventFontSize?: string;
|
||
eventFontWeight?: string;
|
||
weekdayColor?: string;
|
||
dateColor?: string;
|
||
taskColor?: string;
|
||
todayHighlightColor?: string;
|
||
}) => void;
|
||
showTimeGrid: boolean;
|
||
setShowTimeGrid: (show: boolean) => void;
|
||
cellDuration: CellDuration;
|
||
setCellDuration: (duration: CellDuration) => void;
|
||
weekStartDay: number;
|
||
setWeekStartDay: (day: number) => void;
|
||
viewStyle: ViewStyle;
|
||
setViewStyle: (style: ViewStyle) => void;
|
||
showSomeday: boolean;
|
||
setShowSomeday: (show: boolean) => void;
|
||
showAllDay: boolean;
|
||
setShowAllDay: (show: boolean) => void;
|
||
showSchedule: boolean;
|
||
setShowSchedule: (show: boolean) => void;
|
||
goal: string;
|
||
setGoal: (goal: string) => void;
|
||
saveGoal: (goal: string) => void;
|
||
connections: any[];
|
||
onUpdateConnections: (connections: any[]) => void;
|
||
focusTimerDuration: number;
|
||
setFocusTimerDuration: (duration: number) => void;
|
||
focusBreakDuration: number;
|
||
setFocusBreakDuration: (duration: number) => void;
|
||
fontSize: 'S' | 'M' | 'L';
|
||
setFontSize: (size: 'S' | 'M' | 'L') => void;
|
||
showNextTask: boolean;
|
||
setShowNextTask: (show: boolean) => void;
|
||
headlineFont: string;
|
||
headlineFontSize: string;
|
||
headlineFontWeight: string;
|
||
dateFontFamily: string;
|
||
dateFontSize: string;
|
||
dateFontWeight: string;
|
||
timeTaskFontFamily: string;
|
||
timeTaskFontSize: string;
|
||
timeTaskFontWeight: string;
|
||
bodyFont: string;
|
||
taskFontFamily: string;
|
||
taskFontSize: string;
|
||
taskFontWeight: string;
|
||
eventFontFamily?: string;
|
||
eventFontSize?: string;
|
||
eventFontWeight?: string;
|
||
fontWeight: string;
|
||
weekendColorSat: string;
|
||
weekendColorSun: string;
|
||
protectEventTimes: boolean;
|
||
setProtectEventTimes: (protect: boolean) => void;
|
||
onRemoveConnection: (id: string) => void | Promise<void>;
|
||
goalDefaultSentence?: string;
|
||
goalFallbackType?: string;
|
||
importingTasksState: boolean;
|
||
executeImport: (provider: 'google' | 'apple') => Promise<void>;
|
||
onImportLists: (lists: { id: string, title: string }[]) => Promise<void>;
|
||
importStatusMsg: { type: 'success' | 'error', text: string } | null;
|
||
}
|
||
|
||
function SettingsSidebar({
|
||
onClose,
|
||
onSettingsChanged,
|
||
showTimeGrid,
|
||
setShowTimeGrid,
|
||
cellDuration,
|
||
setCellDuration,
|
||
weekStartDay,
|
||
setWeekStartDay,
|
||
viewStyle,
|
||
setViewStyle,
|
||
showSomeday,
|
||
setShowSomeday,
|
||
showAllDay,
|
||
setShowAllDay,
|
||
showSchedule,
|
||
setShowSchedule,
|
||
goal,
|
||
setGoal,
|
||
saveGoal,
|
||
connections,
|
||
onUpdateConnections,
|
||
onRemoveConnection,
|
||
focusTimerDuration,
|
||
setFocusTimerDuration,
|
||
focusBreakDuration,
|
||
setFocusBreakDuration,
|
||
fontSize,
|
||
setFontSize,
|
||
showNextTask,
|
||
setShowNextTask,
|
||
headlineFont,
|
||
headlineFontSize,
|
||
headlineFontWeight,
|
||
dateFontFamily,
|
||
dateFontSize,
|
||
dateFontWeight,
|
||
timeTaskFontFamily,
|
||
timeTaskFontSize,
|
||
timeTaskFontWeight,
|
||
bodyFont,
|
||
taskFontFamily,
|
||
taskFontSize,
|
||
taskFontWeight,
|
||
eventFontFamily,
|
||
eventFontSize,
|
||
eventFontWeight,
|
||
fontWeight,
|
||
weekendColorSat,
|
||
weekendColorSun,
|
||
protectEventTimes,
|
||
setProtectEventTimes,
|
||
goalFallbackType,
|
||
goalDefaultSentence,
|
||
importingTasksState,
|
||
executeImport,
|
||
onImportLists,
|
||
importStatusMsg
|
||
}: SettingsSidebarProps) {
|
||
const [activeTab, setActiveTab] = useState<'general' | 'calendar' | 'account'>('general');
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [exportStartDate, setExportStartDate] = useState('');
|
||
const [exportEndDate, setExportEndDate] = useState('');
|
||
const [passwords, setPasswords] = useState({ new: '', confirm: '' });
|
||
const [accountMsg, setAccountMsg] = useState('');
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
|
||
// Apple Calendar (CalDAV) State
|
||
const [showAppleCalendarModal, setShowAppleCalendarModal] = useState(false);
|
||
const [appleCalEmail, setAppleCalEmail] = useState('');
|
||
const [appleCalPassword, setAppleCalPassword] = useState('');
|
||
const [isConnectingAppleCal, setIsConnectingAppleCal] = useState(false);
|
||
const [appleCalError, setAppleCalError] = useState('');
|
||
|
||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(null);
|
||
const [connMsg, setConnMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||
|
||
const showConnMsg = (type: 'success' | 'error', text: string) => {
|
||
setConnMsg({ type, text });
|
||
setTimeout(() => setConnMsg(null), 5000);
|
||
};
|
||
|
||
const [profile, setProfile] = useState<{
|
||
name: string;
|
||
email: string;
|
||
timezone: string;
|
||
autoRolling?: boolean;
|
||
protectEventTimes?: boolean;
|
||
language?: string;
|
||
dateFormat?: string;
|
||
timeFormat?: string;
|
||
startHour?: number;
|
||
endHour?: number;
|
||
focusTimerDuration?: number;
|
||
focusBreakDuration?: number;
|
||
showTimeGrid?: boolean;
|
||
cellDuration?: number;
|
||
viewStyle?: string;
|
||
fontSize?: 'S' | 'M' | 'L';
|
||
showNextTask?: boolean;
|
||
showSomeday?: boolean;
|
||
showAllDayEvents?: boolean;
|
||
showSchedule?: boolean;
|
||
headlineFont?: string;
|
||
headlineFontSize?: string;
|
||
headlineFontWeight?: string;
|
||
dateFontFamily?: string;
|
||
dateFontSize?: string;
|
||
dateFontWeight?: string;
|
||
timeTaskFontFamily?: string;
|
||
timeTaskFontSize?: string;
|
||
timeTaskFontWeight?: string;
|
||
bodyFont?: string;
|
||
taskFontFamily?: string;
|
||
taskFontSize?: string;
|
||
taskFontWeight?: string;
|
||
eventFontFamily?: string;
|
||
eventFontSize?: string;
|
||
eventFontWeight?: string;
|
||
fontWeight?: string;
|
||
weekendColorSat?: string;
|
||
weekendColorSun?: string;
|
||
weekdayColor?: string;
|
||
dateColor?: string;
|
||
taskColor?: string;
|
||
todayHighlightColor?: string;
|
||
pastDayColor?: string;
|
||
goalFallbackType?: 'quote' | 'next_todo' | 'default';
|
||
goalDefaultSentence?: string;
|
||
goalFontFamily?: string;
|
||
goalFontSize?: string;
|
||
goalFontWeight?: string;
|
||
goalScope?: 'week' | 'day';
|
||
}>({
|
||
name: '',
|
||
email: '',
|
||
timezone: 'Europe/Berlin',
|
||
autoRolling: false,
|
||
protectEventTimes: false,
|
||
language: 'de',
|
||
dateFormat: 'yyyy-MM-dd',
|
||
timeFormat: '24h',
|
||
startHour: 8,
|
||
endHour: 18,
|
||
focusTimerDuration: 25,
|
||
focusBreakDuration: 5,
|
||
showTimeGrid: true,
|
||
cellDuration: 30,
|
||
viewStyle: 'list',
|
||
fontSize: 'M',
|
||
showNextTask: false,
|
||
showSomeday: true,
|
||
showAllDayEvents: true,
|
||
showSchedule: true,
|
||
headlineFont: 'Inter',
|
||
headlineFontSize: '1.25rem',
|
||
headlineFontWeight: '900',
|
||
dateFontFamily: 'Inter',
|
||
dateFontSize: '0.65rem',
|
||
dateFontWeight: '400',
|
||
timeTaskFontFamily: 'Inter',
|
||
timeTaskFontSize: '0.75rem',
|
||
timeTaskFontWeight: '500',
|
||
bodyFont: 'Inter',
|
||
taskFontFamily: 'Inter',
|
||
taskFontSize: '0.9rem',
|
||
taskFontWeight: '400',
|
||
fontWeight: '400',
|
||
goalFontFamily: 'Inter',
|
||
goalFontSize: '0.9rem',
|
||
goalFontWeight: '500',
|
||
weekendColorSat: '#666666',
|
||
weekendColorSun: '#dc2626',
|
||
weekdayColor: '#888888',
|
||
dateColor: '#888888',
|
||
taskColor: '#333333',
|
||
todayHighlightColor: '#f0fafa'
|
||
});
|
||
|
||
const t = translations[profile.language || 'en'] || translations['en'];
|
||
|
||
// Load fonts for preview
|
||
// Font loading moved to top level WeeklyView component
|
||
|
||
useEffect(() => {
|
||
try {
|
||
fetchProfile();
|
||
// Trigger slide-in after mount
|
||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||
return () => clearTimeout(timer);
|
||
} catch (e) {
|
||
console.error('Error mounting SettingsSidebar:', e);
|
||
}
|
||
}, []);
|
||
|
||
const handleClose = () => {
|
||
setIsVisible(false);
|
||
setTimeout(onClose, 300);
|
||
};
|
||
|
||
async function fetchProfile() {
|
||
try {
|
||
const res = await fetch('/api/user/profile');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.user) {
|
||
setProfile({
|
||
name: data.user.name || '',
|
||
email: data.user.email || '',
|
||
timezone: data.user.timezone || 'Europe/Berlin',
|
||
autoRolling: data.user.autoRolling || false,
|
||
protectEventTimes: data.user.protectEventTimes || false,
|
||
language: data.user.language || 'de',
|
||
dateFormat: data.user.dateFormat || 'yyyy-MM-dd',
|
||
timeFormat: data.user.timeFormat || '24h',
|
||
startHour: data.user.startHour !== undefined ? data.user.startHour : 8,
|
||
endHour: data.user.endHour !== undefined ? data.user.endHour : 18,
|
||
focusTimerDuration: data.user.focusTimerDuration || 25,
|
||
showTimeGrid: data.user.showTimeGrid !== undefined ? data.user.showTimeGrid : true,
|
||
cellDuration: data.user.cellDuration || 30,
|
||
viewStyle: data.user.viewStyle || 'list',
|
||
fontSize: data.user.fontSize || 'M',
|
||
headlineFont: data.user.headlineFont || 'Inter',
|
||
bodyFont: data.user.bodyFont || 'Inter',
|
||
fontWeight: data.user.fontWeight || '400',
|
||
showSchedule: data.user.showSchedule !== undefined ? data.user.showSchedule : true,
|
||
focusBreakDuration: data.user.focusBreakDuration || 5,
|
||
headlineFontSize: data.user.headlineFontSize || '1.25rem',
|
||
headlineFontWeight: data.user.headlineFontWeight || '900',
|
||
dateFontFamily: data.user.dateFontFamily || 'Inter',
|
||
dateFontSize: data.user.dateFontSize || '0.65rem',
|
||
dateFontWeight: data.user.dateFontWeight || '400',
|
||
timeTaskFontFamily: data.user.timeTaskFontFamily || 'Inter',
|
||
timeTaskFontSize: data.user.timeTaskFontSize || '0.75rem',
|
||
timeTaskFontWeight: data.user.timeTaskFontWeight || '500',
|
||
taskFontFamily: data.user.taskFontFamily || 'Inter',
|
||
taskFontSize: data.user.taskFontSize || '0.9rem',
|
||
taskFontWeight: data.user.taskFontWeight || '400',
|
||
eventFontFamily: data.user.eventFontFamily || 'Inter',
|
||
eventFontSize: data.user.eventFontSize || '0.85rem',
|
||
eventFontWeight: data.user.eventFontWeight || '400',
|
||
goalFontFamily: data.user.goalFontFamily || 'Inter',
|
||
goalFontSize: data.user.goalFontSize || '0.9rem',
|
||
goalFontWeight: data.user.goalFontWeight || '500',
|
||
goalScope: data.user.goalScope || 'week',
|
||
weekendColorSat: data.user.weekendColorSat || '#666666',
|
||
weekendColorSun: data.user.weekendColorSun || '#dc2626',
|
||
weekdayColor: data.user.weekdayColor || '#888888',
|
||
dateColor: data.user.dateColor || '#888888',
|
||
taskColor: data.user.taskColor || '#333333',
|
||
todayHighlightColor: data.user.todayHighlightColor || '#f0fafa',
|
||
});
|
||
|
||
if (data.user.showTimeGrid !== undefined) setShowTimeGrid(data.user.showTimeGrid);
|
||
if (data.user.cellDuration) setCellDuration(data.user.cellDuration as CellDuration);
|
||
if (data.user.viewStyle) setViewStyle(data.user.viewStyle as ViewStyle);
|
||
if (data.user.fontSize) setFontSize(data.user.fontSize as 'S' | 'M' | 'L');
|
||
if (data.user.focusTimerDuration) setFocusTimerDuration(data.user.focusTimerDuration);
|
||
if (data.user.showSchedule !== undefined) setShowSchedule(data.user.showSchedule);
|
||
if (data.user.focusBreakDuration) setFocusBreakDuration(data.user.focusBreakDuration);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
const handleGoogleConnect = () => {
|
||
window.location.href = '/api/calendar/google/start';
|
||
};
|
||
|
||
// --- Apple Calendar (CalDAV) handlers ---
|
||
const handleAppleCalendarConnect = () => {
|
||
setShowAppleCalendarModal(true);
|
||
setAppleCalError('');
|
||
setAppleCalEmail('');
|
||
setAppleCalPassword('');
|
||
};
|
||
|
||
const submitAppleCalendarConnection = async () => {
|
||
if (!appleCalEmail || !appleCalPassword) {
|
||
setAppleCalError('Please enter both email and app-specific password.');
|
||
return;
|
||
}
|
||
|
||
setIsConnectingAppleCal(true);
|
||
setAppleCalError('');
|
||
|
||
try {
|
||
const response = await fetch('/api/calendar/apple/connect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: appleCalEmail, password: appleCalPassword }),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to connect Apple Calendar');
|
||
}
|
||
|
||
setShowAppleCalendarModal(false);
|
||
showConnMsg('success', 'Apple Calendar connected successfully!');
|
||
setTimeout(() => window.location.reload(), 1200);
|
||
} catch (err: any) {
|
||
setAppleCalError(err.message || 'Connection failed');
|
||
} finally {
|
||
setIsConnectingAppleCal(false);
|
||
}
|
||
};
|
||
|
||
const handleOutlookConnect = () => {
|
||
window.location.href = '/api/calendar/outlook/start';
|
||
};
|
||
|
||
const handleUpdateCalendar = async (connectionId: string, calendarId: string, updates: { selected?: boolean; editable?: boolean }) => {
|
||
// Optimistic Update
|
||
const updatedConnections = connections.map(conn => {
|
||
if (conn.id === connectionId && conn.calendars) {
|
||
return {
|
||
...conn,
|
||
calendars: conn.calendars.map((c: any) =>
|
||
c.id === calendarId ? { ...c, ...updates } : c
|
||
)
|
||
};
|
||
}
|
||
return conn;
|
||
});
|
||
|
||
onUpdateConnections(updatedConnections); // used props instead of setConnections
|
||
|
||
// API Call
|
||
try {
|
||
const conn = updatedConnections.find(c => c.id === connectionId);
|
||
if (conn) {
|
||
await fetch('/api/calendar/connections', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
id: connectionId,
|
||
calendars: conn.calendars
|
||
})
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update calendar selection', error);
|
||
// Revert on error - tough to do without refetching from parent or keeping prev state
|
||
}
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
// Only validate password if in Account tab and password field is filled
|
||
if (activeTab === 'account' && passwords.new && passwords.new !== passwords.confirm) {
|
||
setAccountMsg('Passwords do not match');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/user/profile', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...profile,
|
||
showTimeGrid: showTimeGrid,
|
||
cellDuration: cellDuration,
|
||
viewStyle: viewStyle,
|
||
showNextTask: showNextTask,
|
||
showSomeday: showSomeday,
|
||
showAllDayEvents: showAllDay,
|
||
showSchedule: showSchedule,
|
||
// The following will be taken from profile if present,
|
||
// ensuring edited state is saved.
|
||
// Validate numeric fields to avoid NaN
|
||
focusBreakDuration: !isNaN(Number(profile.focusBreakDuration)) ? Number(profile.focusBreakDuration) : (focusBreakDuration || 5),
|
||
focusTimerDuration: !isNaN(Number(profile.focusTimerDuration)) ? Number(profile.focusTimerDuration) : (focusTimerDuration || 25),
|
||
password: (passwords.new && passwords.new.trim() !== "") ? passwords.new : undefined
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
|
||
if (res.ok) {
|
||
setAccountMsg('Profile updated successfully!');
|
||
|
||
// Update local app state
|
||
if (onSettingsChanged) {
|
||
onSettingsChanged({
|
||
showTimeGrid: showTimeGrid,
|
||
cellDuration: cellDuration,
|
||
viewStyle: viewStyle,
|
||
language: profile.language || 'en',
|
||
dateFormat: profile.dateFormat || 'MM/dd/yyyy',
|
||
timeFormat: profile.timeFormat || '12h',
|
||
startHour: profile.startHour || 8,
|
||
endHour: profile.endHour || 18,
|
||
fontSize: fontSize,
|
||
showNextTask: showNextTask,
|
||
showSomeday: showSomeday,
|
||
showAllDayEvents: showAllDay,
|
||
showSchedule: showSchedule,
|
||
headlineFont: headlineFont,
|
||
headlineFontSize: headlineFontSize,
|
||
headlineFontWeight: headlineFontWeight,
|
||
dateFontFamily: dateFontFamily,
|
||
dateFontSize: dateFontSize,
|
||
dateFontWeight: dateFontWeight,
|
||
timeTaskFontFamily: timeTaskFontFamily,
|
||
timeTaskFontSize: timeTaskFontSize,
|
||
timeTaskFontWeight: timeTaskFontWeight,
|
||
bodyFont: bodyFont,
|
||
taskFontFamily: taskFontFamily,
|
||
taskFontSize: taskFontSize,
|
||
taskFontWeight: profile.taskFontWeight || taskFontWeight,
|
||
eventFontFamily: profile.eventFontFamily || eventFontFamily,
|
||
eventFontSize: profile.eventFontSize || eventFontSize,
|
||
eventFontWeight: profile.eventFontWeight || eventFontWeight,
|
||
fontWeight: fontWeight,
|
||
weekendColorSat: weekendColorSat,
|
||
weekendColorSun: weekendColorSun,
|
||
weekdayColor: profile.weekdayColor,
|
||
dateColor: profile.dateColor,
|
||
taskColor: profile.taskColor,
|
||
todayHighlightColor: profile.todayHighlightColor,
|
||
autoRolling: profile.autoRolling,
|
||
protectEventTimes: profile.protectEventTimes || protectEventTimes,
|
||
focusTimerDuration: profile.focusTimerDuration || focusTimerDuration,
|
||
focusBreakDuration: profile.focusBreakDuration || focusBreakDuration,
|
||
pastDayColor: profile.pastDayColor,
|
||
goalScope: profile.goalScope
|
||
} as any);
|
||
}
|
||
|
||
if (profile.focusTimerDuration && setFocusTimerDuration) {
|
||
setFocusTimerDuration(profile.focusTimerDuration);
|
||
}
|
||
if (profile.focusBreakDuration && setFocusBreakDuration) {
|
||
setFocusBreakDuration(profile.focusBreakDuration);
|
||
}
|
||
|
||
// Temporary success message
|
||
setTimeout(() => setAccountMsg(''), 3000);
|
||
} else {
|
||
console.error('Failed to update profile:', data);
|
||
setAccountMsg(data.details ? `${data.error}: ${data.details}` : (data.error || 'Failed to update profile'));
|
||
}
|
||
} catch (e) {
|
||
console.error('Error updating profile:', e);
|
||
setAccountMsg('Error updating profile');
|
||
}
|
||
};
|
||
|
||
const handleDownloadData = () => {
|
||
window.open('/api/user/export', '_blank');
|
||
};
|
||
|
||
const handleDeleteAccount = async () => {
|
||
if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/user/profile', { method: 'DELETE' });
|
||
if (res.ok) {
|
||
window.location.href = '/';
|
||
} else {
|
||
alert('Failed to delete account');
|
||
}
|
||
} catch (e) {
|
||
alert('Error deleting account');
|
||
}
|
||
};
|
||
|
||
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className={`weekly-settings-overlay ${isVisible ? 'show' : ''}`}
|
||
onClick={handleClose}
|
||
style={{ zIndex: 1999 }}
|
||
/>
|
||
<div className={`weekly-settings-sidebar ${isVisible ? 'open' : ''}`}>
|
||
<header className="weekly-settings-header">
|
||
<h2 className="weekly-settings-title">{t.settings}</h2>
|
||
<button className="weekly-settings-close" onClick={handleClose}>×</button>
|
||
</header>
|
||
|
||
<div className="weekly-settings-tabs" style={{ display: 'flex', borderBottom: '1px solid #eee', padding: '0 24px' }}>
|
||
<button
|
||
onClick={() => setActiveTab('general')}
|
||
style={{ padding: '12px 16px', borderBottom: activeTab === 'general' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'general' ? 1 : 0.6 }}
|
||
>
|
||
{t.general}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('calendar')}
|
||
style={{ padding: '12px 16px', borderBottom: activeTab === 'calendar' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'calendar' ? 1 : 0.6 }}
|
||
>
|
||
{t.calendar}
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('account')}
|
||
style={{ padding: '12px 16px', borderBottom: activeTab === 'account' ? '2px solid black' : 'none', fontWeight: 600, background: 'none', border: 'none', cursor: 'pointer', opacity: activeTab === 'account' ? 1 : 0.6 }}
|
||
>
|
||
{t.account}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="weekly-settings-content" style={{ flex: 1, overflowY: 'auto', padding: '24px' }}>
|
||
{activeTab === 'general' ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||
{/* Goal */}
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.goalOfWeek}</label>
|
||
<input
|
||
type="text"
|
||
value={goal}
|
||
onChange={(e) => setGoal(e.target.value)}
|
||
onBlur={() => saveGoal(goal)}
|
||
onKeyDown={(e) => e.key === 'Enter' && (e.currentTarget.blur())}
|
||
className="weekly-input"
|
||
placeholder={t.goalOfWeek}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Visibility Toggles */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showSomeday"
|
||
checked={showSomeday}
|
||
onChange={e => setShowSomeday(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showSomeday" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.showSomeday}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showAllDay"
|
||
checked={showAllDay}
|
||
onChange={e => setShowAllDay(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showAllDay" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.showAllDay}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showSchedule"
|
||
checked={showSchedule}
|
||
onChange={e => setShowSchedule(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showSchedule" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
Show Schedule / Calendar
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="autoRolling"
|
||
checked={profile.autoRolling || false}
|
||
onChange={e => setProfile({ ...profile, autoRolling: e.target.checked })}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="autoRolling" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.runningList}
|
||
</label>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="protectEventTimes"
|
||
checked={profile.protectEventTimes || false}
|
||
onChange={e => setProfile({ ...profile, protectEventTimes: e.target.checked })}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="protectEventTimes" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.protectEventTimes}
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showTimeGrid"
|
||
checked={showTimeGrid}
|
||
onChange={e => setShowTimeGrid(e.target.checked)}
|
||
style={{ width: '16px', height: '16px' }}
|
||
/>
|
||
<label htmlFor="showTimeGrid" style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{t.showTimeGrid}
|
||
</label>
|
||
</div>
|
||
|
||
{showTimeGrid && (
|
||
<div style={{ marginLeft: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.timeSlotDuration}</label>
|
||
<select
|
||
value={cellDuration}
|
||
onChange={(e) => setCellDuration(Number(e.target.value) as CellDuration)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value={15}>15 min</option>
|
||
<option value={30}>30 min</option>
|
||
<option value={60}>1 hour</option>
|
||
<option value={120}>2 hours</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.viewStyle}</label>
|
||
<select
|
||
value={viewStyle}
|
||
onChange={(e) => setViewStyle(e.target.value as ViewStyle)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="simple">{t.simpleView}</option>
|
||
<option value="calendar">{t.calendarView}</option>
|
||
<option value="list">{t.listView}</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Configurable Hours */}
|
||
<div style={{ display: 'flex', gap: '12px' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.startHour}</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max="23"
|
||
value={profile.startHour}
|
||
onChange={(e) => setProfile(prev => ({ ...prev, startHour: parseInt(e.target.value) || 0 }))}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.endHour}</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="24"
|
||
value={profile.endHour}
|
||
onChange={(e) => setProfile(prev => ({ ...prev, endHour: parseInt(e.target.value) || 0 }))}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ borderTop: '1px solid #eee', marginTop: '16px', paddingTop: '16px' }}></div>
|
||
<h4 style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{t.localization}</h4>
|
||
|
||
{/* Start Week Setting */}
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>Start week on</label>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button
|
||
className={`px-3 py-2 rounded text-sm ${weekStartDay === 1 ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'}`}
|
||
onClick={() => setWeekStartDay(1)}
|
||
>
|
||
Monday
|
||
</button>
|
||
<button
|
||
className={`px-3 py-2 rounded text-sm ${weekStartDay === 0 ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'}`}
|
||
onClick={() => setWeekStartDay(0)}
|
||
>
|
||
Sunday
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.language}</label>
|
||
<select
|
||
value={profile.language}
|
||
onChange={e => setProfile({ ...profile, language: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="en">English</option>
|
||
<option value="de">German</option>
|
||
<option value="fr">French</option>
|
||
<option value="es">Spanish</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.dateFormat}</label>
|
||
<select
|
||
value={profile.dateFormat}
|
||
onChange={e => setProfile({ ...profile, dateFormat: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="MM/dd/yyyy">MM/DD/YYYY</option>
|
||
<option value="dd/MM/yyyy">DD/MM/YYYY</option>
|
||
<option value="yyyy-MM-dd">YYYY-MM-DD</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.timeFormat}</label>
|
||
<select
|
||
value={profile.timeFormat}
|
||
onChange={e => setProfile({ ...profile, timeFormat: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="12h">12h AM/PM</option>
|
||
<option value="24h">24H</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Text Size setting */}
|
||
<div className="settings-item" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||
<label style={{ fontWeight: 500, color: 'var(--weekly-settings-label)' }}>Text size</label>
|
||
<div className="weekly-toggle-group" style={{ display: 'flex', background: 'var(--weekly-settings-toggle-bg)', borderRadius: '6px', padding: '2px' }}>
|
||
{(['S', 'M', 'L'] as const).map((size) => (
|
||
<button
|
||
key={size}
|
||
type="button"
|
||
onClick={() => {
|
||
setProfile({ ...profile, fontSize: size });
|
||
setFontSize(size);
|
||
}}
|
||
style={{
|
||
padding: '6px 12px',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
fontSize: '0.75rem',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
background: (profile.fontSize || fontSize) === size ? 'var(--weekly-settings-toggle-active-bg)' : 'transparent',
|
||
color: (profile.fontSize || fontSize) === size ? 'var(--weekly-settings-toggle-active-text)' : 'var(--weekly-settings-toggle-text)',
|
||
boxShadow: (profile.fontSize || fontSize) === size ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
{size}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Typography Settings */}
|
||
<div style={{ marginBottom: '1.5rem', borderBottom: '1px solid var(--weekly-border)', paddingBottom: '1rem' }}>
|
||
<label style={{ display: 'block', fontSize: '1rem', fontWeight: 700, marginBottom: '12px', color: 'var(--weekly-settings-title)' }}>Font Customization</label>
|
||
|
||
{/* Day Names */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Weekday Font (e.g. MONTAG)</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '8px' }}>
|
||
<select
|
||
value={profile.headlineFont || 'Inter'}
|
||
onChange={(e) => setProfile({ ...profile, headlineFont: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
|
||
>
|
||
{AVAILABLE_FONTS.map(font => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={profile.headlineFontSize || '1.25rem'}
|
||
onChange={(e) => setProfile({ ...profile, headlineFontSize: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
|
||
>
|
||
<option value="1rem">Small 16px</option>
|
||
<option value="1.25rem">Normal 20px</option>
|
||
<option value="1.5rem">Large 24px</option>
|
||
<option value="1.75rem">XL 28px</option>
|
||
<option value="2rem">Huge 32px</option>
|
||
</select>
|
||
<select
|
||
value={profile.headlineFontWeight || '900'}
|
||
onChange={(e) => setProfile({ ...profile, headlineFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
<option value="300">Light</option>
|
||
<option value="400">Normal</option>
|
||
<option value="500">Medium</option>
|
||
<option value="600">Semi</option>
|
||
<option value="700">Bold</option>
|
||
<option value="900">Black</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Date Font (e.g. 12. Feb.)</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '8px' }}>
|
||
<select
|
||
value={profile.dateFontFamily || 'Inter'}
|
||
onChange={(e) => setProfile({ ...profile, dateFontFamily: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
{AVAILABLE_FONTS.map(font => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={profile.dateFontSize || '0.65rem'}
|
||
onChange={(e) => setProfile({ ...profile, dateFontSize: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
<option value="0.55rem">XS 9px</option>
|
||
<option value="0.65rem">Normal 10px</option>
|
||
<option value="0.75rem">Small 12px</option>
|
||
<option value="0.85rem">Medium 14px</option>
|
||
<option value="1rem">Large 16px</option>
|
||
</select>
|
||
<select
|
||
value={profile.dateFontWeight || '400'}
|
||
onChange={(e) => setProfile({ ...profile, dateFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
|
||
>
|
||
<option value="300">Light</option>
|
||
<option value="400">Normal</option>
|
||
<option value="500">Medium</option>
|
||
<option value="600">Semi</option>
|
||
<option value="700">Bold</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tasks */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Task Font</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '8px' }}>
|
||
<select
|
||
value={profile.taskFontFamily || 'Inter'}
|
||
onChange={(e) => setProfile({ ...profile, taskFontFamily: e.target.value, timeTaskFontFamily: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
{AVAILABLE_FONTS.map(font => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={profile.taskFontSize || '0.9rem'}
|
||
onChange={(e) => setProfile({ ...profile, taskFontSize: e.target.value, timeTaskFontSize: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
<option value="0.75rem">Small 12px</option>
|
||
<option value="0.9rem">Normal 14px</option>
|
||
<option value="1rem">Medium 16px</option>
|
||
<option value="1.1rem">Large 18px</option>
|
||
<option value="1.25rem">XL 20px</option>
|
||
</select>
|
||
<select
|
||
value={profile.taskFontWeight || '400'}
|
||
onChange={(e) => setProfile({ ...profile, taskFontWeight: e.target.value, timeTaskFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
|
||
>
|
||
<option value="300">Light</option>
|
||
<option value="400">Normal</option>
|
||
<option value="500">Medium</option>
|
||
<option value="600">Semi</option>
|
||
<option value="700">Bold</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendar Event Font */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Calendar Event Font</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '8px' }}>
|
||
<select
|
||
value={profile.eventFontFamily || 'Inter'}
|
||
onChange={(e) => setProfile({ ...profile, eventFontFamily: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
{AVAILABLE_FONTS.map(font => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={profile.eventFontSize || '0.85rem'}
|
||
onChange={(e) => setProfile({ ...profile, eventFontSize: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
<option value="0.75rem">Small 12px</option>
|
||
<option value="0.85rem">Normal 14px</option>
|
||
<option value="1rem">Medium 16px</option>
|
||
<option value="1.1rem">Large 18px</option>
|
||
</select>
|
||
<select
|
||
value={profile.eventFontWeight || '400'}
|
||
onChange={(e) => setProfile({ ...profile, eventFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
|
||
>
|
||
<option value="300">Light</option>
|
||
<option value="400">Normal</option>
|
||
<option value="500">Medium</option>
|
||
<option value="600">Semi</option>
|
||
<option value="700">Bold</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal Font */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Goal Font</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: '8px' }}>
|
||
<select
|
||
value={profile.goalFontFamily || 'Inter'}
|
||
onChange={(e) => setProfile({ ...profile, goalFontFamily: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
{AVAILABLE_FONTS.map(font => (
|
||
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>{font.name}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={profile.goalFontSize || '0.9rem'}
|
||
onChange={(e) => setProfile({ ...profile, goalFontSize: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem' }}
|
||
>
|
||
<option value="0.75rem">Small 12px</option>
|
||
<option value="0.85rem">Normal 14px</option>
|
||
<option value="0.9rem">Default 14px</option>
|
||
<option value="1rem">Medium 16px</option>
|
||
<option value="1.1rem">Large 18px</option>
|
||
<option value="1.25rem">XL 20px</option>
|
||
</select>
|
||
<select
|
||
value={profile.goalFontWeight || '500'}
|
||
onChange={(e) => setProfile({ ...profile, goalFontWeight: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', fontSize: '0.9rem', border: '1px solid var(--weekly-settings-input-border)', borderRadius: '4px', background: 'var(--weekly-settings-input-bg)', color: 'var(--weekly-settings-text)' }}
|
||
>
|
||
<option value="300">Light</option>
|
||
<option value="400">Normal</option>
|
||
<option value="500">Medium</option>
|
||
<option value="600">Semi</option>
|
||
<option value="700">Bold</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Element Colors */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Element Colors</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Weekday Name</label>
|
||
<input
|
||
type="color"
|
||
value={profile.weekdayColor || '#888888'}
|
||
onChange={(e) => setProfile({ ...profile, weekdayColor: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Date</label>
|
||
<input
|
||
type="color"
|
||
value={profile.dateColor || '#888888'}
|
||
onChange={(e) => setProfile({ ...profile, dateColor: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Task Text</label>
|
||
<input
|
||
type="color"
|
||
value={profile.taskColor || '#333333'}
|
||
onChange={(e) => setProfile({ ...profile, taskColor: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Today Highlight</label>
|
||
<input
|
||
type="color"
|
||
value={profile.todayHighlightColor || '#f0fafa'}
|
||
onChange={(e) => setProfile({ ...profile, todayHighlightColor: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Past Days</label>
|
||
<input
|
||
type="color"
|
||
value={profile.pastDayColor || '#a6a6a7'}
|
||
onChange={(e) => setProfile({ ...profile, pastDayColor: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weekend Colors */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '12px', borderRadius: '8px', marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '8px' }}>Weekend Highlight Colors</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Saturday</label>
|
||
<input
|
||
type="color"
|
||
value={profile.weekendColorSat || '#666666'}
|
||
onChange={(e) => setProfile({ ...profile, weekendColorSat: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>Sunday</label>
|
||
<input
|
||
type="color"
|
||
value={profile.weekendColorSun || '#dc2626'}
|
||
onChange={(e) => setProfile({ ...profile, weekendColorSun: e.target.value })}
|
||
style={{ width: '100%', height: '30px', cursor: 'pointer', border: 'none', background: 'transparent' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
id="showNextTask"
|
||
checked={showNextTask || false}
|
||
onChange={(e) => setShowNextTask(e.target.checked)}
|
||
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
|
||
/>
|
||
<label htmlFor="showNextTask" style={{ cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 }}>
|
||
Show "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, color: 'var(--weekly-settings-label)' }}>Focus Timer (min)</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="120"
|
||
value={profile.focusTimerDuration || 25}
|
||
onChange={(e) => setProfile({ ...profile, focusTimerDuration: parseInt(e.target.value) || 25 })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500, color: 'var(--weekly-settings-label)' }}>Focus Break (min)</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="60"
|
||
value={profile.focusBreakDuration || 5}
|
||
onChange={(e) => setProfile({ ...profile, focusBreakDuration: parseInt(e.target.value) || 5 })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal Scope Setting */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '16px', borderRadius: '8px', marginBottom: '1.5rem', border: '1px solid var(--weekly-border)' }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '12px' }}>{t.goalScope}</label>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button
|
||
onClick={() => setProfile({ ...profile, goalScope: 'week' })}
|
||
style={{
|
||
flex: 1, padding: '8px 12px', borderRadius: '6px',
|
||
border: profile.goalScope === 'week' || !profile.goalScope ? '2px solid var(--weekly-accent, #4A90D9)' : '1px solid #ddd',
|
||
background: profile.goalScope === 'week' || !profile.goalScope ? 'var(--weekly-accent-light, #e8f0fe)' : 'white',
|
||
fontWeight: profile.goalScope === 'week' || !profile.goalScope ? 600 : 400,
|
||
cursor: 'pointer', fontSize: '0.85rem'
|
||
}}
|
||
>{t.goalScopeWeek}</button>
|
||
<button
|
||
onClick={() => setProfile({ ...profile, goalScope: 'day' })}
|
||
style={{
|
||
flex: 1, padding: '8px 12px', borderRadius: '6px',
|
||
border: profile.goalScope === 'day' ? '2px solid var(--weekly-accent, #4A90D9)' : '1px solid #ddd',
|
||
background: profile.goalScope === 'day' ? 'var(--weekly-accent-light, #e8f0fe)' : 'white',
|
||
fontWeight: profile.goalScope === 'day' ? 600 : 400,
|
||
cursor: 'pointer', fontSize: '0.85rem'
|
||
}}
|
||
>{t.goalScopeDay}</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Goal Fallback Settings */}
|
||
<div style={{ background: 'var(--weekly-settings-item-bg)', padding: '16px', borderRadius: '8px', marginBottom: '1.5rem', border: '1px solid var(--weekly-border)' }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, color: 'var(--weekly-settings-label)', marginBottom: '12px' }}>{t.goalOfWeek} Fallback</label>
|
||
|
||
<div style={{ marginBottom: '12px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>{t.goalFallback}</label>
|
||
<select
|
||
value={profile.goalFallbackType || 'quote'}
|
||
onChange={(e) => setProfile({ ...profile, goalFallbackType: e.target.value as any })}
|
||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd', background: 'white' }}
|
||
>
|
||
<option value="quote">Motivational Quote / Holiday Hint</option>
|
||
<option value="next_todo">Next Pending Task</option>
|
||
<option value="default">Custom Default Sentence</option>
|
||
</select>
|
||
</div>
|
||
|
||
{profile.goalFallbackType === 'default' && (
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--weekly-settings-label)', marginBottom: '4px' }}>{t.defaultGoal}</label>
|
||
<input
|
||
type="text"
|
||
value={profile.goalDefaultSentence || ''}
|
||
onChange={(e) => setProfile({ ...profile, goalDefaultSentence: e.target.value })}
|
||
placeholder="e.g. goal of the week"
|
||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd', background: 'white' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ marginTop: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||
<button
|
||
onClick={handleUpdateProfile}
|
||
className="weekly-btn-primary"
|
||
style={{ padding: '10px 20px' }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && (
|
||
<span style={{
|
||
fontSize: '0.9rem',
|
||
color: accountMsg.toLowerCase().includes('success') ? '#059669' : '#dc2626',
|
||
fontWeight: 600
|
||
}}>
|
||
{accountMsg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : activeTab === 'calendar' ? (
|
||
isLoading ? (
|
||
<p>Loading connections...</p>
|
||
) : (
|
||
<>
|
||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>{t.connectedCalendars}</h3>
|
||
|
||
{connMsg && (
|
||
<div style={{
|
||
padding: '8px 12px',
|
||
borderRadius: '4px',
|
||
marginBottom: '12px',
|
||
fontSize: '0.875rem',
|
||
background: connMsg.type === 'success' ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||
color: connMsg.type === 'success' ? '#059669' : '#dc2626',
|
||
border: `1px solid ${connMsg.type === 'success' ? '#10b981' : '#ef4444'}`
|
||
}}>
|
||
{connMsg.text}
|
||
</div>
|
||
)}
|
||
|
||
{connections.length === 0 ? (
|
||
<p style={{ color: 'var(--weekly-text-light)', marginBottom: '1.5rem' }}>
|
||
{t.noCalendars}
|
||
</p>
|
||
) : (
|
||
<ul style={{ marginBottom: '1.5rem', listStyle: 'none', padding: 0 }}>
|
||
{connections.map(conn => (
|
||
<li key={conn.id} style={{ padding: '1rem 0', borderBottom: '1px solid var(--weekly-border)' }}>
|
||
<div style={{ marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span>{conn.provider === 'google' ? '📅' : conn.provider === 'apple' ? '🍎' : '📧'}</span>
|
||
{conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : 'Outlook Calendar'}
|
||
</div>
|
||
{confirmDisconnectId === conn.id ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<span style={{ fontSize: '0.8rem', color: 'var(--weekly-text)' }}>Sure?</span>
|
||
<button
|
||
type="button"
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setConfirmDisconnectId(null);
|
||
setDisconnectingId(conn.id);
|
||
try {
|
||
await onRemoveConnection(conn.id);
|
||
showConnMsg('success', 'Calendar disconnected.');
|
||
} catch (err: any) {
|
||
console.error('Failed to disconnect:', err);
|
||
showConnMsg('error', err.message || 'Failed to disconnect calendar');
|
||
} finally {
|
||
setDisconnectingId(null);
|
||
}
|
||
}}
|
||
style={{ padding: '3px 8px', fontSize: '0.8rem', background: '#dc2626', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
|
||
>Yes</button>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setConfirmDisconnectId(null); }}
|
||
style={{ padding: '3px 8px', fontSize: '0.8rem', background: '#e5e7eb', color: '#374151', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
|
||
>No</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setConfirmDisconnectId(conn.id); }}
|
||
disabled={disconnectingId === conn.id}
|
||
style={{
|
||
padding: '4px 8px',
|
||
fontSize: '0.8rem',
|
||
color: disconnectingId === conn.id ? '#999' : '#dc2626',
|
||
background: 'none',
|
||
border: `1px solid ${disconnectingId === conn.id ? '#999' : '#dc2626'}`,
|
||
borderRadius: '4px',
|
||
cursor: disconnectingId === conn.id ? 'not-allowed' : 'pointer',
|
||
opacity: disconnectingId === conn.id ? 0.7 : 1
|
||
}}
|
||
>
|
||
{disconnectingId === conn.id ? 'Disconnecting...' : 'Disconnect'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Calendar Event Selection List */}
|
||
{conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
|
||
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
|
||
{conn.calendars.map((cal: any) => {
|
||
const isShared = /⚠/.test(cal.title);
|
||
const cleanTitle = cal.title.replace(/\s*⚠️?\s*/g, '').trim();
|
||
return (
|
||
<li key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={cal.selected !== false}
|
||
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { selected: e.target.checked })}
|
||
style={{ cursor: 'pointer' }}
|
||
/>
|
||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text)' }}>
|
||
{cleanTitle}
|
||
{isShared && <span title="Shared calendar" style={{ marginLeft: '5px', fontSize: '0.75rem', opacity: 0.5 }}>🔗</span>}
|
||
{cal.isPrimary && <span style={{ fontSize: '0.8em', color: 'var(--weekly-text-light)', marginLeft: '4px' }}>(Primary)</span>}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Editable toggle */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', opacity: 0.8 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={cal.editable === true}
|
||
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { editable: e.target.checked })}
|
||
style={{ cursor: 'pointer', width: '12px', height: '12px' }}
|
||
title="Allow adding/editing events"
|
||
/>
|
||
<span style={{ fontSize: '0.8rem', color: '#888' }} title="Allow adding/editing events">Editable</span>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<div style={{ fontSize: '0.85rem', color: '#888', paddingLeft: '24px' }}>
|
||
{conn.provider === 'google'
|
||
? 'No calendars found or permission denied.'
|
||
: conn.provider === 'apple'
|
||
? 'No calendars loaded. Please disconnect and reconnect Apple Calendar to load your calendars.'
|
||
: 'Selection available after connect.'}
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600 }}>{t.connectMore}</h3>
|
||
|
||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||
<button onClick={handleGoogleConnect} className="calendar-connect-btn">
|
||
<span>📅</span> {t.connectGoogle}
|
||
</button>
|
||
<button onClick={handleAppleCalendarConnect} className="calendar-connect-btn">
|
||
<span>🍎</span> {t.connectApple}
|
||
</button>
|
||
<button onClick={handleOutlookConnect} className="calendar-connect-btn">
|
||
<span>📧</span> Connect Outlook
|
||
</button>
|
||
</div>
|
||
|
||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600, marginTop: '2rem' }}>Import Tasks</h3>
|
||
<p style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', marginBottom: '1rem' }}>
|
||
Import tasks from Google Tasks into a Someday list.
|
||
</p>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||
<button
|
||
onClick={() => executeImport('google')}
|
||
className="calendar-connect-btn"
|
||
disabled={!connections.some(c => c.provider === 'google') || importingTasksState}
|
||
style={{ opacity: (!connections.some(c => c.provider === 'google') || importingTasksState) ? 0.5 : 1 }}
|
||
>
|
||
<span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'}
|
||
</button>
|
||
</div>
|
||
{importStatusMsg && (
|
||
<div style={{
|
||
padding: '8px 12px',
|
||
borderRadius: '4px',
|
||
fontSize: '0.9rem',
|
||
background: importStatusMsg.type === 'success' ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||
color: importStatusMsg.type === 'success' ? '#059669' : '#dc2626',
|
||
border: `1px solid ${importStatusMsg.type === 'success' ? '#10b981' : '#ef4444'}`
|
||
}}>
|
||
{importStatusMsg.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
) : (
|
||
/* Account Tab */
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||
<form onSubmit={handleUpdateProfile} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.name}</label>
|
||
<input
|
||
type="text"
|
||
value={profile.name}
|
||
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.email}</label>
|
||
<input
|
||
type="email"
|
||
value={profile.email}
|
||
disabled
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #eee', borderRadius: '4px', background: '#f5f5f5', color: '#555' }}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.timezone}</label>
|
||
<select
|
||
value={profile.timezone}
|
||
onChange={e => setProfile({ ...profile, timezone: e.target.value })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
>
|
||
<option value="UTC">UTC</option>
|
||
<option value="Europe/Berlin">Europe/Berlin</option>
|
||
<option value="America/New_York">America/New_York</option>
|
||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style={{ borderTop: '1px solid #eee', paddingTop: '12px', marginTop: '8px' }}>
|
||
<label style={{ display: 'block', fontSize: '0.9rem', fontWeight: 600, marginBottom: '4px' }}>{t.changePassword}</label>
|
||
<input
|
||
type="password"
|
||
placeholder={t.newPassword}
|
||
value={passwords.new}
|
||
onChange={e => setPasswords({ ...passwords, new: e.target.value })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px', marginBottom: '8px' }}
|
||
/>
|
||
<input
|
||
type="password"
|
||
placeholder={t.confirmPassword}
|
||
value={passwords.confirm}
|
||
onChange={e => setPasswords({ ...passwords, confirm: e.target.value })}
|
||
style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
<small className="help-text" style={{ fontSize: '0.75rem', color: '#666', marginTop: '4px', display: 'block' }}>{translations[profile.language || 'en']?.newPasswordDesc || translations['en'].newPasswordDesc}</small>
|
||
</div>
|
||
|
||
|
||
<div style={{ marginTop: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||
<button
|
||
type="submit"
|
||
className="weekly-btn-primary"
|
||
style={{ padding: '10px 20px' }}
|
||
>
|
||
{t.saveChanges}
|
||
</button>
|
||
{accountMsg && (
|
||
<span style={{
|
||
fontSize: '0.9rem',
|
||
color: accountMsg.toLowerCase().includes('success') ? '#059669' : '#dc2626',
|
||
fontWeight: 600
|
||
}}>
|
||
{accountMsg}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</form>
|
||
|
||
{/* Data Export Section */}
|
||
<div style={{ marginTop: '30px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
|
||
<h4 style={{ marginBottom: '10px', fontSize: '1.1rem' }}>{(profile.language === 'de') ? 'Datenexport' : 'Data Export'}</h4>
|
||
<p style={{ fontSize: '0.9rem', color: '#666', marginBottom: '15px' }}>
|
||
{(profile.language === 'de') ? 'Laden Sie eine CSV-Datei Ihrer erledigten Aufgaben herunter.' : 'Download a CSV file of your completed tasks.'}
|
||
</p>
|
||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, marginBottom: '4px' }}>Start</label>
|
||
<input
|
||
type="date"
|
||
value={exportStartDate}
|
||
onChange={(e) => setExportStartDate(e.target.value)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '6px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, marginBottom: '4px' }}>End</label>
|
||
<input
|
||
type="date"
|
||
value={exportEndDate}
|
||
onChange={(e) => setExportEndDate(e.target.value)}
|
||
className="weekly-input"
|
||
style={{ width: '100%', padding: '6px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={`/api/user/export?startDate=${exportStartDate}&endDate=${exportEndDate}`}
|
||
target="_blank"
|
||
className="weekly-auth-button"
|
||
style={{ display: 'inline-flex', textDecoration: 'none', background: '#f8fafc', border: '1px solid #e2e8f0', color: '#0f172a', justifyContent: 'center' }}
|
||
>
|
||
{(profile.language === 'de') ? 'Erledigte Aufgaben exportieren (CSV)' : 'Export Completed Tasks (CSV)'}
|
||
</a>
|
||
</div>
|
||
|
||
<div className="account-danger-zone" style={{ marginTop: '30px', paddingTop: '20px', borderTop: '1px solid #eee' }}>
|
||
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '10px' }}>{t.dataPrivacy}</h3>
|
||
<div style={{ display: 'flex', gap: '10px' }}>
|
||
<button
|
||
onClick={handleDownloadData}
|
||
style={{ padding: '8px 12px', border: '1px solid #ddd', background: 'white', borderRadius: '4px', cursor: 'pointer' }}
|
||
>
|
||
{t.downloadData}
|
||
</button>
|
||
<button
|
||
onClick={handleDeleteAccount}
|
||
style={{ padding: '8px 12px', border: '1px solid #d32f2f', background: 'white', color: '#d32f2f', borderRadius: '4px', cursor: 'pointer' }}
|
||
>
|
||
{t.deleteAccount}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div >
|
||
|
||
{/* Apple Calendar (CalDAV) Connection Modal */}
|
||
{showAppleCalendarModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
|
||
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||
<h3 className="text-xl font-bold mb-4">Connect Apple Calendar</h3>
|
||
|
||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||
<p style={{ marginBottom: '6px' }}>Connect your iCloud Calendar events via CalDAV.</p>
|
||
<p style={{ fontSize: '0.8rem', opacity: 0.85 }}>
|
||
This requires an{' '}
|
||
<a href="https://support.apple.com/en-us/102654" target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'underline' }}>app-specific password</a>
|
||
{' '}generated at appleid.apple.com.
|
||
</p>
|
||
</div>
|
||
|
||
{appleCalError && (
|
||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||
{appleCalError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Apple ID (Email)</label>
|
||
<input
|
||
type="email"
|
||
value={appleCalEmail}
|
||
onChange={(e) => setAppleCalEmail(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
placeholder="name@icloud.com"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">App-Specific Password</label>
|
||
<input
|
||
type="password"
|
||
value={appleCalPassword}
|
||
onChange={(e) => setAppleCalPassword(e.target.value)}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||
onKeyDown={(e) => e.key === 'Enter' && submitAppleCalendarConnection()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 mt-6">
|
||
<button
|
||
onClick={() => { setShowAppleCalendarModal(false); setAppleCalError(''); }}
|
||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||
disabled={isConnectingAppleCal}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={submitAppleCalendarConnection}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||
disabled={isConnectingAppleCal}
|
||
>
|
||
{isConnectingAppleCal ? (
|
||
<>
|
||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
Connecting...
|
||
</>
|
||
) : 'Connect'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</>
|
||
);
|
||
}
|
||
|
||
|