From 446d599ed2b52f752b304afb0d7b62e43fc9d00c Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Wed, 25 Mar 2026 12:25:37 -0500 Subject: [PATCH] auto-cleanup implemented --- src/components/Layout.tsx | 9 ++ src/store/index.ts | 181 ++++++++++++++++++++++++++++++++++--- src/views/SettingsView.tsx | 2 +- 3 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 0831554..cabfd41 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -60,6 +60,15 @@ export const Layout: Component = (props) => { } }); + createEffect(() => { + const noteId = selectedNoteId(); + if (!noteId) return; + const noteExists = store.notes.some(note => note.id === noteId); + if (!noteExists) { + setSelectedNoteId(null); + } + }); + return (
{/* Desktop UI Container */} diff --git a/src/store/index.ts b/src/store/index.ts index bddf363..a88f0ab 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -606,7 +606,7 @@ const findContextForShareTag = (tag: { key: string; raw: string }) => const getContextByRef = (ref: TaskShareRef) => (ref.kind === "note" ? (() => { - const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key); + const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key)); return note ? buildNoteContext(note) : null; })() : null) || @@ -617,8 +617,8 @@ const getContextByRef = (ref: TaskShareRef) => null; const getNoteByRef = (ref: NoteRef) => - store.notes.find(note => note.id === ref.noteId) || - store.notes.find(note => note.key === ref.key); + store.notes.find(note => !note.deletedAt && note.id === ref.noteId) || + store.notes.find(note => !note.deletedAt && note.key === ref.key); const DEFAULT_SHARE_POLICY_BY_KIND: Record = { user: "collaborative", @@ -634,7 +634,7 @@ export const getNoteSharePolicy = (note: Pick | null | undefined): export const getTaskSharePolicy = (ref: Pick): "collaborative" | "handoff" => { if (ref.kind === "note") { if (ref.policy) return ref.policy; - const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key); + const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key)); return getNoteSharePolicy(note); } @@ -705,7 +705,7 @@ const buildTaskShareRef = ( shareModeOverrides: Record = {} ): TaskShareRef => { if (parsedTag.kind === "note") { - const note = store.notes.find(existing => existing.key === parsedTag.key); + const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key); const nextRefBase = { contextId: note?.id || parsedTag.key, key: parsedTag.key, @@ -761,7 +761,7 @@ export const deriveTaskRelationsFromTags = ( const noteRefs = parsedTags .filter(tag => tag.kind === "note") .map(tag => { - const note = store.notes.find(existing => existing.key === tag.key); + const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key @@ -789,6 +789,158 @@ const runDailyCleanupIfNeeded = () => { const today = new Date().toLocaleDateString('en-CA'); if (store.lastCompletedTaskCleanupDate === today) return; setStore("lastCompletedTaskCleanupDate", today); + return true; +}; + +const clearActiveSelectionsForDeletedRecord = (recordType: "task" | "note", id: string) => { + if (recordType === "task" && activeTaskId() === id) { + setActiveTaskId(null); + } + + if (recordType === "note" && activeNoteId() === id) { + setActiveNoteId(null); + } +}; + +const removeDeletedTaskFromStore = (id: string) => { + clearActiveSelectionsForDeletedRecord("task", id); + setStore("tasks", tasks => tasks.filter(task => task.id !== id)); +}; + +const removeDeletedNoteFromStore = (id: string) => { + clearActiveSelectionsForDeletedRecord("note", id); + loadedNoteDetailIds.delete(id); + failedNoteDetailIds.delete(id); + setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id)); + setStore("notes", notes => notes.filter(note => note.id !== id)); +}; + +const removeNoteReferencesFromTask = (task: Task, note: Note): Partial | null => { + const noteKey = (note.key || normalizeNoteKey(note.title)).toLowerCase(); + const noteTag = buildNoteTag(note.key || note.title).toLowerCase(); + const nextNoteRefs = task.noteRefs.filter(ref => + ref.noteId !== note.id && + ref.key.toLowerCase() !== noteKey + ); + const nextShareRefs = task.shareRefs.filter(ref => + !(ref.kind === "note" && (ref.contextId === note.id || ref.key.toLowerCase() === noteKey)) + ); + const nextTags = (task.tags || []).filter(tag => tag.toLowerCase() !== noteTag); + + const noteRefsChanged = nextNoteRefs.length !== task.noteRefs.length; + const shareRefsChanged = nextShareRefs.length !== task.shareRefs.length; + const tagsChanged = nextTags.length !== (task.tags || []).length; + + if (!noteRefsChanged && !shareRefsChanged && !tagsChanged) { + return null; + } + + return { + tags: nextTags, + noteRefs: nextNoteRefs, + shareRefs: nextShareRefs + }; +}; + +const cleanupTasksForPermanentlyDeletedNote = async (note: Note) => { + const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ + fields: getTaskListFields(), + requestKey: null + }); + const affectedTasks = taskRecords + .filter((record: any) => !record.tags?.includes("__template__")) + .map((record: any) => mapRecordToTask(record)) + .filter(task => !!removeNoteReferencesFromTask(task, note)); + + for (const task of affectedTasks) { + const cleanup = removeNoteReferencesFromTask(task, note); + if (!cleanup) continue; + await updateTask(task.id, cleanup); + } +}; + +const purgeExpiredTrashIfNeeded = async () => { + if (!runDailyCleanupIfNeeded()) return; + + const expiryMs = 7 * 24 * 60 * 60 * 1000; + const cutoff = Date.now() - expiryMs; + const cutoffIso = new Date(cutoff).toISOString(); + const expiredTaskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`, + fields: "id,tags", + requestKey: null + }).catch(() => []); + const expiredTaskIds = expiredTaskRecords + .filter((record: any) => !record.tags?.includes("__template__")) + .map((record: any) => record.id as string); + const expiredNoteRecords = await pb.collection(NOTES_COLLECTION).getFullList({ + filter: `deletedAt != "" && deletedAt <= "${cutoffIso}"`, + fields: "id,title,key,content,tags,isPrivate,user,tasks,created,updated,deletedAt", + requestKey: null + }).catch(() => []); + const expiredNotes = expiredNoteRecords.map((record: any) => mapRecordToNote(record)); + + for (const taskId of expiredTaskIds) { + await deleteTaskPermanently(taskId); + } + + for (const note of expiredNotes) { + await deleteNotePermanently(note.id); + } +}; + +const STALE_COMPLETED_TASK_DAYS = 90; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const getStaleCompletedTaskDeletedAt = (task: Pick) => { + if (task.deletedAt || !task.completed) return null; + + const updatedAt = new Date(task.updated).getTime(); + if (Number.isNaN(updatedAt)) return null; + + const deletedAt = updatedAt + (STALE_COMPLETED_TASK_DAYS * MS_PER_DAY); + return deletedAt <= Date.now() ? deletedAt : null; +}; + +const taskHasAnyActiveContextAssignment = (task: Pick) => { + const currentUserId = pb.authStore.model?.id || ""; + if (currentUserId && taskHasPersonalContextAccess(task, currentUserId, true)) { + return true; + } + + const hasActiveShareContext = task.shareRefs.some(ref => !!getContextByRef(ref)); + if (hasActiveShareContext) { + return true; + } + + return task.noteRefs.some(ref => !!getNoteByRef(ref)); +}; + +const runFinalInitTaskAutoTrash = async () => { + if (!pb.authStore.isValid) return; + + const currentUserId = pb.authStore.model?.id; + if (!currentUserId) return; + + const visibleTasks = store.tasks.filter(task => + !task.deletedAt && + !task.tags?.includes("__template__") && + shouldTaskBeVisible(task, currentUserId) + ); + + for (const task of visibleTasks) { + if (task.deletedAt) continue; + + const staleCompletedDeletedAt = getStaleCompletedTaskDeletedAt(task); + if (staleCompletedDeletedAt) { + await updateTask(task.id, { deletedAt: staleCompletedDeletedAt }); + continue; + } + + if (!taskHasAnyActiveContextAssignment(task)) { + await updateTask(task.id, { deletedAt: Date.now() }); + } + } }; const recordHasField = (record: any, field: string) => @@ -1419,7 +1571,7 @@ const mapRecordToTask = (r: any): Task => { : parsedTags .filter(tag => tag.kind === "note") .map(tag => { - const note = store.notes.find(existing => existing.key === tag.key); + const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key @@ -2348,7 +2500,6 @@ export const initStore = async () => { heartbeatInterval = setInterval(() => { setNow(Date.now()); checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.) - runDailyCleanupIfNeeded(); // Once-per-day completed task cleanup }, 60000); try { @@ -2405,7 +2556,6 @@ export const initStore = async () => { // --- WINDOWED SYNC STRATEGY --- - // STAGE 2 & 3: Background Sync const backgroundSync = async () => { const subscribedBucketTags = getSubscribedBucketContexts() @@ -2574,6 +2724,9 @@ export const initStore = async () => { // Defer note metadata sync until task loading is fully settled. scheduleDeferredNotesMetadataLoad(); + + await purgeExpiredTrashIfNeeded(); + await runFinalInitTaskAutoTrash(); } catch (err) { if (store.tasks.length === 0) { console.error("Failed to load data:", err); @@ -3139,7 +3292,7 @@ export const deleteTaskPermanently = async (id: string) => { if (!pb.authStore.isValid) return; // Optimistic - setStore("tasks", (t) => t.filter((t) => t.id !== id)); + removeDeletedTaskFromStore(id); try { await pb.collection(TASGRID_COLLECTION).delete(id); @@ -3289,8 +3442,12 @@ export const restoreNote = (id: string) => { export const deleteNotePermanently = async (id: string) => { if (!pb.authStore.isValid) return; - // Optimistic - setStore("notes", (n) => n.filter((n) => n.id !== id)); + const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id); + if (note) { + await cleanupTasksForPermanentlyDeletedNote(note); + } + + removeDeletedNoteFromStore(id); try { await pb.collection(NOTES_COLLECTION).delete(id); diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index 5632f25..0329a5b 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -946,7 +946,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props Trash -

Items are permanently deleted after 7 days.

+

Expired trash is permanently deleted when the app loads.

{isTrashOpen() ? : }