feat: task trash, triage trash and retention settings for every area; admin can forbid keeping things for good
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 15:44:34 +01:00
1 parent a927a94a95
commit 5a9c5bac37
36 files changed
+1356 -175

No files matched your search

+67 -95
View File
@@ -28,8 +28,8 @@ import {
Filter,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types';
import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api';
@@ -129,7 +129,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
}, [selectedListId, viewMode]);
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [discarded, setDiscarded] = useState<Thought[]>([]);
// Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones
const [taskTrash, setTaskTrash] = useState<TrashedTask[]>([]);
const [loading, setLoading] = useState(true);
// Drag and Drop state
@@ -215,12 +216,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
const listId = selectedListId;
if (loadedListRef.current !== listId) setLoading(true);
try {
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
const [allTasks, allLists, allTeams, allCollabs, trash] = await Promise.all([
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getUserTeams().catch(() => []),
api.getCollaborators().catch(() => []),
api.getThoughts('discarded').catch(() => []),
api.getTaskTrash(listId === 'all' ? '' : listId).catch(() => []),
]);
if (seq !== loadSeqRef.current) return; // a newer reload is under way
loadedListRef.current = listId;
@@ -232,7 +233,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
setTeams(Array.isArray(allTeams) ? allTeams : []);
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
setTaskTrash(Array.isArray(trash) ? trash : []);
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
@@ -447,7 +448,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
// Available unique hashtags from current tasks
const allAvailableTags = useMemo(() => {
@@ -751,13 +751,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const handleRestore = async (item: Thought) => {
triggerHaptic(15);
await api.triageAction(item.id, 'restore');
await loadData();
onRefreshStats();
};
// Deleting a task puts it into the task trash (task_trash.go)
const handleDeletePermanent = async (id: string) => {
triggerHaptic(20);
await api.deleteThought(id);
@@ -765,37 +759,32 @@ export const TasksView: React.FC<TasksViewProps> = ({
onRefreshStats();
};
// Completed tasks: cleared by hand or deleted after the owner's delay (done_cleanup.go)
const [doneRetentionDays, setDoneRetentionDays] = useState<number>(currentUser?.doneRetentionDays ?? 30);
useEffect(() => {
if (currentUser?.doneRetentionDays !== undefined) setDoneRetentionDays(currentUser.doneRetentionDays);
}, [currentUser?.doneRetentionDays]);
const handleRestoreTrashed = async (item: TrashedTask) => {
triggerHaptic(15);
await api.restoreTrashedTask(item.id);
await loadData();
onRefreshStats();
};
const handleChangeDoneRetention = async (days: number) => {
const previous = doneRetentionDays;
setDoneRetentionDays(days);
try {
await api.updateSettings({ doneRetentionDays: days });
} catch {
setDoneRetentionDays(previous);
}
const handlePurgeTrashed = async (item: TrashedTask) => {
triggerHaptic(20);
await api.purgeTrashedTask(item.id);
await loadData();
};
const handleClearDone = async () => {
const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `aus „${currentSelectedList?.title || 'dieser Liste'}“`;
if (!window.confirm(`Alle erledigten Aufgaben ${scope} endgültig löschen?`)) return;
triggerHaptic(20);
await api.clearDoneTasks(selectedListId === 'all' ? '' : selectedListId);
await loadData();
onRefreshStats();
};
const handlePurgeAllDiscarded = async () => {
if (!window.confirm('Alle verworfenen Gedanken endgültig löschen?')) return;
const handleEmptyTaskTrash = async () => {
const scope = selectedListId === 'all' ? 'aller Listen, die du bearbeiten darfst,' : `von „${currentSelectedList?.title || 'dieser Liste'}“`;
if (!window.confirm(`Den Papierkorb ${scope} endgültig leeren?`)) return;
triggerHaptic(20);
await api.purgeDiscarded();
await api.emptyTaskTrash(selectedListId === 'all' ? '' : selectedListId);
await loadData();
onRefreshStats();
};
const handleQuickAddTask = async (e: React.FormEvent) => {
@@ -1278,7 +1267,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
id: 'filter-discarded',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: safeDiscarded.length,
badge: taskTrash.length,
active: viewMode === 'list' && filter === 'discarded',
onClick: () => {
setViewMode('list');
@@ -1357,7 +1346,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
sortBy,
openTasks.length,
doneTasks.length,
safeDiscarded.length,
taskTrash.length,
kanbanTasks.length,
safeTaskLists,
openTasksCount,
@@ -1837,7 +1826,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
? `Offen (${openTasks.length})`
: filter === 'done'
? `Erledigt (${doneTasks.length})`
: `Papierkorb (${safeDiscarded.length})`}
: `Papierkorb (${taskTrash.length})`}
</span>
</div>
{currentSelectedList && (
@@ -2755,12 +2744,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Papierkorb ({safeDiscarded.length})
Papierkorb ({taskTrash.length})
</button>
</div>
{filter === 'discarded' && safeDiscarded.length > 0 && (
<button onClick={handlePurgeAllDiscarded} className="btn-action min-h-[38px] sm:min-h-0">
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
<button onClick={handleEmptyTaskTrash} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
<Trash2 className="w-3.5 h-3.5" />
Papierkorb leeren
</button>
@@ -2773,68 +2762,51 @@ export const TasksView: React.FC<TasksViewProps> = ({
)}
</div>
{/* What happens to completed tasks; the delay of the list owner counts */}
{filter === 'done' && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted px-1">
{currentSelectedList && currentSelectedList.isOwner === false ? (
<span>
Erledigte Aufgaben dieser Liste räumt die Einstellung von {currentSelectedList.ownerName || 'der Eigentümerin bzw. dem Eigentümer'} auf.
</span>
) : (
<>
<span>Erledigte Aufgaben löschen</span>
<select
value={doneRetentionDays}
onChange={(e) => handleChangeDoneRetention(Number(e.target.value))}
className="h-9 sm:h-6 px-1.5 input-field text-xs bg-panel"
aria-label="Erledigte Aufgaben löschen nach"
>
<option value={7}>nach 7 Tagen</option>
<option value={30}>nach 30 Tagen</option>
<option value={90}>nach 90 Tagen</option>
<option value={365}>nach einem Jahr</option>
<option value={0}>nie</option>
</select>
<span className="text-faint">
{doneRetentionDays > 0 ? '(gezählt ab dem Abhaken; gilt für deine eigenen Listen)' : '(bleiben, bis du sie leerst)'}
</span>
</>
)}
</div>
)}
{loading ? (
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
) : filter === 'discarded' ? (
<div className="panel-surface divide-y divide-border">
{safeDiscarded.length === 0 ? (
<div className="px-4 py-6 text-center text-muted text-sm">
Keine verworfenen Gedanken im Papierkorb.
</div>
{taskTrash.length === 0 ? (
<div className="px-4 py-6 text-center text-muted text-sm">Der Papierkorb ist leer.</div>
) : (
safeDiscarded.map((item) => (
<div
key={item.id}
className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover"
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
<p className="text-sm text-muted line-through truncate">{item.content}</p>
taskTrash.map((item) => {
const listOfItem = item.taskListId ? safeTaskLists.find((l) => l.id === item.taskListId) : undefined;
return (
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
<div className="min-w-0">
<p className={`text-sm truncate ${item.isCompleted ? 'text-muted line-through' : 'text-main'}`}>{item.content}</p>
<p className="text-[11px] text-faint truncate">
{selectedListId === 'all' && (
<>
{listOfItem ? `${listOfItem.icon || '📋'} ${listOfItem.title}` : 'Persönlich'}
{' · '}
</>
)}
gelöscht {formatDeletedAt(item.trashedAt)}
{item.trashedBy ? ` von ${item.trashedBy}` : ' (automatisch)'}
</p>
</div>
</div>
{item.canEdit && (
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleRestoreTrashed(item)} className="btn-action min-h-[38px] sm:min-h-0">
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
</button>
<button
onClick={() => handlePurgeTrashed(item)}
className="btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0"
title="Endgültig löschen"
aria-label="Endgültig löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleRestore(item)} className="btn-action">
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
</button>
<button
onClick={() => handleDeletePermanent(item.id)}
className="btn-icon btn-icon-danger"
title="Endgültig löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
);
})
)}
</div>
) : (