Add a weekly task view to help users organize their week
Introduces a new "Week List" view accessible via the bottom navigation. This view displays tasks scheduled for the current week, allowing users to filter, sort, and manage them. The implementation includes new components for the week list view and integrates internationalization support for English and German. 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/R3zkvYP
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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, Label } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import TimeCompletionModal from './TimeCompletionModal';
|
||||
import { addDays, format, isSameDay, startOfWeek, endOfWeek, startOfDay, endOfDay, addWeeks } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WeekListViewProps {
|
||||
tasks: Task[];
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
onStartTimer?: (taskId: string) => void;
|
||||
onStopTimer?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
|
||||
|
||||
export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: WeekListViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('all');
|
||||
const [selectedWeekStart, setSelectedWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
const [completionModal, setCompletionModal] = useState<{ isOpen: boolean; task: Task | null }>({
|
||||
isOpen: false,
|
||||
task: null
|
||||
});
|
||||
|
||||
// Fetch labels to get label colors
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Always show 7 days for the selected week (Monday to Sunday)
|
||||
const weekStart = startOfDay(selectedWeekStart);
|
||||
const weekEnd = endOfDay(endOfWeek(selectedWeekStart, { weekStartsOn: 1 }));
|
||||
const dates = Array.from({ length: 7 }, (_, i) => addDays(selectedWeekStart, i));
|
||||
|
||||
const isOverdue = (task: Task) => {
|
||||
if (!task.dueDate) return false;
|
||||
return task.dueDate < new Date() && task.status !== 'done';
|
||||
};
|
||||
|
||||
const isInCurrentWeek = (task: Task) => {
|
||||
if (!task.dueDate) return false;
|
||||
return task.dueDate >= weekStart && task.dueDate <= weekEnd;
|
||||
};
|
||||
|
||||
const filteredAndSortedTasks = tasks
|
||||
.filter(task => {
|
||||
// Only show tasks in the selected week
|
||||
if (!isInCurrentWeek(task)) return false;
|
||||
|
||||
// Search filter
|
||||
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(task.description?.toLowerCase()?.includes(searchQuery.toLowerCase()) ?? false);
|
||||
|
||||
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 as keyof typeof priorityOrder] - priorityOrder[a.priority as keyof typeof priorityOrder];
|
||||
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
|
||||
case 'status':
|
||||
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
|
||||
return statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder];
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const getFilterCount = (filter: FilterOption) => {
|
||||
const weekTasks = tasks.filter(isInCurrentWeek);
|
||||
switch (filter) {
|
||||
case 'overdue':
|
||||
return weekTasks.filter(isOverdue).length;
|
||||
case 'all':
|
||||
return weekTasks.length;
|
||||
default:
|
||||
return weekTasks.filter(task => task.status === filter).length;
|
||||
}
|
||||
};
|
||||
|
||||
const getTasksForDate = (date: Date) => {
|
||||
return filteredAndSortedTasks.filter(task =>
|
||||
task.dueDate && isSameDay(task.dueDate, date)
|
||||
);
|
||||
};
|
||||
|
||||
const navigateCalendar = (direction: 'prev' | 'next') => {
|
||||
const newWeekStart = addWeeks(selectedWeekStart, direction === 'next' ? 1 : -1);
|
||||
setSelectedWeekStart(newWeekStart);
|
||||
console.log(`Navigating to week starting ${newWeekStart.toLocaleDateString()}`);
|
||||
};
|
||||
|
||||
const goToCurrentWeek = () => {
|
||||
setSelectedWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
};
|
||||
|
||||
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 pb-4">
|
||||
{/* Header and Search Filters */}
|
||||
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4">
|
||||
<div className="space-y-3">
|
||||
{/* Title Bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold" data-testid="text-week-list-title">
|
||||
{t('weekList.title')}
|
||||
</h2>
|
||||
<Badge variant="secondary" data-testid="badge-week-task-count">
|
||||
{t('weekList.taskCount', { count: filteredAndSortedTasks.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('weekList.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
console.log('Search query:', e.target.value);
|
||||
}}
|
||||
className="pl-10"
|
||||
data-testid="input-search-week-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-week">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('weekList.filter.all')} ({getFilterCount('all')})</SelectItem>
|
||||
<SelectItem value="todo">{t('weekList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
|
||||
<SelectItem value="inProgress">{t('weekList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||
<SelectItem value="done">{t('weekList.filter.done')} ({getFilterCount('done')})</SelectItem>
|
||||
<SelectItem value="overdue">{t('weekList.filter.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-week">
|
||||
<div className="flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">{t('weekList.sort.dueDate')}</SelectItem>
|
||||
<SelectItem value="priority">{t('weekList.sort.priority')}</SelectItem>
|
||||
<SelectItem value="title">{t('weekList.sort.title')}</SelectItem>
|
||||
<SelectItem value="status">{t('weekList.sort.status')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Week Selector */}
|
||||
<Card className="p-4">
|
||||
<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-week-range">
|
||||
{format(weekStart, 'MMM d')} - {format(weekEnd, 'MMM d, yyyy')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateCalendar('prev')}
|
||||
data-testid="button-prev-week-list"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t('weekList.previous')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToCurrentWeek}
|
||||
data-testid="button-today-week-list"
|
||||
>
|
||||
{t('weekList.today')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateCalendar('next')}
|
||||
data-testid="button-next-week-list"
|
||||
>
|
||||
{t('weekList.next')}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Calendar Bar */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}>
|
||||
{dates.map((date, index) => {
|
||||
const dayTasks = getTasksForDate(date);
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-2 rounded border-2 text-center ${
|
||||
isToday ? 'border-primary bg-primary/5' : isWeekend ? 'bg-muted/50' : 'border-border'
|
||||
}`}
|
||||
data-testid={`week-calendar-date-${format(date, 'yyyy-MM-dd')}`}
|
||||
>
|
||||
<div className={`text-xs font-medium ${isWeekend ? 'text-muted-foreground' : 'text-muted-foreground'}`}>
|
||||
{format(date, 'EEE')}
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>
|
||||
{format(date, 'd')}
|
||||
</div>
|
||||
{dayTasks.length > 0 && (
|
||||
<Badge variant="secondary" className="mt-1 text-xs">
|
||||
{dayTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="space-y-3">
|
||||
{filteredAndSortedTasks.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground" data-testid="text-no-week-tasks">
|
||||
{searchQuery || filterBy !== 'all'
|
||||
? t('weekList.noMatchingTasks')
|
||||
: t('weekList.noTasks')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{t('weekList.tasksFor', { start: format(weekStart, 'MMM d'), end: format(weekEnd, 'MMM d') })}
|
||||
</div>
|
||||
{filteredAndSortedTasks.map((task) => (
|
||||
<div key={task.id}>
|
||||
<TaskCard
|
||||
task={task}
|
||||
onStartTimer={() => onStartTimer?.(task.id)}
|
||||
onStopTimer={() => onStopTimer?.(task.id)}
|
||||
onEdit={() => {
|
||||
onTaskEdit?.(task);
|
||||
console.log(`Edit task ${task.title}`);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onTaskDelete?.(task.id);
|
||||
console.log(`Delete 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}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time Completion Modal */}
|
||||
<TimeCompletionModal
|
||||
isOpen={completionModal.isOpen}
|
||||
onClose={() => setCompletionModal({ isOpen: false, task: null })}
|
||||
onSave={handleTimeLogged}
|
||||
taskTitle={completionModal.task?.title || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user