diff --git a/.replit b/.replit index 43487c6..2b8bbf4 100644 --- a/.replit +++ b/.replit @@ -15,7 +15,7 @@ localPort = 5000 externalPort = 80 [[ports]] -localPort = 38607 +localPort = 39317 externalPort = 3000 [env] diff --git a/client/src/App.tsx b/client/src/App.tsx index 63deb8c..350cf67 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,7 +9,7 @@ import { Card } from "@/components/ui/card"; // Components import BottomNavigation from './components/BottomNavigation'; import TaskCreationModal from './components/TaskCreationModal'; -import TaskList from './components/TaskList'; +import TasksWithCalendar from './components/TasksWithCalendar'; import CalendarView from './components/CalendarView'; import KanbanBoard from './components/KanbanBoard'; import ProjectTemplate from './components/ProjectTemplate'; @@ -118,7 +118,7 @@ function App() { switch (currentTab) { case 'tasks': return ( - console.log('Edit task:', task.title)} diff --git a/client/src/components/TasksWithCalendar.tsx b/client/src/components/TasksWithCalendar.tsx new file mode 100644 index 0000000..337d0fd --- /dev/null +++ b/client/src/components/TasksWithCalendar.tsx @@ -0,0 +1,423 @@ +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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Search, Filter, SortAsc, Calendar, List, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Task } from './TaskCard'; +import TaskCard from './TaskCard'; +import TimeCompletionModal from './TimeCompletionModal'; +import { addDays, format, isSameDay, startOfWeek } from 'date-fns'; + +interface TasksWithCalendarProps { + tasks: Task[]; + onTaskUpdate?: (taskId: string, updates: Partial) => 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('dueDate'); + const [filterBy, setFilterBy] = useState('all'); + const [currentDate, setCurrentDate] = useState(new Date()); + const [draggedTask, setDraggedTask] = useState(null); + const [activeView, setActiveView] = useState<'list' | 'calendar'>('list'); + const [completionModal, setCompletionModal] = useState<{ isOpen: boolean; task: Task | null }>({ + isOpen: false, + task: null + }); + + // Get two weeks starting from current week for calendar + const startDate = startOfWeek(currentDate); + const dates = Array.from({ length: 14 }, (_, i) => addDays(startDate, 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 navigateWeek = (direction: 'prev' | 'next') => { + const newDate = addDays(currentDate, direction === 'next' ? 7 : -7); + setCurrentDate(newDate); + console.log(`Navigating to week of ${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 ( +
+ {/* Header */} +
+

+ My Tasks +

+ + {filteredAndSortedTasks.length} tasks + +
+ + {/* Search and Filters */} +
+
+ + { + setSearchQuery(e.target.value); + console.log('Search query:', e.target.value); + }} + className="pl-10" + data-testid="input-search-tasks" + /> +
+ +
+ + + +
+
+ + {/* View Toggle */} + setActiveView(value as 'list' | 'calendar')}> + + + + List View + + + + Calendar View + + + + + {/* Task List */} +
+ {unscheduledTasks.length === 0 ? ( +
+

+ {searchQuery || filterBy !== 'all' + ? 'No unscheduled tasks match your filters' + : 'No unscheduled tasks. Drag tasks to the calendar to schedule them!' + } +

+
+ ) : ( + <> +
+ Unscheduled Tasks - Drag to calendar to schedule +
+ {unscheduledTasks.map((task) => ( +
handleDragStart(task.id, e)} + onDragEnd={handleDragEnd} + className={`cursor-move transition-transform ${ + draggedTask === task.id ? 'opacity-50 scale-95' : 'hover:scale-[1.02]' + }`} + > + { + 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} + /> +
+ ))} + + )} +
+
+ + + {/* Calendar Header */} +
+
+ +

+ Next Two Weeks +

+
+ +
+ + + +
+
+ + {/* Calendar Grid */} +
+ {dates.map((date, index) => { + const dayTasks = getTasksForDate(date); + const isToday = isSameDay(date, new Date()); + const isWeekend = date.getDay() === 0 || date.getDay() === 6; + + return ( + handleDrop(date, e)} + onClick={() => { + console.log('Date selected:', date.toLocaleDateString()); + }} + data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`} + > +
+
+ {format(date, 'EEE')} +
+
+ {format(date, 'd')} +
+
+ +
+ {dayTasks.slice(0, 3).map((task) => ( +
handleDragStart(task.id, e)} + onDragEnd={handleDragEnd} + className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`} + data-testid={`calendar-task-${task.id}`} + > + +
+ {task.title} +
+
+
+ ))} + + {dayTasks.length > 3 && ( + + +{dayTasks.length - 3} more + + )} + + {dayTasks.length === 0 && draggedTask && ( +
+ Drop task here +
+ )} +
+
+ ); + })} +
+ + {/* Calendar Legend */} +
+
+
+ Today +
+
+
+ Weekend +
+
+
+ Drop zone +
+
+
+
+ + {/* Time Completion Modal */} + setCompletionModal({ isOpen: false, task: null })} + onSave={handleTimeLogged} + taskTitle={completionModal.task?.title || ''} + /> +
+ ); +} \ No newline at end of file diff --git a/client/src/components/TimeCompletionModal.tsx b/client/src/components/TimeCompletionModal.tsx new file mode 100644 index 0000000..1ffcfa5 --- /dev/null +++ b/client/src/components/TimeCompletionModal.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Clock, Timer } from 'lucide-react'; + +interface TimeCompletionModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (timeSpent: number) => void; // in minutes + taskTitle: string; +} + +export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle }: TimeCompletionModalProps) { + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const [manualHours, setManualHours] = useState(''); + const [activeTab, setActiveTab] = useState('clock'); + + const handleSave = () => { + let totalMinutes = 0; + + if (activeTab === 'clock') { + totalMinutes = hours * 60 + minutes; + } else { + const parsedHours = parseFloat(manualHours) || 0; + totalMinutes = Math.round(parsedHours * 60); + } + + onSave(totalMinutes); + console.log(`Time logged: ${totalMinutes} minutes for task: ${taskTitle}`); + handleClose(); + }; + + const handleClose = () => { + setHours(0); + setMinutes(0); + setManualHours(''); + setActiveTab('clock'); + onClose(); + }; + + const formatTime = (h: number, m: number) => { + return `${h}h ${m}m`; + }; + + const getCurrentTimeDisplay = () => { + if (activeTab === 'clock') { + return formatTime(hours, minutes); + } else { + const parsedHours = parseFloat(manualHours) || 0; + const h = Math.floor(parsedHours); + const m = Math.round((parsedHours - h) * 60); + return formatTime(h, m); + } + }; + + return ( + + + + + + Task Completed! + + + +
+
+

+ How much time did you spend on: +

+

+ {taskTitle} +

+
+ + + + + + Clock + + + + Manual + + + + + {/* Apple-style Clock Picker */} +
+
+
+ {formatTime(hours, minutes)} +
+
+ +
+ {/* Hours Picker */} +
+ +
+ {Array.from({ length: 13 }, (_, i) => ( + + ))} +
+
+ + {/* Minutes Picker */} +
+ +
+ {Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => ( + + ))} +
+
+
+
+
+ + + {/* Manual Input */} +
+
+
+ {getCurrentTimeDisplay()} +
+
+ +
+ + setManualHours(e.target.value)} + data-testid="input-manual-hours" + /> +

+ Examples: 0.5 = 30min, 1.25 = 1h 15min, 2.75 = 2h 45min +

+
+
+
+
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file