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 (
|
return (
|
||||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||||
{/* Desktop UI Container */}
|
{/* Desktop UI Container */}
|
||||||
|
|||||||
+169
-12
@@ -606,7 +606,7 @@ const findContextForShareTag = (tag: { key: string; raw: string }) =>
|
|||||||
const getContextByRef = (ref: TaskShareRef) =>
|
const getContextByRef = (ref: TaskShareRef) =>
|
||||||
(ref.kind === "note"
|
(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;
|
return note ? buildNoteContext(note) : null;
|
||||||
})()
|
})()
|
||||||
: null) ||
|
: null) ||
|
||||||
@@ -617,8 +617,8 @@ const getContextByRef = (ref: TaskShareRef) =>
|
|||||||
null;
|
null;
|
||||||
|
|
||||||
const getNoteByRef = (ref: NoteRef) =>
|
const getNoteByRef = (ref: NoteRef) =>
|
||||||
store.notes.find(note => note.id === ref.noteId) ||
|
store.notes.find(note => !note.deletedAt && note.id === ref.noteId) ||
|
||||||
store.notes.find(note => note.key === ref.key);
|
store.notes.find(note => !note.deletedAt && note.key === ref.key);
|
||||||
|
|
||||||
const DEFAULT_SHARE_POLICY_BY_KIND: Record<TaskShareRef["kind"], "collaborative" | "handoff"> = {
|
const DEFAULT_SHARE_POLICY_BY_KIND: Record<TaskShareRef["kind"], "collaborative" | "handoff"> = {
|
||||||
user: "collaborative",
|
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" => {
|
export const getTaskSharePolicy = (ref: Pick<TaskShareRef, "kind" | "key" | "policy" | "contextId">): "collaborative" | "handoff" => {
|
||||||
if (ref.kind === "note") {
|
if (ref.kind === "note") {
|
||||||
if (ref.policy) return ref.policy;
|
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);
|
return getNoteSharePolicy(note);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +705,7 @@ const buildTaskShareRef = (
|
|||||||
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
|
shareModeOverrides: Record<string, "collaborative" | "handoff"> = {}
|
||||||
): TaskShareRef => {
|
): TaskShareRef => {
|
||||||
if (parsedTag.kind === "note") {
|
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 = {
|
const nextRefBase = {
|
||||||
contextId: note?.id || parsedTag.key,
|
contextId: note?.id || parsedTag.key,
|
||||||
key: parsedTag.key,
|
key: parsedTag.key,
|
||||||
@@ -761,7 +761,7 @@ export const deriveTaskRelationsFromTags = (
|
|||||||
const noteRefs = parsedTags
|
const noteRefs = parsedTags
|
||||||
.filter(tag => tag.kind === "note")
|
.filter(tag => tag.kind === "note")
|
||||||
.map(tag => {
|
.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 {
|
return {
|
||||||
noteId: note?.id || tag.key,
|
noteId: note?.id || tag.key,
|
||||||
key: tag.key
|
key: tag.key
|
||||||
@@ -789,6 +789,158 @@ const runDailyCleanupIfNeeded = () => {
|
|||||||
const today = new Date().toLocaleDateString('en-CA');
|
const today = new Date().toLocaleDateString('en-CA');
|
||||||
if (store.lastCompletedTaskCleanupDate === today) return;
|
if (store.lastCompletedTaskCleanupDate === today) return;
|
||||||
setStore("lastCompletedTaskCleanupDate", today);
|
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) =>
|
const recordHasField = (record: any, field: string) =>
|
||||||
@@ -1419,7 +1571,7 @@ const mapRecordToTask = (r: any): Task => {
|
|||||||
: parsedTags
|
: parsedTags
|
||||||
.filter(tag => tag.kind === "note")
|
.filter(tag => tag.kind === "note")
|
||||||
.map(tag => {
|
.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 {
|
return {
|
||||||
noteId: note?.id || tag.key,
|
noteId: note?.id || tag.key,
|
||||||
key: tag.key
|
key: tag.key
|
||||||
@@ -2348,7 +2500,6 @@ export const initStore = async () => {
|
|||||||
heartbeatInterval = setInterval(() => {
|
heartbeatInterval = setInterval(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.)
|
checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.)
|
||||||
runDailyCleanupIfNeeded(); // Once-per-day completed task cleanup
|
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2405,7 +2556,6 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// --- WINDOWED SYNC STRATEGY ---
|
// --- WINDOWED SYNC STRATEGY ---
|
||||||
|
|
||||||
|
|
||||||
// STAGE 2 & 3: Background Sync
|
// STAGE 2 & 3: Background Sync
|
||||||
const backgroundSync = async () => {
|
const backgroundSync = async () => {
|
||||||
const subscribedBucketTags = getSubscribedBucketContexts()
|
const subscribedBucketTags = getSubscribedBucketContexts()
|
||||||
@@ -2574,6 +2724,9 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// Defer note metadata sync until task loading is fully settled.
|
// Defer note metadata sync until task loading is fully settled.
|
||||||
scheduleDeferredNotesMetadataLoad();
|
scheduleDeferredNotesMetadataLoad();
|
||||||
|
|
||||||
|
await purgeExpiredTrashIfNeeded();
|
||||||
|
await runFinalInitTaskAutoTrash();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (store.tasks.length === 0) {
|
if (store.tasks.length === 0) {
|
||||||
console.error("Failed to load data:", err);
|
console.error("Failed to load data:", err);
|
||||||
@@ -3139,7 +3292,7 @@ export const deleteTaskPermanently = async (id: string) => {
|
|||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
// Optimistic
|
// Optimistic
|
||||||
setStore("tasks", (t) => t.filter((t) => t.id !== id));
|
removeDeletedTaskFromStore(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pb.collection(TASGRID_COLLECTION).delete(id);
|
await pb.collection(TASGRID_COLLECTION).delete(id);
|
||||||
@@ -3289,8 +3442,12 @@ export const restoreNote = (id: string) => {
|
|||||||
export const deleteNotePermanently = async (id: string) => {
|
export const deleteNotePermanently = async (id: string) => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
// Optimistic
|
const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id);
|
||||||
setStore("notes", (n) => n.filter((n) => n.id !== id));
|
if (note) {
|
||||||
|
await cleanupTasksForPermanentlyDeletedNote(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDeletedNoteFromStore(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||||
|
|||||||
@@ -946,7 +946,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
Trash
|
Trash
|
||||||
</h3>
|
</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>
|
||||||
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
|
<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} />}
|
{isTrashOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user