auto-cleanup implemented
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user