- Replace broken CloudKit/pyicloud Apple Reminders integration with CalDAV-based approach (getAppleReminderLists, fetchTasks from apple-calendar.ts) in tasks/lists, tasks/import, and tasks/sync routes - Restore calendar date picker icon in header for jump-to-date navigation - Add goalScope field to User model with migration - Force-dynamic Google OAuth routes to fix redirect issues - Update Google Tasks client and sync logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6033 lines
332 KiB
TypeScript
6033 lines
332 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',
|
||
connectAppleReminders: 'Connect Apple Reminders',
|
||
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',
|
||
connectAppleReminders: 'Apple Reminders 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(); // Fetch connections
|
||
fetchCalendarEvents();
|
||
}
|
||
}, [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),
|
||
}));
|
||
|
||
const dayTasks = fetchedTasks.filter((t: Task) => t.dayOfWeek !== null && !t.somedayListId);
|
||
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
|
||
|
||
setTasks(dayTasks);
|
||
|
||
// Populate lists with tasks
|
||
const populatedLists = fetchedLists.map(list => ({
|
||
...list,
|
||
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id)
|
||
}));
|
||
|
||
// Fallback: If there are someday tasks with IDs that don't match any list (orphans),
|
||
// or if we rely on the old "default" list for legacy data.
|
||
// The old code had a default list.
|
||
// Let's ensure we use the fetched lists.
|
||
setSomedayLists(populatedLists);
|
||
|
||
// 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.dayOfWeek !== null && !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);
|
||
}
|
||
};
|
||
|
||
const toggleTask = async (taskId: string) => {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const updatedCompleted = !task.completed;
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId
|
||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||
: t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, completed: updatedCompleted }),
|
||
});
|
||
|
||
// Trigger external sync if applicable
|
||
if (task.externalId && task.externalProvider) {
|
||
// Don't await this strictly or handle error silently?
|
||
// Better to fire and forget for UI responsiveness, or await if we want to ensure consistency?
|
||
// Let's await but catch separately
|
||
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;
|
||
}
|
||
|
||
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() }),
|
||
});
|
||
|
||
// Sync title change to external provider if applicable
|
||
const task = tasks.find(t => t.id === taskId);
|
||
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) => {
|
||
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 }),
|
||
});
|
||
|
||
// Sync notes to external provider
|
||
const task = tasks.find(t => t.id === taskId);
|
||
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 = tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
|
||
const newRollingState = !task.isRolling;
|
||
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t
|
||
));
|
||
|
||
try {
|
||
await fetch('/api/tasks', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: taskId, isRolling: newRollingState }),
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating task rolling state:', error);
|
||
// Revert on error
|
||
setTasks(tasks.map(t =>
|
||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t
|
||
));
|
||
}
|
||
};
|
||
|
||
const moveTaskToSlot = async (taskId: string, dayOfWeek: number, startTime: string, scheduledDate?: Date) => {
|
||
const newScheduledDate = scheduledDate ? formatDateToISO(scheduledDate) : undefined;
|
||
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 = tasks.find(t => t.id === taskId);
|
||
const isVirtual = taskId.startsWith('virtual-');
|
||
|
||
let originalId = taskId;
|
||
if (isVirtual) {
|
||
const match = taskId.match(/^virtual-(.+)-(\d{4}-\d{2}-\d{2})$/);
|
||
if (match) {
|
||
originalId = match[1];
|
||
}
|
||
}
|
||
|
||
// Check if it's a series (virtual or real recurring)
|
||
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
|
||
|
||
if (isSeries) {
|
||
// Confirm deletion type
|
||
const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE.");
|
||
|
||
if (deleteSeries) {
|
||
// DELETE SERIES
|
||
// Remove all tasks related to this series from the UI immediately
|
||
setTasks(prev => prev.filter(t => {
|
||
// Check if t is the original task
|
||
if (t.id === originalId) return false;
|
||
// Check if t is a virtual task of this series
|
||
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
|
||
// Check if t is the specific task being clicked (if logic above didn't catch it)
|
||
if (t.id === taskId) return false;
|
||
return true;
|
||
}));
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
// Sync delete to external provider if applicable
|
||
const origTask = tasks.find(t => t.id === 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));
|
||
}
|
||
|
||
// Deleting the original ID stops the series
|
||
await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' });
|
||
} catch (error) {
|
||
console.error('Error deleting series:', error);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// NORMAL DELETE (Single instance)
|
||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||
setEditingTaskId(null);
|
||
|
||
try {
|
||
// Sync delete to external provider if applicable
|
||
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 {
|
||
await fetchCalendarEvents();
|
||
setSyncStatus('synced');
|
||
// Reset status after 3 seconds
|
||
setTimeout(() => setSyncStatus('idle'), 3000);
|
||
} catch (error) {
|
||
console.error('Error syncing calendar:', error);
|
||
setSyncStatus('idle');
|
||
}
|
||
};
|
||
|
||
// Start adding someday list UI
|
||
const handleStartAddSomedayList = () => {
|
||
setIsAddingSomedayList(true);
|
||
// Focus will happen in render logic if possible or via ref, but let's render conditional input first
|
||
};
|
||
|
||
const saveSomedayList = async () => {
|
||
if (!newSomedayListName.trim()) {
|
||
setIsAddingSomedayList(false);
|
||
setNewSomedayListName('');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/someday-lists', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: newSomedayListName.trim() }),
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setSomedayLists(prev => [...prev, {
|
||
...data.list,
|
||
tasks: [] // Initially empty
|
||
}]);
|
||
setNewSomedayListName('');
|
||
setIsAddingSomedayList(false);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding someday list:', error);
|
||
}
|
||
};
|
||
|
||
// Get time slots to display
|
||
// Get time slots to display
|
||
const visibleSlots = getTimeSlots(cellDuration, workingHoursStart, workingHoursEnd);
|
||
|
||
const containerStyle = {
|
||
'--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>
|
||
) : (
|
||
<>
|
||
{/* Calendar Events */}
|
||
{getEventsForDate(date).map(event => {
|
||
const eventColor = event.calendarColor || '#009a9a';
|
||
const bgColor = eventColor.startsWith('#') ? `${eventColor}20` : eventColor;
|
||
const borderColor = eventColor.startsWith('#') ? eventColor : 'var(--weekly-teal)';
|
||
|
||
return (
|
||
<div key={event.id} className="weekly-calendar-event"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (event.editable) {
|
||
setCalendarEventModal({
|
||
isOpen: true,
|
||
event: event
|
||
});
|
||
}
|
||
}}
|
||
style={{
|
||
backgroundColor: bgColor,
|
||
borderLeftColor: borderColor,
|
||
color: borderColor,
|
||
cursor: event.editable ? 'pointer' : 'default'
|
||
}}>
|
||
<div className="weekly-calendar-event-time" style={{ color: 'inherit', opacity: 0.8 }}>
|
||
{new Date(event.startTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||
</div>
|
||
<div className="weekly-calendar-event-title" style={{ color: 'inherit' }}>{event.title}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Tasks */}
|
||
<ol className="weekly-task-list">
|
||
{getTasksForDate(date).map(task => (
|
||
<TaskItem
|
||
key={task.id}
|
||
task={task}
|
||
isEditing={editingTaskId === task.id}
|
||
onToggle={() => toggleTask(task.id)}
|
||
onEdit={() => setEditingTaskId(task.id)}
|
||
onUpdate={(newTitle) => updateTask(task.id, newTitle)}
|
||
onDelete={() => deleteTask(task.id)}
|
||
onNotes={(notes) => updateTaskNotes(task.id, notes)}
|
||
onRollToggle={() => toggleTaskRolling(task.id)}
|
||
onRecurrence={() => setSelectedTaskForRecurrence(task)}
|
||
onDragStart={(e, t) => handleDragStart(e, t)}
|
||
onDragEnd={handleDragEnd}
|
||
/>
|
||
))}
|
||
</ol>
|
||
</>
|
||
)
|
||
}
|
||
|
||
</div>
|
||
);
|
||
})}
|
||
</main >
|
||
</div >
|
||
|
||
{/* All-Day Events Section */}
|
||
{
|
||
(() => {
|
||
if (!showAllDay) return null;
|
||
const allDayEvents = calendarEvents.filter(event => isAllDayEvent(event));
|
||
if (allDayEvents.length === 0) return null;
|
||
|
||
return (
|
||
<section className={`all-day-events-section ${isAllDayExpanded ? 'expanded' : 'collapsed'}`}>
|
||
<div 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) => {
|
||
// Only drag if clicking the handle
|
||
const target = e.target as HTMLElement;
|
||
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('');
|
||
|
||
// Apple Reminders (iCloud) State
|
||
const [showAppleModal, setShowAppleModal] = useState(false);
|
||
const [appleEmail, setAppleEmail] = useState('');
|
||
const [applePassword, setApplePassword] = useState('');
|
||
const [isConnectingApple, setIsConnectingApple] = useState(false);
|
||
const [appleError, setAppleError] = useState('');
|
||
const [needs2FA, setNeeds2FA] = useState(false);
|
||
const [securityCode, setSecurityCode] = 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);
|
||
|
||
// Apple Reminders inline state
|
||
const [reminderLists, setReminderLists] = useState<{ id: string, title: string }[]>([]);
|
||
const [isFetchingReminderLists, setIsFetchingReminderLists] = useState(false);
|
||
const [selectedReminderIds, setSelectedReminderIds] = useState<string[]>([]);
|
||
const [reminderListsLoaded, setReminderListsLoaded] = useState(false);
|
||
const [reminderListsError, setReminderListsError] = useState<string | null>(null);
|
||
const [needsReconnect, setNeedsReconnect] = useState(false);
|
||
|
||
const loadReminderLists = async () => {
|
||
setIsFetchingReminderLists(true);
|
||
setReminderListsError(null);
|
||
try {
|
||
const res = await fetch('/api/tasks/lists?provider=apple');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setReminderLists(data.lists || []);
|
||
setSelectedReminderIds((data.lists || []).map((l: any) => l.id));
|
||
setReminderListsLoaded(true);
|
||
} else {
|
||
const errData = await res.json().catch(() => ({}));
|
||
setReminderListsError(errData.error || 'Failed to load reminder lists.');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load reminder lists:', e);
|
||
setReminderListsError('Failed to load reminder lists.');
|
||
} finally {
|
||
setIsFetchingReminderLists(false);
|
||
}
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// --- Apple Reminders (iCloud) handlers ---
|
||
const handleAppleConnect = () => {
|
||
setShowAppleModal(true);
|
||
setAppleError('');
|
||
setAppleEmail('');
|
||
setApplePassword('');
|
||
setNeeds2FA(false);
|
||
setSecurityCode('');
|
||
};
|
||
|
||
const submitAppleConnection = async () => {
|
||
if (!appleEmail || !applePassword) {
|
||
setAppleError('Please enter both email and password.');
|
||
return;
|
||
}
|
||
|
||
setIsConnectingApple(true);
|
||
setAppleError('');
|
||
|
||
try {
|
||
const response = await fetch('/api/reminders/connect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: appleEmail, password: applePassword }),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to connect Apple Reminders');
|
||
}
|
||
|
||
if (data.needs2FA) {
|
||
setNeeds2FA(true);
|
||
return;
|
||
}
|
||
|
||
setShowAppleModal(false);
|
||
setNeeds2FA(false);
|
||
setNeedsReconnect(false);
|
||
showConnMsg('success', data.message || 'Apple Reminders connected!');
|
||
setTimeout(() => window.location.reload(), 1200);
|
||
} catch (err: any) {
|
||
setAppleError(err.message || 'Connection failed');
|
||
} finally {
|
||
setIsConnectingApple(false);
|
||
}
|
||
};
|
||
|
||
const submitSecurityCode = async () => {
|
||
if (!securityCode || securityCode.length < 4) {
|
||
setAppleError('Please enter a valid security code.');
|
||
return;
|
||
}
|
||
|
||
setIsConnectingApple(true);
|
||
setAppleError('');
|
||
|
||
try {
|
||
const response = await fetch('/api/reminders/verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: appleEmail, code: securityCode, password: applePassword }),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Verification failed');
|
||
}
|
||
|
||
setShowAppleModal(false);
|
||
setNeeds2FA(false);
|
||
setNeedsReconnect(false);
|
||
showConnMsg('success', data.message || 'Apple Reminders verified and connected!');
|
||
setTimeout(() => window.location.reload(), 1200);
|
||
} catch (err: any) {
|
||
setAppleError(err.message || 'Verification failed');
|
||
} finally {
|
||
setIsConnectingApple(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' ? '🍎' : conn.provider === 'apple-reminders' ? '🔔' : '📧'}</span>
|
||
{conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : conn.provider === 'apple-reminders' ? 'Apple Reminders' : '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 (not for apple-reminders) */}
|
||
{conn.provider !== 'apple-reminders' && (
|
||
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>
|
||
)
|
||
)}
|
||
|
||
{/* Apple Reminders inline import section */}
|
||
{conn.provider === 'apple-reminders' && (
|
||
<div style={{ marginTop: '0.75rem', paddingLeft: '24px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-text)' }}>Reminders</span>
|
||
<div style={{ display: 'flex', gap: '6px' }}>
|
||
{!reminderListsLoaded && (
|
||
<button
|
||
onClick={loadReminderLists}
|
||
disabled={isFetchingReminderLists}
|
||
style={{ fontSize: '0.78rem', padding: '2px 8px', background: 'none', border: '1px solid #aaa', borderRadius: '4px', cursor: isFetchingReminderLists ? 'not-allowed' : 'pointer', color: 'var(--weekly-text)' }}
|
||
>
|
||
{isFetchingReminderLists ? 'Loading…' : 'Load Lists'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{reminderListsError && (
|
||
<div style={{ fontSize: '0.8rem', color: '#dc2626', marginTop: '4px', lineHeight: 1.4 }}>
|
||
{reminderListsError}
|
||
</div>
|
||
)}
|
||
{reminderListsLoaded && reminderLists.length > 0 && (
|
||
<>
|
||
<ul style={{ listStyle: 'none', padding: 0, marginBottom: '0.5rem' }}>
|
||
{reminderLists.map(list => {
|
||
const isShared = /⚠/.test(list.title);
|
||
const cleanTitle = list.title.replace(/\s*⚠️?\s*/g, '').trim();
|
||
return (
|
||
<li key={list.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedReminderIds.includes(list.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) setSelectedReminderIds(prev => [...prev, list.id]);
|
||
else setSelectedReminderIds(prev => prev.filter(id => id !== list.id));
|
||
}}
|
||
style={{ cursor: 'pointer', width: '13px', height: '13px' }}
|
||
/>
|
||
<span style={{ fontSize: '0.85rem', color: 'var(--weekly-text)' }}>
|
||
{cleanTitle}
|
||
{isShared && <span title="Shared list" style={{ marginLeft: '5px', fontSize: '0.75rem', opacity: 0.5 }}>🔗</span>}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
<button
|
||
onClick={() => onImportLists(reminderLists.filter(l => selectedReminderIds.includes(l.id)))}
|
||
disabled={selectedReminderIds.length === 0 || importingTasksState}
|
||
style={{
|
||
fontSize: '0.8rem',
|
||
padding: '4px 10px',
|
||
background: selectedReminderIds.length === 0 || importingTasksState ? '#9ca3af' : '#2563eb',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: selectedReminderIds.length === 0 || importingTasksState ? 'not-allowed' : 'pointer'
|
||
}}
|
||
>
|
||
{importingTasksState ? 'Importing…' : `Import ${selectedReminderIds.length} list(s)`}
|
||
</button>
|
||
</>
|
||
)}
|
||
{reminderListsLoaded && reminderLists.length === 0 && (
|
||
<div style={{ fontSize: '0.8rem', color: '#888' }}>No reminder lists found.</div>
|
||
)}
|
||
</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>
|
||
<button onClick={handleAppleConnect} className="calendar-connect-btn">
|
||
<span>🔔</span> {t.connectAppleReminders}
|
||
</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. For Apple Reminders, use the Reminders section above.
|
||
</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>
|
||
)}
|
||
|
||
{/* Apple Reminders (iCloud) Connection Modal */}
|
||
{showAppleModal && (
|
||
<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">
|
||
{needs2FA ? 'Two-Factor Authentication' : 'Connect Apple Reminders'}
|
||
</h3>
|
||
|
||
{!needs2FA && (
|
||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||
Sign in with your Apple ID to import your Reminders lists via iCloud.
|
||
Two-factor authentication will be required.
|
||
</div>
|
||
)}
|
||
|
||
{needs2FA && (
|
||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||
A verification code was sent to your trusted devices. Enter it below.
|
||
</div>
|
||
)}
|
||
|
||
{appleError && (
|
||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||
{appleError}
|
||
</div>
|
||
)}
|
||
|
||
{!needs2FA ? (
|
||
<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={appleEmail}
|
||
onChange={(e) => setAppleEmail(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">Apple ID Password</label>
|
||
<input
|
||
type="password"
|
||
value={applePassword}
|
||
onChange={(e) => setApplePassword(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="Your Apple ID password"
|
||
onKeyDown={(e) => e.key === 'Enter' && submitAppleConnection()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification Code</label>
|
||
<input
|
||
type="text"
|
||
value={securityCode}
|
||
onChange={(e) => setSecurityCode(e.target.value.replace(/\D/g, ''))}
|
||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-center text-xl tracking-widest"
|
||
placeholder="000000"
|
||
maxLength={8}
|
||
autoFocus
|
||
onKeyDown={(e) => e.key === 'Enter' && submitSecurityCode()}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-3 mt-6">
|
||
<button
|
||
onClick={() => { setShowAppleModal(false); setNeeds2FA(false); setSecurityCode(''); setAppleError(''); }}
|
||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||
disabled={isConnectingApple}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={needs2FA ? submitSecurityCode : submitAppleConnection}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||
disabled={isConnectingApple}
|
||
>
|
||
{isConnectingApple ? (
|
||
<>
|
||
<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>
|
||
{needs2FA ? 'Verifying...' : 'Connecting...'}
|
||
</>
|
||
) : (needs2FA ? 'Verify' : 'Sign In')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</>
|
||
);
|
||
}
|
||
|
||
|