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
+13
View File
@@ -25,6 +25,7 @@ import TasksWithCalendar from './components/TasksWithCalendar';
import CalendarView from './components/CalendarView';
import KanbanBoard from './components/KanbanBoard';
import ProjectTemplate from './components/ProjectTemplate';
import WeekListView from './components/WeekListView';
import ThemeToggle from './components/ThemeToggle';
import Settings from './pages/settings';
import { Task, Label } from '@shared/schema';
@@ -325,6 +326,18 @@ function App() {
/>
);
case 'weeklist':
return (
<WeekListView
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'templates':
return (
<ProjectTemplate
+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>
);
}
+25
View File
@@ -6,6 +6,7 @@
"navigation": {
"tasks": "Aufgaben",
"calendar": "Kalender",
"weekList": "Woche",
"create": "Erstellen",
"kanban": "Kanban",
"settings": "Einstellungen"
@@ -30,6 +31,30 @@
"status": "Status"
}
},
"weekList": {
"title": "Wochenaufgaben",
"taskCount": "{{count}} Aufgaben",
"searchPlaceholder": "Aufgaben suchen...",
"noTasks": "Keine Aufgaben für diese Woche geplant",
"noMatchingTasks": "Keine Aufgaben entsprechen deinen Filtern für diese Woche",
"filter": {
"all": "Alle",
"todo": "Zu erledigen",
"inProgress": "In Bearbeitung",
"done": "Erledigt",
"overdue": "Überfällig"
},
"sort": {
"dueDate": "Fälligkeitsdatum",
"priority": "Priorität",
"title": "Titel",
"status": "Status"
},
"previous": "Zurück",
"today": "Heute",
"next": "Weiter",
"tasksFor": "Aufgaben für {{start}} - {{end}}"
},
"taskCreation": {
"title": "Neue Aufgabe",
"titlePlaceholder": "Was muss erledigt werden?",
+25
View File
@@ -6,6 +6,7 @@
"navigation": {
"tasks": "Tasks",
"calendar": "Calendar",
"weekList": "Week",
"create": "Create",
"kanban": "Kanban",
"settings": "Settings"
@@ -30,6 +31,30 @@
"status": "Status"
}
},
"weekList": {
"title": "Week Tasks",
"taskCount": "{{count}} tasks",
"searchPlaceholder": "Search tasks...",
"noTasks": "No tasks scheduled for this week",
"noMatchingTasks": "No tasks match your filters for this week",
"filter": {
"all": "All",
"todo": "To Do",
"inProgress": "In Progress",
"done": "Done",
"overdue": "Overdue"
},
"sort": {
"dueDate": "Due Date",
"priority": "Priority",
"title": "Title",
"status": "Status"
},
"previous": "Previous",
"today": "Today",
"next": "Next",
"tasksFor": "Tasks for {{start}} - {{end}}"
},
"taskCreation": {
"title": "New Task",
"titlePlaceholder": "What needs to be done?",
+124
View File
@@ -0,0 +1,124 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { Task } from '@shared/schema';
import WeekListView from '@/components/WeekListView';
import { queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useState } from 'react';
import TaskDetailsModal from '@/components/TaskDetailsModal';
export default function WeekListPage() {
const { toast } = useToast();
const [editingTask, setEditingTask] = useState<Task | null>(null);
// Fetch all tasks
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
// Update task mutation
const updateTaskMutation = useMutation({
mutationFn: async ({ taskId, updates }: { taskId: string; updates: Partial<Task> }) => {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Failed to update task');
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
toast({
title: 'Task updated',
description: 'Task has been updated successfully',
});
},
onError: (error) => {
console.error('Error updating task:', error);
toast({
title: 'Error',
description: 'Failed to update task',
variant: 'destructive',
});
},
});
// Delete task mutation
const deleteTaskMutation = useMutation({
mutationFn: async (taskId: string) => {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete task');
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
toast({
title: 'Task deleted',
description: 'Task has been deleted successfully',
});
},
onError: (error) => {
console.error('Error deleting task:', error);
toast({
title: 'Error',
description: 'Failed to delete task',
variant: 'destructive',
});
},
});
const handleTaskUpdate = (taskId: string, updates: Partial<Task>) => {
updateTaskMutation.mutate({ taskId, updates });
};
const handleTaskEdit = (task: Task) => {
setEditingTask(task);
};
const handleTaskDelete = (taskId: string) => {
deleteTaskMutation.mutate(taskId);
};
const handleStartTimer = (taskId: string) => {
// Stop all other timers
const runningTasks = tasks.filter(t => t.isTracking && t.id !== taskId);
runningTasks.forEach(t => {
updateTaskMutation.mutate({ taskId: t.id, updates: { isTracking: false } });
});
// Start this timer
updateTaskMutation.mutate({ taskId, updates: { isTracking: true } });
};
const handleStopTimer = (taskId: string) => {
updateTaskMutation.mutate({ taskId, updates: { isTracking: false } });
};
return (
<div className="container mx-auto px-3 py-4 max-w-screen-2xl">
<WeekListView
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskEdit}
onTaskDelete={handleTaskDelete}
onStartTimer={handleStartTimer}
onStopTimer={handleStopTimer}
/>
{/* Task Edit Modal */}
<TaskDetailsModal
task={editingTask}
isOpen={!!editingTask}
onClose={() => setEditingTask(null)}
onSave={(updatedTask: Task) => {
if (editingTask) {
handleTaskUpdate(editingTask.id, updatedTask);
setEditingTask(null);
}
}}
/>
</div>
);
}