auto-cleanup implemented
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m12s

This commit is contained in:
2026-03-25 12:25:37 -05:00
parent 3876047dbe
commit 446d599ed2
3 changed files with 179 additions and 13 deletions
+9
View File
@@ -60,6 +60,15 @@ export const Layout: Component<LayoutProps> = (props) => {
}
});
createEffect(() => {
const noteId = selectedNoteId();
if (!noteId) return;
const noteExists = store.notes.some(note => note.id === noteId);
if (!noteExists) {
setSelectedNoteId(null);
}
});
return (
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
{/* Desktop UI Container */}
+169 -12
View File
@@ -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<TaskShareRef["kind"], "collaborative" | "handoff"> = {
user: "collaborative",
@@ -634,7 +634,7 @@ export const getNoteSharePolicy = (note: Pick<Note, "tags"> | null | undefined):
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "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<string, "collaborative" | "handoff"> = {}
): 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<Task> | 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<Task, "completed" | "updated" | "deletedAt">) => {
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<Task, "ownerId" | "shareRefs" | "noteRefs">) => {
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);
+1 -1
View File
@@ -946,7 +946,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<Trash2 size={16} />
Trash
</h3>
<p class="text-sm text-muted-foreground truncate">Items are permanently deleted after 7 days.</p>
<p class="text-sm text-muted-foreground truncate">Expired trash is permanently deleted when the app loads.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isTrashOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}