Files
task-manager/client/src/components/TaskCard.tsx
T
NotiBot 8e8b96fcde
continuous-integration/drone/push Build is passing
Horizontalen Scroll verhindern: Lange Task-Titel werden überall abgeschnitten
- Globales overflow-x: hidden auf html/body/#root in index.css
- App.tsx: overflow-x-hidden und min-w-0 auf Hauptcontainer
- TaskCard: Eltern-Task-Titel mit truncate/min-w-0 im Flex-Layout
- KanbanBoard: min-w-0 overflow-hidden auf Spalten-Cards
- FiveMinuteStarter: truncate auf Task-Titel
- HyperfocusGuard: truncate auf Task-Buttons
- TaskBreakdownModal: truncate auf Task- und Subtask-Titel
- NotificationsPage: truncate + min-w-0 auf Benachrichtigungs-Titel
2026-02-03 14:59:40 +01:00

705 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock, CornerDownRight, Share2, Users } from "lucide-react";
import { Task, Label, User } from '@shared/schema';
import { ShareTaskModal } from './ShareTaskModal';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
import { motion, PanInfo, useAnimation } from 'framer-motion';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { triggerConfetti } from "@/lib/confetti";
import { playSuccessSound } from "@/lib/sounds";
import { simulateAIDecomposition } from "@/lib/ai-simulator";
import { Wand2, Loader2 } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
interface TaskCardProps {
task: Task;
onStartTimer?: () => void;
onStopTimer?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onStatusChange?: (status: Task['status']) => void;
onUpdate?: (updates: Partial<Task>) => void;
onAutoSchedule?: () => void;
isDragging?: boolean;
}
function SharedTaskIcon({ task }: { task: Task }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
const isOwner = user?.id === task.userId;
// If owner, fetch shared users
const { data: sharedUsers } = useQuery<any[]>({
queryKey: [`/api/tasks/${task.id}/shared-users`],
enabled: !!isOwner && !!task.id,
retry: false
});
if (!user) return null;
// Case 1: Owner and task is shared
if (isOwner && sharedUsers && sharedUsers.length > 0) {
const names = sharedUsers.map(u => u.username).join(", ");
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900 cursor-help">
<Share2 className="w-3 h-3 text-indigo-600 dark:text-indigo-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWith')}: {names}</p>
</TooltipContent>
</Tooltip>
);
}
// Case 2: Not owner (Shared WITH me)
if (!isOwner && task.userId) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 dark:bg-orange-900 cursor-help">
<Users className="w-3 h-3 text-orange-600 dark:text-orange-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWithMe') || "Shared with me"}</p>
</TooltipContent>
</Tooltip>
);
}
return null;
}
function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
// Only owner can share
if (user?.id !== task.userId) return null;
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Share2 className="w-4 h-4 mr-2" />
{t('share.taskTitle')}
</DropdownMenuItem>
);
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const handleAIMagic = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isAnalyzing) return;
setIsAnalyzing(true);
try {
const aiContent = await simulateAIDecomposition(task.title);
const newDescription = (task.description || '') + aiContent;
onUpdate?.({ description: newDescription });
triggerConfetti(0.5, 0.5); // Small burst for "Magic"
playSuccessSound();
} catch (err) {
console.error(err);
} finally {
setIsAnalyzing(false);
}
};
const { data: allTasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
staleTime: 5 * 60 * 1000,
});
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
const isBlocked = blockingTasks.length > 0;
const subtasks = allTasks.filter(t => t.parentTaskId === task.id);
const completedSubtasks = subtasks.filter(t => t.status === 'done');
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
});
// Find the label for this task
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
const handleToggleTimer = () => {
if (task.isTracking) {
onStopTimer?.();
console.log(`Timer stopped for task: ${task.title}`);
} else {
onStartTimer?.();
console.log(`Timer started for task: ${task.title}`);
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'bg-destructive text-destructive-foreground';
case 'medium': return 'bg-yellow-500 text-white';
default: return 'bg-muted text-muted-foreground';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'done': return 'bg-green-500 text-white';
case 'inProgress': return 'bg-primary text-primary-foreground';
default: return 'bg-muted text-muted-foreground';
}
};
const formatTime = (minutes: number) => {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
};
/* AI Follow-up Check */
const checkFollowUp = async () => {
// Fire and forget - don't block UI
try {
const res = await apiRequest("POST", "/api/ai/analyze-completion", { taskId: task.id });
if (res.ok) {
const data = await res.json();
if (data.needed && data.title) {
toast({
title: t('ai.followUpSuggestion', 'Follow-up Suggested'),
description: `${data.title} - ${data.description || ''}`,
action: (
<Button
variant="outline"
size="sm"
onClick={async (e) => {
e.stopPropagation(); // prevent toast click from doing generic things
// Create the task immediately
try {
await apiRequest("POST", "/api/tasks", {
title: data.title,
description: data.description,
status: "todo",
priority: "medium",
labelId: task.labelId, // Inherit label
dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // Tomorrow
});
toast({ title: t('ai.taskCreated', 'Follow-up task created!') });
// Invalidate queries to show new task
// queryClient.invalidateQueries... (Need queryClient access)
// Since we don't have queryClient here easily without import useQueryClient
// We can just reload or rely on auto-refetch
window.location.reload(); // Crude but effective for now, or useQueryClient
} catch (err) {
toast({ title: t('ai.errorCreating', 'Failed to create task'), variant: "destructive" });
}
}}
>
{t('common.create', 'Create')}
</Button>
),
duration: 8000,
});
}
}
} catch (e) {
console.error("Follow-up check failed", e);
}
};
const handleToggleComplete = (e: React.MouseEvent) => {
e.stopPropagation();
if (isBlocked) return;
if (task.status === 'done') {
onStatusChange?.('todo');
} else {
triggerConfetti(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
playSuccessSound();
onStatusChange?.('done');
// Trigger AI check
checkFollowUp();
}
};
const controls = useAnimation();
const handleDragEnd = async (event: any, info: PanInfo) => {
if (info.offset.x > 100) {
if (isBlocked) {
controls.start({ x: 0 });
return;
}
// Swiped right -> Complete
triggerConfetti(0.5, 0.5);
playSuccessSound();
onStatusChange?.('done');
await controls.start({ x: 500, opacity: 0 });
} else if (info.offset.x < -100 && onDelete) {
// Swiped left -> Delete (optional, maybe just shake for now)
onDelete();
} else {
controls.start({ x: 0 });
}
};
/* Retrieve parent task if this is a subtask */
const parentTask = task.parentTaskId
? allTasks.find(t => String(t.id) === String(task.parentTaskId))
: null;
return (
<motion.div
drag="x"
dragConstraints={{ left: -50, right: 150 }} // Limit swipe distance
dragElastic={0.1}
onDragEnd={handleDragEnd}
animate={controls}
whileDrag={{ scale: 1.02, cursor: 'grabbing' }}
className="touch-pan-y" // Allow vertical scroll, horizontal swipe
>
<ContextMenu>
<ContextMenuTrigger asChild>
<Card
className={`p-5 hover-elevate active-elevate-2 transition-all duration-300 relative hover:-translate-y-1 hover:shadow-xl ${isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
} ${task.status === 'done' ? 'opacity-60' : ''} ${parentTask ? 'border-l-4 border-l-indigo-400 bg-indigo-50/10' : ''}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid',
borderLeftWidth: parentTask ? '4px' : '2px', // Make left border thicker if subtask
borderLeftColor: parentTask ? '#818cf8' : taskLabel.color // Indigo for subtask
} : {}}
data-testid={`card-task-${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"
disabled={isBlocked}
/>
</TooltipTrigger>
<TooltipContent>
{isBlocked ? (
<p className="text-destructive font-medium">
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
</p>
) : (
<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}`);
}}
>
{/* Parent Task Link rendered in title now */}
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
{parentTask ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-muted-foreground font-normal truncate shrink">{parentTask.title}</span>
<span className="text-muted-foreground shrink-0"></span>
<span className="truncate shrink">{task.title}</span>
</span>
) : (
task.title
)}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3 flex-wrap">
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
data-testid={`badge-priority-${task.id}`}
>
{t(`priority.${task.priority}`)}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getStatusColor(task.status)}`}
data-testid={`badge-status-${task.id}`}
>
{t(`status.${task.status}`)}
</Badge>
{/* Shared Status Icon */}
<SharedTaskIcon task={task} />
{(task.dueDate || task.startDate) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span data-testid={`text-due-date-${task.id}`}>
{task.startDate ? `${new Date(task.startDate).toLocaleDateString()} - ` : ''}
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ''}
</span>
</div>
)}
{task.energyLevel && task.energyLevel !== 'medium' && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t(`energy.${task.energyLevel}`)}
</Badge>
)}
{task.estimatedDuration && (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
{formatTime(task.estimatedDuration)}
</Badge>
)}
{subtasks.length > 0 && (
<Badge variant="secondary" className="text-xs">
{completedSubtasks.length}/{subtasks.length} Subtasks
</Badge>
)}
{isBlocked && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs border-destructive text-destructive gap-1 animate-pulse">
<Lock className="w-3 h-3" />
Blocked
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Waiting for: {blockingTasks.map(t => t?.title).join(", ")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
<div className="flex items-center gap-1 mt-2 text-xs">
<Clock className={`w-3 h-3 ${task.isTracking ? 'text-primary animate-pulse' : 'text-muted-foreground'}`} />
<span
data-testid={`text-time-tracked-${task.id}`}
className={task.isTracking ? 'text-primary font-medium' : 'text-muted-foreground'}
>
{task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'}
{task.isTracking && ` (${t('taskCard.running')})`}
</span>
</div>
)}
</div>
<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
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
data-testid={`button-menu-${task.id}`}
className="w-6 h-6"
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
console.log(`Edit task: ${task.title}`);
}}
data-testid={`menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
{onAutoSchedule && !task.dueDate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onAutoSchedule();
}}
className="text-indigo-600 dark:text-indigo-400"
data-testid={`menu-schedule-${task.id}`}
>
<Wand2 className="w-4 h-4 mr-2" />
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
{/* Share Option - Check if owner */}
<SharedMenuItem task={task} onShare={() => setIsShareModalOpen(true)} />
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (isBlocked) return;
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
disabled={isBlocked}
data-testid={`menu-complete-${task.id}`}
>
{isBlocked ? <Lock className="w-4 h-4 mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
{t('taskCard.markAsDone')}
</DropdownMenuItem>
{task.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Start working on: ${task.title}`);
}}
data-testid={`menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{task.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
console.log(`Move back to todo: ${task.title}`);
}}
data-testid={`menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{task.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Reopen task: ${task.title}`);
}}
data-testid={`menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
console.log(`Delete task: ${task.title}`);
}}
className="text-destructive"
data-testid={`menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
data-testid={`context-menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
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();
onStatusChange?.('done');
}}
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();
onStatusChange?.('inProgress');
}}
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();
onStatusChange?.('todo');
}}
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();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive"
data-testid={`context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
<ShareTaskModal
taskId={task.id}
open={isShareModalOpen}
onOpenChange={setIsShareModalOpen}
/>
</motion.div>
);
}