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:
paul-nothaft
2025-10-24 09:09:25 +00:00
parent b69726b428
commit 9c8ec283ed
6 changed files with 537 additions and 1 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Home, Calendar, LayoutGrid, Plus, Settings } from 'lucide-react';
import { Home, Calendar, List, LayoutGrid, Plus, Settings } from 'lucide-react';
import { useState } from 'react';
interface BottomNavigationProps {
@@ -17,6 +17,7 @@ export default function BottomNavigation({ onTabChange, onCreateTask, activeTab
const tabs = [
{ id: 'tasks', label: t('navigation.tasks'), icon: Home },
{ id: 'calendar', label: t('navigation.calendar'), icon: Calendar },
{ id: 'weeklist', label: t('navigation.weekList'), icon: List },
{ id: 'create', label: t('navigation.create'), icon: Plus, isCreate: true },
{ id: 'kanban', label: t('navigation.kanban'), icon: LayoutGrid },
{ id: 'settings', label: t('navigation.settings'), icon: Settings }
+348
View File
@@ -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>
);
}