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

+14 -1
View File
@@ -10,6 +10,7 @@ import {
LogOut,
ChevronDown,
RotateCcw,
Trash2,
Palette,
HelpCircle,
Plus,
@@ -73,6 +74,7 @@ import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter';
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
import { AboutModal } from './components/AboutModal';
import { TriageTrash } from './features/triage/TriageTrash';
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
import { useUpdateCheck } from './lib/useUpdateCheck';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
@@ -191,6 +193,7 @@ export const App: React.FC = () => {
const [showProfileMenu, setShowProfileMenu] = useState(false);
const serverOnline = useServerOnline();
const [showAbout, setShowAbout] = useState(false);
const [showTriageTrash, setShowTriageTrash] = useState(false);
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -314,6 +317,13 @@ export const App: React.FC = () => {
badge: stats.inboxCount,
onClick: () => refreshAll(),
},
{
id: 'triage-trash',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: stats.discardedCount,
onClick: () => setShowTriageTrash(true),
},
],
},
{
@@ -394,7 +404,7 @@ export const App: React.FC = () => {
},
]);
}
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount, stats.discardedCount]);
// Mobile Web-App (PWA) Installation State
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
@@ -1275,6 +1285,8 @@ export const App: React.FC = () => {
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
discardedCount={stats.discardedCount}
onOpenTrash={() => setShowTriageTrash(true)}
/>
)}
@@ -1886,6 +1898,7 @@ export const App: React.FC = () => {
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{showTriageTrash && <TriageTrash onClose={() => setShowTriageTrash(false)} onChanged={refreshAll} />}
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
</div>
);
+1 -1
View File
@@ -264,7 +264,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
</li>
<li>
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 30 Tage) wird sie endgültig gelöscht. Gelöschte Aufgaben liegen im Reiter „Papierkorb“ der Aufgaben, verworfene Gedanken im Papierkorb der Triage.
</li>
</ul>
</div>
@@ -158,6 +158,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
backupEnabled: Boolean(sys.backupEnabled),
backupTime: sys.backupTime || '03:00',
backupRetentionDays: sys.backupRetentionDays || 3,
retentionAllowNever: sys.retentionAllowNever ?? true,
retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv),
});
}
} catch (err: any) {
@@ -1315,6 +1317,23 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
/>
</div>
{/* Retention: may people keep finished and deleted things for good? */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Dauerhaftes Aufbewahren erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Eingeschaltet kann jede Person unter Kontoeinstellungen → Aufbewahrung „nie“ wählen, also Erledigtes und
Gelöschtes behalten. Ausgeschaltet gilt dort für „nie“ die Voreinstellung des Bereichs (z. B. 30 Tage).
{settings.retentionNeverViaEnv && ' Festgelegt per RETENTION_ALLOW_NEVER.'}
</p>
</div>
<ToggleSwitch
checked={settings.retentionAllowNever !== false}
disabled={Boolean(settings.retentionNeverViaEnv)}
onChange={(retentionAllowNever) => handleSaveSystemSettings({ retentionAllowNever })}
/>
</div>
{/* Who may register without an invite */}
{(() => {
const mode = settings.registrationMode || 'closed';
@@ -19,8 +19,9 @@ import {
Copy,
Archive,
Mic,
Clock,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, Retention, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
@@ -68,7 +69,25 @@ interface UserSettingsViewProps {
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'webhooks' | 'data';
// Kontoeinstellungen → Aufbewahrung: the areas and the delays offered (storage.RetentionChoices)
const RETENTION_AREAS: { key: keyof Retention; title: string; text: string; verb: string }[] = [
{ key: 'doneTasks', title: 'Erledigte Aufgaben', text: 'Wandern danach in den Papierkorb der Aufgaben.', verb: 'in den Papierkorb nach' },
{ key: 'taskTrash', title: 'Papierkorb der Aufgaben', text: 'Gelöschte und geleerte Aufgaben.', verb: 'endgültig löschen nach' },
{ key: 'triageTrash', title: 'Papierkorb der Triage', text: 'Beim Sortieren verworfene Gedanken.', verb: 'endgültig löschen nach' },
{ key: 'noteTrash', title: 'Papierkorb der Notizen', text: 'Gelöschte Notizen im Workspace.', verb: 'endgültig löschen nach' },
{ key: 'listDone', title: 'Abgehakte Listeneinträge', text: 'In Einkaufs- und Checklisten.', verb: 'löschen nach' },
];
const RETENTION_OPTIONS = [
{ days: 7, label: '7 Tagen' },
{ days: 14, label: '14 Tagen' },
{ days: 30, label: '30 Tagen' },
{ days: 90, label: '90 Tagen' },
{ days: 365, label: 'einem Jahr' },
{ days: 730, label: 'zwei Jahren' },
{ days: 0, label: 'nie' },
];
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'retention' | 'webhooks' | 'data';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
@@ -140,6 +159,22 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
useEffect(() => {
void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable()));
}, []);
// Retention: one delay per area (storage/retention.go); "nie" only when the server allows it
const [retentionSaving, setRetentionSaving] = useState(false);
const [retentionError, setRetentionError] = useState<string | null>(null);
const handleChangeRetention = async (key: keyof Retention, days: number) => {
if (!currentUser.retention) return;
setRetentionSaving(true);
setRetentionError(null);
try {
onUserUpdated(await api.updateSettings({ retention: { ...currentUser.retention, [key]: days } }));
} catch (err: any) {
setRetentionError(err?.message || 'Speichern fehlgeschlagen');
} finally {
setRetentionSaving(false);
}
};
const handleChangeVoiceInput = async (next: '' | 'browser') => {
setVoiceSaving(true);
try {
@@ -675,6 +710,21 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button>
)}
<button
onClick={() => setSection('retention')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'retention'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Clock className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Aufbewahrung</p>
<p className="text-[10px] text-faint">Erledigtes & Papierkörbe aufräumen</p>
</div>
</button>
<button
onClick={() => setSection('webhooks')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
@@ -1964,6 +2014,55 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
)}
{/* ================================================================= */}
{/* SECTION: AUFBEWAHRUNG */}
{/* ================================================================= */}
{section === 'retention' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Aufbewahrung</h2>
<p className="text-muted mt-0.5">
Wie lange Erledigtes und Gelöschtes bleibt, gezählt ab dem Abhaken bzw. Löschen. In geteilten Aufgabenlisten,
Listen und Notizen gilt die Einstellung der Person, der sie gehören.
</p>
</div>
{retentionError && (
<div className="alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>{retentionError}</span>
</div>
)}
{currentUser.retention && (
<div className="divide-y divide-border">
{RETENTION_AREAS.map((area) => (
<div key={area.key} className="py-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold text-main">{area.title}</p>
<p className="text-muted">{area.text}</p>
</div>
<label className="flex items-center gap-2 flex-shrink-0">
<span className="text-muted">{area.verb}</span>
<select
value={currentUser.retention![area.key]}
disabled={retentionSaving}
onChange={(e) => handleChangeRetention(area.key, Number(e.target.value))}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label={`${area.title}: ${area.verb}`}
>
{RETENTION_OPTIONS.filter((o) => o.days > 0 || currentUser.retentionNeverAllowed !== false).map((o) => (
<option key={o.days} value={o.days}>
{o.label}
</option>
))}
</select>
</label>
</div>
))}
</div>
)}
</div>
)}
{/* ================================================================= */}
{/* SECTION 5: EXPORT & IMPORT */}
{/* ================================================================= */}
+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>
) : (
+31 -9
View File
@@ -20,6 +20,7 @@ import {
ArrowLeft,
ArrowRight,
ChevronDown,
Trash2,
} from 'lucide-react';
import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due';
@@ -32,6 +33,9 @@ interface TriageDeckProps {
onDeckChange: () => void;
onOpenDocument: (docId: string) => void;
onFocusCapture: () => void;
// The triage's own trash (TriageTrash): thoughts thrown away while sorting
discardedCount: number;
onOpenTrash: () => void;
}
interface SwipeableQueueItemProps {
@@ -219,6 +223,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
lists,
onDeckChange,
onFocusCapture,
discardedCount,
onOpenTrash,
}) => {
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
@@ -469,6 +475,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Letzte Aktion rückgängig
</button>
)}
{discardedCount > 0 && (
<button onClick={onOpenTrash} className="btn-action h-10 px-4 text-sm">
<Trash2 className="w-4 h-4" />
Papierkorb ({discardedCount})
</button>
)}
</div>
</div>
);
@@ -488,15 +501,24 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</span>
</div>
{lastTriaged && (
<button
onClick={handleUndo}
className="btn-action h-8 px-3 text-xs"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Rückgängig
</button>
)}
<div className="flex items-center gap-2">
{lastTriaged && (
<button
onClick={handleUndo}
className="btn-action h-8 px-3 text-xs"
>
<RotateCcw className="w-3.5 h-3.5 text-accent" />
Rückgängig
</button>
)}
{/* Phones reach the trash through the menu */}
{discardedCount > 0 && (
<button onClick={onOpenTrash} className="hidden sm:inline-flex btn-action h-8 px-3 text-xs" title="Papierkorb der Triage">
<Trash2 className="w-3.5 h-3.5" />
{discardedCount}
</button>
)}
</div>
</div>
{/* Card Stack Container */}
@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { Loader2, RotateCcw, Trash2 } from 'lucide-react';
import { api, triggerHaptic } from '../../lib/api';
import { formatDeletedAt } from '../../lib/due';
import type { Thought } from '../../lib/types';
import { ReadingModal } from '../../components/ReadingModal';
interface TriageTrashProps {
onClose: () => void;
// Something was restored or deleted: counters and the deck change
onChanged: () => void;
}
// The triage's own trash: thoughts thrown away while sorting. Back into the Quick Dump, or deleted for
// good (one by one, all at once, or after the delay from Kontoeinstellungen → Aufbewahrung). Deleting
// for good takes a second tap on the same button, like the notes trash.
export const TriageTrash: React.FC<TriageTrashProps> = ({ onClose, onChanged }) => {
const [items, setItems] = useState<Thought[] | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
const load = async () => {
const list = await api.getThoughts('discarded').catch(() => []);
setItems(Array.isArray(list) ? list : []);
};
useEffect(() => {
void load();
}, []);
const restore = async (item: Thought) => {
triggerHaptic(15);
await api.triageAction(item.id, 'restore');
await load();
onChanged();
};
const purge = async (id: string) => {
if (confirmId !== id) {
setConfirmId(id);
return;
}
triggerHaptic(20);
setConfirmId(null);
if (id === '__all__') await api.purgeDiscarded();
else await api.deleteThought(id);
await load();
onChanged();
};
return (
<ReadingModal title="Papierkorb der Triage" onClose={onClose}>
{items === null ? (
<div className="py-6 flex justify-center text-muted">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
) : items.length === 0 ? (
<p className="py-6 text-center text-muted">Keine verworfenen Gedanken.</p>
) : (
<div className="space-y-3">
<div className="divide-y divide-border border border-border rounded-card overflow-hidden">
{items.map((item) => (
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm text-main truncate">{item.content}</p>
{item.triagedAt && <p className="text-[11px] text-faint">verworfen {formatDeletedAt(item.triagedAt)}</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button type="button" onClick={() => restore(item)} className="btn-action min-h-[38px] sm:min-h-0">
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
</button>
<button
type="button"
onClick={() => purge(item.id)}
className={`btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ${confirmId === item.id ? 'text-status-err' : ''}`}
title={confirmId === item.id ? 'Wirklich endgültig löschen?' : 'Endgültig löschen'}
aria-label="Endgültig löschen"
>
{confirmId === item.id ? <span className="text-[11px] font-semibold px-1">Wirklich?</span> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<button type="button" onClick={() => purge('__all__')} className="btn-action min-h-[38px] sm:min-h-0 text-status-err">
<Trash2 className="w-3.5 h-3.5" />
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
</button>
</div>
</div>
)}
</ReadingModal>
);
};
@@ -71,6 +71,8 @@ describe('TriageDeck single-list shortcut', () => {
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
@@ -100,6 +102,8 @@ describe('TriageDeck single-list shortcut', () => {
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
@@ -123,6 +127,8 @@ describe('TriageDeck single-list shortcut', () => {
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
@@ -149,6 +155,8 @@ describe('TriageDeck single-list shortcut', () => {
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
@@ -171,6 +179,8 @@ describe('TriageDeck single-list shortcut', () => {
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
import { TrashedDocument } from '../../lib/types';
import { CountBadge } from '../../components/CountBadge';
import { formatDeletedAt } from '../../lib/due';
interface TrashViewProps {
items: TrashedDocument[];
@@ -12,12 +13,6 @@ interface TrashViewProps {
onBackMobile: () => void;
}
const formatDeletedAt = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
+21
View File
@@ -4,6 +4,8 @@ import {
DashboardStats,
Document,
TrashedDocument,
TrashedTask,
Retention,
JournalDay,
PushDevice,
BackupFile,
@@ -302,6 +304,7 @@ export const api = {
notificationSettings?: NotificationSettings;
doneRetentionDays?: number;
voiceInput?: '' | 'browser';
retention?: Retention;
}): Promise<User> {
return request<User>('/api/v1/auth/settings', {
method: 'PATCH',
@@ -479,6 +482,24 @@ export const api = {
return request<{ deleted: number }>('/api/v1/tasks/clear-done', { method: 'POST', body: JSON.stringify({ taskListId }) });
},
// Task trash: deleted tasks, cleared and automatically cleaned-up completed ones
async getTaskTrash(taskListId = ''): Promise<TrashedTask[]> {
const q = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
return request<TrashedTask[]>(`/api/v1/tasks/trash${q}`);
},
async restoreTrashedTask(id: string): Promise<Thought> {
return request<Thought>(`/api/v1/tasks/trash/${id}/restore`, { method: 'POST' });
},
async purgeTrashedTask(id: string): Promise<{ deleted: boolean }> {
return request<{ deleted: boolean }>(`/api/v1/tasks/trash/${id}`, { method: 'DELETE' });
},
async emptyTaskTrash(taskListId: string): Promise<{ deleted: number }> {
return request<{ deleted: number }>('/api/v1/tasks/trash/empty', { method: 'POST', body: JSON.stringify({ taskListId }) });
},
async purgeDiscarded(): Promise<{ purged: number }> {
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
},
+7
View File
@@ -48,3 +48,10 @@ export const formatDue = (due: string | undefined, now: Date = new Date()): stri
else label = `${pad(d)}.${pad(m)}.${y}`;
return time ? `${label}, ${time}` : label;
};
/** When something went to a trash, e.g. "01.11.2026, 14:05". */
export const formatDeletedAt = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
+26
View File
@@ -115,6 +115,9 @@ export interface User {
doneRetentionDays?: number;
// Voice input: '' = the server's Whisper (default), 'browser' = the browser's recognition
voiceInput?: '' | 'browser';
// How long finished and deleted things are kept, in days (0 = for good; only when allowed)
retention?: Retention;
retentionNeverAllowed?: boolean;
organizationId?: string;
createdAt: string;
}
@@ -293,6 +296,9 @@ export interface SystemSettings {
backupEnabled?: boolean;
backupTime?: string;
backupRetentionDays?: number;
// People may keep finished and deleted things for good ("nie"); fixed when set via RETENTION_ALLOW_NEVER
retentionAllowNever?: boolean;
retentionNeverViaEnv?: boolean;
}
/** One file of a server backup listing. */
@@ -382,6 +388,26 @@ export interface JournalDay {
empty: boolean;
}
/** Delays in days per area (Kontoeinstellungen → Aufbewahrung); 0 = keep for good. */
export interface Retention {
doneTasks: number;
taskTrash: number;
triageTrash: number;
noteTrash: number;
listDone: number;
}
/** A deleted task waiting in the task trash. */
export interface TrashedTask {
id: string;
content: string;
taskListId: string;
isCompleted: boolean;
trashedAt: string;
trashedBy: string;
canEdit: boolean;
}
export interface TrashedDocument {
id: string;
title: string;