Add context menu to calendar view for task management
Integrates context menu functionality into the CalendarView component, allowing task management via right-click (desktop) or long-press (mobile), mirroring the existing task list menu options. Includes handlers for task update, edit, delete, and timer start/stop. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/7gWYrTe
This commit is contained in:
@@ -14,10 +14,6 @@ run = ["npm", "run", "start"]
|
|||||||
localPort = 5000
|
localPort = 5000
|
||||||
externalPort = 80
|
externalPort = 80
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 33679
|
|
||||||
externalPort = 4200
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 35345
|
localPort = 35345
|
||||||
externalPort = 3002
|
externalPort = 3002
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ function App() {
|
|||||||
onTaskDrop={handleTaskDrop}
|
onTaskDrop={handleTaskDrop}
|
||||||
onTaskClick={handleTaskClick}
|
onTaskClick={handleTaskClick}
|
||||||
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
|
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
|
||||||
|
onTaskUpdate={handleTaskUpdate}
|
||||||
|
onTaskEdit={handleTaskClick}
|
||||||
|
onTaskDelete={handleTaskDelete}
|
||||||
|
onStartTimer={startTimer}
|
||||||
|
onStopTimer={stopTimer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,48 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Calendar, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
|
||||||
import { Task, Label } from '@shared/schema';
|
import { Task, Label } from '@shared/schema';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
|
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
|
||||||
import { de, enUS } from 'date-fns/locale';
|
import { de, enUS } from 'date-fns/locale';
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
interface CalendarViewProps {
|
interface CalendarViewProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onTaskDrop?: (taskId: string, newDate: Date) => void;
|
onTaskDrop?: (taskId: string, newDate: Date) => void;
|
||||||
onTaskClick?: (task: Task) => void;
|
onTaskClick?: (task: Task) => void;
|
||||||
onDateSelect?: (date: Date) => void;
|
onDateSelect?: (date: Date) => void;
|
||||||
|
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
||||||
|
onTaskEdit?: (task: Task) => void;
|
||||||
|
onTaskDelete?: (taskId: string) => void;
|
||||||
|
onStartTimer?: (taskId: string) => void;
|
||||||
|
onStopTimer?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSelect }: CalendarViewProps) {
|
export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSelect, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: CalendarViewProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||||
|
const [longPressTask, setLongPressTask] = useState<Task | null>(null);
|
||||||
|
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const longPressTriggered = useRef(false);
|
||||||
|
|
||||||
// Get date-fns locale based on current language
|
// Get date-fns locale based on current language
|
||||||
const dateLocale = i18n.language === 'de' ? de : enUS;
|
const dateLocale = i18n.language === 'de' ? de : enUS;
|
||||||
@@ -69,6 +92,158 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
|||||||
console.log(`Navigating to week of ${newDate.toLocaleDateString()}`);
|
console.log(`Navigating to week of ${newDate.toLocaleDateString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Long press handlers for mobile
|
||||||
|
const handleTouchStart = (task: Task) => {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
longPressTriggered.current = false;
|
||||||
|
|
||||||
|
longPressTimerRef.current = setTimeout(() => {
|
||||||
|
longPressTriggered.current = true;
|
||||||
|
setLongPressTask(task);
|
||||||
|
setDropdownOpen(true);
|
||||||
|
console.log('Long press detected for task:', task.title);
|
||||||
|
|
||||||
|
// Reset after a delay to allow next click
|
||||||
|
setTimeout(() => {
|
||||||
|
longPressTriggered.current = false;
|
||||||
|
}, 300);
|
||||||
|
}, 500); // 500ms long press threshold
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (longPressTimerRef.current) {
|
||||||
|
clearTimeout(longPressTimerRef.current);
|
||||||
|
longPressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskClick = (task: Task, e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
// Prevent click when dragging
|
||||||
|
if (draggedTask === task.id) return;
|
||||||
|
|
||||||
|
// Skip click if long press was just triggered
|
||||||
|
if (longPressTriggered.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskClick?.(task);
|
||||||
|
console.log('Calendar task clicked:', task.title);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Menu content component to avoid duplication
|
||||||
|
const TaskMenuContent = ({ task, closeMenu }: { task: Task; closeMenu?: () => void }) => (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskEdit?.(task);
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
data-testid={`context-menu-edit-${task.id}`}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.editTask')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (task.isTracking) {
|
||||||
|
onStopTimer?.(task.id);
|
||||||
|
} else {
|
||||||
|
onStartTimer?.(task.id);
|
||||||
|
}
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
data-testid={`context-menu-timer-${task.id}`}
|
||||||
|
>
|
||||||
|
<Timer className="w-4 h-4 mr-2" />
|
||||||
|
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
{task.status !== 'done' && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(task.id, { status: 'done' });
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
data-testid={`context-menu-complete-${task.id}`}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.markAsDone')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.status === 'todo' && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(task.id, { status: 'inProgress' });
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
data-testid={`context-menu-start-${task.id}`}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.startWorking')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.status === 'inProgress' && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(task.id, { status: 'todo' });
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
data-testid={`context-menu-todo-${task.id}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.moveToTodo')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.status === 'done' && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(task.id, { status: 'inProgress' });
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
data-testid={`context-menu-reopen-${task.id}`}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.reopenTask')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
{onTaskDelete && (
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskDelete(task.id);
|
||||||
|
closeMenu?.();
|
||||||
|
}}
|
||||||
|
className="text-destructive"
|
||||||
|
data-testid={`context-menu-delete-${task.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.deleteTask')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -150,35 +325,38 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
|||||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ContextMenu key={task.id}>
|
||||||
key={task.id}
|
<ContextMenuTrigger asChild>
|
||||||
draggable
|
<div
|
||||||
onDragStart={() => handleDragStart(task.id)}
|
draggable
|
||||||
onDragEnd={handleDragEnd}
|
onDragStart={() => handleDragStart(task.id)}
|
||||||
onClick={(e) => {
|
onDragEnd={handleDragEnd}
|
||||||
// Prevent click when dragging
|
onTouchStart={() => handleTouchStart(task)}
|
||||||
if (draggedTask === task.id) return;
|
onTouchEnd={handleTouchEnd}
|
||||||
e.stopPropagation();
|
onTouchMove={handleTouchEnd}
|
||||||
onTaskClick?.(task);
|
onClick={(e) => handleTaskClick(task, e)}
|
||||||
console.log('Calendar task clicked:', task.title);
|
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||||
}}
|
data-testid={`calendar-task-${task.id}`}
|
||||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
>
|
||||||
data-testid={`calendar-task-${task.id}`}
|
<Badge
|
||||||
>
|
variant="outline"
|
||||||
<Badge
|
className="w-full justify-start text-xs p-1 h-auto pointer-events-none"
|
||||||
variant="outline"
|
style={taskLabel ? {
|
||||||
className="w-full justify-start text-xs p-1 h-auto pointer-events-none"
|
borderColor: taskLabel.color,
|
||||||
style={taskLabel ? {
|
borderWidth: '2px',
|
||||||
borderColor: taskLabel.color,
|
borderStyle: 'solid'
|
||||||
borderWidth: '2px',
|
} : {}}
|
||||||
borderStyle: 'solid'
|
>
|
||||||
} : {}}
|
<div className="truncate flex-1 text-left">
|
||||||
>
|
{task.title}
|
||||||
<div className="truncate flex-1 text-left">
|
</div>
|
||||||
{task.title}
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</Badge>
|
</ContextMenuTrigger>
|
||||||
</div>
|
<ContextMenuContent>
|
||||||
|
<TaskMenuContent task={task} />
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
@@ -208,6 +386,125 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
|||||||
<span>{t('calendar.previousWeek')}</span>
|
<span>{t('calendar.previousWeek')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile long-press fallback menu */}
|
||||||
|
{longPressTask && (
|
||||||
|
<DropdownMenu
|
||||||
|
open={dropdownOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDropdownOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setLongPressTask(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger className="hidden" />
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskEdit?.(longPressTask);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`dropdown-menu-edit-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.editTask')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (longPressTask.isTracking) {
|
||||||
|
onStopTimer?.(longPressTask.id);
|
||||||
|
} else {
|
||||||
|
onStartTimer?.(longPressTask.id);
|
||||||
|
}
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`dropdown-menu-timer-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<Timer className="w-4 h-4 mr-2" />
|
||||||
|
{longPressTask.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{longPressTask.status !== 'done' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(longPressTask.id, { status: 'done' });
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`dropdown-menu-complete-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.markAsDone')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{longPressTask.status === 'todo' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`dropdown-menu-start-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.startWorking')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{longPressTask.status === 'inProgress' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(longPressTask.id, { status: 'todo' });
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`dropdown-menu-todo-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.moveToTodo')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{longPressTask.status === 'done' && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
data-testid={`dropdown-menu-reopen-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.reopenTask')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{onTaskDelete && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTaskDelete(longPressTask.id);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-destructive"
|
||||||
|
data-testid={`dropdown-menu-delete-${longPressTask.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
{t('taskCard.deleteTask')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user