diff --git a/backend/routes/tasks/index.js b/backend/routes/tasks/index.js index c1a82f6..8a220d4 100644 --- a/backend/routes/tasks/index.js +++ b/backend/routes/tasks/index.js @@ -177,7 +177,7 @@ router.get('/tasks', async (req, res) => { limit: limitParam, offset: offsetParam, } = req.query; - const { id: userId, timezone } = req.currentUser; + const { id: userId, timezone, language } = req.currentUser; await handleRecurringTasks(userId, type); @@ -247,7 +247,8 @@ router.get('/tasks', async (req, res) => { groupBy, maxDays, order_by, - timezone + timezone, + language || 'en' ); const serializationOptions = diff --git a/backend/routes/tasks/operations/grouping.js b/backend/routes/tasks/operations/grouping.js index a436c68..5200e78 100644 --- a/backend/routes/tasks/operations/grouping.js +++ b/backend/routes/tasks/operations/grouping.js @@ -31,17 +31,132 @@ function categorizeTasksByDate(tasks, cutoffDate, safeTimezone) { return tasksByDate; } -function generateGroupName(dateKey, now, safeTimezone) { - const dateMoment = moment.tz(dateKey, safeTimezone); +function generateGroupName(dateKey, now, safeTimezone, language = 'en') { + // Map language codes to moment.js locale codes + const localeMap = { + en: 'en', + ar: 'ar', + bg: 'bg', + da: 'da', + de: 'de', + el: 'el', + es: 'es', + fi: 'fi', + fr: 'fr', + id: 'id', + it: 'it', + jp: 'ja', + ko: 'ko', + nl: 'nl', + no: 'nb', + pl: 'pl', + pt: 'pt', + ro: 'ro', + ru: 'ru', + sl: 'sl', + sv: 'sv', + tr: 'tr', + ua: 'uk', + vi: 'vi', + zh: 'zh-cn', + }; + + const momentLocale = localeMap[language] || 'en'; + const dateMoment = moment.tz(dateKey, safeTimezone).locale(momentLocale); const dayName = dateMoment.format('dddd'); const dateDisplay = dateMoment.format('MMMM D'); const isToday = dateMoment.isSame(now, 'day'); const isTomorrow = dateMoment.isSame(now.clone().add(1, 'day'), 'day'); + // Translation map for "Today", "Tomorrow", and "No Due Date" + const translations = { + today: { + en: 'Today', + ar: 'اليوم', + bg: 'Днес', + da: 'I dag', + de: 'Heute', + el: 'Σήμερα', + es: 'Hoy', + fi: 'Tänään', + fr: "Aujourd'hui", + id: 'Hari ini', + it: 'Oggi', + jp: '今日', + ko: '오늘', + nl: 'Vandaag', + no: 'I dag', + pl: 'Dzisiaj', + pt: 'Hoje', + ro: 'Astăzi', + ru: 'Сегодня', + sl: 'Danes', + sv: 'Idag', + tr: 'Bugün', + ua: 'Сьогодні', + vi: 'Hôm nay', + zh: '今天', + }, + tomorrow: { + en: 'Tomorrow', + ar: 'غداً', + bg: 'Утре', + da: 'I morgen', + de: 'Morgen', + el: 'Αύριο', + es: 'Mañana', + fi: 'Huomenna', + fr: 'Demain', + id: 'Besok', + it: 'Domani', + jp: '明日', + ko: '내일', + nl: 'Morgen', + no: 'I morgen', + pl: 'Jutro', + pt: 'Amanhã', + ro: 'Mâine', + ru: 'Завтра', + sl: 'Jutri', + sv: 'Imorgon', + tr: 'Yarın', + ua: 'Завтра', + vi: 'Ngày mai', + zh: '明天', + }, + noDueDate: { + en: 'No Due Date', + ar: 'لا يوجد تاريخ استحقاق', + bg: 'Няма краен срок', + da: 'Ingen frist', + de: 'Kein Fälligkeitsdatum', + el: 'Χωρίς προθεσμία', + es: 'Sin fecha de vencimiento', + fi: 'Ei määräaikaa', + fr: "Pas de date d'échéance", + id: 'Tidak ada tanggal jatuh tempo', + it: 'Nessuna scadenza', + jp: '期限なし', + ko: '마감일 없음', + nl: 'Geen deadline', + no: 'Ingen frist', + pl: 'Brak terminu', + pt: 'Sem prazo', + ro: 'Fără termen limită', + ru: 'Нет срока', + sl: 'Ni roka', + sv: 'Ingen deadline', + tr: 'Son tarih yok', + ua: 'Немає терміну', + vi: 'Không có hạn', + zh: '无截止日期', + }, + }; + if (isToday) { - return 'Today'; + return translations.today[language] || translations.today.en; } else if (isTomorrow) { - return 'Tomorrow'; + return translations.tomorrow[language] || translations.tomorrow.en; } else { return `${dayName}, ${dateDisplay}`; } @@ -51,7 +166,8 @@ async function groupTasksByDay( tasks, userTimezone, maxDays = 14, - orderBy = 'created_at:desc' + orderBy = 'created_at:desc', + language = 'en' ) { const safeTimezone = getSafeTimezone(userTimezone); const now = moment.tz(safeTimezone); @@ -66,7 +182,12 @@ async function groupTasksByDay( const groupedTasks = {}; sortedDates.forEach((dateKey) => { - const groupName = generateGroupName(dateKey, now, safeTimezone); + const groupName = generateGroupName( + dateKey, + now, + safeTimezone, + language + ); const tasksForDate = tasksByDate.get(dateKey); sortTasksByOrder(tasksForDate, orderBy, safeTimezone); groupedTasks[groupName] = tasksForDate; @@ -75,7 +196,39 @@ async function groupTasksByDay( if (tasksByDate.has('no-date')) { const noDateTasks = tasksByDate.get('no-date'); sortTasksByOrder(noDateTasks, orderBy, safeTimezone); - groupedTasks['No Due Date'] = noDateTasks; + // Use translated "No Due Date" + const translations = { + noDueDate: { + en: 'No Due Date', + ar: 'لا يوجد تاريخ استحقاق', + bg: 'Няма краен срок', + da: 'Ingen frist', + de: 'Kein Fälligkeitsdatum', + el: 'Χωρίς προθεσμία', + es: 'Sin fecha de vencimiento', + fi: 'Ei määräaikaa', + fr: "Pas de date d'échéance", + id: 'Tidak ada tanggal jatuh tempo', + it: 'Nessuna scadenza', + jp: '期限なし', + ko: '마감일 없음', + nl: 'Geen deadline', + no: 'Ingen frist', + pl: 'Brak terminu', + pt: 'Sem prazo', + ro: 'Fără termen limită', + ru: 'Нет срока', + sl: 'Ni roka', + sv: 'Ingen deadline', + tr: 'Son tarih yok', + ua: 'Немає терміну', + vi: 'Không có hạn', + zh: '无截止日期', + }, + }; + const noDueDateLabel = + translations.noDueDate[language] || translations.noDueDate.en; + groupedTasks[noDueDateLabel] = noDateTasks; } return groupedTasks; diff --git a/backend/routes/tasks/operations/list.js b/backend/routes/tasks/operations/list.js index 42d8463..1e13f42 100644 --- a/backend/routes/tasks/operations/list.js +++ b/backend/routes/tasks/operations/list.js @@ -12,7 +12,8 @@ async function buildGroupedTasks( groupBy, maxDays, orderBy, - timezone + timezone, + language = 'en' ) { if (queryType !== 'upcoming' || groupBy !== 'day') { return null; @@ -21,7 +22,13 @@ async function buildGroupedTasks( const days = maxDays ? parseInt(maxDays, 10) : 7; const dayGroupingOrderBy = orderBy || 'due_date:asc'; - return await groupTasksByDay(tasks, timezone, days, dayGroupingOrderBy); + return await groupTasksByDay( + tasks, + timezone, + days, + dayGroupingOrderBy, + language + ); } async function serializeGroupedTasks(groupedTasks, timezone) { diff --git a/frontend/components/Calendar.tsx b/frontend/components/Calendar.tsx index a9802a4..19f7d6d 100644 --- a/frontend/components/Calendar.tsx +++ b/frontend/components/Calendar.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import TaskModal from './Task/TaskModal'; import { Task } from '../entities/Task'; import { Project } from '../entities/Project'; -import { deleteTask } from '../utils/tasksService'; +import { deleteTask, updateTask } from '../utils/tasksService'; import { ChevronLeftIcon, ChevronRightIcon, @@ -136,7 +136,7 @@ const Calendar: React.FC = () => { start: dueDate, end: new Date(dueDate.getTime() + 60 * 60 * 1000), // 1 hour duration type: 'task' as const, - color: task.completed_at ? '#22c55e' : '#ef4444', // Green if completed, red if not + color: task.completed_at ? '#22c55e' : '#3b82f6', // Green if completed, blue if not }; taskEvents.push(taskEvent); } @@ -312,24 +312,121 @@ const Calendar: React.FC = () => { } }; + const handleEventDrop = async ( + eventId: string, + newDate: Date, + newHour?: number + ) => { + console.log('Event drop:', { eventId, newDate, newHour }); + + // Extract task ID from event ID + const taskId = eventId.replace( + /^task(-defer|-created|-fallback)?-/, + '' + ); + const task = allTasks.find((t) => t.id.toString() === taskId); + + if (!task) { + console.error('Task not found:', taskId); + return; + } + + if (!task.uid) { + console.error('Task has no uid:', task); + return; + } + + console.log('Found task:', task); + + // Calculate new date/time + const newDateTime = new Date(newDate); + if (newHour !== undefined) { + newDateTime.setHours(newHour, 0, 0, 0); + } else { + // If no hour specified (month view), keep the original time or set to start of day + if (task.due_date) { + const originalTime = new Date(task.due_date); + newDateTime.setHours( + originalTime.getHours(), + originalTime.getMinutes(), + 0, + 0 + ); + } else { + newDateTime.setHours(0, 0, 0, 0); + } + } + + // Determine which field to update based on event type + const isDeferEvent = eventId.startsWith('task-defer-'); + const fieldToUpdate = isDeferEvent ? 'defer_until' : 'due_date'; + + console.log('Updating task:', { + uid: task.uid, + field: fieldToUpdate, + newDateTime: newDateTime.toISOString(), + }); + + // Optimistically update the UI first + const updatedTask = { + ...task, + [fieldToUpdate]: newDateTime.toISOString(), + }; + + // Update local tasks state + setAllTasks((prev) => + prev.map((t) => (t.id === task.id ? updatedTask : t)) + ); + + // Update events state + setEvents((prevEvents) => + prevEvents.map((event) => { + if (event.id === eventId) { + return { + ...event, + start: newDateTime, + end: new Date(newDateTime.getTime() + 60 * 60 * 1000), + }; + } + return event; + }) + ); + + // Update in background + try { + await updateTask(task.uid, { + [fieldToUpdate]: newDateTime.toISOString(), + }); + console.log('Task updated successfully'); + } catch (error) { + console.error('Error updating task:', error); + // Revert on error + await loadTasks(); + } + }; + return ( -
+
{/* Header */} -
+
-

- - {t('sidebar.calendar')} -

- - {format(currentDate, 'MMMM yyyy', { locale })} - +
+ +
+
+

+ {t('sidebar.calendar')} +

+ + {format(currentDate, 'MMMM yyyy', { locale })} + +
-
+
{/* View selector */} -
+
{['month', 'week', 'day'].map((viewType) => ( @@ -352,31 +449,33 @@ const Calendar: React.FC = () => { {/* Navigation */}
{/* Loading indicator */} {isLoadingTasks && ( -
- {t('calendar.loadingTasks')} +
+ + {t('calendar.loadingTasks')} +
)} @@ -388,6 +487,7 @@ const Calendar: React.FC = () => { events={events} onDateClick={handleDateClick} onEventClick={handleEventClick} + onEventDrop={handleEventDrop} /> )} @@ -398,6 +498,7 @@ const Calendar: React.FC = () => { onDateClick={handleDateClick} onEventClick={handleEventClick} onTimeSlotClick={handleTimeSlotClick} + onEventDrop={handleEventDrop} /> )} @@ -407,6 +508,7 @@ const Calendar: React.FC = () => { events={events} onEventClick={handleEventClick} onTimeSlotClick={handleTimeSlotClick} + onEventDrop={handleEventDrop} /> )}
diff --git a/frontend/components/Calendar/CalendarDayView.tsx b/frontend/components/Calendar/CalendarDayView.tsx index f44bba3..4191fc2 100644 --- a/frontend/components/Calendar/CalendarDayView.tsx +++ b/frontend/components/Calendar/CalendarDayView.tsx @@ -15,6 +15,7 @@ interface CalendarDayViewProps { events: CalendarEvent[]; onEventClick?: (event: CalendarEvent) => void; onTimeSlotClick?: (date: Date, hour: number) => void; + onEventDrop?: (eventId: string, newDate: Date, newHour: number) => void; } const CalendarDayView: React.FC = ({ @@ -22,8 +23,12 @@ const CalendarDayView: React.FC = ({ events, onEventClick, onTimeSlotClick, + onEventDrop, }) => { const hours = Array.from({ length: 24 }, (_, i) => i); + const [draggedEventId, setDraggedEventId] = React.useState( + null + ); const getEventsForTimeSlot = (hour: number) => { return events.filter((event) => { @@ -59,41 +64,74 @@ const CalendarDayView: React.FC = ({ return (minutes / 60) * 48; // 48px per hour }; + const handleDragStart = (event: CalendarEvent, e: React.DragEvent) => { + e.stopPropagation(); + setDraggedEventId(event.id); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', event.id); + }; + + const handleDragEnd = () => { + setDraggedEventId(null); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (hour: number, e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const eventId = e.dataTransfer.getData('text/plain'); + if (eventId && onEventDrop) { + onEventDrop(eventId, currentDate, hour); + } + setDraggedEventId(null); + }; + return ( -
+
{/* Header */} -
+
{format(currentDate, 'EEEE')}
- {format(currentDate, 'd')} + {isToday(currentDate) ? ( + + {format(currentDate, 'd')} + + ) : ( + format(currentDate, 'd') + )}
-
+
{format(currentDate, 'MMMM yyyy')}
{/* All day events */} -
-
+
+
All day
-
+
{events .filter((event) => { const eventDay = format(event.start, 'yyyy-MM-dd'); @@ -101,7 +139,6 @@ const CalendarDayView: React.FC = ({ currentDate, 'yyyy-MM-dd' ); - // Check if it's an all-day event (spans 24 hours or more) const duration = event.end.getTime() - event.start.getTime(); return ( @@ -113,17 +150,17 @@ const CalendarDayView: React.FC = ({
handleEventClick(event, e)} - className={`text-xs p-2 rounded text-white cursor-pointer hover:opacity-80 transition-opacity ${ + className={`text-sm px-3 py-2 rounded-lg text-white cursor-pointer hover:scale-[1.01] hover:shadow-md transition-all duration-200 font-medium ${ event.type === 'task' - ? 'border-l-2 border-l-white/50' + ? 'border-l-3 border-l-white/60' : '' }`} style={{ backgroundColor: event.color || '#3b82f6', + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', }} - title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`} + title={event.title} > - {event.type === 'task' && '📋 '} {event.title}
))} @@ -138,11 +175,11 @@ const CalendarDayView: React.FC = ({ return (
{/* Time column */} -
+
{format( addHours( new Date().setHours(hour, 0, 0, 0), @@ -155,41 +192,71 @@ const CalendarDayView: React.FC = ({ {/* Event area */}
handleTimeSlotClick(hour)} - className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative" + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(hour, e)} + className="flex-1 min-h-[60px] cursor-pointer hover:bg-blue-50/40 dark:hover:bg-blue-900/10 relative transition-colors duration-150 bg-white dark:bg-gray-900" > - {timeSlotEvents.map((event) => ( -
- handleEventClick(event, e) - } - className={`absolute left-1 right-1 text-xs p-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity z-10 ${ - event.type === 'task' - ? 'border-l-2 border-l-white/50' - : '' - }`} - style={{ - backgroundColor: - event.color || '#3b82f6', - top: calculateEventPosition( - event - ), - height: calculateEventHeight( - event - ), - }} - title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`} - > -
- {event.type === 'task' && '📋 '} - {event.title} + {timeSlotEvents.map((event, index) => { + const eventCount = + timeSlotEvents.length; + const widthPercentage = + eventCount > 1 + ? 100 / eventCount - 2 + : 100; + const leftOffset = + eventCount > 1 + ? (100 / eventCount) * index + : 0; + + return ( +
+ handleDragStart(event, e) + } + onDragEnd={handleDragEnd} + onClick={(e) => + handleEventClick(event, e) + } + className={`absolute text-sm px-3 py-2 rounded-lg text-white transition-all duration-200 z-10 font-medium ${ + event.type === 'task' + ? 'border-l-3 border-l-white/60 cursor-move hover:scale-[1.01] hover:shadow-lg' + : 'cursor-pointer' + } ${draggedEventId === event.id ? 'opacity-50' : ''}`} + style={{ + backgroundColor: + event.color || + '#3b82f6', + top: calculateEventPosition( + event + ), + height: calculateEventHeight( + event + ), + left: `calc(0.5rem + ${leftOffset}%)`, + width: `${widthPercentage}%`, + boxShadow: + '0 3px 6px rgba(0,0,0,0.15)', + }} + title={`${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`} + > +
+ {event.title} +
+
+ {format( + event.start, + 'HH:mm' + )}{' '} + -{' '} + {format(event.end, 'HH:mm')} +
-
- {format(event.start, 'HH:mm')} -{' '} - {format(event.end, 'HH:mm')} -
-
- ))} + ); + })}
diff --git a/frontend/components/Calendar/CalendarMonthView.tsx b/frontend/components/Calendar/CalendarMonthView.tsx index 6ba7498..a1ab2a3 100644 --- a/frontend/components/Calendar/CalendarMonthView.tsx +++ b/frontend/components/Calendar/CalendarMonthView.tsx @@ -29,6 +29,7 @@ interface CalendarMonthViewProps { events: CalendarEvent[]; onDateClick?: (date: Date) => void; onEventClick?: (event: CalendarEvent) => void; + onEventDrop?: (eventId: string, newDate: Date) => void; } const CalendarMonthView: React.FC = ({ @@ -36,9 +37,11 @@ const CalendarMonthView: React.FC = ({ events, onDateClick, onEventClick, + onEventDrop, }) => { const { t } = useTranslation(); const [firstDayOfWeek, setFirstDayOfWeek] = useState(1); // Default to Monday + const [draggedEventId, setDraggedEventId] = useState(null); // Load first day of week setting useEffect(() => { @@ -104,14 +107,41 @@ const CalendarMonthView: React.FC = ({ } }; + const handleDragStart = (event: CalendarEvent, e: React.DragEvent) => { + e.stopPropagation(); + setDraggedEventId(event.id); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', event.id); + }; + + const handleDragEnd = () => { + setDraggedEventId(null); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (day: Date, e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const eventId = e.dataTransfer.getData('text/plain'); + if (eventId && onEventDrop) { + onEventDrop(eventId, day); + } + setDraggedEventId(null); + }; + return ( -
+
{/* Week days header */} -
+
{weekDays.map((day) => (
{day}
@@ -119,7 +149,7 @@ const CalendarMonthView: React.FC = ({
{/* Calendar grid */} -
+
{days.map((day) => { const dayEvents = events.filter( (event) => @@ -134,52 +164,65 @@ const CalendarMonthView: React.FC = ({
handleDateClick(day)} - className={`p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 flex flex-col ${ + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(day, e)} + className={`p-2.5 border-b border-gray-200 dark:border-gray-700 cursor-pointer transition-all duration-200 flex flex-col min-h-[100px] ${ !isCurrentMonth - ? 'bg-gray-50 dark:bg-gray-800' - : 'bg-white dark:bg-gray-900' - } ${isTodayDate ? 'bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600' : ''}`} + ? 'bg-gray-100/50 dark:bg-gray-800/30' + : 'bg-white dark:bg-gray-900 hover:bg-blue-50/50 dark:hover:bg-blue-900/10' + } ${isTodayDate ? 'bg-gradient-to-br from-blue-50 to-blue-100/50 dark:from-blue-900/30 dark:to-blue-800/20 ring-2 ring-inset ring-blue-400 dark:ring-blue-500' : ''}`} > -
- {isTodayDate && ( - +
+ {isTodayDate ? ( + + {format(day, 'd')} + + ) : ( + {format(day, 'd')} )} - {!isTodayDate && format(day, 'd')}
{/* Events */} -
+
{dayEvents.slice(0, 3).map((event) => (
+ handleDragStart(event, e) + } + onDragEnd={handleDragEnd} onClick={(e) => handleEventClick(event, e) } - className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity ${ + className={`text-xs px-2 py-1.5 rounded-md text-white truncate transition-all duration-200 font-medium ${ event.type === 'task' - ? 'border-l-2 border-l-white/50' - : '' - }`} + ? 'border-l-3 border-l-white/60 cursor-move hover:scale-[1.02] hover:shadow-md' + : 'cursor-pointer' + } ${draggedEventId === event.id ? 'opacity-50' : ''}`} style={{ backgroundColor: event.color || '#3b82f6', + boxShadow: + '0 1px 3px rgba(0,0,0,0.1)', }} - title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`} + title={event.title} > - {event.type === 'task' && '📋 '} - {event.title} + + {event.title} +
))} {dayEvents.length > 3 && ( -
+
+{dayEvents.length - 3} more
)} diff --git a/frontend/components/Calendar/CalendarWeekView.tsx b/frontend/components/Calendar/CalendarWeekView.tsx index 51f9c5e..0d5511c 100644 --- a/frontend/components/Calendar/CalendarWeekView.tsx +++ b/frontend/components/Calendar/CalendarWeekView.tsx @@ -27,6 +27,7 @@ interface CalendarWeekViewProps { onDateClick?: (date: Date) => void; onEventClick?: (event: CalendarEvent) => void; onTimeSlotClick?: (date: Date, hour: number) => void; + onEventDrop?: (eventId: string, newDate: Date, newHour: number) => void; } const CalendarWeekView: React.FC = ({ @@ -34,8 +35,10 @@ const CalendarWeekView: React.FC = ({ events, onEventClick, onTimeSlotClick, + onEventDrop, }) => { const [firstDayOfWeek, setFirstDayOfWeek] = useState(1); // Default to Monday + const [draggedEventId, setDraggedEventId] = useState(null); // Load first day of week setting useEffect(() => { @@ -86,25 +89,54 @@ const CalendarWeekView: React.FC = ({ } }; + const handleDragStart = (event: CalendarEvent, e: React.DragEvent) => { + e.stopPropagation(); + setDraggedEventId(event.id); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', event.id); + }; + + const handleDragEnd = () => { + setDraggedEventId(null); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDrop = (day: Date, hour: number, e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const eventId = e.dataTransfer.getData('text/plain'); + if (eventId && onEventDrop) { + onEventDrop(eventId, day, hour); + } + setDraggedEventId(null); + }; + return ( -
+
{/* Header with days */} -
-
+
+
Time
{weekDays.map((day) => (
{format(day, 'EEE')} @@ -113,11 +145,11 @@ const CalendarWeekView: React.FC = ({ className={`text-lg ${ isToday(day) ? 'text-blue-600 dark:text-blue-400 font-bold' - : 'text-gray-600 dark:text-gray-400' + : 'text-gray-700 dark:text-gray-300' }`} > {isToday(day) ? ( - + {format(day, 'd')} ) : ( @@ -133,10 +165,10 @@ const CalendarWeekView: React.FC = ({ {hours.map((hour) => (
{/* Time column */} -
+
{format( addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm' @@ -149,6 +181,7 @@ const CalendarWeekView: React.FC = ({ day, hour ); + const eventCount = timeSlotEvents.length; return (
= ({ onClick={() => handleTimeSlotClick(day, hour) } - className={`h-12 p-1 border-l border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative ${ + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(day, hour, e)} + className={`min-h-[80px] p-2 border-l border-gray-200 dark:border-gray-700 cursor-pointer transition-all duration-150 hover:bg-blue-50/40 dark:hover:bg-blue-900/10 relative ${ isToday(day) - ? 'bg-blue-50/30 dark:bg-blue-900/10' - : '' + ? 'bg-blue-50/20 dark:bg-blue-900/5' + : 'bg-white dark:bg-gray-900' }`} > - {timeSlotEvents.map((event) => ( -
- handleEventClick(event, e) - } - className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity absolute inset-1 ${ - event.type === 'task' - ? 'border-l-2 border-l-white/50' - : '' - }`} - style={{ - backgroundColor: - event.color || '#3b82f6', - }} - title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`} - > - {event.type === 'task' && '📋 '} - {event.title} -
- ))} + {timeSlotEvents.map((event, index) => { + const widthPercentage = + eventCount > 1 + ? 100 / eventCount - 1 + : 100; + const leftPercentage = + eventCount > 1 + ? (100 / eventCount) * index + : 0; + + return ( +
+ handleDragStart(event, e) + } + onDragEnd={handleDragEnd} + onClick={(e) => + handleEventClick(event, e) + } + className={`text-sm px-2 py-2 rounded-lg text-white transition-all duration-200 absolute font-medium overflow-hidden ${ + event.type === 'task' + ? 'border-l-3 border-l-white/60 cursor-move hover:scale-[1.02] hover:shadow-lg' + : 'cursor-pointer' + } ${draggedEventId === event.id ? 'opacity-50' : ''}`} + style={{ + backgroundColor: + event.color || + '#3b82f6', + boxShadow: + '0 2px 4px rgba(0,0,0,0.15)', + left: `${leftPercentage}%`, + width: `${widthPercentage}%`, + top: '0.5rem', + bottom: '0.5rem', + }} + title={`${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`} + > +
+
+ {event.title} +
+
+ {format( + event.start, + 'HH:mm' + )} +
+
+
+ ); + })}
); })} diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index effde41..f00558a 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -13,6 +13,7 @@ import UniversalSearch from './UniversalSearch/UniversalSearch'; import NotificationsDropdown from './Notifications/NotificationsDropdown'; import { getApiPath } from '../config/paths'; import { getFeatureFlags, FeatureFlags } from '../utils/featureFlags'; +import { setUserTimezone } from '../utils/dateUtils'; interface NavbarProps { isDarkMode: boolean; @@ -99,6 +100,10 @@ const Navbar: React.FC = ({ ? profile.pomodoro_enabled : true ); + // Set user timezone for date formatting + if (profile.timezone) { + setUserTimezone(profile.timezone); + } } } catch (error) { console.error('Error fetching profile:', error); diff --git a/frontend/components/Profile/ProfileSettings.tsx b/frontend/components/Profile/ProfileSettings.tsx index 4c8bd57..1e6b273 100644 --- a/frontend/components/Profile/ProfileSettings.tsx +++ b/frontend/components/Profile/ProfileSettings.tsx @@ -6,6 +6,7 @@ import React, { useCallback, } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate, useLocation } from 'react-router-dom'; import { getLocalesPath, getApiPath } from '../../config/paths'; import { UserIcon, @@ -25,6 +26,7 @@ import { getTimezonesByRegion, getRegionDisplayName, } from '../../utils/timezoneUtils'; +import { setUserTimezone } from '../../utils/dateUtils'; import type { ApiKeySummary } from '../../utils/apiKeysService'; import { fetchApiKeys, @@ -67,8 +69,26 @@ const ProfileSettings: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const { showSuccessToast, showErrorToast } = useToast(); + const navigate = useNavigate(); + const location = useLocation(); - const [activeTab, setActiveTab] = useState('general'); + // Get initial tab from URL query parameter (e.g., /profile?section=notifications) + const getTabFromQuery = useCallback(() => { + const params = new URLSearchParams(location.search); + const section = params.get('section'); + const validTabs = [ + 'general', + 'security', + 'api-keys', + 'productivity', + 'telegram', + 'ai', + 'notifications', + ]; + return section && validTabs.includes(section) ? section : 'general'; + }, [location.search]); + + const [activeTab, setActiveTab] = useState(() => getTabFromQuery()); const timezonesByRegion = React.useMemo(() => { return getTimezonesByRegion(); }, []); @@ -124,6 +144,50 @@ const ProfileSettings: React.FC = ({ const [deleteInFlightId, setDeleteInFlightId] = useState( null ); + // Update URL query parameter when tab changes (not on mount) + const isInitialMount = React.useRef(true); + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + const params = new URLSearchParams(location.search); + const currentSection = params.get('section'); + + let shouldNavigate = false; + let newUrl = ''; + + if (activeTab === 'general') { + // Remove section param for general tab + if (currentSection) { + params.delete('section'); + const newSearch = params.toString(); + newUrl = `/profile${newSearch ? `?${newSearch}` : ''}`; + shouldNavigate = true; + } + } else { + // Set section param for other tabs + if (currentSection !== activeTab) { + params.set('section', activeTab); + newUrl = `/profile?${params.toString()}`; + shouldNavigate = true; + } + } + + if (shouldNavigate) { + navigate(newUrl, { replace: true }); + } + }, [activeTab, navigate]); + + // Update tab when URL query parameter changes (e.g., browser back/forward) + useEffect(() => { + const tabFromQuery = getTabFromQuery(); + if (tabFromQuery !== activeTab) { + setActiveTab(tabFromQuery); + } + }, [getTabFromQuery]); + const [apiKeyToDelete, setApiKeyToDelete] = useState( null ); @@ -891,6 +955,11 @@ const ProfileSettings: React.FC = ({ // Avatar removal is now handled immediately by handleAvatarRemove // No need to handle it here anymore + // Update timezone for date formatting + if (updatedProfile.timezone) { + setUserTimezone(updatedProfile.timezone); + } + setProfile(updatedProfile); setFormData((prev) => ({ @@ -1022,7 +1091,7 @@ const ProfileSettings: React.FC = ({ icon: , }, { - id: 'apiKeys', + id: 'api-keys', name: t('profile.tabs.apiKeys', 'API Keys'), icon: , }, @@ -1051,17 +1120,17 @@ const ProfileSettings: React.FC = ({ return ( <>
-

+

{t('profile.title')}

-
+
{/* Left Sidebar */} -