Compare commits
5 Commits
b21d13d580
...
64114cfb9c
| Author | SHA1 | Date | |
|---|---|---|---|
| 64114cfb9c | |||
| 4a5a59fb34 | |||
| 82ffb73d41 | |||
| 7870d0b31e | |||
| 037256401f |
@@ -304,6 +304,11 @@ function App() {
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskClick={handleTaskClick}
|
||||
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 { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { useQuery } from '@tanstack/react-query';
|
||||
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
|
||||
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 {
|
||||
tasks: Task[];
|
||||
onTaskDrop?: (taskId: string, newDate: Date) => void;
|
||||
onTaskClick?: (task: Task) => 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 [currentDate, setCurrentDate] = useState(new Date());
|
||||
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
|
||||
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()}`);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
{/* 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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
// Prevent click when dragging
|
||||
if (draggedTask === task.id) return;
|
||||
e.stopPropagation();
|
||||
onTaskClick?.(task);
|
||||
console.log('Calendar task clicked:', task.title);
|
||||
}}
|
||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto pointer-events-none"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate flex-1 text-left">
|
||||
{task.title}
|
||||
<ContextMenu key={task.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onTouchStart={() => handleTouchStart(task)}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
onClick={(e) => handleTaskClick(task, e)}
|
||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto pointer-events-none"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate flex-1 text-left">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<TaskMenuContent task={task} />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -208,6 +386,125 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
||||
<span>{t('calendar.previousWeek')}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle } from "lucide-react";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X } from "lucide-react";
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
@@ -74,25 +80,51 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
return `${hours}h ${mins}m`;
|
||||
};
|
||||
|
||||
const handleToggleComplete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (task.status === 'done') {
|
||||
onStatusChange?.('todo');
|
||||
} else {
|
||||
onStatusChange?.('done');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-4 hover-elevate active-elevate-2 transition-all duration-200 relative cursor-pointer ${
|
||||
className={`p-4 hover-elevate active-elevate-2 transition-all duration-200 relative ${
|
||||
isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
|
||||
}`}
|
||||
} ${task.status === 'done' ? 'opacity-60' : ''}`}
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
data-testid={`card-task-${task.id}`}
|
||||
onClick={() => {
|
||||
onEdit?.();
|
||||
console.log(`Task card clicked: ${task.title}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm leading-tight truncate" data-testid={`text-task-title-${task.id}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Checkbox for quick completion */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Checkbox
|
||||
checked={task.status === 'done'}
|
||||
onClick={handleToggleComplete}
|
||||
data-testid={`checkbox-complete-${task.id}`}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('taskCard.toggleComplete')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
onEdit?.();
|
||||
console.log(`Task card clicked: ${task.title}`);
|
||||
}}
|
||||
>
|
||||
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
|
||||
{task.title}
|
||||
</h3>
|
||||
{task.description && (
|
||||
@@ -142,7 +174,30 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-1">
|
||||
{/* Quick delete button */}
|
||||
{onDelete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
data-testid={`button-delete-${task.id}`}
|
||||
className="w-6 h-6 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('taskCard.quickDelete')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -255,19 +310,6 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant={task.isTracking ? "default" : "outline"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleTimer();
|
||||
}}
|
||||
data-testid={`button-timer-${task.id}`}
|
||||
className="w-6 h-6"
|
||||
>
|
||||
{task.isTracking ? <Pause className="w-3 h-3" /> : <Play className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
"moveToTodo": "Zu erledigen verschieben",
|
||||
"reopenTask": "Aufgabe erneut öffnen",
|
||||
"deleteTask": "Aufgabe löschen",
|
||||
"quickDelete": "Schnell löschen",
|
||||
"toggleComplete": "Aufgabe als erledigt/unerledigt markieren",
|
||||
"running": "läuft",
|
||||
"overdue": "Überfällig",
|
||||
"timeTracked": "{{hours}}Std {{minutes}}Min erfasst"
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
"moveToTodo": "Move to Todo",
|
||||
"reopenTask": "Reopen Task",
|
||||
"deleteTask": "Delete Task",
|
||||
"quickDelete": "Quick delete",
|
||||
"toggleComplete": "Mark task as complete/incomplete",
|
||||
"running": "running",
|
||||
"overdue": "Overdue",
|
||||
"timeTracked": "{{hours}}h {{minutes}}m tracked"
|
||||
|
||||
Reference in New Issue
Block a user