feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-17 14:26:54 +01:00
parent 9819d8db0b
commit 2579df0b89
32 changed files with 2219 additions and 456 deletions
+17 -2
View File
@@ -45,6 +45,7 @@ interface TaskCardProps {
onDelete?: () => void;
onStatusChange?: (status: Task['status']) => void;
onUpdate?: (updates: Partial<Task>) => void;
onAutoSchedule?: () => void;
isDragging?: boolean;
}
@@ -119,7 +120,7 @@ function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void })
);
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -332,7 +333,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
<TooltipContent>
{isBlocked ? (
<p className="text-destructive font-medium">
Blocked by: {blockingTasks.map(t => t?.title).join(", ")}
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
</p>
) : (
<p>{t('taskCard.toggleComplete')}</p>
@@ -490,6 +491,20 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{t('taskCard.editTask')}
</DropdownMenuItem>
{onAutoSchedule && !task.dueDate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onAutoSchedule();
}}
className="text-indigo-600 dark:text-indigo-400"
data-testid={`menu-schedule-${task.id}`}
>
<Wand2 className="w-4 h-4 mr-2" />
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
+32 -26
View File
@@ -219,26 +219,32 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
</SelectContent>
</Select>
<div className="col-span-1">
<Select
value={estimatedDuration ? estimatedDuration.toString() : "0"}
onValueChange={(val) => setEstimatedDuration(val === "0" ? undefined : parseInt(val))}
>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.duration')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
<SelectItem value="15">15m</SelectItem>
<SelectItem value="30">30m</SelectItem>
<SelectItem value="45">45m</SelectItem>
<SelectItem value="60">1h</SelectItem>
<SelectItem value="90">1.5h</SelectItem>
<SelectItem value="120">2h</SelectItem>
<SelectItem value="240">4h</SelectItem>
<SelectItem value="480">8h (1 Day)</SelectItem>
</SelectContent>
</Select>
<div className="col-span-2 sm:col-span-1 space-y-2">
<label className="text-xs font-medium text-muted-foreground">{t('taskCreation.duration')} ({t('planning.minutes')})</label>
<div className="flex items-center gap-2">
<Input
type="number"
min="0"
step="5"
placeholder={t('planning.durationPlaceholder')}
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="w-full"
data-testid="input-duration"
/>
</div>
<div className="flex flex-wrap gap-1">
{[15, 30, 45, 60, 90, 120].map((mins) => (
<Badge
key={mins}
variant={estimatedDuration === mins ? "default" : "outline"}
className="cursor-pointer text-[10px] px-1.5 py-0.5"
onClick={() => setEstimatedDuration(mins)}
>
{mins}m
</Badge>
))}
</div>
</div>
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
@@ -326,15 +332,15 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setRecurrenceInterval(v === 'none' ? undefined : v);
setIsRecurring(v !== 'none');
}}>
<SelectTrigger className="h-8">
<SelectTrigger className="h-8" data-testid="recurrence-trigger">
<SelectValue placeholder={t('taskCreation.recurrence.none')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.recurrence.none')}</SelectItem>
<SelectItem value="daily">{t('taskCreation.recurrence.daily')}</SelectItem>
<SelectItem value="weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
<SelectItem value="monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
<SelectItem value="yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
<SelectItem value="none" data-testid="recurrence-option-none">{t('taskCreation.recurrence.none')}</SelectItem>
<SelectItem value="daily" data-testid="recurrence-option-daily">{t('taskCreation.recurrence.daily')}</SelectItem>
<SelectItem value="weekly" data-testid="recurrence-option-weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
<SelectItem value="monthly" data-testid="recurrence-option-monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
<SelectItem value="yearly" data-testid="recurrence-option-yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
</SelectContent>
</Select>
+24 -20
View File
@@ -611,26 +611,30 @@ export default function TaskDetailsModal({
{/* Estimated Duration */}
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')}</label>
<Select
value={editedEstimatedDuration ? editedEstimatedDuration.toString() : "0"}
onValueChange={(val) => setEditedEstimatedDuration(val === "0" ? undefined : parseInt(val))}
>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.duration')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
<SelectItem value="15">15m</SelectItem>
<SelectItem value="30">30m</SelectItem>
<SelectItem value="45">45m</SelectItem>
<SelectItem value="60">1h</SelectItem>
<SelectItem value="90">1.5h</SelectItem>
<SelectItem value="120">2h</SelectItem>
<SelectItem value="240">4h</SelectItem>
<SelectItem value="480">8h (1 Day)</SelectItem>
</SelectContent>
</Select>
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')} ({t('planning.minutes')})</label>
<div className="space-y-2">
<Input
type="number"
min="0"
step="5"
placeholder={t('planning.durationPlaceholder')}
value={editedEstimatedDuration || ''}
onChange={(e) => setEditedEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
data-testid="input-edit-duration"
/>
<div className="flex flex-wrap gap-1">
{[15, 30, 45, 60, 90, 120].map((mins) => (
<Badge
key={mins}
variant={editedEstimatedDuration === mins ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => setEditedEstimatedDuration(mins)}
>
{mins}m
</Badge>
))}
</div>
</div>
</div>
{/* Dependencies */}
+14 -4
View File
@@ -35,7 +35,7 @@ interface TasksWithCalendarProps {
}
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
const { t } = useTranslation();
@@ -100,6 +100,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
switch (filterBy) {
case 'overdue':
return isOverdue(task);
case 'planned':
return !!task.dueDate || !!task.startDate;
case 'unplanned':
return !task.dueDate && !task.startDate;
case 'all':
return true;
default:
@@ -163,6 +167,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
switch (filter) {
case 'overdue':
return tasks.filter(isOverdue).length;
case 'planned':
return tasks.filter(t => !!t.dueDate || !!t.startDate).length;
case 'unplanned':
return tasks.filter(t => !t.dueDate && !t.startDate).length;
case 'all':
return tasks.length;
default:
@@ -283,6 +291,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
<SelectItem value="done">{t('taskList.filter.done')} ({getFilterCount('done')})</SelectItem>
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
<SelectItem value="planned">{t('taskList.filter.planned')} ({getFilterCount('planned')})</SelectItem>
<SelectItem value="unplanned">{t('taskList.filter.unplanned')} ({getFilterCount('unplanned')})</SelectItem>
</SelectContent>
</Select>
@@ -321,7 +331,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
Task List
{t('taskList.listHeader')}
</div>
{unscheduledTasks.map((task) => (
<div
@@ -592,7 +602,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
<div className="p-3 border-b bg-muted/30">
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
<span className="text-[10px] font-normal opacity-75">{t('calendar.clickToEdit')}</span>
</h4>
</div>
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
@@ -637,7 +647,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
? 'border-primary/50 text-primary/70'
: 'border-muted-foreground/30 text-muted-foreground'
}`}>
{isHovered && draggedTask ? '✓ Drop here' : draggedTask ? 'Drop' : ''}
{isHovered && draggedTask ? t('calendar.dropHere') : draggedTask ? t('calendar.drop') : ''}
</div>
)}
</div>
@@ -60,16 +60,18 @@ export function AuditLogsTable() {
{logs?.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap">
{format(new Date(log.createdAt), "MMM d, HH:mm:ss")}
{new Date(log.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' })}
</TableCell>
<TableCell>
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
{log.source}
{t(`audit.source.${log.source}`, { defaultValue: log.source })}
</Badge>
</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell className="font-medium">
{t(`audit.action.${log.action}`, { defaultValue: log.action })}
</TableCell>
<TableCell>
{log.entityType}
{t(`audit.entity.${log.entityType}`, { defaultValue: log.entityType })}
{log.entityId && <span className="text-xs text-muted-foreground block truncate max-w-[100px]">{log.entityId}</span>}
</TableCell>
<TableCell className="text-sm text-muted-foreground w-1/3">
@@ -28,7 +28,7 @@ export function DataExportCard({ user }: DataExportCardProps) {
const handleExport = async () => {
try {
setIsExporting(true);
const response = await apiRequest("POST", "/api/user/export", {
const response = await apiRequest("POST", "/api/user/data-export", {
includeTasks,
includeLabels,
includeSettings
@@ -106,6 +106,7 @@ export function DataExportCard({ user }: DataExportCardProps) {
<Button
onClick={handleExport}
disabled={isExporting || (!includeTasks && !includeLabels && !includeSettings)}
data-testid="button-export-data"
>
{isExporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{t('settings.export.button')}