Files
task-manager/client/src/components/TasksWithCalendar.tsx
T
paul-nothaft 4ea8a7c6a1 Add label functionality to tasks for better organization and tracking
Update TaskCard and TaskCreationModal components to support task labels, including UI elements for selection and display. Refactor schema imports to use '@shared/schema'.

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/vtpluZY
2025-09-11 12:36:38 +00:00

431 lines
16 KiB
TypeScript

import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
import { Task } from '@shared/schema';
import TaskCard from './TaskCard';
import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
interface TasksWithCalendarProps {
tasks: Task[];
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
onTaskEdit?: (task: Task) => void;
}
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: TasksWithCalendarProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [calendarStartDate, setCalendarStartDate] = useState(startOfToday());
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
const [completionModal, setCompletionModal] = useState<{ isOpen: boolean; task: Task | null }>({
isOpen: false,
task: null
});
// Get 7 days starting from today for calendar
const dates = Array.from({ length: 7 }, (_, i) => addDays(calendarStartDate, i));
const isOverdue = (task: Task) => {
if (!task.dueDate) return false;
return task.dueDate < new Date() && task.status !== 'done';
};
const filteredAndSortedTasks = tasks
.filter(task => {
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
return isOverdue(task);
case 'all':
return true;
default:
return task.status === filterBy;
}
})
.sort((a, b) => {
switch (sortBy) {
case 'dueDate':
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status] - statusOrder[b.status];
default:
return 0;
}
});
// Tasks that are NOT on the calendar (no due date or not scheduled)
const unscheduledTasks = filteredAndSortedTasks.filter(task => !task.dueDate);
const getTasksForDate = (date: Date) => {
return filteredAndSortedTasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
};
const getFilterCount = (filter: FilterOption) => {
switch (filter) {
case 'overdue':
return tasks.filter(isOverdue).length;
case 'all':
return tasks.length;
default:
return tasks.filter(task => task.status === filter).length;
}
};
const handleDragStart = (taskId: string, e: React.DragEvent) => {
setDraggedTask(taskId);
e.dataTransfer.effectAllowed = 'move';
console.log('Drag started for task:', taskId);
};
const handleDragEnd = () => {
setDraggedTask(null);
};
const handleDrop = (date: Date, e: React.DragEvent) => {
e.preventDefault();
if (draggedTask) {
onTaskUpdate?.(draggedTask, { dueDate: date });
console.log(`Task ${draggedTask} moved to`, date.toLocaleDateString());
setDraggedTask(null);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDragEnter = (date: Date) => {
if (draggedTask) {
setHoveredDate(date);
}
};
const handleDragLeave = (e: React.DragEvent) => {
// Only clear hover if we're leaving the calendar entirely
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setHoveredDate(null);
}
};
const handleMouseMove = (e: React.MouseEvent, date: Date) => {
if (draggedTask) {
setHoveredDate(date);
}
};
const navigateCalendar = (direction: 'prev' | 'next') => {
const newDate = addDays(calendarStartDate, direction === 'next' ? 7 : -7);
setCalendarStartDate(newDate);
console.log(`Navigating calendar to ${newDate.toLocaleDateString()}`);
};
const handleTaskComplete = (task: Task) => {
setCompletionModal({ isOpen: true, task });
};
const handleTimeLogged = (timeSpent: number) => {
if (completionModal.task) {
onTaskUpdate?.(completionModal.task.id, {
status: 'done',
timeTracked: (completionModal.task.timeTracked || 0) + timeSpent,
isTracking: false
});
}
setCompletionModal({ isOpen: false, task: null });
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold" data-testid="text-task-list-title">
My Tasks
</h2>
<Badge variant="secondary" data-testid="badge-task-count">
{filteredAndSortedTasks.length} tasks
</Badge>
</div>
{/* Search and Filters */}
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
console.log('Search query:', e.target.value);
}}
className="pl-10"
data-testid="input-search-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
console.log('Filter changed to:', value);
}}>
<SelectTrigger className="flex-1" data-testid="select-filter">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ({getFilterCount('all')})</SelectItem>
<SelectItem value="todo">To Do ({getFilterCount('todo')})</SelectItem>
<SelectItem value="inProgress">In Progress ({getFilterCount('inProgress')})</SelectItem>
<SelectItem value="done">Done ({getFilterCount('done')})</SelectItem>
<SelectItem value="overdue">Overdue ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
}}>
<SelectTrigger className="flex-1" data-testid="select-sort">
<div className="flex items-center gap-2">
<SortAsc className="w-4 h-4" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="dueDate">Due Date</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="title">Title</SelectItem>
<SelectItem value="status">Status</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Unscheduled Task List */}
<div className="space-y-3">
{unscheduledTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
{searchQuery || filterBy !== 'all'
? 'No unscheduled tasks match your filters'
: 'No unscheduled tasks. Drag tasks to the calendar below to schedule them!'
}
</p>
</div>
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
Unscheduled Tasks - Drag to calendar below to schedule
</div>
{unscheduledTasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={(e) => handleDragStart(task.id, e)}
onDragEnd={handleDragEnd}
className={`cursor-move transition-transform hover-elevate ${
draggedTask === task.id ? 'opacity-50 scale-95' : ''
}`}
>
<TaskCard
task={task}
onPlay={() => {
onTaskUpdate?.(task.id, { isTracking: true, timeTracked: task.timeTracked });
console.log(`Timer started for ${task.title}`);
}}
onPause={() => {
onTaskUpdate?.(task.id, { isTracking: false });
console.log(`Timer paused for ${task.title}`);
}}
onEdit={() => {
onTaskEdit?.(task);
console.log(`Edit task ${task.title}`);
}}
onStatusChange={(newStatus) => {
if (newStatus === 'done') {
handleTaskComplete(task);
} else {
onTaskUpdate?.(task.id, { status: newStatus });
}
console.log(`Task ${task.title} status changed to ${newStatus}`);
}}
isDragging={draggedTask === task.id}
/>
</div>
))}
</>
)}
</div>
{/* Calendar Section - Fixed at bottom with full width, above bottom nav */}
<div className="fixed bottom-24 left-0 right-0 bg-background z-[5]" style={{ borderTop: '1px solid rgb(229, 231, 235)' }}>
<div className="p-3 max-h-[32vh] overflow-y-auto">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold" data-testid="text-calendar-title">
Next 7 Days
</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => navigateCalendar('prev')}
data-testid="button-prev-week"
>
<ChevronLeft className="w-3 h-3" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => navigateCalendar('next')}
data-testid="button-next-week"
>
<ChevronRight className="w-3 h-3" />
</Button>
</div>
</div>
{/* Full Width Calendar Grid - Responsive */}
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{dates.map((date, index) => {
const dayTasks = getTasksForDate(date);
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const isHovered = hoveredDate && isSameDay(date, hoveredDate);
// Use important modifier to ensure weekend styling overrides any conflicting CSS
const weekendClasses = isWeekend ? '!bg-muted/80 border-muted-foreground/30 shadow-sm' : '';
const todayClasses = isToday ? 'border-primary ring-1 ring-primary ring-offset-1 ring-offset-background' : 'border-border';
const hoverClasses = isHovered && draggedTask ? 'ring-2 ring-primary bg-primary/15 border-primary shadow-lg' : '';
const dragClasses = draggedTask && !isHovered ? 'border-dashed border-primary/30' : '';
const finalClassName = `p-2 min-h-[100px] transition-all border-2 ${todayClasses} ${weekendClasses} ${hoverClasses} ${dragClasses}`;
return (
<Card
key={index}
className={finalClassName}
onDragOver={handleDragOver}
onDragEnter={() => handleDragEnter(date)}
onDragLeave={handleDragLeave}
onMouseMove={(e) => handleMouseMove(e, date)}
onDrop={(e) => handleDrop(date, e)}
onClick={() => {
console.log('Date selected:', date.toLocaleDateString());
}}
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className="text-center mb-2">
<div className={`text-xs font-medium ${
isWeekend ? 'text-muted-foreground font-semibold' : 'text-muted-foreground'
}`}>
{format(date, 'EEE')}
</div>
<div className={`text-xs font-semibold ${
isToday ? 'text-primary' : isWeekend ? 'text-primary/80' : ''
}`}>
{format(date, 'MMM d')}
</div>
</div>
<div className="space-y-1">
{dayTasks.slice(0, 2).map((task) => (
<div
key={task.id}
draggable
onDragStart={(e) => handleDragStart(task.id, e)}
onDragEnd={handleDragEnd}
className={`cursor-move ${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 text-left"
>
<div className="truncate text-xs">
{task.title}
</div>
</Badge>
</div>
))}
{dayTasks.length > 2 && (
<Badge variant="secondary" className="w-full justify-center text-xs py-0">
+{dayTasks.length - 2}
</Badge>
)}
{dayTasks.length === 0 && (
<div className={`text-center text-xs py-2 rounded border border-dashed transition-all ${
isHovered && draggedTask
? 'border-primary text-primary bg-primary/5'
: draggedTask
? 'border-primary/50 text-primary/70'
: 'border-muted-foreground/30 text-muted-foreground'
}`}>
{isHovered && draggedTask ? '✓ Drop here' : draggedTask ? 'Drop' : ''}
</div>
)}
</div>
</Card>
);
})}
</div>
</div>
</div>
{/* Add bottom padding to prevent content overlap */}
<div className="h-[37vh]" />
{/* Time Completion Modal */}
<TimeCompletionModal
isOpen={completionModal.isOpen}
onClose={() => setCompletionModal({ isOpen: false, task: null })}
onSave={handleTimeLogged}
taskTitle={completionModal.task?.title || ''}
/>
</div>
);
}