diff --git a/src/store/bootstrap.ts b/src/store/bootstrap.ts new file mode 100644 index 0000000..feea960 --- /dev/null +++ b/src/store/bootstrap.ts @@ -0,0 +1,929 @@ +import { createEffect, createRoot } from "solid-js"; +import { toast } from "solid-sonner"; +import { pb } from "@/lib/pocketbase"; +import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants"; +import { PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED, TASGRID_IS_DEV_DATA } from "@/lib/app-config"; +import { buildNoteTag, buildShareTag, normalizeNoteKey } from "@/lib/tags"; +import { + activeNoteId, + applyScopedPrefsToStore, + currentTaskContext, + getPersistedStoreSnapshot, + getStorageKey, + type Note, + readScopedPrefs, + setCurrentTaskContext, + setNow, + setStore, + store, + type Task, + unwrapRelationId, + writeScopedPrefs +} from "./state"; +import { + buildPersonalAccessFilter, + getNoteByRef, + getPersonalContext, + getSubscribedBucketContexts, + loadContextsIntoStore, + mapContextToShareRule, + mapRecordToContext, + relationFilter, + setContexts, + shouldTaskBeVisible, + syncSupervisedContexts, + syncSystemTagsAndRules, + syncUserContexts, + taskHasPersonalContextAccess +} from "./contexts"; +import { deleteNotePermanently, loadedNoteDetailIds, mapRecordToNote, scheduleDeferredNotesMetadataLoad } from "./notes"; +import { loadFilterTemplates, syncPreferences } from "./preferences"; +import { + buildNoteLinkMap, + buildTaskMigrationPayload, + checkRecurringTasks, + deleteTaskPermanently, + ensureUpdatedByFieldSupport, + getCombinedScore, + getTaskListFields, + mapRecordToTask, + upsertTagDefinition +} from "./tasks"; + +let realtimeRecoveryAttached = false; +let realtimeRecoveryTimer: number | undefined; +let lastRealtimeRecoveryAt = 0; +let contextSyncInterval: number | undefined; +let heartbeatInterval: number | undefined; +let quickloadFadeTimer: number | undefined; + +const scheduleRealtimeRecovery = (reason: string, delayMs = 400) => { + if (typeof window === "undefined") return; + if (!pb.authStore.isValid) return; + + const nowTs = Date.now(); + if (nowTs - lastRealtimeRecoveryAt < 10000) return; + + if (realtimeRecoveryTimer) { + window.clearTimeout(realtimeRecoveryTimer); + } + + realtimeRecoveryTimer = window.setTimeout(async () => { + realtimeRecoveryTimer = undefined; + if (!pb.authStore.isValid || store.isInitializing) return; + lastRealtimeRecoveryAt = Date.now(); + console.log(`[REALTIME] Recovering subscriptions after ${reason}`); + try { + await initStore(); + } catch (err) { + console.warn("[REALTIME] Recovery failed:", err); + } + }, delayMs); +}; + +const ensureRealtimeRecovery = () => { + if (realtimeRecoveryAttached || typeof window === "undefined") return; + realtimeRecoveryAttached = true; + + const recoverIfDisconnected = (reason: string) => { + if (!pb.authStore.isValid) return; + if (pb.realtime.isConnected) return; + scheduleRealtimeRecovery(reason); + }; + + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + recoverIfDisconnected("visibilitychange"); + } + }); + + window.addEventListener("focus", () => recoverIfDisconnected("focus")); + window.addEventListener("online", () => recoverIfDisconnected("online")); + + pb.realtime.onDisconnect = () => { + if (!pb.authStore.isValid) return; + }; +}; + +export const startContextPolling = () => { + if (contextSyncInterval) { + clearInterval(contextSyncInterval); + contextSyncInterval = undefined; + } + + const ctx = currentTaskContext(); + const personalOrUserId = + typeof ctx === "object" && "userId" in ctx + ? ctx.userId + : typeof ctx === "object" && "bucketId" in ctx + ? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId + : null; + + if (personalOrUserId) { + contextSyncInterval = window.setInterval(async () => { + if (!pb.authStore.isValid) return; + + try { + const timeStr = new Date(Date.now() - 30000).toISOString(); + const contextFilter = buildPersonalAccessFilter([personalOrUserId]); + const records = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `${contextFilter} && updated >= "${timeStr}"`, + sort: "-updated", + fields: getTaskListFields(), + requestKey: "context-poll" + }); + + if (records.length === 0) return; + + setStore("tasks", currentTasks => { + const taskMap = new Map(currentTasks.map(t => [t.id, t])); + let changed = false; + + records.forEach((r: any) => { + if (r.tags?.includes("__template__")) return; + + const existing = taskMap.get(r.id); + if (existing && existing.updated === r.updated) return; + + const newTask = mapRecordToTask(r); + if (existing && existing.content) { + newTask.content = existing.content; + } + taskMap.set(r.id, newTask); + changed = true; + }); + + if (changed) return Array.from(taskMap.values()); + return currentTasks; + }); + } catch (err: any) { + if (!err.isAbort) { + console.warn("Context poll failed:", err); + } + } + }, 5000); + } +}; + +createRoot(() => { + createEffect(() => { + currentTaskContext(); + startContextPolling(); + }); +}); + +const runDailyCleanupIfNeeded = () => { + const today = new Date().toLocaleDateString("en-CA"); + if (store.lastCompletedTaskCleanupDate === today) return; + setStore("lastCompletedTaskCleanupDate", today); + return true; +}; + +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 => !!store.contexts.find(context => context.id === ref.contextId && !context.deletedAt)); + 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) { + const { updateTask } = await import("./tasks"); + await updateTask(task.id, { deletedAt: staleCompletedDeletedAt }); + continue; + } + + if (!taskHasAnyActiveContextAssignment(task)) { + const { updateTask } = await import("./tasks"); + await updateTask(task.id, { deletedAt: Date.now() }); + } + } +}; + +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); + } +}; + +export const subscribeToRealtime = async () => { + const currentUserId = pb.authStore.model?.id; + + await pb.collection(TASGRID_COLLECTION).unsubscribe("*"); + + pb.collection(TASGRID_COLLECTION).subscribe("*", e => { + console.log("Realtime event:", e.action, e.record.id); + + if (e.action === "create") { + const isTemplate = e.record.tags?.includes("__template__"); + if (isTemplate) { + const templateExists = store.templates?.some(t => t.id === e.record.id); + if (templateExists) return; + + let meta: any = {}; + try { meta = JSON.parse(e.record.content || "{}"); } catch { } + const newTemplate = { + id: e.record.id, + name: e.record.title, + title: meta.title || "", + priority: e.record.priority, + urgency: meta.urgency || 5, + tags: meta.tags || [], + content: meta.content || "" + }; + setStore("templates", t => [...(t || []), newTemplate]); + return; + } + + const exists = store.tasks.find(t => t.id === e.record.id); + if (!exists) { + const authUserId = pb.authStore.model?.id; + const isOwnTask = unwrapRelationId(e.record.user) === authUserId; + const newTask = mapRecordToTask(e.record); + + if (isOwnTask || (authUserId && shouldTaskBeVisible(newTask, authUserId))) { + setStore("tasks", t => [newTask, ...t]); + } + } + } + + if (e.action === "update") { + const isTemplate = e.record.tags?.includes("__template__"); + if (isTemplate) { + let meta: any = {}; + try { meta = JSON.parse(e.record.content || "{}"); } catch { } + setStore("templates", t => t.id === e.record.id, { + name: e.record.title, + title: meta.title || "", + priority: e.record.priority, + urgency: meta.urgency || 5, + tags: meta.tags || [], + content: meta.content || "" + }); + return; + } + + const authUserId = pb.authStore.model?.id; + const existingTask = store.tasks.find(t => t.id === e.record.id); + const isOwnTask = unwrapRelationId(e.record.user) === authUserId; + const incomingUpdated = new Date(e.record.updated).getTime(); + const updatedTask = mapRecordToTask(e.record); + + if (existingTask) { + const localUpdated = new Date(existingTask.updated).getTime(); + + if (!existingTask.updated || incomingUpdated >= localUpdated) { + if (!isOwnTask && authUserId && !shouldTaskBeVisible(updatedTask, authUserId)) { + setStore("tasks", t => t.filter(x => x.id !== e.record.id)); + return; + } + + setStore("tasks", t => t.id === e.record.id, updatedTask); + } else { + console.log("Skipping stale realtime update for task:", e.record.id); + } + } else if (authUserId && shouldTaskBeVisible(updatedTask, authUserId)) { + setStore("tasks", t => [updatedTask, ...t]); + } + } + + if (e.action === "delete") { + setStore("tasks", t => t.filter(x => x.id !== e.record.id)); + setStore("templates", t => (t || []).filter(x => x.id !== e.record.id)); + } + }); + + await pb.collection(NOTES_COLLECTION).unsubscribe("*"); + pb.collection(NOTES_COLLECTION).subscribe("*", e => { + const authUserId = pb.authStore.model?.id; + const isVisible = !e.record.isPrivate || unwrapRelationId(e.record.user) === authUserId; + + if (e.action === "create" || e.action === "update") { + if (isVisible) { + const shouldSyncContent = loadedNoteDetailIds.has(e.record.id) || activeNoteId() === e.record.id; + const updatedNote = mapRecordToNote(e.record, shouldSyncContent); + setStore("notes", prev => { + const exists = prev.find(n => n.id === updatedNote.id); + if (exists) { + const mergedNote = shouldSyncContent || exists.content === undefined + ? updatedNote + : { ...updatedNote, content: exists.content }; + return prev.map(n => n.id === updatedNote.id ? mergedNote : n); + } + return [updatedNote, ...prev]; + }); + } else { + setStore("notes", prev => prev.filter(n => n.id !== e.record.id)); + } + } + + if (e.action === "delete") { + setStore("notes", prev => prev.filter(n => n.id !== e.record.id)); + } + }); + + await pb.collection(CONTEXTS_COLLECTION).unsubscribe("*"); + pb.collection(CONTEXTS_COLLECTION).subscribe("*", e => { + if (e.action === "delete") { + const remaining = store.contexts.filter(context => context.id !== e.record.id); + setContexts(remaining); + setStore("shareRules", remaining.map(mapContextToShareRule)); + setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || ""))); + void syncSupervisedContexts(); + return; + } + + const context = mapRecordToContext(e.record); + const nextContexts = store.contexts.some(existing => existing.id === context.id) + ? store.contexts.map(existing => existing.id === context.id ? context : existing) + : [...store.contexts, context]; + setContexts(nextContexts); + setStore("shareRules", nextContexts.map(mapContextToShareRule)); + if (context.targetUserId === pb.authStore.model?.id) { + setStore("personalContextSupervisorIds", context.supervisorUserIds || []); + } + void syncSupervisedContexts(); + }); + + if (currentUserId) { + await pb.collection("users").unsubscribe(currentUserId); + pb.collection("users").subscribe(currentUserId, e => { + if (e.action !== "update") return; + + const prefs = readScopedPrefs(e.record?.Taskgrid_pref || {}); + applyScopedPrefsToStore({ + ...prefs, + subscribedBuckets: prefs.subscribedBuckets || e.record?.subscribedBuckets || [] + }); + }); + } +}; + +const runLegacyTagMigration = async (force = false) => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId) return; + + try { + const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null }); + const rawPrefs = userRec.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + + if (!force && prefs.migratedStructuredTagsV2) { + return; + } + + console.log("[MIGRATION] Starting structured tag migration..."); + + await syncUserContexts(); + const contexts = await loadContextsIntoStore(); + const ownerContextByUserId = new Map( + contexts + .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) + .map(context => [context.targetUserId as string, context]) + ); + + const notes = await pb.collection(NOTES_COLLECTION).getFullList({ + requestKey: null + }); + const migratedNotes: Note[] = []; + for (const noteRecord of notes) { + const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id); + const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : []; + const nextTags = [...new Set([buildNoteTag(key), ...existingTags])]; + const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))]; + + await pb.collection(NOTES_COLLECTION).update(noteRecord.id, { + key, + tags: nextTags, + tasks: nextTasks + }, { requestKey: null }); + + migratedNotes.push({ + ...mapRecordToNote(noteRecord), + key, + tags: nextTags, + tasks: nextTasks + }); + } + setStore("notes", migratedNotes); + const noteLinksByTaskId = buildNoteLinkMap(migratedNotes); + + const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({ + requestKey: null + }); + for (const taskRecord of tasks) { + if (taskRecord.tags?.includes("__template__")) continue; + const task = mapRecordToTask(taskRecord); + const payload = buildTaskMigrationPayload( + task, + ownerContextByUserId, + noteLinksByTaskId.get(task.id) || [], + currentUserId + ); + await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null }); + } + + const nextPrefs = { ...prefs, migratedStructuredTagsV2: true }; + await pb.collection("users").update(currentUserId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs) + }, { requestKey: null }); + } catch (err) { + console.error("[MIGRATION] Failed:", err); + } +}; + +const runContextOwnershipMigration = async (force = false) => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId) return; + + try { + const userRec = await pb.collection("users").getOne(currentUserId, { requestKey: null }); + const rawPrefs = userRec.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + + if (!force && prefs.migratedContextOwnershipV2) { + return; + } + + await syncUserContexts(); + const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore(); + const ownerContextByUserId = new Map( + contexts + .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) + .map(context => [context.targetUserId as string, context]) + ); + const noteRecords = await pb.collection(NOTES_COLLECTION).getFullList({ + fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt", + requestKey: null + }).catch(() => []); + const allNotes = noteRecords.map(record => mapRecordToNote(record)); + const noteLinksByTaskId = buildNoteLinkMap(allNotes); + + const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ + fields: getTaskListFields(), + requestKey: null + }); + + for (const taskRecord of taskRecords) { + if (taskRecord.tags?.includes("__template__")) continue; + + const task = mapRecordToTask(taskRecord); + if (!task.ownerId) continue; + const payload = buildTaskMigrationPayload( + task, + ownerContextByUserId, + noteLinksByTaskId.get(task.id) || [], + currentUserId + ); + + const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs); + const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs); + const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags); + const sameOwnerId = payload.ownerId === task.ownerId; + if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy && sameOwnerId) continue; + + const pbPayload = { + ...payload, + user: payload.ownerId || task.ownerId || null + } as any; + delete pbPayload.ownerId; + + await pb.collection(TASGRID_COLLECTION).update(task.id, pbPayload, { requestKey: null }); + } + + await pb.collection("users").update(currentUserId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...prefs, + migratedPersonalContextRefsV1: true, + migratedContextOwnershipV2: true + }) + }, { requestKey: null }); + } catch (err) { + console.error("[MIGRATION] Failed context ownership migration:", err); + } +}; + +export const runDevDataMigration = async () => { + if (!pb.authStore.isValid) return; + if (!TASGRID_IS_DEV_DATA) { + toast.error("Dev migration is only available in dev data mode."); + return; + } + + await toast.promise((async () => { + await runLegacyTagMigration(true); + await runContextOwnershipMigration(true); + await loadContextsIntoStore(); + await syncSupervisedContexts(); + return "Dev data migration completed."; + })(), { + loading: "Running dev data migration...", + success: message => message, + error: "Dev data migration failed." + }); +}; + +export const canRunProdMigration = () => { + if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false; + const email = (pb.authStore.model?.email || "").trim().toLowerCase(); + return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email); +}; + +export const runProdDataMigration = async () => { + if (!pb.authStore.isValid) return; + if (!canRunProdMigration()) { + toast.error("Prod migration is not enabled for this account."); + return; + } + + await toast.promise((async () => { + await runLegacyTagMigration(true); + await runContextOwnershipMigration(true); + await loadContextsIntoStore(); + await syncSupervisedContexts(); + return "Production data migration completed."; + })(), { + loading: "Running production data migration...", + success: message => message, + error: "Production data migration failed." + }); +}; + +export const initStore = async () => { + ensureRealtimeRecovery(); + if (!pb.authStore.isValid || store.isInitializing) return; + setStore("isInitializing", true); + + const currentUserId = pb.authStore.model?.id; + const refreshedTaskIds = new Set(); + + if (currentUserId) { + try { + const prefs = readScopedPrefs(pb.authStore.model?.Taskgrid_pref || {}); + const cachedQuickload = prefs.quickloadTasks || []; + + let focusRecords; + if (cachedQuickload.length > 0) { + const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(" || "); + const res = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `(${idFilter}) && deletedAt = ""`, + requestKey: null + }); + + res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id)); + focusRecords = { items: res }; + } else { + const personalAccessFilter = buildPersonalAccessFilter([currentUserId]); + focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, { + filter: `${personalAccessFilter} && completed = false && deletedAt = ""`, + sort: "-priority,dueDate", + requestKey: null + }); + } + + const focusTasks = focusRecords.items + .map(mapRecordToTask) + .filter(t => !t.tags?.includes("__template__")); + + setStore("tasks", focusTasks); + setStore("quickloadFadeTaskIds", focusTasks.map(task => task.id)); + focusTasks.forEach(task => refreshedTaskIds.add(task.id)); + if (quickloadFadeTimer) clearTimeout(quickloadFadeTimer); + quickloadFadeTimer = window.setTimeout(() => { + setStore("quickloadFadeTaskIds", []); + quickloadFadeTimer = undefined; + }, 200); + } catch (focusErr) { + console.warn("Stage 1 load failed:", focusErr); + } + } + + const key = getStorageKey(); + if (key) { + const saved = localStorage.getItem(key); + if (saved) { + try { + const parsed = JSON.parse(saved); + const cachedTasks = Array.isArray(parsed?.tasks) ? parsed.tasks : []; + const mergedCachedTasks = [ + ...store.tasks, + ...cachedTasks.filter((task: Task) => !refreshedTaskIds.has(task.id)) + ]; + setStore({ + ...parsed, + tasks: mergedCachedTasks, + isInitializing: true + }); + } catch (e) { + console.error("Failed to parse cached data", e); + } + } + } + + if (heartbeatInterval) clearInterval(heartbeatInterval); + heartbeatInterval = setInterval(() => { + setNow(Date.now()); + checkRecurringTasks(); + }, 60000); + + try { + try { + const userId = pb.authStore.model?.id; + if (userId) { + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); + applyScopedPrefsToStore({ + ...prefs, + subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || [] + }); + } + } catch (prefErr) { + console.warn("Failed to load preferences:", prefErr); + } + + try { + const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + sort: "displayName,key", + requestKey: null + }); + const contexts = contextRecords.map(mapRecordToContext); + setContexts(contexts); + setStore("shareRules", contexts.map(mapContextToShareRule)); + await syncUserContexts(); + await syncSupervisedContexts(); + const personalContext = getPersonalContext(pb.authStore.model?.id); + if (personalContext) { + setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []); + } + if (personalContext && currentTaskContext() === "mine") { + setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true }); + } + } catch (contextErr) { + console.warn("Failed to load contexts (collection might not exist yet):", contextErr); + } + + await syncSystemTagsAndRules(); + + const authUserId = pb.authStore.model?.id; + if (!authUserId) return; + + const backgroundSync = async () => { + const subscribedBucketTags = getSubscribedBucketContexts() + .map(context => buildShareTag(context.displayName)); + const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); + const currentUserAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []); + const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds); + + const incompletePromises = [ + pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `${currentUserAccessFilter} && completed = false`, + sort: "-created", + fields: getTaskListFields(), + requestKey: null + }), + subscribedBucketTags.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(" || ")}) && completed = false`, + sort: "-created", + fields: getTaskListFields(), + requestKey: null + }).catch(() => []) : Promise.resolve([]), + supervisedUserIds.length > 0 ? pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `${supervisedAccessFilter} && completed = false`, + sort: "-created", + fields: getTaskListFields(), + requestKey: null + }).catch(() => []) : Promise.resolve([]) + ]; + + const stage2Results = await Promise.all(incompletePromises); + const allIncomplete = stage2Results.flat(); + + setStore("tasks", currentTasks => { + const taskMap = new Map(currentTasks.map(t => [t.id, t])); + allIncomplete.forEach((r: any) => { + if (r.tags?.includes("__template__")) return; + if (refreshedTaskIds.has(r.id)) return; + + const existing = taskMap.get(r.id); + if (existing && existing.updated === r.updated) { + return; + } + + const newTask = mapRecordToTask(r); + if (existing && existing.content) { + newTask.content = existing.content; + } + taskMap.set(r.id, newTask); + refreshedTaskIds.add(r.id); + }); + return Array.from(taskMap.values()); + }); + + const tasksNeedingContent = store.tasks + .filter(t => !t.completed && t.content === undefined && !refreshedTaskIds.has(t.id)) + .map(t => t.id); + if (tasksNeedingContent.length > 0) { + const chunkSize = 20; + for (let i = 0; i < tasksNeedingContent.length; i += chunkSize) { + const ids = tasksNeedingContent.slice(i, i + chunkSize); + try { + const contentRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: ids.map(id => `id="${id}"`).join(" || "), + fields: "id,content", + requestKey: null + }); + + setStore("tasks", tasks => tasks.map(t => { + const match = contentRecords.find((r: any) => r.id === t.id); + if (match) return { ...t, content: match.content }; + return t; + })); + } catch (e) { + console.warn("Backfill chunk failed", e); + } + } + } + + const currentCount = store.tasks.length; + const remainingSlots = 150 - currentCount; + + if (remainingSlots > 0) { + try { + const completedAccessFilter = buildPersonalAccessFilter(authUserId ? [authUserId] : []); + const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, { + filter: `${completedAccessFilter} && completed = true`, + sort: "-updated", + fields: getTaskListFields(), + requestKey: null + }); + + setStore("tasks", prev => { + const newTasks = [...prev]; + recentCompleted.items.forEach((r: any) => { + if (!newTasks.some(t => t.id === r.id)) { + newTasks.push(mapRecordToTask(r)); + } + }); + return newTasks; + }); + } catch (e) { + console.warn("Stage 3 load failed", e); + } + } + + checkRecurringTasks(); + + const foundTagNames = new Set(); + store.tasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag))); + for (const tagName of foundTagNames) { + if (tagName === "__template__" || tagName.startsWith("@") || tagName.startsWith("#")) continue; + if (!store.tagDefinitions.find(d => d.name === tagName)) { + await upsertTagDefinition(tagName, 5, undefined, document.documentElement.classList.contains("dark") ? "dark" : "light"); + } + } + }; + + await backgroundSync(); + await ensureUpdatedByFieldSupport(); + + if (TASGRID_IS_DEV_DATA) { + await runLegacyTagMigration(); + await runContextOwnershipMigration(); + } + + try { + const templates = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `${relationFilter("user", authUserId)} && tags ~ "__template__"`, + requestKey: null + }); + const loadedTemplates = templates.map(r => { + let meta: any = {}; + try { meta = JSON.parse(r.content || "{}"); } catch { } + return { + id: r.id, + name: r.title, + title: meta.title || "", + priority: r.priority, + urgency: meta.urgency || 5, + tags: meta.tags || [], + content: meta.content || "" + }; + }); + setStore("templates", loadedTemplates); + } catch { } + + await subscribeToRealtime(); + await loadFilterTemplates(); + scheduleDeferredNotesMetadataLoad(); + + await purgeExpiredTrashIfNeeded(); + await runFinalInitTaskAutoTrash(); + } catch (err) { + if (store.tasks.length === 0) { + console.error("Failed to load data:", err); + toast.error("Failed to sync with server."); + } + } finally { + setStore("isInitializing", false); + } +}; + +export const setupPersistence = () => { + initStore(); +}; + +createRoot(() => { + createEffect(() => { + const key = getStorageKey(); + if (key) { + localStorage.setItem(key, JSON.stringify(getPersistedStoreSnapshot())); + } + }); + + let debouncedSync: number | undefined; + createEffect(() => { + if (store.isInitializing || !pb.authStore.model?.id) return; + + const incompleteTasks = store.tasks.filter(t => + !t.completed && + taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) && + !t.tags?.includes("__template__") && + !t.deletedAt + ); + + const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) })); + scoredTasks.sort((a, b) => b.score - a.score); + + const top20Ids = scoredTasks.slice(0, 20).map(t => t.id); + const currentIds = store.quickloadTasks || []; + const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]); + + if (isDifferent) { + setStore("quickloadTasks", top20Ids); + + if (debouncedSync) window.clearTimeout(debouncedSync); + debouncedSync = window.setTimeout(() => { + syncPreferences(); + }, 10000); + } + }); +}); diff --git a/src/store/contexts.ts b/src/store/contexts.ts new file mode 100644 index 0000000..0aa644b --- /dev/null +++ b/src/store/contexts.ts @@ -0,0 +1,864 @@ +import { reconcile } from "solid-js/store"; +import { toast } from "solid-sonner"; +import { pb } from "@/lib/pocketbase"; +import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants"; +import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags"; +import { + activeNoteId, + currentTaskContext, + type Bucket, + type Note, + type NoteRef, + readScopedPrefs, + type ShareContext, + type ShareRule, + setStore, + store, + type TagDefinition, + type Task, + type TaskContext, + type TaskShareRef, + unwrapRelationId, + unwrapRelationIds, + writeScopedPrefs +} from "./state"; + +export const relationFilter = (field: string, id: string | undefined | null) => + id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false"; + +export const buildUserContextKey = (userId: string) => `user-${userId}`; + +const DEFAULT_LABEL_THEME: "light" | "dark" = "dark"; + +const mapContextToBucket = (context: ShareContext): Bucket => ({ + id: context.id, + name: context.displayName, + color: context.color || "#64748b", + description: context.description, + policy: context.policy, + key: context.key, + deletedAt: context.deletedAt ?? null +}); + +const deriveBuckets = (contexts: ShareContext[]) => + contexts + .filter(context => context.kind === "bucket" && !context.deletedAt) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + .map(mapContextToBucket); + +const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] => + contexts + .filter(context => !context.deletedAt) + .map(context => ({ + id: context.id, + name: buildShareTag(context.displayName), + value: 5, + color: context.color, + theme: DEFAULT_LABEL_THEME, + isUser: context.kind === "user", + isBucket: context.kind === "bucket" + })); + +const findContextForShareTag = (tag: { key: string; raw: string }) => + store.contexts.find(context => !context.deletedAt && context.key === tag.key) || + store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) || + store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) || + store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) || + null; + +const buildNoteContext = (note: Note): ShareContext => ({ + id: note.id, + key: note.key, + displayName: note.key || note.title, + kind: "note", + policy: getNoteSharePolicy(note), + targetUserId: note.user, + deletedAt: note.deletedAt ?? null +}); + +export const getContextByRef = (ref: TaskShareRef) => + (ref.kind === "note" + ? (() => { + const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key)); + return note ? buildNoteContext(note) : null; + })() + : null) || + store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) || + store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) || + store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) || + store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) || + null; + +export const getNoteByRef = (ref: NoteRef) => + 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", + bucket: "handoff", + note: "handoff" +}; + +const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__"; + +export const getNoteSharePolicy = (note: Pick | null | undefined): "collaborative" | "handoff" => + note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; + +export const getTaskSharePolicy = (ref: Pick): "collaborative" | "handoff" => { + if (ref.kind === "note") { + if (ref.policy) return ref.policy; + const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key)); + return getNoteSharePolicy(note); + } + + const context = getContextByRef(ref as TaskShareRef); + if (ref.policy) return ref.policy; + if (context?.policy) return context.policy; + + return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind]; +}; + +export const buildTaskTags = ( + labelTags: string[], + shareRefs: TaskShareRef[], + noteRefs: NoteRef[], + favoriteTag?: string +) => { + const tags = [ + ...labelTags, + ...shareRefs.map(ref => { + const context = getContextByRef(ref); + if (ref.kind === "note") { + return buildNoteTag(context?.key || ref.key); + } + if (context?.displayName) { + return buildShareTag(context.displayName); + } + if (ref.kind === "user" && ref.key.startsWith("user-")) { + return null; + } + return buildShareTag(ref.key); + }), + ...noteRefs.map(ref => { + const note = getNoteByRef(ref); + return buildNoteTag(note?.key || ref.key); + }) + ]; + + if (favoriteTag && !tags.includes(favoriteTag)) { + tags.push(favoriteTag); + } + + return [...new Set(tags.filter((tag): tag is string => !!tag))]; +}; + +export const dedupeShareRefs = (refs: TaskShareRef[]) => { + const seen = new Set(); + return refs.filter(ref => { + const key = `${ref.kind}:${ref.contextId}:${ref.key}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +export const buildTaskShareRef = ( + parsedTag: Extract[number], { kind: "share" | "note" }>, + existingRefs: TaskShareRef[] = [], + shareModeOverrides: Record = {} +): TaskShareRef => { + if (parsedTag.kind === "note") { + const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key); + const nextRefBase = { + contextId: note?.id || parsedTag.key, + key: parsedTag.key, + kind: "note" as const + }; + const existing = existingRefs.find(ref => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)); + return { + ...nextRefBase, + policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase) + }; + } + + const context = findContextForShareTag(parsedTag); + const nextRefBase = { + contextId: context?.id || parsedTag.key, + key: parsedTag.key, + kind: (context?.kind || "bucket") as "user" | "bucket" + }; + const existing = existingRefs.find(ref => + ref.kind === nextRefBase.kind && + (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key) + ); + return { + ...nextRefBase, + policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase) + }; +}; + +export const getTaskShareModeForTag = (task: Pick, tag: string): "collaborative" | "handoff" | undefined => { + const parsed = parseTags([tag])[0]; + if (!parsed || (parsed.kind !== "share" && parsed.kind !== "note")) return undefined; + + const ref = task.shareRefs.find(existing => + existing.key === parsed.key && ( + (parsed.kind === "note" && existing.kind === "note") || + (parsed.kind === "share" && existing.kind !== "note") + ) + ); + + return ref ? getTaskSharePolicy(ref) : undefined; +}; + +export const deriveTaskRelationsFromTags = ( + tags: string[], + existingShareRefs: TaskShareRef[] = [], + shareModeOverrides: Record = {} +) => { + const parsedTags = parseTags(tags); + const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); + const shareRefs = parsedTags + .filter((tag): tag is Extract => tag.kind === "share" || tag.kind === "note") + .map(tag => buildTaskShareRef(tag, existingShareRefs, shareModeOverrides)); + const noteRefs = parsedTags + .filter(tag => tag.kind === "note") + .map(tag => { + const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key); + return { + noteId: note?.id || tag.key, + key: tag.key + } satisfies NoteRef; + }); + + return { + labelTags, + shareRefs: dedupeShareRefs(shareRefs), + noteRefs: dedupeNoteRefs(noteRefs) + }; +}; + +export const dedupeNoteRefs = (refs: NoteRef[]) => { + const seen = new Set(); + return refs.filter(ref => { + const key = `${ref.noteId}:${ref.key}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +export const setContexts = (contexts: ShareContext[]) => { + setStore("contexts", reconcile(contexts)); + setStore("buckets", reconcile(deriveBuckets(contexts))); + setStore("tasks", tasks => tasks.map(task => { + const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); + return { + ...task, + tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag) + }; + })); + + const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@")); + const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)]; + setStore("tagDefinitions", reconcile(mergedDefs)); +}; + +export const mapContextToShareRule = (context: ShareContext): ShareRule => ({ + id: context.id, + ownerId: pb.authStore.model?.id || "", + targetUserId: context.targetUserId || pb.authStore.model?.id || "", + type: "tag", + tagName: buildShareTag(context.displayName), + share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER" +}); + +export const mapRecordToContext = (r: any): ShareContext => ({ + id: r.id, + key: r.key || normalizeContextKey(r.displayName || r.name || ""), + displayName: r.displayName || r.name || r.key, + kind: (r.kind || "bucket") as "user" | "bucket", + policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff", + targetUserId: unwrapRelationId(r.targetUserId), + supervisorUserIds: unwrapRelationIds(r.supervisorUserIds), + color: r.color, + description: r.description, + deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null, + deletedBy: unwrapRelationId(r.deletedBy) +}); + +export const loadContextsIntoStore = async () => { + const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + sort: "displayName,key", + requestKey: null + }).catch(() => []); + const nextContexts = contextRecords.map(mapRecordToContext); + setContexts(nextContexts); + setStore("shareRules", nextContexts.map(mapContextToShareRule)); + return nextContexts; +}; + +export const getPersonalContext = (userId = pb.authStore.model?.id) => + userId + ? store.contexts.find(context => + !context.deletedAt && + context.kind === "user" && + context.targetUserId === userId + ) || null + : null; + +const getCurrentUserDisplayName = () => + (pb.authStore.model?.name || pb.authStore.model?.email || "").trim() || null; + +const getPersonalContextTag = (userId = pb.authStore.model?.id) => { + const context = getPersonalContext(userId); + if (context?.displayName) { + return buildShareTag(context.displayName); + } + + if (userId && userId === pb.authStore.model?.id) { + const displayName = getCurrentUserDisplayName(); + if (displayName) { + return buildShareTag(displayName); + } + } + + return null; +}; + +const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + +export const buildPersonalAccessFilter = (userIds: string[], includeLegacyUser = true) => { + const clauses = userIds.flatMap(userId => { + const nextClauses: string[] = []; + const personalTag = getPersonalContextTag(userId); + if (personalTag) { + nextClauses.push(`tags ~ "${escapeFilterValue(personalTag)}"`); + } + if (includeLegacyUser) { + nextClauses.push(relationFilter("user", userId)); + } + return nextClauses; + }).filter(Boolean); + + return clauses.length > 0 ? `(${clauses.join(" || ")})` : "false"; +}; + +export const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => { + if (ref.kind !== "user") return null; + + const context = getContextByRef(ref); + if (context?.targetUserId) { + return context.targetUserId; + } + + if (ref.key.startsWith("user-")) { + return ref.key.replace(/^user-/, "") || null; + } + + return null; +}; + +export const getTaskPersonalRefs = (task: Pick) => + task.shareRefs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref)); + +const getTaskPersonalUserIds = (task: Pick) => + [...new Set(getTaskPersonalRefs(task).map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))]; + +const taskHasLegacyPersonalAccess = (task: Pick, userId: string) => + task.ownerId === userId && + getTaskPersonalRefs(task).length === 0 && + !task.shareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"); + +export const taskHasPersonalContextAccess = (task: Pick, userId: string, allowLegacyFallback = true) => + getTaskPersonalUserIds(task).includes(userId) || + (allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId)); + +export const resolveMirroredOwnerId = ( + currentOwnerId: string | null | undefined, + shareRefs: TaskShareRef[], + preferredOwnerId?: string | null +) => { + const personalUserIds = [...new Set( + shareRefs + .filter(ref => ref.kind === "user") + .map(ref => getPersonalContextUserIdFromRef(ref)) + .filter((value): value is string => !!value) + )]; + + if (personalUserIds.length === 1) { + return personalUserIds[0]; + } + + if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) { + return preferredOwnerId; + } + + return currentOwnerId || null; +}; + +export const withMirroredOwnerId = ( + currentOwnerId: string | null | undefined, + shareRefs: TaskShareRef[], + updates: Partial, + preferredOwnerId?: string | null +) => ({ + ...updates, + ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId) +}); + +export const withActiveContextTags = (rawTags: string[]) => { + const nextTags = [...rawTags]; + const ctx = currentTaskContext(); + + if (ctx === "mine") { + const personalContext = getPersonalContext(); + if (personalContext) { + const personalTag = buildShareTag(personalContext.displayName); + if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) { + nextTags.push(personalTag); + } + } + } else if (typeof ctx === "object") { + if ("bucketId" in ctx && ctx.name) { + const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); + const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name); + if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) { + nextTags.push(bucketTag); + } + } else if ("userId" in ctx && ctx.name) { + const userTag = buildShareTag(ctx.name); + if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) { + nextTags.push(userTag); + } + } + } + + if (store.isNotepadMode) { + const noteId = activeNoteId(); + const note = noteId ? store.notes.find(existing => existing.id === noteId) : null; + if (note) { + const noteTag = buildNoteTag(note.key || note.title); + if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) { + nextTags.push(noteTag); + } + } + } + + return [...new Set(nextTags)]; +}; + +export const isCurrentUserContextTag = (tag: string) => { + const normalizedTag = tag.trim().toLowerCase(); + if (!normalizedTag.startsWith("@")) return false; + + const personalContext = getPersonalContext(); + const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim(); + const hiddenTags = new Set(); + + if (personalContext?.displayName) { + hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase()); + } + + if (currentUserDisplayName) { + hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase()); + } + + return hiddenTags.has(normalizedTag); +}; + +export const isTaskLockedNoteTag = (task: Pick, tag: string) => { + const normalizedTag = tag.trim(); + if (!normalizedTag.startsWith("#")) return false; + + const noteKey = normalizeNoteKey(normalizedTag); + const matchingNoteRefs = task.noteRefs.filter(ref => ref.key === noteKey || ref.noteId === noteKey); + if (matchingNoteRefs.length === 0) return false; + + const nonNoteContexts = task.shareRefs.filter(ref => ref.kind !== "note"); + const uniqueNoteRefs = dedupeNoteRefs(task.noteRefs); + return uniqueNoteRefs.length === matchingNoteRefs.length && nonNoteContexts.length === 0; +}; + +export const getVisibleTaskTags = (task: Pick, context: TaskContext = currentTaskContext()) => { + const tags = task.tags || []; + + return tags.filter(tag => { + if (tag.endsWith("_favorite__") || isCurrentUserContextTag(tag)) { + return false; + } + + if (isTaskLockedNoteTag(task, tag)) { + return false; + } + + if (typeof context === "object" && "bucketId" in context) { + const bucketContext = store.contexts.find(existing => existing.id === context.bucketId); + const bucketTagName = buildShareTag(bucketContext?.displayName || context.name).toLowerCase(); + if (tag.toLowerCase() === bucketTagName) { + return false; + } + } + + return true; + }); +}; + +export const getSubscribedBucketContexts = () => + store.subscribedBuckets + .map(id => store.contexts.find(context => context.id === id)) + .filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket"); + +export const shouldTaskBeVisible = (task: Task, currentUserId: string): boolean => { + if (taskHasPersonalContextAccess(task, currentUserId)) return true; + if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(task, context.ownerUserId))) return true; + + const hasSubscribedBucket = task.shareRefs.some(ref => + ref.kind === "bucket" && ( + store.subscribedBuckets.includes(ref.contextId) || + store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key) + ) + ); + if (hasSubscribedBucket) return true; + + const hasAccessibleNote = task.noteRefs.some(ref => { + const note = getNoteByRef(ref); + return !!note && (!note.isPrivate || note.user === currentUserId); + }); + if (hasAccessibleNote) return true; + + return task.shareRefs.some(ref => { + if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false; + const context = getContextByRef(ref); + return !!context && !context.deletedAt && context.targetUserId === currentUserId; + }); +}; + +export const syncUserContexts = async () => { + const allUsers = await pb.collection("users").getFullList({ + filter: "verified = true", + fields: "id,name,email", + requestKey: null + }); + const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + requestKey: null + }).catch(() => []); + let createdAny = false; + + for (const user of allUsers) { + const displayName = user.name || user.email; + const existingUserContext = existingContexts.find((context: any) => + (context.kind || "bucket") === "user" && + unwrapRelationId(context.targetUserId) === user.id + ); + if (!displayName || existingUserContext) continue; + + const created = await pb.collection(CONTEXTS_COLLECTION).create({ + key: buildUserContextKey(user.id), + displayName, + kind: "user", + policy: "collaborative", + targetUserId: user.id + }, { requestKey: null }).catch(() => null); + if (created) { + createdAny = true; + } + } + + if (createdAny || store.contexts.length === 0) { + await loadContextsIntoStore(); + } +}; + +export const syncSupervisedContexts = async () => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId) { + setStore("supervisedContexts", []); + return; + } + + try { + const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ + sort: "displayName,key", + requestKey: null + }).catch(() => []); + const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts; + + if (contextRecords.length > 0) { + setContexts(contexts); + setStore("shareRules", contexts.map(mapContextToShareRule)); + } + + const nextSupervisedContexts = contexts + .filter(context => + !context.deletedAt && + context.kind === "user" && + !!context.targetUserId && + context.targetUserId !== currentUserId && + (context.supervisorUserIds || []).includes(currentUserId) + ) + .map(context => ({ + ownerUserId: context.targetUserId as string, + ownerName: context.displayName, + contextId: context.id, + contextName: context.displayName + })) + .sort((a, b) => a.ownerName.localeCompare(b.ownerName)); + + setStore("supervisedContexts", nextSupervisedContexts); + } catch (err) { + console.error("Failed to load supervised contexts:", err); + } +}; + +export const toggleBucketSubscription = async (bucketId: string) => { + const currentSubscribed = store.subscribedBuckets; + const isSubscribed = currentSubscribed.includes(bucketId); + + const newSubscribed = isSubscribed + ? currentSubscribed.filter(id => id !== bucketId) + : [...currentSubscribed, bucketId]; + + setStore("subscribedBuckets", newSubscribed); + + const userId = pb.authStore.model?.id; + if (userId) { + try { + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const scopedPrefs = readScopedPrefs(rawPrefs); + await pb.collection("users").update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...scopedPrefs, + subscribedBuckets: newSubscribed + }) + }); + + if (!isSubscribed) { + const bucket = store.buckets.find(b => b.id === bucketId); + if (bucket) { + const { getTaskListFields, mapRecordToTask } = await import("./tasks"); + const records = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `tags ~ "${buildShareTag(bucket.name)}"`, + sort: "-created", + fields: getTaskListFields() + }); + + const newTasks: Task[] = []; + records.forEach((r: any) => { + const exists = store.tasks.find(t => t.id === r.id); + if (!exists && !r.tags?.includes("__template__")) { + newTasks.push(mapRecordToTask(r)); + } + }); + + if (newTasks.length > 0) { + setStore("tasks", t => [...newTasks, ...t]); + } + } + } + } catch (err) { + console.error("Failed to update subscriptions:", err); + setStore("subscribedBuckets", currentSubscribed); + } + } +}; + +export const createBucket = async (name: string, color: string, description?: string) => { + try { + const key = normalizeContextKey(name); + await pb.collection(CONTEXTS_COLLECTION).create({ + key, + displayName: name, + kind: "bucket", + policy: "handoff", + color, + description + }); + toast.success("Bucket created"); + } catch (err) { + console.error("Failed to create bucket:", err); + toast.error("Failed to create bucket"); + } +}; + +export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => { + try { + const payload: any = { ...data }; + if (data.name) payload.key = normalizeContextKey(data.name); + if (data.name) payload.displayName = data.name; + await pb.collection(CONTEXTS_COLLECTION).update(id, payload); + toast.success("Bucket updated"); + } catch (err) { + console.error("Failed to update bucket:", err); + toast.error("Failed to update bucket"); + } +}; + +export const deleteBucket = async (id: string) => { + try { + await pb.collection(CONTEXTS_COLLECTION).update(id, { + deletedAt: new Date().toISOString(), + deletedBy: pb.authStore.model?.id || null + }); + toast.success("Bucket moved to trash"); + } catch (err) { + console.error("Failed to delete bucket:", err); + toast.error("Failed to delete bucket"); + } +}; + +export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => { + const success = await setContextPolicy(contextId, policy); + if (success) { + toast.success("User context updated"); + } +}; + +export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => { + const previousContext = store.contexts.find(context => context.id === contextId); + const previousPolicy = previousContext?.policy; + + if (previousContext && previousPolicy !== policy) { + setStore("contexts", context => context.id === contextId, "policy", policy); + setStore("shareRules", store.contexts.map(mapContextToShareRule)); + } + + try { + await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null }); + return true; + } catch (err) { + console.error("Failed to update user context:", err); + if (previousContext && previousPolicy) { + setStore("contexts", context => context.id === contextId, "policy", previousPolicy); + setStore("shareRules", store.contexts.map(mapContextToShareRule)); + } + toast.error("Failed to update user context"); + return false; + } +}; + +export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => { + if (!pb.authStore.isValid || !noteId) return false; + + const note = store.notes.find(existing => existing.id === noteId); + if (!note) return false; + + const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG); + const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags; + const previousTags = note.tags || []; + + setStore("notes", n => n.id === noteId, "tags", nextTags); + + try { + await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null }); + return true; + } catch (err) { + console.error("Failed to update note context policy:", err); + setStore("notes", n => n.id === noteId, "tags", previousTags); + toast.error("Failed to update note sharing mode"); + return false; + } +}; + +export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => { + const userId = pb.authStore.model?.id; + if (!userId) return; + + const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))]; + setStore("personalContextSupervisorIds", uniqueSupervisorIds); + + try { + await syncUserContexts(); + const personalContext = getPersonalContext(userId); + if (!personalContext) { + throw new Error("Missing personal context record"); + } + + await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, { + supervisorUserIds: uniqueSupervisorIds + }, { requestKey: null }); + + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const scopedPrefs = readScopedPrefs(rawPrefs); + await pb.collection("users").update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...scopedPrefs, + supervisorUserIds: uniqueSupervisorIds + }) + }, { requestKey: null }); + await syncSupervisedContexts(); + toast.success("Personal supervision updated"); + } catch (err) { + console.error("Failed to update personal supervision:", err); + toast.error("Failed to update personal supervision"); + } +}; + +export const loadShareRules = async () => { + if (!pb.authStore.isValid) return; + setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule))); +}; + +export const addShareRule = async (type: "tag" | "all", targetUserId: string, tagName?: string, share_mode: "ADD_USER" | "SEND_TASK" = "SEND_TASK") => { + if (!pb.authStore.isValid) return; + if (type !== "tag" || !tagName?.startsWith("@")) { + toast.error("Only @tag contexts are supported now."); + return; + } + + const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName); + if (existing) return; + + try { + await pb.collection(CONTEXTS_COLLECTION).create({ + key: normalizeContextKey(tagName), + displayName: tagName.replace(/^@/, ""), + kind: "user", + policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative", + targetUserId + }, { requestKey: null }); + toast.success("Shared context created"); + } catch (err) { + console.error("Failed to create shared context:", err); + toast.error("Failed to create shared context."); + } +}; + +export const updateShareRule = async (ruleId: string, updates: Partial) => { + if (!pb.authStore.isValid) return; + + try { + const context = store.contexts.find(existing => existing.id === ruleId); + if (!context) return; + await pb.collection(CONTEXTS_COLLECTION).update(ruleId, { + policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative" + }, { requestKey: null }); + toast.success("Context updated"); + } catch (err) { + console.error("Failed to update share rule:", err); + toast.error("Failed to update share rule."); + } +}; + +export const removeShareRule = async (ruleId: string) => { + if (!pb.authStore.isValid) return; + + try { + await pb.collection(CONTEXTS_COLLECTION).delete(ruleId); + toast.success("Context removed"); + } catch (err) { + console.error("Failed to delete share rule:", err); + toast.error("Failed to delete share rule."); + } +}; + +export const syncSystemTagsAndRules = async () => { + if (!pb.authStore.isValid) return; + setStore("shareRules", store.contexts.map(mapContextToShareRule)); + setStore("tagDefinitions", defs => { + const plainDefs = defs.filter(def => !def.name.startsWith("@")); + return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)]; + }); +}; diff --git a/src/store/index.ts b/src/store/index.ts index a88f0ab..a7cce2f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4025 +1,6 @@ -import { createStore, reconcile } from "solid-js/store"; -import { createSignal, createEffect, createRoot } from "solid-js"; -import { pb } from "@/lib/pocketbase"; -import { toast } from "solid-sonner"; -import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants"; -import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE, TASGRID_IS_DEV_DATA, PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED } from "@/lib/app-config"; -import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags"; - -// Helper to get the current user's personalized favorite tag -export const getFavoriteTag = () => { - const userId = pb.authStore.model?.id; - return userId ? `__${userId}_favorite__` : "__favorite__"; -}; - -const BASE_LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,labelTags,shareRefs,noteRefs,createdBy,user,size,recurrence,deletedAt'; -const NOTE_LIST_FIELDS = 'id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt'; -let updatedByFieldSupport: boolean | null = null; -let realtimeRecoveryAttached = false; -let realtimeRecoveryTimer: number | undefined; -let lastRealtimeRecoveryAt = 0; - -const getTaskListFields = () => - updatedByFieldSupport ? `${BASE_LIST_FIELDS},updatedBy` : BASE_LIST_FIELDS; - -const scheduleRealtimeRecovery = (reason: string, delayMs = 400) => { - if (typeof window === "undefined") return; - if (!pb.authStore.isValid) return; - - const nowTs = Date.now(); - if (nowTs - lastRealtimeRecoveryAt < 10000) return; - - if (realtimeRecoveryTimer) { - window.clearTimeout(realtimeRecoveryTimer); - } - - realtimeRecoveryTimer = window.setTimeout(async () => { - realtimeRecoveryTimer = undefined; - if (!pb.authStore.isValid || store.isInitializing) return; - lastRealtimeRecoveryAt = Date.now(); - console.log(`[REALTIME] Recovering subscriptions after ${reason}`); - try { - await initStore(); - } catch (err) { - console.warn("[REALTIME] Recovery failed:", err); - } - }, delayMs); -}; - -const ensureRealtimeRecovery = () => { - if (realtimeRecoveryAttached || typeof window === "undefined") return; - realtimeRecoveryAttached = true; - - const recoverIfDisconnected = (reason: string) => { - if (!pb.authStore.isValid) return; - if (pb.realtime.isConnected) return; - scheduleRealtimeRecovery(reason); - }; - - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") { - recoverIfDisconnected("visibilitychange"); - } - }); - - window.addEventListener("focus", () => recoverIfDisconnected("focus")); - window.addEventListener("online", () => recoverIfDisconnected("online")); - - pb.realtime.onDisconnect = () => { - if (!pb.authStore.isValid) return; - }; -}; - -const getStorageKey = () => { - const userId = pb.authStore.model?.id; - return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null; -}; - -const getPersistedStoreSnapshot = () => { - const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store; - return persisted; -}; - -export const [now, setNow] = createSignal(Date.now()); -// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket. -type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean }; -export const [activeTaskId, setActiveTaskId] = createSignal(null); -export const [activeNoteId, setActiveNoteId] = createSignal(null); -export const [currentTaskContext, setCurrentTaskContext] = createSignal('mine'); - -export type UrgencyLevel = number; - -export interface Task { - id: string; - title: string; - startDate: string; - dueDate: string; - priority: number; // 1-10 - status: number; // 0-10 - completed: boolean; // Deprecated, kept for compat/computed from status = 10 - tags: string[]; - labelTags: string[]; - shareRefs: TaskShareRef[]; - noteRefs: NoteRef[]; - ownerId: string | null; // Add ownerId to interface - createdBy: string | null; - updatedBy?: string | null; - content?: string; // HTML content from Tiptap - deletedAt?: number | null; // Timestamp of soft delete or null if restored - created: string; // ISO string from PB - updated: string; // ISO string from PB - recurrence?: { - type: 'daily' | 'weekly' | 'monthly'; - days?: number[]; // For weekly (0-6, 0=Sunday) - dayOfMonth?: number; // For monthly (1-31) - lastUncompleted?: string; // ISO date of last automatic reset - }; - size?: number; // 0-10, task complexity/size - sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; - attachments?: string[]; // Array of filenames from PocketBase -} - -export const checkRecurringTasks = () => { - const tasks = store.tasks; - const nowObj = new Date(); - const todayStr = nowObj.toLocaleDateString('en-CA'); // YYYY-MM-DD in local time - - tasks.forEach(task => { - if (task.status !== 10 || !task.recurrence) return; - - // If deleted, we don't recurse? - if (task.deletedAt) return; - - const { type, days, dayOfMonth, lastUncompleted } = task.recurrence; - let shouldReset = false; - - // Check if we already reset it today/this cycle - if (lastUncompleted) { - const lastDate = new Date(lastUncompleted).toLocaleDateString('en-CA'); - if (lastDate === todayStr) return; // Already reset today - } - - // Also check 'updated' date. If it was marked completed TODAY, don't reset immediately. - // We only reset if the completion happened BEFORE the current cycle trigger. - // E.g. Completed yesterday, today is a new day -> Reset. - const updatedDate = new Date(task.updated).toLocaleDateString('en-CA'); - if (updatedDate === todayStr) { - // If manual loop: user completes it, we shouldn't immediately uncomplete it. - // But what if it's supposed to be uncompleted today? - // "Tasks that 'uncomplete' at given intervals" implies: - // If I complete it today, it stays completed until the NEXT interval. - // So we only reset if it was completed BEFORE today (for daily). - if (type === 'daily') return; - } - - if (type === 'daily') { - // If completed before today, reset. - if (updatedDate < todayStr) { - shouldReset = true; - } - } - else if (type === 'weekly') { - // If today is one of the recurrence days - const currentDay = nowObj.getDay(); - if (days?.includes(currentDay)) { - // Reset if it was completed BEFORE today. - if (updatedDate < todayStr) { - shouldReset = true; - } - } - } - else if (type === 'monthly') { - const currentDate = nowObj.getDate(); - if (dayOfMonth === currentDate) { - if (updatedDate < todayStr) { - shouldReset = true; - } - } - } - - if (shouldReset) { - console.log(`Resetting recurring task: ${task.title}`); - updateTask(task.id, { - status: 0, - completed: false, - recurrence: { - ...task.recurrence, - lastUncompleted: new Date().toISOString() - } - }); - } - }); -}; - -export interface TaskTemplate { - id: string; - name: string; - title: string; - priority: number; - urgency: number; - tags: string[]; - content: string; -} - -export interface TagDefinition { - id: string; - name: string; - value: number; - color?: string; - theme?: "light" | "dark"; - isUser?: boolean; - isBucket?: boolean; -} - -export interface Note { - id: string; - title: string; - key: string; - content?: string; - tags: string[]; - isPrivate: boolean; - user: string; - tasks: string[]; // List of related task IDs - created: string; - updated: string; - deletedAt?: number | null; // Timestamp of soft delete or null if restored -} - -export interface FilterTag { - name: string; - excluded: boolean; -} - -export interface Filter { - query: string; - tags: FilterTag[]; - priorityMin: number; - priorityMax: number; - urgencyMin: number; - urgencyMax: number; - editedToday: boolean; - ownedByMe: boolean; - starred: boolean; - jobStatus: FilterTag[]; - jobDivision: FilterTag[]; -} - -// Background sync for Context Switcher (polling fallback for realtime) -let contextSyncInterval: number | undefined; - -export const startContextPolling = () => { - if (contextSyncInterval) { - clearInterval(contextSyncInterval); - contextSyncInterval = undefined; - } - - const ctx = currentTaskContext(); - const personalOrUserId = - typeof ctx === 'object' && 'userId' in ctx - ? ctx.userId - : typeof ctx === 'object' && 'bucketId' in ctx - ? store.contexts.find(context => context.id === ctx.bucketId && context.kind === "user")?.targetUserId - : null; - - if (personalOrUserId) { - contextSyncInterval = window.setInterval(async () => { - if (!pb.authStore.isValid) return; - - try { - // Fetch only recent updates (last 30s) to minimize payload - const timeStr = new Date(Date.now() - 30000).toISOString(); - const contextFilter = buildPersonalAccessFilter([personalOrUserId]); - const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `${contextFilter} && updated >= "${timeStr}"`, - sort: '-updated', - fields: getTaskListFields(), - requestKey: 'context-poll' - }); - - if (records.length === 0) return; - - setStore("tasks", (currentTasks) => { - const taskMap = new Map(currentTasks.map(t => [t.id, t])); - let changed = false; - - records.forEach((r: any) => { - if (r.tags?.includes("__template__")) return; - - const existing = taskMap.get(r.id); - if (existing && existing.updated === r.updated) return; - - const newTask = mapRecordToTask(r); - if (existing && existing.content) { - newTask.content = existing.content; - } - taskMap.set(r.id, newTask); - changed = true; - }); - - if (changed) return Array.from(taskMap.values()); - return currentTasks; - }); - } catch (err: any) { - if (!err.isAbort) { - console.warn("Context poll failed:", err); - } - } - }, 5000); // 5 seconds - } -}; - -createRoot(() => { - createEffect(() => { - // Re-run whenever ctx changes - currentTaskContext(); - startContextPolling(); - }); -}); - -export interface FilterTemplate { - id: string; - name: string; - filter: Filter; -} - -interface ScopedTaskgridPrefs { - pWeight?: number; - uWeight?: number; - matrixScaleDays?: number; - tagDefinitions?: TagDefinition[]; - filter?: Filter; - filterTemplates?: FilterTemplate[]; - noteFilterTemplates?: FilterTemplate[]; - quickloadTasks?: string[]; - noteFilter?: Filter; - subscribedBuckets?: string[]; - supervisorUserIds?: string[]; - dismissedTaskUpdateIndicators?: Record; - collapsedUntilUpdatedTasks?: Record; - migratedStructuredTagsV2?: boolean; - migratedPersonalContextRefsV1?: boolean; - migratedContextOwnershipV2?: boolean; - lastCompletedTaskCleanupDate?: string; // YYYY-MM-DD, per user/environment -} - -const PREF_ENVIRONMENTS_KEY = "environments"; - -const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => { - const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE]; - return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs; -}; - -const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({ - ...(rawPrefs || {}), - [PREF_ENVIRONMENTS_KEY]: { - ...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}), - [TASGRID_DATA_MODE]: scopedPrefs - } -}); - -const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => { - const currentUserId = pb.authStore.model?.id; - const personalContext = currentUserId ? getPersonalContext(currentUserId) : null; - - setStore({ - pWeight: prefs.pWeight || 1.0, - uWeight: prefs.uWeight || 1.0, - matrixScaleDays: prefs.matrixScaleDays || 30, - prefId: currentUserId, - subscribedBuckets: prefs.subscribedBuckets || [], - personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [], - tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")), - filterTemplates: prefs.filterTemplates || [], - quickloadTasks: prefs.quickloadTasks || [], - dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {}, - collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {}, - noteFilter: store.noteFilter, - filter: store.filter - }); -}; - -const unwrapRelationId = (value: unknown): string | null => { - if (typeof value === "string") { - return value || null; - } - - if (Array.isArray(value)) { - const first = value[0]; - return typeof first === "string" ? first : null; - } - - return null; -}; - -const unwrapRelationIds = (value: unknown): string[] => { - if (Array.isArray(value)) { - return value - .map(item => { - if (typeof item === "string") return item; - if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") { - return (item as any).id; - } - return null; - }) - .filter((item): item is string => !!item); - } - - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return []; - try { - const parsed = JSON.parse(trimmed); - return unwrapRelationIds(parsed); - } catch { - return trimmed.includes(",") - ? trimmed.split(",").map(item => item.trim()).filter(Boolean) - : [trimmed]; - } - } - - if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") { - return [(value as any).id]; - } - - return []; -}; - -const relationFilter = (field: string, id: string | undefined | null) => - id ? `(${field} = "${id}" || ${field} ?= "${id}")` : "false"; - -const buildUserContextKey = (userId: string) => `user-${userId}`; - -export interface ShareContext { - id: string; - key: string; - displayName: string; - kind: 'user' | 'bucket' | 'note'; - policy: 'collaborative' | 'handoff'; - targetUserId?: string | null; - supervisorUserIds?: string[]; - color?: string; - description?: string; - deletedAt?: number | null; - deletedBy?: string | null; -} - -export interface TaskShareRef { - contextId: string; - key: string; - kind: 'user' | 'bucket' | 'note'; - policy?: 'collaborative' | 'handoff'; -} - -export interface NoteRef { - noteId: string; - key: string; -} - -export interface ShareRule { - id: string; - ownerId: string; - targetUserId: string; - type: 'tag' | 'all'; - tagName?: string; - share_mode?: 'ADD_USER' | 'SEND_TASK'; -} - -export interface SupervisedContextAccess { - ownerUserId: string; - ownerName: string; - contextId: string; - contextName: string; -} - -export interface Bucket { - id: string; - name: string; - color: string; - description?: string; - policy?: 'collaborative' | 'handoff'; - key?: string; - deletedAt?: number | null; -} - -interface TaskStore { - tasks: Task[]; - isInitializing: boolean; - quickloadFadeTaskIds: string[]; - dismissedTaskUpdateIndicators: Record; - collapsedUntilUpdatedTasks: Record; - isNotesLoading: boolean; - hasLoadedNotesMetadata: boolean; - loadingNoteContentIds: string[]; - pWeight: number; - uWeight: number; - matrixScaleDays: number; - prefId?: string; // ID of the user record - tagDefinitions: TagDefinition[]; - templates: TaskTemplate[]; - filter: Filter; - shareRules: ShareRule[]; - contexts: ShareContext[]; - supervisedContexts: SupervisedContextAccess[]; - filterTemplates: FilterTemplate[]; - buckets: Bucket[]; - subscribedBuckets: string[]; // IDs of buckets I'm subscribed to - personalContextSupervisorIds: string[]; - notes: Note[]; - isNotepadMode: boolean; - quickloadTasks: string[]; - noteFilter: Filter; - lastCompletedTaskCleanupDate: string; // YYYY-MM-DD -} - -// Initial empty state -export const [store, setStore] = createStore({ - tasks: [], - isInitializing: false, - quickloadFadeTaskIds: [], - dismissedTaskUpdateIndicators: {}, - collapsedUntilUpdatedTasks: {}, - isNotesLoading: false, - hasLoadedNotesMetadata: false, - loadingNoteContentIds: [], - pWeight: 1.0, - uWeight: 1.0, - matrixScaleDays: 30, - tagDefinitions: [], - templates: [], - filter: { - query: "", - tags: [], - priorityMin: 1, - priorityMax: 10, - urgencyMin: 1, - urgencyMax: 10, - editedToday: false, - ownedByMe: false, - starred: false, - jobStatus: [], - jobDivision: [] - }, - shareRules: [], - contexts: [], - supervisedContexts: [], - filterTemplates: [], - buckets: [], - subscribedBuckets: [], - personalContextSupervisorIds: [], - notes: [], - isNotepadMode: false, - quickloadTasks: [], - lastCompletedTaskCleanupDate: "", - noteFilter: { - query: "", - tags: [], - priorityMin: 1, - priorityMax: 10, - urgencyMin: 1, - urgencyMax: 10, - editedToday: false, - ownedByMe: false, - starred: false, - jobStatus: [], - jobDivision: [] - } -}); - -const DEFAULT_LABEL_THEME: "light" | "dark" = "dark"; - -const mapContextToBucket = (context: ShareContext): Bucket => ({ - id: context.id, - name: context.displayName, - color: context.color || "#64748b", - description: context.description, - policy: context.policy, - key: context.key, - deletedAt: context.deletedAt ?? null -}); - -const deriveBuckets = (contexts: ShareContext[]) => - contexts - .filter(context => context.kind === "bucket" && !context.deletedAt) - .sort((a, b) => a.displayName.localeCompare(b.displayName)) - .map(mapContextToBucket); - -const deriveSystemTagDefinitions = (contexts: ShareContext[]): TagDefinition[] => - contexts - .filter(context => !context.deletedAt) - .map(context => ({ - id: context.id, - name: buildShareTag(context.displayName), - value: 5, - color: context.color, - theme: DEFAULT_LABEL_THEME, - isUser: context.kind === "user", - isBucket: context.kind === "bucket" - })); - -const findContextForShareTag = (tag: { key: string; raw: string }) => - store.contexts.find(context => !context.deletedAt && context.key === tag.key) || - store.contexts.find(context => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === tag.key) || - store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === tag.key) || - store.contexts.find(context => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === tag.raw.toLowerCase()) || - null; - -const getContextByRef = (ref: TaskShareRef) => - (ref.kind === "note" - ? (() => { - const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key)); - return note ? buildNoteContext(note) : null; - })() - : null) || - store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) || - store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) || - store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) || - store.contexts.find(context => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) || - null; - -const getNoteByRef = (ref: NoteRef) => - 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", - bucket: "handoff", - note: "handoff" -}; - -const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__"; - -export const getNoteSharePolicy = (note: Pick | null | undefined): "collaborative" | "handoff" => - note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; - -export const getTaskSharePolicy = (ref: Pick): "collaborative" | "handoff" => { - if (ref.kind === "note") { - if (ref.policy) return ref.policy; - const note = store.notes.find(existing => !existing.deletedAt && (existing.id === ref.contextId || existing.key === ref.key)); - return getNoteSharePolicy(note); - } - - const context = getContextByRef(ref as TaskShareRef); - if (ref.policy) return ref.policy; - if (context?.policy) return context.policy; - - return DEFAULT_SHARE_POLICY_BY_KIND[ref.kind]; -}; - -const buildNoteContext = (note: Note): ShareContext => ({ - id: note.id, - key: note.key, - displayName: note.key || note.title, - kind: "note", - policy: getNoteSharePolicy(note), - targetUserId: note.user, - deletedAt: note.deletedAt ?? null -}); - -const buildTaskTags = ( - labelTags: string[], - shareRefs: TaskShareRef[], - noteRefs: NoteRef[], - favoriteTag?: string -) => { - const tags = [ - ...labelTags, - ...shareRefs.map(ref => { - const context = getContextByRef(ref); - if (ref.kind === "note") { - return buildNoteTag(context?.key || ref.key); - } - if (context?.displayName) { - return buildShareTag(context.displayName); - } - if (ref.kind === "user" && ref.key.startsWith("user-")) { - return null; - } - return buildShareTag(ref.key); - }), - ...noteRefs.map(ref => { - const note = getNoteByRef(ref); - return buildNoteTag(note?.key || ref.key); - }) - ]; - - if (favoriteTag && !tags.includes(favoriteTag)) { - tags.push(favoriteTag); - } - - return [...new Set(tags.filter((tag): tag is string => !!tag))]; -}; - -const dedupeShareRefs = (refs: TaskShareRef[]) => { - const seen = new Set(); - return refs.filter(ref => { - const key = `${ref.kind}:${ref.contextId}:${ref.key}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -}; - -const buildTaskShareRef = ( - parsedTag: Extract[number], { kind: "share" | "note" }>, - existingRefs: TaskShareRef[] = [], - shareModeOverrides: Record = {} -): TaskShareRef => { - if (parsedTag.kind === "note") { - const note = store.notes.find(existing => !existing.deletedAt && existing.key === parsedTag.key); - const nextRefBase = { - contextId: note?.id || parsedTag.key, - key: parsedTag.key, - kind: "note" as const - }; - const existing = existingRefs.find(ref => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)); - return { - ...nextRefBase, - policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase) - }; - } - - const context = findContextForShareTag(parsedTag); - const nextRefBase = { - contextId: context?.id || parsedTag.key, - key: parsedTag.key, - kind: (context?.kind || "bucket") as "user" | "bucket" - }; - const existing = existingRefs.find(ref => - ref.kind === nextRefBase.kind && - (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key) - ); - return { - ...nextRefBase, - policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(nextRefBase) - }; -}; - -export const getTaskShareModeForTag = (task: Pick, tag: string): "collaborative" | "handoff" | undefined => { - const parsed = parseTags([tag])[0]; - if (!parsed || (parsed.kind !== "share" && parsed.kind !== "note")) return undefined; - - const ref = task.shareRefs.find(existing => - existing.key === parsed.key && ( - (parsed.kind === "note" && existing.kind === "note") || - (parsed.kind === "share" && existing.kind !== "note") - ) - ); - - return ref ? getTaskSharePolicy(ref) : undefined; -}; - -export const deriveTaskRelationsFromTags = ( - tags: string[], - existingShareRefs: TaskShareRef[] = [], - shareModeOverrides: Record = {} -) => { - const parsedTags = parseTags(tags); - const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); - const shareRefs = parsedTags - .filter((tag): tag is Extract => tag.kind === "share" || tag.kind === "note") - .map(tag => buildTaskShareRef(tag, existingShareRefs, shareModeOverrides)); - const noteRefs = parsedTags - .filter(tag => tag.kind === "note") - .map(tag => { - const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key); - return { - noteId: note?.id || tag.key, - key: tag.key - } satisfies NoteRef; - }); - - return { - labelTags, - shareRefs: dedupeShareRefs(shareRefs), - noteRefs: dedupeNoteRefs(noteRefs) - }; -}; - -const dedupeNoteRefs = (refs: NoteRef[]) => { - const seen = new Set(); - return refs.filter(ref => { - const key = `${ref.noteId}:${ref.key}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -}; - -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) => - !!record && Object.prototype.hasOwnProperty.call(record, field); - -const detectUpdatedBySupportFromRecord = (record: any) => { - if (updatedByFieldSupport !== true && recordHasField(record, "updatedBy")) { - updatedByFieldSupport = true; - } -}; - -const refreshLoadedTaskUpdatedBy = async () => { - if (updatedByFieldSupport !== true || !pb.authStore.isValid) return; - - const taskIds = store.tasks - .map(task => task.id) - .filter(id => !!id && !id.startsWith("temp-")); - - if (taskIds.length === 0) return; - - const chunkSize = 50; - for (let i = 0; i < taskIds.length; i += chunkSize) { - const ids = taskIds.slice(i, i + chunkSize); - try { - const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: ids.map(id => `id="${id}"`).join(' || '), - fields: 'id,updated,updatedBy', - requestKey: null - }); - - const updatesById = new Map( - records.map((record: any) => { - detectUpdatedBySupportFromRecord(record); - return [ - record.id, - { - updated: record.updated, - updatedBy: unwrapRelationId(record.updatedBy) - } - ] as const; - }) - ); - - setStore("tasks", tasks => tasks.map(task => { - const update = updatesById.get(task.id); - return update ? { ...task, ...update } : task; - })); - } catch (err) { - console.warn("Failed to refresh task update actors:", err); - break; - } - } -}; - -const ensureUpdatedByFieldSupport = async () => { - if (updatedByFieldSupport !== null || !pb.authStore.isValid) return updatedByFieldSupport; - - const sampleTaskId = store.tasks.find(task => !task.id.startsWith("temp-"))?.id; - if (!sampleTaskId) return updatedByFieldSupport; - - try { - const record = await pb.collection(TASGRID_COLLECTION).getOne(sampleTaskId, { requestKey: null }); - updatedByFieldSupport = recordHasField(record, "updatedBy"); - detectUpdatedBySupportFromRecord(record); - - if (updatedByFieldSupport) { - await refreshLoadedTaskUpdatedBy(); - } - } catch (err) { - console.warn("Failed to detect task update actor support:", err); - updatedByFieldSupport = false; - } - - return updatedByFieldSupport; -}; - -const withActiveContextTags = (rawTags: string[]) => { - const nextTags = [...rawTags]; - const ctx = currentTaskContext(); - - if (ctx === "mine") { - const personalContext = getPersonalContext(); - if (personalContext) { - const personalTag = buildShareTag(personalContext.displayName); - if (!nextTags.some(tag => tag.toLowerCase() === personalTag.toLowerCase())) { - nextTags.push(personalTag); - } - } - } else if (typeof ctx === "object") { - if ("bucketId" in ctx && ctx.name) { - const bucketContext = store.contexts.find(context => context.id === ctx.bucketId); - const bucketTag = buildShareTag(bucketContext?.displayName || ctx.name); - if (!nextTags.some(tag => tag.toLowerCase() === bucketTag.toLowerCase())) { - nextTags.push(bucketTag); - } - } else if ("userId" in ctx && ctx.name) { - const userTag = buildShareTag(ctx.name); - if (!nextTags.some(tag => tag.toLowerCase() === userTag.toLowerCase())) { - nextTags.push(userTag); - } - } - } - - if (store.isNotepadMode) { - const noteId = activeNoteId(); - const note = noteId ? store.notes.find(existing => existing.id === noteId) : null; - if (note) { - const noteTag = buildNoteTag(note.key || note.title); - if (!nextTags.some(tag => tag.toLowerCase() === noteTag.toLowerCase())) { - nextTags.push(noteTag); - } - } - } - - return [...new Set(nextTags)]; -}; - -const setContexts = (contexts: ShareContext[]) => { - setStore("contexts", reconcile(contexts)); - setStore("buckets", reconcile(deriveBuckets(contexts))); - setStore("tasks", tasks => tasks.map(task => { - const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); - return { - ...task, - tags: buildTaskTags(task.labelTags, task.shareRefs, task.noteRefs, favoriteTag) - }; - })); - - const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@")); - const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)]; - setStore("tagDefinitions", reconcile(mergedDefs)); -}; - -const loadContextsIntoStore = async () => { - const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ - sort: 'displayName,key', - requestKey: null - }).catch(() => []); - const nextContexts = contextRecords.map(mapRecordToContext); - setContexts(nextContexts); - setStore("shareRules", nextContexts.map(mapContextToShareRule)); - return nextContexts; -}; - -const getPersonalContext = (userId = pb.authStore.model?.id) => - userId - ? store.contexts.find(context => - !context.deletedAt && - context.kind === "user" && - context.targetUserId === userId - ) || null - : null; - -const getCurrentUserDisplayName = () => - (pb.authStore.model?.name || pb.authStore.model?.email || "").trim() || null; - -const getPersonalContextTag = (userId = pb.authStore.model?.id) => { - const context = getPersonalContext(userId); - if (context?.displayName) { - return buildShareTag(context.displayName); - } - - if (userId && userId === pb.authStore.model?.id) { - const displayName = getCurrentUserDisplayName(); - if (displayName) { - return buildShareTag(displayName); - } - } - - return null; -}; - -const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - -const buildPersonalAccessFilter = (userIds: string[], includeLegacyUser = true) => { - const clauses = userIds.flatMap(userId => { - const nextClauses: string[] = []; - const personalTag = getPersonalContextTag(userId); - if (personalTag) { - nextClauses.push(`tags ~ "${escapeFilterValue(personalTag)}"`); - } - if (includeLegacyUser) { - nextClauses.push(relationFilter("user", userId)); - } - return nextClauses; - }).filter(Boolean); - - return clauses.length > 0 ? `(${clauses.join(" || ")})` : "false"; -}; - -const getPersonalContextUserIdFromRef = (ref: TaskShareRef) => { - if (ref.kind !== "user") return null; - - const context = getContextByRef(ref); - if (context?.targetUserId) { - return context.targetUserId; - } - - if (ref.key.startsWith("user-")) { - return ref.key.replace(/^user-/, "") || null; - } - - return null; -}; - -const getTaskPersonalRefs = (task: Pick) => - task.shareRefs.filter(ref => ref.kind === "user" && !!getPersonalContextUserIdFromRef(ref)); - -const getTaskPersonalUserIds = (task: Pick) => - [...new Set(getTaskPersonalRefs(task).map(ref => getPersonalContextUserIdFromRef(ref)).filter((value): value is string => !!value))]; - -const taskHasLegacyPersonalAccess = (task: Pick, userId: string) => - task.ownerId === userId && - getTaskPersonalRefs(task).length === 0 && - !task.shareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"); - -const taskHasPersonalContextAccess = (task: Pick, userId: string, allowLegacyFallback = true) => - getTaskPersonalUserIds(task).includes(userId) || - (allowLegacyFallback && taskHasLegacyPersonalAccess(task, userId)); - -const resolveMirroredOwnerId = ( - currentOwnerId: string | null | undefined, - shareRefs: TaskShareRef[], - preferredOwnerId?: string | null -) => { - const personalUserIds = [...new Set( - shareRefs - .filter(ref => ref.kind === "user") - .map(ref => getPersonalContextUserIdFromRef(ref)) - .filter((value): value is string => !!value) - )]; - - if (personalUserIds.length === 1) { - return personalUserIds[0]; - } - - if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) { - return preferredOwnerId; - } - - return currentOwnerId || null; -}; - -const withMirroredOwnerId = ( - currentOwnerId: string | null | undefined, - shareRefs: TaskShareRef[], - updates: Partial, - preferredOwnerId?: string | null -) => ({ - ...updates, - ownerId: resolveMirroredOwnerId(currentOwnerId, shareRefs, preferredOwnerId) -}); - -export const isCurrentUserContextTag = (tag: string) => { - const normalizedTag = tag.trim().toLowerCase(); - if (!normalizedTag.startsWith("@")) return false; - - const personalContext = getPersonalContext(); - const currentUserDisplayName = (pb.authStore.model?.name || pb.authStore.model?.email || "").trim(); - const hiddenTags = new Set(); - - if (personalContext?.displayName) { - hiddenTags.add(buildShareTag(personalContext.displayName).toLowerCase()); - } - - if (currentUserDisplayName) { - hiddenTags.add(buildShareTag(currentUserDisplayName).toLowerCase()); - } - - return hiddenTags.has(normalizedTag); -}; - -export const isTaskLockedNoteTag = (task: Pick, tag: string) => { - const normalizedTag = tag.trim(); - if (!normalizedTag.startsWith("#")) return false; - - const noteKey = normalizeNoteKey(normalizedTag); - const matchingNoteRefs = task.noteRefs.filter(ref => ref.key === noteKey || ref.noteId === noteKey); - if (matchingNoteRefs.length === 0) return false; - - const nonNoteContexts = task.shareRefs.filter(ref => ref.kind !== "note"); - const uniqueNoteRefs = dedupeNoteRefs(task.noteRefs); - return uniqueNoteRefs.length === matchingNoteRefs.length && nonNoteContexts.length === 0; -}; - -export const getVisibleTaskTags = (task: Pick, context: TaskContext = currentTaskContext()) => { - const tags = task.tags || []; - - return tags.filter(tag => { - if (tag.endsWith("_favorite__") || isCurrentUserContextTag(tag)) { - return false; - } - - if (isTaskLockedNoteTag(task, tag)) { - return false; - } - - if (typeof context === "object" && "bucketId" in context) { - const bucketContext = store.contexts.find(existing => existing.id === context.bucketId); - const bucketTagName = buildShareTag(bucketContext?.displayName || context.name).toLowerCase(); - if (tag.toLowerCase() === bucketTagName) { - return false; - } - } - - return true; - }); -}; - -const getSubscribedBucketContexts = () => - store.subscribedBuckets - .map(id => store.contexts.find(context => context.id === id)) - .filter((context): context is ShareContext => !!context && !context.deletedAt && context.kind === "bucket"); - -export const matchesFilter = (task: Task) => { - const currentUserId = pb.authStore.model?.id; - const ctx = currentTaskContext(); - const personalContext = getPersonalContext(currentUserId); - const rawBucketRefs = task.shareRefs.filter(ref => ref.kind === "bucket"); - const activeShareContexts = task.shareRefs - .map(ref => getContextByRef(ref)) - .filter((context): context is ShareContext => !!context && !context.deletedAt); - const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket"); - const isPersonalView = ctx === "mine" || ( - typeof ctx === "object" && - "bucketId" in ctx && - !!personalContext && - ctx.bucketId === personalContext.id - ); - - // 0. Context Filter - if (isPersonalView) { - if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return false; - } else if (typeof ctx === 'object' && 'bucketId' in ctx) { - const activeBucketContext = store.contexts.find(context => context.id === (ctx as { bucketId: string }).bucketId); - const activeBucketKey = activeBucketContext?.key || normalizeContextKey((ctx as { name: string }).name); - const inCurrentBucket = activeBucketContext?.kind === "user" - ? !!activeBucketContext.targetUserId && taskHasPersonalContextAccess(task, activeBucketContext.targetUserId) - : rawBucketRefs.some(ref => - ref.contextId === (ctx as { bucketId: string }).bucketId || - ref.key === activeBucketKey - ) || bucketRefs.some(context => context.id === (ctx as { bucketId: string }).bucketId); - if (!inCurrentBucket) return false; - } else { - const targetUserId = (ctx as { userId: string }).userId; - if (!taskHasPersonalContextAccess(task, targetUserId)) { - return false; - } - } - - // Hide templates from all regular views - if (task.tags?.includes("__template__")) return false; - - const f = store.filter; - - // Query search (title, content, or currently visible tags) - if (f.query) { - const q = f.query.toLowerCase(); - const inTitle = task.title.toLowerCase().includes(q); - const inContent = task.content?.toLowerCase().includes(q); - const inVisibleTags = getVisibleTaskTags(task, ctx).some(tag => tag.toLowerCase().includes(q)); - if (!inTitle && !inContent && !inVisibleTags) return false; - } - - // Tags - if (f.tags.length > 0) { - const includedTags = f.tags.filter(t => !t.excluded).map(t => t.name); - const excludedTags = f.tags.filter(t => t.excluded).map(t => t.name); - - // 1. Task must NOT have any of the excluded tags - if (excludedTags.length > 0) { - const hasExcluded = excludedTags.some(tag => task.tags?.includes(tag)); - if (hasExcluded) return false; - } - - // 2. Task must have at least one of the included tags (if any are specified) - if (includedTags.length > 0) { - const hasIncluded = includedTags.some(tag => task.tags?.includes(tag)); - if (!hasIncluded) return false; - } - } - - // Priority - if (task.priority < f.priorityMin || task.priority > f.priorityMax) return false; - - // Urgency (calculated) - const urgency = calculateUrgencyFromDate(task.dueDate); - if (urgency < f.urgencyMin || urgency > f.urgencyMax) return false; - - // Edited Today - if (f.editedToday) { - const today = new Date().toLocaleDateString('en-CA'); - const updated = new Date(task.updated).toLocaleDateString('en-CA'); - if (today !== updated) return false; - } - - // Owned By Me - if (f.ownedByMe && (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId))) { - return false; - } - - // Starred - if (f.starred && !task.tags?.includes(getFavoriteTag())) { - return false; - } - - return true; -}; - -export const isTaskUpdatedByAnotherUser = (task: Task) => { - const currentUserId = pb.authStore.model?.id; - if (!currentUserId || !task.updatedBy || task.updatedBy === currentUserId) return false; - return store.dismissedTaskUpdateIndicators[task.id] !== task.updated; -}; - -export const dismissTaskUpdateIndicator = (taskId: string, updatedAt: string) => { - setStore("dismissedTaskUpdateIndicators", taskId, updatedAt); - void syncPreferences(); -}; - -export const isTaskCollaborativelyShared = (task: Task) => - task.shareRefs.some(ref => { - const currentUserId = pb.authStore.model?.id; - const context = getContextByRef(ref); - const policy = getTaskSharePolicy(ref); - const targetUserId = context?.targetUserId || null; - return ref.kind === "user" && policy === "collaborative" && !!targetUserId && targetUserId !== currentUserId; - }); - -export const canHandoffCollaborativelySharedTask = (task: Pick) => { - const currentUserId = pb.authStore.model?.id; - if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return false; - - return task.shareRefs.some(ref => { - if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false; - const targetUserId = getPersonalContextUserIdFromRef(ref); - return !!targetUserId && targetUserId !== currentUserId; - }); -}; - -export const isTaskCollapsedUntilUpdated = (task: Task) => - store.collapsedUntilUpdatedTasks[task.id] === task.updated; - -export const collapseTaskUntilUpdated = (taskId: string, updatedAt: string) => { - setStore("collapsedUntilUpdatedTasks", taskId, updatedAt); - void syncPreferences(); -}; - -export const expandCollapsedTask = (taskId: string) => { - setStore("collapsedUntilUpdatedTasks", taskId, ""); - void syncPreferences(); -}; - -export const handoffCollaborativelySharedTask = async (taskId: string) => { - const task = store.tasks.find(existing => existing.id === taskId); - const currentUserId = pb.authStore.model?.id; - const senderPersonalContext = currentUserId ? getPersonalContext(currentUserId) : null; - - if (!task || !currentUserId || !senderPersonalContext) return false; - if (!canHandoffCollaborativelySharedTask(task)) { - toast.info("This task needs another collaborative @user before you can hand it off."); - return false; - } - - const nextShareRefs = task.shareRefs.filter(ref => !( - ref.kind === "user" && - (ref.contextId === senderPersonalContext.id || ref.key === senderPersonalContext.key) - )); - - const didUpdate = await updateTask(taskId, { - shareRefs: nextShareRefs, - labelTags: task.labelTags, - noteRefs: task.noteRefs, - tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, getFavoriteTag()) - }); - - if (didUpdate) { - toast.success("Task handed off successfully."); - } - - return didUpdate; -}; - -// Auto-persist changes to localStorage (wrapped in root to avoid console warning) -createRoot(() => { - createEffect(() => { - const key = getStorageKey(); - if (key) { - localStorage.setItem(key, JSON.stringify(getPersistedStoreSnapshot())); - } - }); - - let debouncedSync: number | undefined; - createEffect(() => { - if (store.isInitializing || !pb.authStore.model?.id) return; - - // Calculate Top 20 Tasks - const incompleteTasks = store.tasks.filter(t => - !t.completed && - taskHasPersonalContextAccess(t, pb.authStore.model?.id || "", true) && - !t.tags?.includes("__template__") && - !t.deletedAt - ); - - const scoredTasks = incompleteTasks.map(t => ({ id: t.id, score: getCombinedScore(t) })); - scoredTasks.sort((a, b) => b.score - a.score); - - const top20Ids = scoredTasks.slice(0, 20).map(t => t.id); - - // Compare with current - const currentIds = store.quickloadTasks || []; - const isDifferent = top20Ids.length !== currentIds.length || top20Ids.some((id, i) => id !== currentIds[i]); - - if (isDifferent) { - setStore("quickloadTasks", top20Ids); - - // Debounce save by 10s - if (debouncedSync) window.clearTimeout(debouncedSync); - debouncedSync = window.setTimeout(() => { - syncPreferences(); - }, 10000); - } - }); -}); - -// -- Calibration -- -export const calculateUrgencyScore = (dueDateStr: string): number => { - const due = new Date(dueDateStr).getTime(); - const diffHours = (due - now()) / (1000 * 60 * 60); - - if (diffHours <= URGENCY_HOURS[10]) return 10; - if (diffHours >= URGENCY_HOURS[1]) return 1; - - // Linear interpolation between breakpoints - const levels = Object.keys(URGENCY_HOURS).map(Number).sort((a, b) => b - a); // 10, 9, 8... - for (let i = 0; i < levels.length - 1; i++) { - const highLevel = levels[i]; - const lowLevel = levels[i + 1]; - const highHours = URGENCY_HOURS[highLevel]; - const lowHours = URGENCY_HOURS[lowLevel]; - - if (diffHours >= highHours && diffHours <= lowHours) { - // Level decreases as hours increase - const range = lowHours - highHours; - const progress = (diffHours - highHours) / range; - return highLevel - progress * (highLevel - lowLevel); - } - } - - return 1; -}; - -export const calculateDateFromUrgency = (level: number): string => { - const roundedLevel = Math.max(1, Math.min(10, Math.round(level))); - const hours = URGENCY_HOURS[roundedLevel] || 24; - return new Date(now() + hours * 60 * 60 * 1000).toISOString(); -}; - -export const calculateUrgencyFromDate = (dateStr: string): number => { - return Math.round(calculateUrgencyScore(dateStr)); -}; - -export const getCombinedScore = (task: Task): number => { - // 1. Calculate an effective due date by subtracting size hours - const size = task.size ?? 3; // Default size 3 - const sizeHours = SIZE_HOURS[size] ?? 12; - const dueTime = new Date(task.dueDate).getTime(); - if (isNaN(dueTime)) return 0; - - // effective due date = due date - size task time - const effectiveDueTime = dueTime - (sizeHours * 60 * 60 * 1000); - const effectiveDueDateStr = new Date(effectiveDueTime).toISOString(); - - const urgencyScore = calculateUrgencyScore(effectiveDueDateStr); - let baseScore = (task.priority * store.pWeight) + (urgencyScore * store.uWeight); - - // Tag adjustments - if (task.tags && task.tags.length > 0) { - task.tags.forEach(tagName => { - const def = store.tagDefinitions.find(d => d.name === tagName); - if (def) { - // Formula: (Value - 5) * 0.1 - // 5 -> 0, 10 -> 0.5, 0 -> -0.5 - baseScore += (def.value - 5) * 0.1; - } - }); - } - - // Size adjustment: linear scale from +1.9 (for size 0) to 0 (for size 10) - const sizeScore = (10 - size) * 0.19; - baseScore += sizeScore; - - // Status adjustment: 0-9 -> 0.0-2.0 weight - if (task.status < 10) { - // Map 0-9 to 0-2 range - const statusWeight = (task.status / 9) * 2.0; - baseScore += statusWeight; - } - - return baseScore; -}; - -// -- Persistence & Sync -- - -let heartbeatInterval: number | undefined; -let quickloadFadeTimer: number | undefined; -let notesLoadPromise: Promise | null = null; -let deferredNotesLoadTimer: number | undefined; -const loadedNoteDetailIds = new Set(); -const failedNoteDetailIds = new Set(); - -const mapRecordToTask = (r: any): Task => { - detectUpdatedBySupportFromRecord(r); - const legacyTags: string[] = r.tags || []; - const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__"); - const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs) - ? r.shareRefs.map((ref: any) => ({ - contextId: ref.contextId, - key: ref.key, - kind: ref.kind, - policy: ref.policy - })) - : parsedTags - .filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note") - .map(tag => buildTaskShareRef(tag)); - const noteRefs: NoteRef[] = Array.isArray(r.noteRefs) - ? r.noteRefs - : parsedTags - .filter(tag => tag.kind === "note") - .map(tag => { - const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key); - return { - noteId: note?.id || tag.key, - key: tag.key - }; - }); - const noteShareRefs: TaskShareRef[] = noteRefs.map(ref => ({ - contextId: ref.noteId || ref.key, - key: ref.key, - kind: "note", - policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy - })); - const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]); - const labelTags: string[] = Array.isArray(r.labelTags) - ? r.labelTags - : parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); - const favoriteTag = legacyTags.find((tag: string) => tag.endsWith("_favorite__")); - const tags = buildTaskTags(labelTags, mergedShareRefs, noteRefs, favoriteTag); - return { - id: r.id, - title: r.title, - startDate: r.startDate, - dueDate: r.dueDate, - priority: r.priority, - status: r.status ?? (r.completed ? 10 : 0), - completed: r.status === 10 || r.completed, - tags: tags, - labelTags, - shareRefs: mergedShareRefs, - noteRefs, - ownerId: unwrapRelationId(r.user), - createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user), - updatedBy: recordHasField(r, "updatedBy") - ? (unwrapRelationId(r.updatedBy) || (r.created === r.updated ? (unwrapRelationId(r.createdBy) || unwrapRelationId(r.user)) : null)) - : null, - content: r.content, - deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined, - created: r.created, - updated: r.updated, - recurrence: r.recurrence, - size: (r.size === null || r.size === undefined) ? undefined : r.size, - sharedWith: [], - attachments: r.attachments || [] - }; -}; - -const buildNoteLinkMap = (notes: Note[]) => { - const links = new Map(); - for (const note of notes) { - for (const taskId of note.tasks || []) { - const existing = links.get(taskId) || []; - existing.push({ noteId: note.id, key: note.key }); - links.set(taskId, existing); - } - } - return links; -}; - -const buildTaskMigrationPayload = ( - task: Task, - ownerContextByUserId: Map, - linkedNoteRefs: NoteRef[] = [], - fallbackUserId?: string -) => { - const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); - const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined; - let nextShareRefs = dedupeShareRefs(task.shareRefs); - let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]); - const shouldHideFromLegacyOwner = !!task.ownerId && - getTaskPersonalRefs(task).length === 0 && - nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"); - - if (ownerContext) { - const matchesOwnerContext = (ref: TaskShareRef) => - ref.kind === "user" && ( - ref.contextId === ownerContext.id || - ref.key === ownerContext.key || - normalizeContextKey(ownerContext.displayName) === ref.key - ); - - if (shouldHideFromLegacyOwner) { - nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref)); - } else if (!nextShareRefs.some(matchesOwnerContext)) { - nextShareRefs = [ - { - contextId: ownerContext.id, - key: ownerContext.key, - kind: "user" as const, - policy: getTaskSharePolicy({ contextId: ownerContext.id, key: ownerContext.key, kind: "user" }) - }, - ...nextShareRefs - ]; - } - } - - nextShareRefs = dedupeShareRefs(nextShareRefs); - const mirroredOwnerId = resolveMirroredOwnerId( - task.ownerId || fallbackUserId || pb.authStore.model?.id || null, - nextShareRefs, - task.ownerId || fallbackUserId || null - ); - - return { - tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag), - labelTags: task.labelTags, - shareRefs: nextShareRefs, - noteRefs: nextNoteRefs, - ownerId: mirroredOwnerId, - createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || "" - }; -}; - -export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => { - try { - const formData = new FormData(); - formData.append('attachments+', file); - - const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData); - - if (record.attachments && record.attachments.length > 0) { - const latestFilename = record.attachments[record.attachments.length - 1]; - return { - url: pb.files.getURL(record, latestFilename), - filename: latestFilename - }; - } - - throw new Error("File uploaded but no attachment returned"); - } catch (err: any) { - console.error("Error uploading attachment. Validation details:", JSON.stringify(err.data, null, 2)); - toast.error("Failed to upload image."); - throw err; - } -}; - -export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => { - try { - const formData = new FormData(); - formData.append('attachments+', file); - - const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData); - - if (record.attachments && record.attachments.length > 0) { - const latestFilename = record.attachments[record.attachments.length - 1]; - return { - url: pb.files.getURL(record, latestFilename), - filename: latestFilename - }; - } - - throw new Error("File uploaded but no attachment returned"); - } catch (err: any) { - console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2)); - toast.error("Failed to upload image to note."); - throw err; - } -}; - -export const cleanupNoteAttachments = async (noteId: string) => { - if (!pb.authStore.isValid) return; - - try { - const record = await pb.collection(NOTES_COLLECTION).getOne(noteId); - if (!record.attachments || record.attachments.length === 0) return; - - const content = record.content || ""; - const filesToDelete: string[] = []; - - // Also check decoded content just in case of encoding mismatches - const decodedContent = decodeURIComponent(content); - - for (const filename of record.attachments) { - // Check for literal filename OR the part of the URL that includes the filename - if (!content.includes(filename) && !decodedContent.includes(filename)) { - filesToDelete.push(filename); - } - } - - if (filesToDelete.length > 0) { - await pb.collection(NOTES_COLLECTION).update(noteId, { - "attachments-": filesToDelete - }); - console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`); - } - } catch (err) { - if ((err as any)?.status === 404) return; - console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err); - } -}; - -export const cleanupTaskAttachments = async (taskId: string) => { - if (!pb.authStore.isValid) return; - - try { - const record = await pb.collection(TASGRID_COLLECTION).getOne(taskId); - if (!record.attachments || record.attachments.length === 0) return; - - const content = record.content || ""; - const filesToDelete: string[] = []; - - const decodedContent = decodeURIComponent(content); - - for (const filename of record.attachments) { - if (!content.includes(filename) && !decodedContent.includes(filename)) { - filesToDelete.push(filename); - } - } - - if (filesToDelete.length > 0) { - await pb.collection(TASGRID_COLLECTION).update(taskId, { - "attachments-": filesToDelete - }); - console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for task ${taskId}`); - } - } catch (err) { - console.error("[CLEANUP] Failed to clean up orphaned task attachments:", err); - } -}; - -const mapContextToShareRule = (context: ShareContext): ShareRule => ({ - id: context.id, - ownerId: pb.authStore.model?.id || "", - targetUserId: context.targetUserId || pb.authStore.model?.id || "", - type: 'tag', - tagName: buildShareTag(context.displayName), - share_mode: context.policy === "handoff" ? "SEND_TASK" : "ADD_USER" -}); - -const mapRecordToContext = (r: any): ShareContext => ({ - id: r.id, - key: r.key || normalizeContextKey(r.displayName || r.name || ""), - displayName: r.displayName || r.name || r.key, - kind: (r.kind || "bucket") as "user" | "bucket", - policy: (r.policy || (r.kind === "user" ? "collaborative" : "handoff")) as "collaborative" | "handoff", - targetUserId: unwrapRelationId(r.targetUserId), - supervisorUserIds: unwrapRelationIds(r.supervisorUserIds), - color: r.color, - description: r.description, - deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : null, - deletedBy: unwrapRelationId(r.deletedBy) -}); - -const mapRecordToNote = (r: any, includeContent = true): Note => { - return { - id: r.id, - title: r.title, - key: r.key || normalizeNoteKey(r.title), - content: includeContent ? r.content : undefined, - tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)], - isPrivate: r.isPrivate || false, - user: unwrapRelationId(r.user) || "", - tasks: r.tasks || [], - created: r.created, - updated: r.updated, - deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined - }; -}; - -const reconcileFetchedNotes = (records: any[], includeContent = false) => { - const existingById = new Map(store.notes.map(note => [note.id, note])); - const nextNotes = records.map((record: any) => { - const mapped = mapRecordToNote(record, includeContent); - const existing = existingById.get(mapped.id); - - if (!includeContent && existing?.content !== undefined) { - mapped.content = existing.content; - } - - return mapped; - }); - - setStore("notes", nextNotes); -}; - -export const fetchNoteById = async (id: string): Promise => { - try { - const record = await pb.collection(NOTES_COLLECTION).getOne(id); - if (!record) return null; - return mapRecordToNote(record); - } catch (err) { - console.error("Failed to fetch note by ID:", err); - return null; - } -}; - -export const updateStoreWithNote = (note: Note) => { - failedNoteDetailIds.delete(note.id); - if (note.content !== undefined) { - loadedNoteDetailIds.add(note.id); - } - setStore("notes", (currentNotes) => { - const index = currentNotes.findIndex(n => n.id === note.id); - if (index !== -1) { - const newNotes = [...currentNotes]; - newNotes[index] = note; - return newNotes; - } - return [...currentNotes, note]; - }); -}; - -export const loadNotesMetadata = async () => { - if (!pb.authStore.isValid) return; - if (store.hasLoadedNotesMetadata) return; - if (notesLoadPromise) return notesLoadPromise; - - const currentUserId = pb.authStore.model?.id; - setStore("isNotesLoading", true); - - notesLoadPromise = (async () => { - try { - const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({ - filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`, - sort: '-created', - fields: NOTE_LIST_FIELDS, - requestKey: null - }); - - reconcileFetchedNotes(notesRecords, false); - setStore("hasLoadedNotesMetadata", true); - } catch (notesErr) { - console.warn("Failed to load notes (collection might not exist yet):", notesErr); - } finally { - setStore("isNotesLoading", false); - notesLoadPromise = null; - } - })(); - - return notesLoadPromise; -}; - -export const scheduleDeferredNotesMetadataLoad = () => { - if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return; - - deferredNotesLoadTimer = window.setTimeout(() => { - deferredNotesLoadTimer = undefined; - void loadNotesMetadata(); - }, 0); -}; - -export const requestImmediateNotesMetadataLoad = async () => { - if (deferredNotesLoadTimer) { - clearTimeout(deferredNotesLoadTimer); - deferredNotesLoadTimer = undefined; - } - - await loadNotesMetadata(); -}; - -export const loadNoteContent = async (id: string) => { - if (!id) return; - if (loadedNoteDetailIds.has(id)) return; - if (failedNoteDetailIds.has(id)) return; - if (store.loadingNoteContentIds.includes(id)) return; - - setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]); - - try { - const currentUserId = pb.authStore.model?.id; - const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`; - const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, { - requestKey: `note-content-${id}` - }); - const note = mapRecordToNote(record, true); - updateStoreWithNote(note); - loadedNoteDetailIds.add(id); - } catch (err) { - const status = (err as any)?.status; - if (status === 404) { - failedNoteDetailIds.add(id); - return; - } - console.error("Failed to load note content:", err); - } finally { - setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id)); - } -}; - - -// Helper to check if a task should be visible based on ownership, shares, or rules -const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { - const mappedTask = "shareRefs" in task ? task as Task : mapRecordToTask(task); - if (taskHasPersonalContextAccess(mappedTask, currentUserId)) return true; - if (store.supervisedContexts.some(context => taskHasPersonalContextAccess(mappedTask, context.ownerUserId))) return true; - - const hasSubscribedBucket = mappedTask.shareRefs.some(ref => - ref.kind === "bucket" && ( - store.subscribedBuckets.includes(ref.contextId) || - store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key) - ) - ); - if (hasSubscribedBucket) return true; - - const hasAccessibleNote = mappedTask.noteRefs.some(ref => { - const note = getNoteByRef(ref); - return !!note && (!note.isPrivate || note.user === currentUserId); - }); - if (hasAccessibleNote) return true; - - return mappedTask.shareRefs.some(ref => { - if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false; - const context = getContextByRef(ref); - return !!context && !context.deletedAt && context.targetUserId === currentUserId; - }); -}; - -export const subscribeToRealtime = async () => { - const currentUserId = pb.authStore.model?.id; - - // Unsubscribe first to avoid duplicates - await pb.collection(TASGRID_COLLECTION).unsubscribe('*'); - - pb.collection(TASGRID_COLLECTION).subscribe('*', (e) => { - console.log("Realtime event:", e.action, e.record.id); - - if (e.action === 'create') { - // Check if it's a template - const isTemplate = e.record.tags?.includes("__template__"); - if (isTemplate) { - // Check if template already exists (from optimistic update) - const templateExists = store.templates?.some(t => t.id === e.record.id); - if (templateExists) return; - - let meta: any = {}; - try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { } - const newTemplate: TaskTemplate = { - id: e.record.id, - name: e.record.title, - title: meta.title || "", - priority: e.record.priority, - urgency: meta.urgency || 5, - tags: meta.tags || [], - content: meta.content || "" - }; - setStore("templates", t => [...(t || []), newTemplate]); - return; - } - - // Regular Task - check if already exists and if it should be visible - const exists = store.tasks.find(t => t.id === e.record.id); - if (!exists) { - const currentUserId = pb.authStore.model?.id; - const isOwnTask = unwrapRelationId(e.record.user) === currentUserId; - - // Only add if it's own task or should be visible via sharing - if (isOwnTask || (currentUserId && shouldTaskBeVisible(e.record, currentUserId))) { - const newTask = mapRecordToTask(e.record); - setStore("tasks", t => [newTask, ...t]); - } - } - } - - if (e.action === 'update') { - // Is it a template? - const isTemplate = e.record.tags?.includes("__template__"); - if (isTemplate) { - // Update template store - let meta: any = {}; - try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { } - setStore("templates", t => t.id === e.record.id, { - name: e.record.title, - title: meta.title || "", - priority: e.record.priority, - urgency: meta.urgency || 5, - tags: meta.tags || [], - content: meta.content || "" - }); - return; - } - - const currentUserId = pb.authStore.model?.id; - const existingTask = store.tasks.find(t => t.id === e.record.id); - const isOwnTask = unwrapRelationId(e.record.user) === currentUserId; - const incomingUpdated = new Date(e.record.updated).getTime(); - - if (existingTask) { - const localUpdated = new Date(existingTask.updated).getTime(); - - // Only apply update if incoming is newer (or we don't have a timestamp) - if (!existingTask.updated || incomingUpdated >= localUpdated) { - // For shared tasks, re-evaluate visibility (e.g., tags may have changed) - if (!isOwnTask && currentUserId) { - if (!shouldTaskBeVisible(e.record, currentUserId)) { - // No longer visible, remove it - setStore("tasks", t => t.filter(x => x.id !== e.record.id)); - return; - } - } - - const updatedTask = mapRecordToTask(e.record); - setStore("tasks", t => t.id === e.record.id, updatedTask); - } else { - console.log("Skipping stale realtime update for task:", e.record.id); - } - } else { - // Task doesn't exist locally - check if it should be visible now - if (currentUserId && shouldTaskBeVisible(e.record, currentUserId)) { - const newTask = mapRecordToTask(e.record); - setStore("tasks", t => [newTask, ...t]); - } - } - } - - if (e.action === 'delete') { - // Try deleting from both just in case - setStore("tasks", t => t.filter(x => x.id !== e.record.id)); - setStore("templates", t => (t || []).filter(x => x.id !== e.record.id)); - } - }); - - // Subscribe to Notes - await pb.collection(NOTES_COLLECTION).unsubscribe('*'); - pb.collection(NOTES_COLLECTION).subscribe('*', (e) => { - const currentUserId = pb.authStore.model?.id; - - // Only process if public or owned by current user - const isVisible = !e.record.isPrivate || unwrapRelationId(e.record.user) === currentUserId; - - if (e.action === 'create' || e.action === 'update') { - if (isVisible) { - const shouldSyncContent = loadedNoteDetailIds.has(e.record.id) || activeNoteId() === e.record.id; - const updatedNote = mapRecordToNote(e.record, shouldSyncContent); - setStore("notes", prev => { - const exists = prev.find(n => n.id === updatedNote.id); - if (exists) { - const mergedNote = shouldSyncContent || exists.content === undefined - ? updatedNote - : { ...updatedNote, content: exists.content }; - return prev.map(n => n.id === updatedNote.id ? mergedNote : n); - } - return [updatedNote, ...prev]; - }); - } else { - // If it became private and we're not the owner, remove it - setStore("notes", prev => prev.filter(n => n.id !== e.record.id)); - } - } - - if (e.action === 'delete') { - setStore("notes", prev => prev.filter(n => n.id !== e.record.id)); - } - }); - - await pb.collection(CONTEXTS_COLLECTION).unsubscribe('*'); - pb.collection(CONTEXTS_COLLECTION).subscribe('*', (e) => { - if (e.action === 'delete') { - const remaining = store.contexts.filter(context => context.id !== e.record.id); - setContexts(remaining); - setStore("shareRules", remaining.map(mapContextToShareRule)); - setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, pb.authStore.model?.id || ""))); - void syncSupervisedContexts(); - return; - } - - const context = mapRecordToContext(e.record); - const nextContexts = store.contexts.some(existing => existing.id === context.id) - ? store.contexts.map(existing => existing.id === context.id ? context : existing) - : [...store.contexts, context]; - setContexts(nextContexts); - setStore("shareRules", nextContexts.map(mapContextToShareRule)); - if (context.targetUserId === pb.authStore.model?.id) { - setStore("personalContextSupervisorIds", context.supervisorUserIds || []); - } - void syncSupervisedContexts(); - }); - - if (currentUserId) { - await pb.collection('users').unsubscribe(currentUserId); - pb.collection('users').subscribe(currentUserId, (e) => { - if (e.action !== 'update') return; - - const prefs = readScopedPrefs(e.record?.Taskgrid_pref || {}); - applyScopedPrefsToStore({ - ...prefs, - subscribedBuckets: prefs.subscribedBuckets || e.record?.subscribedBuckets || [] - }); - }); - } -}; - -const runLegacyTagMigration = async (force = false) => { - const currentUserId = pb.authStore.model?.id; - if (!currentUserId) return; - - try { - const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null }); - const rawPrefs = userRec.Taskgrid_pref || {}; - const prefs = readScopedPrefs(rawPrefs); - - if (!force && prefs.migratedStructuredTagsV2) { - return; // Already migrated - } - - console.log("[MIGRATION] Starting structured tag migration..."); - - await syncUserContexts(); - const contexts = await loadContextsIntoStore(); - const ownerContextByUserId = new Map( - contexts - .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) - .map(context => [context.targetUserId as string, context]) - ); - - const notes = await pb.collection(NOTES_COLLECTION).getFullList({ - requestKey: null - }); - const migratedNotes: Note[] = []; - for (const noteRecord of notes) { - const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id); - const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : []; - const nextTags = [...new Set([buildNoteTag(key), ...existingTags])]; - const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))]; - - await pb.collection(NOTES_COLLECTION).update(noteRecord.id, { - key, - tags: nextTags, - tasks: nextTasks - }, { requestKey: null }); - - migratedNotes.push({ - ...mapRecordToNote(noteRecord), - key, - tags: nextTags, - tasks: nextTasks - }); - } - setStore("notes", migratedNotes); - const noteLinksByTaskId = buildNoteLinkMap(migratedNotes); - - const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({ - requestKey: null - }); - for (const taskRecord of tasks) { - if (taskRecord.tags?.includes("__template__")) continue; - const task = mapRecordToTask(taskRecord); - const payload = buildTaskMigrationPayload( - task, - ownerContextByUserId, - noteLinksByTaskId.get(task.id) || [], - currentUserId - ); - await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null }); - } - - const nextPrefs = { ...prefs, migratedStructuredTagsV2: true }; - await pb.collection('users').update(currentUserId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, nextPrefs) - }, { requestKey: null }); - } catch (err) { - console.error("[MIGRATION] Failed:", err); - } -}; - -const syncUserContexts = async () => { - const allUsers = await pb.collection('users').getFullList({ - filter: 'verified = true', - fields: 'id,name,email', - requestKey: null - }); - const existingContexts = await pb.collection(CONTEXTS_COLLECTION).getFullList({ - requestKey: null - }).catch(() => []); - let createdAny = false; - - for (const user of allUsers) { - const displayName = user.name || user.email; - const existingUserContext = existingContexts.find((context: any) => - (context.kind || "bucket") === "user" && - unwrapRelationId(context.targetUserId) === user.id - ); - if (!displayName || existingUserContext) continue; - - const created = await pb.collection(CONTEXTS_COLLECTION).create({ - key: buildUserContextKey(user.id), - displayName, - kind: "user", - policy: "collaborative", - targetUserId: user.id - }, { requestKey: null }).catch(() => null); - if (created) { - createdAny = true; - } - } - - if (createdAny || store.contexts.length === 0) { - await loadContextsIntoStore(); - } -}; - -const syncSupervisedContexts = async () => { - const currentUserId = pb.authStore.model?.id; - if (!currentUserId) { - setStore("supervisedContexts", []); - return; - } - - try { - const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ - sort: 'displayName,key', - requestKey: null - }).catch(() => []); - const contexts = contextRecords.length > 0 ? contextRecords.map(mapRecordToContext) : store.contexts; - - if (contextRecords.length > 0) { - setContexts(contexts); - setStore("shareRules", contexts.map(mapContextToShareRule)); - } - - const nextSupervisedContexts = contexts - .filter(context => - !context.deletedAt && - context.kind === "user" && - !!context.targetUserId && - context.targetUserId !== currentUserId && - (context.supervisorUserIds || []).includes(currentUserId) - ) - .map((context) => ({ - ownerUserId: context.targetUserId as string, - ownerName: context.displayName, - contextId: context.id, - contextName: context.displayName - })) - .sort((a, b) => a.ownerName.localeCompare(b.ownerName)); - - setStore("supervisedContexts", nextSupervisedContexts); - } catch (err) { - console.error("Failed to load supervised contexts:", err); - } -}; - -const runContextOwnershipMigration = async (force = false) => { - const currentUserId = pb.authStore.model?.id; - if (!currentUserId) return; - - try { - const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null }); - const rawPrefs = userRec.Taskgrid_pref || {}; - const prefs = readScopedPrefs(rawPrefs); - - if (!force && prefs.migratedContextOwnershipV2) { - return; - } - - await syncUserContexts(); - const contexts = store.contexts.length > 0 ? store.contexts : await loadContextsIntoStore(); - const ownerContextByUserId = new Map( - contexts - .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) - .map(context => [context.targetUserId as string, context]) - ); - const noteRecords = await pb.collection(NOTES_COLLECTION).getFullList({ - fields: NOTE_LIST_FIELDS, - requestKey: null - }).catch(() => []); - const allNotes = noteRecords.map(record => mapRecordToNote(record)); - const noteLinksByTaskId = buildNoteLinkMap(allNotes); - - const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ - fields: getTaskListFields(), - requestKey: null - }); - - for (const taskRecord of taskRecords) { - if (taskRecord.tags?.includes("__template__")) continue; - - const task = mapRecordToTask(taskRecord); - if (!task.ownerId) continue; - const payload = buildTaskMigrationPayload( - task, - ownerContextByUserId, - noteLinksByTaskId.get(task.id) || [], - currentUserId - ); - - const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs); - const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs); - const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags); - const sameOwnerId = payload.ownerId === task.ownerId; - if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy && sameOwnerId) continue; - - const pbPayload = { - ...payload, - user: payload.ownerId || task.ownerId || null - } as any; - delete pbPayload.ownerId; - - await pb.collection(TASGRID_COLLECTION).update(task.id, pbPayload, { requestKey: null }); - } - - await pb.collection('users').update(currentUserId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, { - ...prefs, - migratedPersonalContextRefsV1: true, - migratedContextOwnershipV2: true - }) - }, { requestKey: null }); - } catch (err) { - console.error("[MIGRATION] Failed context ownership migration:", err); - } -}; - -export const runDevDataMigration = async () => { - if (!pb.authStore.isValid) return; - if (!TASGRID_IS_DEV_DATA) { - toast.error("Dev migration is only available in dev data mode."); - return; - } - - await toast.promise((async () => { - await runLegacyTagMigration(true); - await runContextOwnershipMigration(true); - await loadContextsIntoStore(); - await syncSupervisedContexts(); - return "Dev data migration completed."; - })(), { - loading: "Running dev data migration...", - success: (message) => message, - error: "Dev data migration failed." - }); -}; - -export const canRunProdMigration = () => { - if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false; - const email = (pb.authStore.model?.email || "").trim().toLowerCase(); - return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email); -}; - -export const runProdDataMigration = async () => { - if (!pb.authStore.isValid) return; - if (!canRunProdMigration()) { - toast.error("Prod migration is not enabled for this account."); - return; - } - - await toast.promise((async () => { - await runLegacyTagMigration(true); - await runContextOwnershipMigration(true); - await loadContextsIntoStore(); - await syncSupervisedContexts(); - return "Production data migration completed."; - })(), { - loading: "Running production data migration...", - success: (message) => message, - error: "Production data migration failed." - }); -}; - - -export const initStore = async () => { - ensureRealtimeRecovery(); - if (!pb.authStore.isValid || store.isInitializing) return; - setStore("isInitializing", true); - - const currentUserId = pb.authStore.model?.id; - const refreshedTaskIds = new Set(); - - // STAGE 1: Instant Focus from Local Auth Model (Top 20 + Fallback) - // Goal: < 100ms First Paint. Doesn't wait for any other network requests. - if (currentUserId) { - try { - const prefs = readScopedPrefs(pb.authStore.model?.Taskgrid_pref || {}); - const cachedQuickload = prefs.quickloadTasks || []; - - let focusRecords; - if (cachedQuickload.length > 0) { - const idFilter = cachedQuickload.map((id: string) => `id = "${id}"`).join(' || '); - const res = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `(${idFilter}) && deletedAt = ""`, - requestKey: null - }); - - // Sort manually to match quickload order - res.sort((a, b) => cachedQuickload.indexOf(a.id) - cachedQuickload.indexOf(b.id)); - focusRecords = { items: res }; - } else { - const personalAccessFilter = buildPersonalAccessFilter([currentUserId]); - focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, { - filter: `${personalAccessFilter} && completed = false && deletedAt = ""`, - sort: '-priority,dueDate', - requestKey: null - }); - } - - // Filter out templates client-side to ensure tasks with empty/null tags aren't excluded by DB - const focusTasks = focusRecords.items - .map(mapRecordToTask) - .filter(t => !t.tags?.includes("__template__")); - - setStore("tasks", focusTasks); - setStore("quickloadFadeTaskIds", focusTasks.map(task => task.id)); - focusTasks.forEach(task => refreshedTaskIds.add(task.id)); - if (quickloadFadeTimer) clearTimeout(quickloadFadeTimer); - quickloadFadeTimer = window.setTimeout(() => { - setStore("quickloadFadeTaskIds", []); - quickloadFadeTimer = undefined; - }, 200); - } catch (focusErr) { - console.warn("Stage 1 load failed:", focusErr); - } - } - - // 0. Synchronous hydration from localStorage for "instant" first paint - const key = getStorageKey(); - if (key) { - const saved = localStorage.getItem(key); - if (saved) { - try { - const parsed = JSON.parse(saved); - const cachedTasks = Array.isArray(parsed?.tasks) ? parsed.tasks : []; - const mergedCachedTasks = [ - ...store.tasks, - ...cachedTasks.filter((task: Task) => !refreshedTaskIds.has(task.id)) - ]; - setStore({ - ...parsed, - tasks: mergedCachedTasks, - isInitializing: true - }); - } catch (e) { - console.error("Failed to parse cached data", e); - } - } - } - - // Heartbeat - Ensure only one interval is running - if (heartbeatInterval) clearInterval(heartbeatInterval); - heartbeatInterval = setInterval(() => { - setNow(Date.now()); - checkRecurringTasks(); // Check for recurrence triggers (minutely, etc.) - }, 60000); - - try { - // 1. Fetch Preferences from User Profile (Weights, Scale, Tags) - try { - const userId = pb.authStore.model?.id; - if (userId) { - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const prefs = readScopedPrefs(user.Taskgrid_pref || {}); - applyScopedPrefsToStore({ - ...prefs, - subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || [] - }); - } - } catch (prefErr) { - console.warn("Failed to load preferences:", prefErr); - } - - // 1.2 Fetch Contexts - try { - const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ - sort: 'displayName,key', - requestKey: null - }); - const contexts = contextRecords.map(mapRecordToContext); - setContexts(contexts); - setStore("shareRules", contexts.map(mapContextToShareRule)); - await syncUserContexts(); - await syncSupervisedContexts(); - const personalContext = store.contexts.find(context => - !context.deletedAt && - context.kind === "user" && - context.targetUserId === pb.authStore.model?.id - ); - if (personalContext) { - setStore("personalContextSupervisorIds", personalContext.supervisorUserIds || []); - } - if (personalContext && currentTaskContext() === "mine") { - setCurrentTaskContext({ bucketId: personalContext.id, name: "My Bucket", isPersonal: true }); - } - } catch (contextErr) { - console.warn("Failed to load contexts (collection might not exist yet):", contextErr); - } - - setStore("tagDefinitions", defs => { - const plainDefs = defs.filter(def => !def.name.startsWith("@")); - return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)]; - }); - - const currentUserId = pb.authStore.model?.id; - if (!currentUserId) return; - - // --- PHASE 2: Multi-Stage Loading --- - - // --- WINDOWED SYNC STRATEGY --- - - // STAGE 2 & 3: Background Sync - const backgroundSync = async () => { - const subscribedBucketTags = getSubscribedBucketContexts() - .map(context => buildShareTag(context.displayName)); - const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); - const currentUserAccessFilter = buildPersonalAccessFilter(currentUserId ? [currentUserId] : []); - const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds); - - // 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability - const incompletePromises = [ - pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `${currentUserAccessFilter} && completed = false`, - sort: '-created', - fields: getTaskListFields(), - requestKey: null - }), - (subscribedBucketTags.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`, - sort: '-created', - fields: getTaskListFields(), - requestKey: null - }).catch(() => []) : Promise.resolve([]), - (supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `${supervisedAccessFilter} && completed = false`, - sort: '-created', - fields: getTaskListFields(), - requestKey: null - }).catch(() => []) : Promise.resolve([]) - ]; - - const stage2Results = await Promise.all(incompletePromises); - const allIncomplete = stage2Results.flat(); - - setStore("tasks", (currentTasks) => { - const taskMap = new Map(currentTasks.map(t => [t.id, t])); - allIncomplete.forEach((r: any) => { - if (r.tags?.includes("__template__")) return; - if (refreshedTaskIds.has(r.id)) return; - - const existing = taskMap.get(r.id); - - // Prevent DOM rebuilding/flickering for tasks that haven't changed - if (existing && existing.updated === r.updated) { - return; // Keeps the exact same reference in the map - } - - // If existing has content, keep it. Otherwise use new record (which likely has no content yet) - const newTask = mapRecordToTask(r); - if (existing && existing.content) { - newTask.content = existing.content; - } - taskMap.set(r.id, newTask); - refreshedTaskIds.add(r.id); - }); - return Array.from(taskMap.values()); - }); - - // 2b. Content Backfill for Incomplete Tasks - // Fetch content for tasks that don't have it yet, in chunks - const tasksNeedingContent = store.tasks - .filter(t => !t.completed && t.content === undefined && !refreshedTaskIds.has(t.id)) - .map(t => t.id); - if (tasksNeedingContent.length > 0) { - // Fetch in chunks of 20 to avoid URL length limits - const chunkSize = 20; - for (let i = 0; i < tasksNeedingContent.length; i += chunkSize) { - const ids = tasksNeedingContent.slice(i, i + chunkSize); - // We fetch just ID and Content - try { - const contentRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: ids.map(id => `id="${id}"`).join(' || '), - fields: 'id,content', - requestKey: null - }); - - setStore("tasks", (tasks) => tasks.map(t => { - const match = contentRecords.find((r: any) => r.id === t.id); - if (match) return { ...t, content: match.content }; - return t; - })); - } catch (e) { console.warn("Backfill chunk failed", e); } - } - } - - // STAGE 3: Recent History (Cap 150) - const currentCount = store.tasks.length; - const remainingSlots = 150 - currentCount; - - if (remainingSlots > 0) { - try { - const completedAccessFilter = buildPersonalAccessFilter(currentUserId ? [currentUserId] : []); - const recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, { - filter: `${completedAccessFilter} && completed = true`, - sort: '-updated', - fields: getTaskListFields(), - requestKey: null - }); - - setStore("tasks", (prev) => { - const newTasks = [...prev]; - recentCompleted.items.forEach((r: any) => { - if (!newTasks.some(t => t.id === r.id)) { - newTasks.push(mapRecordToTask(r)); - } - }); - return newTasks; - }); - } catch (e) { - console.warn("Stage 3 load failed", e); - } - } - - checkRecurringTasks(); - - // Reconstructive migration - const foundTagNames = new Set(); - store.tasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag))); - for (const tagName of foundTagNames) { - if (tagName === "__template__" || tagName.startsWith("@") || tagName.startsWith("#")) continue; - if (!store.tagDefinitions.find(d => d.name === tagName)) { - await upsertTagDefinition(tagName, 5, undefined, document.documentElement.classList.contains("dark") ? "dark" : "light"); - } - } - - // Sync System Tags & Share Rules (Moved to top of backgroundSync to ensure rules exist before use) - // await syncSystemTagsAndRules(); - }; - - // Fire off background sync - await backgroundSync(); - await ensureUpdatedByFieldSupport(); - - // Run one-time migration automatically only in dev data mode. - if (TASGRID_IS_DEV_DATA) { - await runLegacyTagMigration(); - await runContextOwnershipMigration(); - } - - // Separate Templates load (less critical) - try { - const templates = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `${relationFilter("user", currentUserId)} && tags ~ "__template__"`, - requestKey: null - }); - const loadedTemplates = templates.map(r => { - let meta: any = {}; - try { meta = JSON.parse(r.content || "{}"); } catch (e) { } - return { - id: r.id, - name: r.title, - title: meta.title || "", - priority: r.priority, - urgency: meta.urgency || 5, - tags: meta.tags || [], - content: meta.content || "" - }; - }); - setStore("templates", loadedTemplates); - } catch (err) { } - - // Start real-time subscription - await subscribeToRealtime(); - - // 2.8 Fetch Filter Templates - await loadFilterTemplates(); - - // 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); - toast.error("Failed to sync with server."); - } - } finally { - setStore("isInitializing", false); - } -}; - -// -- Actions -- - -export const toggleBucketSubscription = async (bucketId: string) => { - const currentSubscribed = store.subscribedBuckets; - const isSubscribed = currentSubscribed.includes(bucketId); - - let newSubscribed = []; - if (isSubscribed) { - newSubscribed = currentSubscribed.filter(id => id !== bucketId); - } else { - newSubscribed = [...currentSubscribed, bucketId]; - } - - setStore("subscribedBuckets", newSubscribed); - - // Persist to user profile - const userId = pb.authStore.model?.id; - if (userId) { - try { - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const rawPrefs = user.Taskgrid_pref || {}; - const scopedPrefs = readScopedPrefs(rawPrefs); - await pb.collection('users').update(userId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, { - ...scopedPrefs, - subscribedBuckets: newSubscribed - }) - }); - - // If we just subscribed, we should fetch the tasks for this bucket immediately - if (!isSubscribed) { - const bucket = store.buckets.find(b => b.id === bucketId); - if (bucket) { - const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `tags ~ "${buildShareTag(bucket.name)}"`, - sort: '-created', - fields: getTaskListFields() - }); - - const newTasks: Task[] = []; - records.forEach((r: any) => { - const exists = store.tasks.find(t => t.id === r.id); - if (!exists && !r.tags?.includes("__template__")) { - newTasks.push(mapRecordToTask(r)); - } - }); - - if (newTasks.length > 0) { - setStore("tasks", t => [...newTasks, ...t]); - } - } - } - } catch (err) { - console.error("Failed to update subscriptions:", err); - // Revert on error - setStore("subscribedBuckets", currentSubscribed); - } - } -}; - -export const createBucket = async (name: string, color: string, description?: string) => { - try { - const key = normalizeContextKey(name); - await pb.collection(CONTEXTS_COLLECTION).create({ - key, - displayName: name, - kind: "bucket", - policy: "handoff", - color, - description - }); - toast.success("Bucket created"); - } catch (err) { - console.error("Failed to create bucket:", err); - toast.error("Failed to create bucket"); - } -}; - -export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string, policy?: "collaborative" | "handoff" }) => { - try { - const payload: any = { ...data }; - if (data.name) payload.key = normalizeContextKey(data.name); - if (data.name) payload.displayName = data.name; - await pb.collection(CONTEXTS_COLLECTION).update(id, payload); - toast.success("Bucket updated"); - } catch (err) { - console.error("Failed to update bucket:", err); - toast.error("Failed to update bucket"); - } -}; - -export const deleteBucket = async (id: string) => { - try { - await pb.collection(CONTEXTS_COLLECTION).update(id, { - deletedAt: new Date().toISOString(), - deletedBy: pb.authStore.model?.id || null - }); - toast.success("Bucket moved to trash"); - } catch (err) { - console.error("Failed to delete bucket:", err); - toast.error("Failed to delete bucket"); - } -}; - -export const updateUserContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => { - const success = await setContextPolicy(contextId, policy); - if (success) { - toast.success("User context updated"); - } -}; - -export const setContextPolicy = async (contextId: string, policy: "collaborative" | "handoff") => { - const previousContext = store.contexts.find(context => context.id === contextId); - const previousPolicy = previousContext?.policy; - - if (previousContext && previousPolicy !== policy) { - setStore("contexts", context => context.id === contextId, "policy", policy); - setStore("shareRules", store.contexts.map(mapContextToShareRule)); - } - - try { - await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null }); - return true; - } catch (err) { - console.error("Failed to update user context:", err); - if (previousContext && previousPolicy) { - setStore("contexts", context => context.id === contextId, "policy", previousPolicy); - setStore("shareRules", store.contexts.map(mapContextToShareRule)); - } - toast.error("Failed to update user context"); - return false; - } -}; - -export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => { - if (!pb.authStore.isValid || !noteId) return false; - - const note = store.notes.find(existing => existing.id === noteId); - if (!note) return false; - - const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG); - const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags; - const previousTags = note.tags || []; - - setStore("notes", n => n.id === noteId, "tags", nextTags); - - try { - await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null }); - return true; - } catch (err) { - console.error("Failed to update note context policy:", err); - setStore("notes", n => n.id === noteId, "tags", previousTags); - toast.error("Failed to update note sharing mode"); - return false; - } -}; - -export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => { - const userId = pb.authStore.model?.id; - if (!userId) return; - - const uniqueSupervisorIds = [...new Set(supervisorUserIds.filter(Boolean))]; - setStore("personalContextSupervisorIds", uniqueSupervisorIds); - - try { - await syncUserContexts(); - const personalContext = getPersonalContext(userId); - if (!personalContext) { - throw new Error("Missing personal context record"); - } - - await pb.collection(CONTEXTS_COLLECTION).update(personalContext.id, { - supervisorUserIds: uniqueSupervisorIds - }, { requestKey: null }); - - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const rawPrefs = user.Taskgrid_pref || {}; - const scopedPrefs = readScopedPrefs(rawPrefs); - await pb.collection('users').update(userId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, { - ...scopedPrefs, - supervisorUserIds: uniqueSupervisorIds - }) - }, { requestKey: null }); - await syncSupervisedContexts(); - toast.success("Personal supervision updated"); - } catch (err) { - console.error("Failed to update personal supervision:", err); - toast.error("Failed to update personal supervision"); - } -}; - -export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number, shareModeByTag?: Record }) => { - if (!pb.authStore.isValid) return; - - // Default to ~24h from now if no date provided - let dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); - if (options?.dueDate) { - dueDate = options.dueDate instanceof Date ? options.dueDate.toISOString() : options.dueDate; - } - - const startDate = new Date().toISOString(); - const priority = options?.priority ?? 5; - const tags = withActiveContextTags(options?.tags ?? ["work"]); - const content = options?.content ?? ""; - const size = options?.size ?? 3; - const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(tags, [], options?.shareModeByTag || {}); - - const tempId = "temp-" + Date.now(); - const initialTask: Task = { - id: tempId, - title, - startDate, - dueDate, - priority, - status: 0, - completed: false, - tags: buildTaskTags(labelTags, shareRefs, noteRefs), - labelTags, - shareRefs, - noteRefs, - ownerId: pb.authStore.model?.id || "", - createdBy: pb.authStore.model?.id || "", - updatedBy: pb.authStore.model?.id || "", - content, - size, - created: new Date().toISOString(), - updated: new Date().toISOString() - }; - - // Apply Handoff Logic (e.g. transfer ownership if tagging for another user) - // We treat the entire new task as "updates" to check against itself - const handoffUpdates = checkForHandoffs(initialTask, initialTask); - const mirroredUpdates = withMirroredOwnerId( - initialTask.ownerId, - handoffUpdates.shareRefs || initialTask.shareRefs, - handoffUpdates, - handoffUpdates.ownerId || initialTask.ownerId - ); - const newTask = { ...initialTask, ...mirroredUpdates }; - - // Optimistic UI update - setStore("tasks", (t) => [newTask, ...t]); - - try { - const updatedBySupported = await ensureUpdatedByFieldSupport(); - const createPayload: any = { - user: newTask.ownerId, // Use the (potentially updated) ownerId - createdBy: newTask.createdBy, - title, - startDate, - dueDate, - priority, - status: 0, - completed: false, - tags: newTask.tags, - labelTags: newTask.labelTags, - shareRefs: newTask.shareRefs, - noteRefs: newTask.noteRefs, - content, - size - }; - - if (updatedBySupported) { - createPayload.updatedBy = pb.authStore.model?.id || null; - } - - const record = await pb.collection(TASGRID_COLLECTION).create(createPayload); - - // Replace temp task with real record (merging server fields like id, created, updated) - setStore("tasks", (t) => { - // Check if REAL ID exists already (from subscription race) - const realExists = t.some(x => x.id === record.id); - if (realExists) { - // If real exists, just remove temp. - return t.filter(x => x.id !== tempId); - } - // Otherwise, replace temp with real - return t.map(task => task.id === tempId ? { - ...task, - id: record.id, - created: record.created, - updated: record.updated, - ownerId: unwrapRelationId(record.user), - createdBy: unwrapRelationId(record.createdBy) || unwrapRelationId(record.user), - updatedBy: recordHasField(record, "updatedBy") - ? (unwrapRelationId(record.updatedBy) || unwrapRelationId(record.createdBy) || unwrapRelationId(record.user)) - : task.updatedBy - } : task); - }); - } catch (err) { - console.error("create failed", err); - toast.error("Failed to save task."); - // Rollback? - setStore("tasks", (t) => t.filter(task => task.id !== tempId)); - } -}; - -export const copyTask = async (id: string) => { - const original = store.tasks.find(t => t.id === id); - if (!original) return; - - const shareModeByTag = Object.fromEntries( - original.shareRefs.map(ref => { - const context = getContextByRef(ref); - const tag = ref.kind === "note" - ? buildNoteTag(context?.key || ref.key) - : buildShareTag(context?.displayName || ref.key); - return [tag.toLowerCase(), getTaskSharePolicy(ref)]; - }) - ) as Record; - - // Create a new task based on the original - await addTask(`${original.title} (Copy)`, { - priority: original.priority, - dueDate: original.dueDate, - tags: [...(original.tags || [])], - content: original.content, - size: original.size, - shareModeByTag - }); - - toast.success("Task duplicated"); -}; - -const checkForHandoffs = (task: Task, updates: Partial): Partial => { - const currentUserId = pb.authStore.model?.id; - if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return updates; - - const refs = updates.shareRefs || task.shareRefs; - const senderPersonalContext = getPersonalContext(currentUserId); - const stripSenderPersonalContext = (inputRefs: TaskShareRef[]) => - senderPersonalContext - ? inputRefs.filter(existingRef => !( - existingRef.kind === "user" && - (existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key) - )) - : inputRefs; - - for (const ref of refs) { - const context = getContextByRef(ref); - if (senderPersonalContext && ref.kind === "user" && ( - ref.contextId === senderPersonalContext.id || - ref.key === senderPersonalContext.key - )) { - continue; - } - if (!context || getTaskSharePolicy(ref) !== "handoff") continue; - const nextShareRefs = stripSenderPersonalContext(refs); - const labelTags = updates.labelTags || task.labelTags; - const noteRefs = updates.noteRefs || task.noteRefs; - const favoriteTag = (updates.tags || task.tags || []).find(tag => tag.endsWith("_favorite__")); - - if (context.kind === "user" && context.targetUserId) { - return { - ...updates, - ownerId: context.targetUserId, - shareRefs: nextShareRefs, - tags: buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag) - }; - } - - if (context.kind === "bucket" || context.kind === "note") { - return { - ...updates, - shareRefs: nextShareRefs, - tags: buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag) - }; - } - } - - return updates; -}; - -export const updateTask = async (id: string, updates: Partial) => { - if (!pb.authStore.isValid) return false; - - // 0. Check for Hand-off Logic (Tag-based ownership transfer) - const currentTask = store.tasks.find(t => t.id === id); - let finalUpdates = { ...updates }; - - // Sync status and completed - if (finalUpdates.status !== undefined) { - finalUpdates.completed = finalUpdates.status === 10; - } else if (finalUpdates.completed !== undefined) { - // If only completed is passed, sync status - // But if both pass, status takes precedence (above) - // Wait, if I change completed to false, status should be 0? Or previous status? - // Let's assume toggleTask handles that. updateTask is lower level. - // But for safety: - if (finalUpdates.completed) { - finalUpdates.status = 10; - } else if (currentTask && currentTask.status === 10) { - // Uncompleting a completed task -> reset to 0 - finalUpdates.status = 0; - } - // If uncompleting a non-completed task (weird), keep status? - } - - if (currentTask) { - let favoriteTagOverride: string | null | undefined; - - if (finalUpdates.tags) { - if (!finalUpdates.shareRefs || !finalUpdates.noteRefs || !finalUpdates.labelTags) { - const derived = deriveTaskRelationsFromTags(finalUpdates.tags, currentTask.shareRefs); - finalUpdates.labelTags = finalUpdates.labelTags || derived.labelTags; - finalUpdates.shareRefs = finalUpdates.shareRefs || derived.shareRefs; - finalUpdates.noteRefs = finalUpdates.noteRefs || derived.noteRefs; - } - favoriteTagOverride = finalUpdates.tags.find(tag => tag.endsWith("_favorite__")) || null; - } - - if (finalUpdates.labelTags || finalUpdates.shareRefs || finalUpdates.noteRefs) { - const favoriteTag = favoriteTagOverride !== undefined - ? favoriteTagOverride || undefined - : currentTask.tags.find(tag => tag.endsWith("_favorite__")); - finalUpdates.tags = buildTaskTags( - finalUpdates.labelTags || currentTask.labelTags, - finalUpdates.shareRefs || currentTask.shareRefs, - finalUpdates.noteRefs || currentTask.noteRefs, - favoriteTag - ); - } - finalUpdates = checkForHandoffs(currentTask, finalUpdates); - const mirroredShareRefs = finalUpdates.shareRefs || currentTask.shareRefs; - finalUpdates = withMirroredOwnerId( - finalUpdates.ownerId ?? currentTask.ownerId, - mirroredShareRefs, - finalUpdates, - finalUpdates.ownerId ?? currentTask.ownerId - ); - } - - // Optimistic update with timestamp to prevent stale realtime events - const optimisticUpdates = { - ...finalUpdates, - updated: new Date().toISOString(), - updatedBy: pb.authStore.model?.id || null - }; - setStore("tasks", (t) => t.id === id, optimisticUpdates); - - try { - // Map Task fields to PB fields if needed (dates are ISO strings, so should matches) - // Check for specific fields being updated - const pbUpdates: any = { ...finalUpdates }; - const updatedBySupported = await ensureUpdatedByFieldSupport(); - - if (finalUpdates.ownerId !== undefined) { - pbUpdates.user = finalUpdates.ownerId || null; // Map ownerId to 'user' field in PB - delete pbUpdates.ownerId; - } - if (finalUpdates.createdBy !== undefined) { - pbUpdates.createdBy = finalUpdates.createdBy; - } - - if ("deletedAt" in finalUpdates) { - // PB expects a date string or null for date fields. - // Using finalUpdates.deletedAt check to distinguish between soft-delete (timestamp) and restore (null/falsy) - if (finalUpdates.deletedAt) { - pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString(); - } else { - pbUpdates.deletedAt = null; // restore task - } - } - - if (updatedBySupported) { - pbUpdates.updatedBy = pb.authStore.model?.id || null; - } - - // Disable auto-cancellation for updates to prevent aborted errors during rapid typing - await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null }); - return true; - } catch (err) { - console.error("update failed", err); - toast.error("Failed to update task."); - return false; - } -}; - -// Wrapper for updateTask to fit setStore style usage in components if needed, -// but for now we'll just intercept the setStore calls or provide specific actions. -// The components currently use `setStore("tasks", ...)` directly in some places. -// We need to verify if we should refactor those components to use actions or use a store effect. -// Ideally, we use a createEffect to watch the store, but that can trigger on every load. -// Better to export specific actions. - -// -- Legacy Adapters -- -// Many components use setStore directly. To support them without full refactor, -// we can watch specific fields or just migrate them to use actions. -// Given strict instructions, let's look at what we've seen. -// TaskDetail uses `removeTask`. -// QuickEntry uses `addTask`. -// TaskDetail uses `setStore` for title/priority updates. THIS IS A PROBLEM for sync. -// We should provide a way to sync these changes. - -// Let's adding a store effect to sync changes? -// It's risky. Let's provide an explicit hook or functions and fix the components. -// OR, we overwrite the `setStore` export? No, that's internal. -// Let's updated `setStore` usage in this file to be purely local, -// and add a `syncTask` helper that components should call? -// Or better: Replace the `setStore` export with a wrapped version? Hard with type inference. -// -// Best approach for now: -// Keep `setStore` for local UI state. -// Add a `createEffect` that watches `store.tasks`? -// No, deep watching is expensive. -// -// Let's sticking to: Components SHOULD call actions. -// But TaskDetail calls `setStore`. -// I will start by modifying `setStore` usages in TaskDetail/etc in a subsequent step if needed. -// FOR NOW: I will export a `updateTaskField` helper and update components to use it. -// Actually, `TaskDetail` was edited to use `setStore` in Step 1520. -// line 33: setStore("tasks", (t) => t.id === props.task.id, "content", html); -// line 38, 44, 52, 60... all use setStore. -// I MUST refactor TaskDetail to use an action. - -export const updateTaskField = (id: string, field: keyof Task, value: any) => { - setStore("tasks", (t) => t.id === id, field, value); - updateTask(id, { [field]: value }); -}; - -// -- Matrix Scale -- - -export const setMatrixScaleDays = async (days: number) => { - setStore("matrixScaleDays", days); - await syncPreferences(); -}; - -export const toggleTask = (id: string) => { - const task = store.tasks.find(t => t.id === id); - if (task) { - const newCompleted = !task.completed; - updateTask(id, { - completed: newCompleted, - status: newCompleted ? 10 : 0 - }); - } -}; - -export const removeTask = (id: string) => { - // Soft delete - const nowTs = Date.now(); - updateTask(id, { deletedAt: nowTs }); -}; - -export const restoreTask = (id: string) => { - // updateTask handles both optimistic update and PB sync - // Pass null for deletedAt to explicitly clear it in PB - updateTask(id, { deletedAt: null }); -}; - -export const deleteTaskPermanently = async (id: string) => { - if (!pb.authStore.isValid) return; - - // Optimistic - removeDeletedTaskFromStore(id); - - try { - await pb.collection(TASGRID_COLLECTION).delete(id); - } catch (err) { - console.error("delete failed", err); - toast.error("Failed to delete task."); - // Reload tasks? - } -}; - -export const upsertTagDefinition = async (name: string, value: number, color?: string, theme?: "light" | "dark") => { - if (!pb.authStore.isValid) return; - - if (name.startsWith("@")) { - toast.error("Shared @tags are managed from contexts."); - return; - } - - const existing = store.tagDefinitions.find(d => d.name === name); - const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light"); - - try { - if (existing) { - setStore("tagDefinitions", (d) => d.id === existing.id, { - value, - color: color !== undefined ? color : existing.color, - theme: theme !== undefined ? theme : existing.theme - }); - } else { - - // Check for case-insensitive duplicate - const nameLower = name.toLowerCase(); - const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === nameLower); - - if (duplicate) { - if (duplicate.name !== name) { - // Case difference only -> Update to new casing? - // Or just warn? "Tag already exists with different casing". - // For now, we'll just return the existing one to be safe, or update it if needed. - // A strict interpretation of "There should never be two tags with the same name" - // usually implies normalization. - // But if the user typed "Work" and "work" exists, maybe they mean "work". - toast.error(`Tag "${duplicate.name}" already exists.`); - return; - } - // Exact match logic handled by 'existing' check above usually, but 'existing' uses strict equality? - // The existing check at top of function is strict: `existing = store.tagDefinitions.find(d => d.name === name);` - // So this block handles case-insensitive duplicates that weren't caught by strict check. - // We should update the existing one if we want to change casing, or just reject. - // Rejection is safer for unique constraint. - toast.error(`Tag "${duplicate.name}" already exists.`); - return; - } - - const record = { - id: `pref-${normalizeContextKey(name) || Date.now().toString()}`, - name, - value, - color: color || "#6366f1", // Default indigo - theme: finalTheme - }; - - const newDef: TagDefinition = { - id: record.id, - name: record.name, - value: record.value, - color: record.color, - theme: record.theme as "light" | "dark" - }; - setStore("tagDefinitions", (prev) => [...prev, newDef]); - } - await syncPreferences(); - } catch (err) { - console.error("Failed to upsert tag:", err); - toast.error("Failed to save tag definition."); - } -}; - -export const removeTagDefinition = async (name: string) => { - if (!pb.authStore.isValid) return; - const def = store.tagDefinitions.find(d => d.name === name); - if (!def) return; - - try { - setStore("tagDefinitions", (prev) => prev.filter(d => d.id !== def.id)); - - const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name)); - for (const task of tasksWithTag) { - const nextTags = (task.tags || []).filter(t => t !== name); - updateTask(task.id, { tags: nextTags }); - } - - await syncPreferences(); - toast.success(`Tag "${name}" deleted`); - } catch (err) { - console.error("Failed to delete tag:", err); - toast.error("Failed to delete tag."); - } -}; - -export const updateNote = async (id: string, updates: Partial) => { - if (!pb.authStore.isValid) return; - - if (updates.content !== undefined) { - loadedNoteDetailIds.add(id); - } - - // Optimistic update - const optimisticUpdates = { - ...updates, - key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined), - updated: new Date().toISOString() - }; - setStore("notes", (n) => n.id === id, optimisticUpdates); - - try { - const pbUpdates: any = { ...updates }; - - if ("deletedAt" in updates) { - if (updates.deletedAt) { - pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString(); - } else { - pbUpdates.deletedAt = null; - } - } - - if (!pbUpdates.key && updates.title) { - pbUpdates.key = normalizeNoteKey(updates.title); - } - - await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null }); - } catch (err) { - console.error("Note update failed", err); - toast.error("Failed to update note."); - } -}; - -export const removeNote = (id: string) => { - const nowTs = Date.now(); - updateNote(id, { deletedAt: nowTs }); -}; - -export const restoreNote = (id: string) => { - updateNote(id, { deletedAt: null }); -}; - -export const deleteNotePermanently = async (id: string) => { - if (!pb.authStore.isValid) return; - - 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); - } catch (err) { - console.error("Note delete failed", err); - toast.error("Failed to delete note."); - } -}; - -export const syncSystemTagsAndRules = async () => { - if (!pb.authStore.isValid) return; - setStore("shareRules", store.contexts.map(mapContextToShareRule)); - setStore("tagDefinitions", defs => { - const plainDefs = defs.filter(def => !def.name.startsWith("@")); - return [...plainDefs, ...deriveSystemTagDefinitions(store.contexts)]; - }); -}; - -export const renameTagDefinition = async (oldName: string, newName: string) => { - if (!newName || !newName.trim() || newName === oldName) return; - if (!pb.authStore.isValid) return; - - const def = store.tagDefinitions.find(d => d.name === oldName); - if (!def) return; - - const finalName = newName.trim(); - const finalNameLower = finalName.toLowerCase(); - - // Check availability (case-insensitive) - const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === finalNameLower && d.id !== def.id); - if (duplicate) { - toast.error(`Tag "${duplicate.name}" already exists.`); - return; - } - - try { - setStore("tagDefinitions", (d) => d.id === def.id, { name: finalName }); - - const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName)); - for (const task of tasksWithTag) { - const newTags = task.tags.map(t => t === oldName ? finalName : t); - const uniqueTags = [...new Set(newTags)]; - updateTask(task.id, { tags: uniqueTags }); - } - - await syncPreferences(); - } catch (err) { - console.error("Failed to rename tag:", err); - toast.error("Failed to rename tag."); - } -}; - -// --- HISTORY ACTIONS --- - -export const loadAllHistory = async () => { - if (!pb.authStore.isValid) return; - const userId = pb.authStore.model?.id; - if (!userId) return; - - try { - toast.promise( - (async () => { - // Fetch ALL tasks for user (including completed) - const bucketTags = getSubscribedBucketContexts() - .map(context => buildShareTag(context.displayName)); - const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); - const userAccessFilter = buildPersonalAccessFilter([userId]); - const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds); - - const promises = [ - pb.collection(TASGRID_COLLECTION).getFullList({ filter: userAccessFilter, sort: '-created', fields: getTaskListFields(), requestKey: null }) - ]; - - if (bucketTags.length > 0) { - promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ - filter: bucketTags.map(tag => `tags ~ "${tag}"`).join(' || '), - sort: '-created', - fields: getTaskListFields(), - requestKey: null - }).catch(() => [])); - } - - if (supervisedUserIds.length > 0) { - promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ - filter: supervisedAccessFilter, - sort: '-created', - fields: getTaskListFields(), - requestKey: null - }).catch(() => [])); - } - - const results = await Promise.all(promises); - const allRecords = results.flat(); - - setStore("tasks", (currentTasks) => { - const taskMap = new Map(currentTasks.map(t => [t.id, t])); - let addedCount = 0; - allRecords.forEach((r: any) => { - if (r.tags?.includes("__template__")) return; - if (!taskMap.has(r.id)) { - taskMap.set(r.id, mapRecordToTask(r)); - addedCount++; - } - }); - return Array.from(taskMap.values()); - }); - - return `Loaded history.`; - })(), - { - loading: 'Loading full history...', - success: (msg) => msg, - error: 'Failed to load history' - } - ); - } catch (err) { - console.error("Failed to load history:", err); - } -}; - -export const loadTaskContent = async (id: string) => { - if (!pb.authStore.isValid) return; - try { - const record = await pb.collection(TASGRID_COLLECTION).getOne(id, { requestKey: null }); - // Only update if content is still undefined (i.e. not edited locally while loading) - setStore("tasks", t => t.id === id && t.content === undefined, { content: record.content }); - } catch (err) { - console.error("Failed to load task content:", err); - } -}; - -export const loadTasksLinkedToNote = async (note: Pick) => { - if (!pb.authStore.isValid || !note?.id) return; - - const noteTag = buildNoteTag(note.key || note.title); - const escapedNoteTag = noteTag.replace(/"/g, '\\"'); - - try { - const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`, - sort: "-updated", - fields: getTaskListFields(), - requestKey: `note-linked-${note.id}` - }).catch(() => []); - - const recordMap = new Map(); - listRecords.forEach(record => { - if (!record?.id) return; - if (record.tags?.includes("__template__")) return; - recordMap.set(record.id, record); - }); - - if (recordMap.size === 0) return; - - setStore("tasks", (currentTasks) => { - const taskMap = new Map(currentTasks.map(task => [task.id, task])); - recordMap.forEach((record: any) => { - const mapped = mapRecordToTask(record); - const existing = taskMap.get(mapped.id); - if (existing?.content) { - mapped.content = existing.content; - } - taskMap.set(mapped.id, mapped); - }); - return Array.from(taskMap.values()); - }); - } catch (err: any) { - console.error("Failed to load note-linked tasks:", err?.status, err?.response || err); - } -}; - - -// -- Templates -- - -export const syncPreferences = async () => { - const userId = pb.authStore.model?.id; - if (!userId) return; - - try { - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const rawPrefs = user.Taskgrid_pref || {}; - const prefs = { - pWeight: store.pWeight, - uWeight: store.uWeight, - matrixScaleDays: store.matrixScaleDays, - tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")), - filterTemplates: store.filterTemplates, - quickloadTasks: store.quickloadTasks, - dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators, - collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks, - subscribedBuckets: store.subscribedBuckets, - supervisorUserIds: store.personalContextSupervisorIds, - lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate - }; - - await pb.collection('users').update(userId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, { - ...readScopedPrefs(rawPrefs), - ...prefs - }) - }, { requestKey: null }); - } catch (e: any) { - if (e.isAbort) return; - console.error("Failed to sync preferences", e); - // We could toast here, but many of these are background syncs - } -}; - -export const addTemplate = async (template: Omit) => { - if (!pb.authStore.isValid) return; - - const meta = { - title: template.title, - urgency: template.urgency, - tags: template.tags, - content: template.content - }; - - try { - const record = await pb.collection(TASGRID_COLLECTION).create({ - user: pb.authStore.model?.id, - title: template.name, - priority: template.priority, - tags: ["__template__"], - content: JSON.stringify(meta), - completed: false, - startDate: new Date().toISOString(), - dueDate: new Date().toISOString() - }, { requestKey: null }); - - const newTemplate = { ...template, id: record.id }; - // Check if already added by realtime subscription - setStore("templates", (prev) => { - if (prev?.some(t => t.id === record.id)) return prev; - return [...(prev || []), newTemplate]; - }); - return newTemplate; - } catch (err) { - console.error("Failed to add template:", err); - toast.error("Failed to save template."); - } -}; - -export const updateTemplate = async (id: string, updates: Partial) => { - if (!pb.authStore.isValid) return; - - // Optimistic - setStore("templates", (t) => t!.id === id, updates); - - const template = store.templates?.find(t => t.id === id); - if (!template) return; - - const meta = { - title: template.title, - urgency: template.urgency, - tags: template.tags, - content: template.content - }; - - try { - await pb.collection(TASGRID_COLLECTION).update(id, { - title: template.name, - priority: template.priority, - content: JSON.stringify(meta) - }, { requestKey: null }); - } catch (err) { - console.error("Failed to update template:", err); - toast.error("Failed to update template."); - } -}; - -export const removeTemplate = async (id: string) => { - if (!pb.authStore.isValid) return; - - setStore("templates", (t) => (t || []).filter(tmpl => tmpl.id !== id)); - - try { - await pb.collection(TASGRID_COLLECTION).delete(id); - } catch (err) { - console.error("Failed to delete template:", err); - toast.error("Failed to delete template."); - } -}; - -export const saveTaskAsTemplate = async (taskId: string, name: string) => { - const task = store.tasks.find(t => t.id === taskId); - if (!task) return; - - const template: Omit = { - name, - title: task.title, - priority: task.priority, - urgency: calculateUrgencyFromDate(task.dueDate), - tags: [...(task.tags || [])], - content: task.content || "" - }; - - return await addTemplate(template); -}; - -export const setFilter = (update: Partial) => { - setStore("filter", (f) => ({ ...f, ...update })); -}; - -// -- Sharing Functions -- - -export const shareTask = async (_taskId: string, _userId: string, _access: 'view' | 'edit' = 'view') => { - if (!pb.authStore.isValid) return; - toast.info("Direct task sharing has been replaced by @user tags."); -}; - -export const loadTasksForOwner = async (userId: string) => { - if (!pb.authStore.isValid || !userId) return; - - try { - const filter = buildPersonalAccessFilter([userId]); - const records = await pb.collection(TASGRID_COLLECTION).getFullList({ - filter, - sort: '-created', - fields: getTaskListFields(), - requestKey: `owner-context-${userId}` - }); - - setStore("tasks", (currentTasks) => { - const taskMap = new Map(currentTasks.map(task => [task.id, task])); - records.forEach((record: any) => { - if (record.tags?.includes("__template__")) return; - const mapped = mapRecordToTask(record); - const existing = taskMap.get(mapped.id); - if (existing && existing.content) { - mapped.content = existing.content; - } - taskMap.set(mapped.id, mapped); - }); - return Array.from(taskMap.values()); - }); - } catch (err) { - console.error("Failed to load owner context tasks:", err); - } -}; - -export const revokeShare = async (_taskId: string, _userId: string) => { - if (!pb.authStore.isValid) return; - toast.info("Remove the matching @user tag from the task instead."); -}; - -export const splitTask = async (_taskId: string, _userId: string) => { - if (!pb.authStore.isValid) return; - toast.info("Task split is no longer supported. Duplicate the task and retag it."); -}; - -// -- Share Rules (for tag-based and all-tasks sharing) -- - - - -export const loadShareRules = async () => { - if (!pb.authStore.isValid) return; - setStore("shareRules", reconcile(store.contexts.map(mapContextToShareRule))); -}; - -export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'SEND_TASK') => { - if (!pb.authStore.isValid) return; - if (type !== 'tag' || !tagName?.startsWith("@")) { - toast.error("Only @tag contexts are supported now."); - return; - } - - const existing = store.contexts.find(context => context.targetUserId === targetUserId && buildShareTag(context.displayName) === tagName); - if (existing) return; - - try { - await pb.collection(CONTEXTS_COLLECTION).create({ - key: normalizeContextKey(tagName), - displayName: tagName.replace(/^@/, ""), - kind: "user", - policy: share_mode === "SEND_TASK" ? "handoff" : "collaborative", - targetUserId - }, { requestKey: null }); - toast.success(`Shared context created`); - } catch (err) { - console.error("Failed to create shared context:", err); - toast.error("Failed to create shared context."); - } -}; - -export const updateShareRule = async (ruleId: string, updates: Partial) => { - if (!pb.authStore.isValid) return; - - try { - const context = store.contexts.find(existing => existing.id === ruleId); - if (!context) return; - await pb.collection(CONTEXTS_COLLECTION).update(ruleId, { - policy: updates.share_mode === "SEND_TASK" ? "handoff" : "collaborative" - }, { requestKey: null }); - toast.success("Context updated"); - } catch (err) { - console.error("Failed to update share rule:", err); - toast.error("Failed to update share rule."); - } -}; - -export const removeShareRule = async (ruleId: string) => { - if (!pb.authStore.isValid) return; - - try { - await pb.collection(CONTEXTS_COLLECTION).delete(ruleId); - toast.success("Context removed"); - } catch (err) { - console.error("Failed to delete share rule:", err); - toast.error("Failed to delete share rule."); - } -}; - -export const loadFilterTemplates = async () => { - if (!pb.authStore.isValid) return; - const userId = pb.authStore.model?.id; - if (!userId) return; - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const prefs = readScopedPrefs(user.Taskgrid_pref || {}); - setStore("filterTemplates", prefs.filterTemplates || []); -}; - -export const saveFilterTemplate = async (name: string) => { - if (!pb.authStore.isValid) return; - try { - const newTemplate: FilterTemplate = { - id: `filter-${Date.now()}`, - name, - filter: store.filter - }; - - setStore("filterTemplates", prev => [...prev, newTemplate]); - await syncPreferences(); - toast.success(`Filter template "${name}" saved`); - } catch (err) { - console.error("Failed to save filter template:", err); - toast.error("Failed to save filter template."); - } -}; - -export const applyFilterTemplate = (id: string) => { - const template = store.filterTemplates.find(t => t.id === id); - if (template) { - setFilter(template.filter); - toast.success(`Applied filter: ${template.name}`); - } -}; - -export const removeFilterTemplate = async (id: string) => { - if (!pb.authStore.isValid) return; - try { - setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); - await syncPreferences(); - toast.success("Filter template deleted"); - } catch (err) { - console.error("Failed to delete filter template:", err); - toast.error("Failed to delete filter template."); - } -}; - -export const clearFilter = () => { - setStore("filter", { - query: "", - tags: [], - priorityMin: 1, - priorityMax: 10, - urgencyMin: 1, - urgencyMax: 10, - editedToday: false, - ownedByMe: false, - starred: false, - jobStatus: [], - jobDivision: [] - }); -}; - -export const setNoteFilter = (update: Partial) => { - setStore("noteFilter", (f) => ({ ...f, ...update })); -}; - -export const loadNoteFilterTemplates = async () => { - if (!pb.authStore.isValid) return; - const userId = pb.authStore.model?.id; - if (!userId) return; - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const prefs = readScopedPrefs(user.Taskgrid_pref || {}); - setStore("filterTemplates", prefs.noteFilterTemplates || []); -}; - -export const saveNoteFilterTemplate = async (name: string) => { - if (!pb.authStore.isValid) return; - try { - const newTemplate: FilterTemplate = { - id: `note-filter-${Date.now()}`, - name, - filter: store.noteFilter - }; - - setStore("filterTemplates", prev => [...prev, newTemplate]); - const userId = pb.authStore.model?.id; - if (userId) { - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const rawPrefs = user.Taskgrid_pref || {}; - const prefs = readScopedPrefs(rawPrefs); - await pb.collection('users').update(userId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, { - ...prefs, - noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate] - }) - }, { requestKey: null }); - } - toast.success(`Note filter template "${name}" saved`); - } catch (err) { - console.error("Failed to save note filter template:", err); - toast.error("Failed to save note filter template."); - } -}; - -export const applyNoteFilterTemplate = (id: string) => { - const template = store.filterTemplates.find(t => t.id === id); - if (template) { - setNoteFilter(template.filter); - toast.success(`Applied note filter: ${template.name}`); - } -}; - -export const removeNoteFilterTemplate = async (id: string) => { - if (!pb.authStore.isValid) return; - try { - setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); - const userId = pb.authStore.model?.id; - if (userId) { - const user = await pb.collection('users').getOne(userId, { requestKey: null }); - const rawPrefs = user.Taskgrid_pref || {}; - const prefs = readScopedPrefs(rawPrefs); - await pb.collection('users').update(userId, { - Taskgrid_pref: writeScopedPrefs(rawPrefs, { - ...prefs, - noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id) - }) - }, { requestKey: null }); - } - toast.success("Note filter template deleted"); - } catch (err) { - console.error("Failed to delete note filter template:", err); - toast.error("Failed to delete note filter template."); - } -}; - -export const clearNoteFilter = () => { - setStore("noteFilter", { - query: "", - tags: [], - priorityMin: 1, - priorityMax: 10, - urgencyMin: 1, - urgencyMax: 10, - editedToday: false, - ownedByMe: false, - starred: false, - jobStatus: [], - jobDivision: [] - }); -}; - -// Legacy cleanup - We don't need local storage persistence setup anymore -export const setupPersistence = () => { - // Moved logic to initStore which is called on auth. - // Keeping function signature to avoid breaking index.js calls immediately, - // but it will be empty or a no-op if called before auth. - // Actually, index.tsx calls this. We can leave it empty or have it check auth. - initStore(); -}; - - +export * from "./state"; +export * from "./contexts"; +export * from "./tasks"; +export * from "./notes"; +export * from "./preferences"; +export * from "./bootstrap"; diff --git a/src/store/notes.ts b/src/store/notes.ts new file mode 100644 index 0000000..158149e --- /dev/null +++ b/src/store/notes.ts @@ -0,0 +1,365 @@ +import { toast } from "solid-sonner"; +import { pb } from "@/lib/pocketbase"; +import { NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants"; +import { buildNoteTag, normalizeNoteKey } from "@/lib/tags"; +import { relationFilter } from "./contexts"; +import { + activeNoteId, + setActiveNoteId, + type Note, + type Task, + setStore, + store, + unwrapRelationId +} from "./state"; +import { getTaskListFields, mapRecordToTask, updateTask } from "./tasks"; + +let notesLoadPromise: Promise | null = null; +let deferredNotesLoadTimer: number | undefined; + +export const loadedNoteDetailIds = new Set(); +export const failedNoteDetailIds = new Set(); + +export const mapRecordToNote = (r: any, includeContent = true): Note => { + return { + id: r.id, + title: r.title, + key: r.key || normalizeNoteKey(r.title), + content: includeContent ? r.content : undefined, + tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)], + isPrivate: r.isPrivate || false, + user: unwrapRelationId(r.user) || "", + tasks: r.tasks || [], + created: r.created, + updated: r.updated, + deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined + }; +}; + +const reconcileFetchedNotes = (records: any[], includeContent = false) => { + const existingById = new Map(store.notes.map(note => [note.id, note])); + const nextNotes = records.map((record: any) => { + const mapped = mapRecordToNote(record, includeContent); + const existing = existingById.get(mapped.id); + + if (!includeContent && existing?.content !== undefined) { + mapped.content = existing.content; + } + + return mapped; + }); + + setStore("notes", nextNotes); +}; + +export const fetchNoteById = async (id: string): Promise => { + try { + const record = await pb.collection(NOTES_COLLECTION).getOne(id); + if (!record) return null; + return mapRecordToNote(record); + } catch (err) { + console.error("Failed to fetch note by ID:", err); + return null; + } +}; + +export const updateStoreWithNote = (note: Note) => { + failedNoteDetailIds.delete(note.id); + if (note.content !== undefined) { + loadedNoteDetailIds.add(note.id); + } + setStore("notes", currentNotes => { + const index = currentNotes.findIndex(n => n.id === note.id); + if (index !== -1) { + const newNotes = [...currentNotes]; + newNotes[index] = note; + return newNotes; + } + return [...currentNotes, note]; + }); +}; + +export const loadNotesMetadata = async () => { + if (!pb.authStore.isValid) return; + if (store.hasLoadedNotesMetadata) return; + if (notesLoadPromise) return notesLoadPromise; + + const currentUserId = pb.authStore.model?.id; + setStore("isNotesLoading", true); + + notesLoadPromise = (async () => { + try { + const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({ + filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`, + sort: "-created", + fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt", + requestKey: null + }); + + reconcileFetchedNotes(notesRecords, false); + setStore("hasLoadedNotesMetadata", true); + } catch (notesErr) { + console.warn("Failed to load notes (collection might not exist yet):", notesErr); + } finally { + setStore("isNotesLoading", false); + notesLoadPromise = null; + } + })(); + + return notesLoadPromise; +}; + +export const scheduleDeferredNotesMetadataLoad = () => { + if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return; + + deferredNotesLoadTimer = window.setTimeout(() => { + deferredNotesLoadTimer = undefined; + void loadNotesMetadata(); + }, 0); +}; + +export const requestImmediateNotesMetadataLoad = async () => { + if (deferredNotesLoadTimer) { + clearTimeout(deferredNotesLoadTimer); + deferredNotesLoadTimer = undefined; + } + + await loadNotesMetadata(); +}; + +export const loadNoteContent = async (id: string) => { + if (!id) return; + if (loadedNoteDetailIds.has(id)) return; + if (failedNoteDetailIds.has(id)) return; + if (store.loadingNoteContentIds.includes(id)) return; + + setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]); + + try { + const currentUserId = pb.authStore.model?.id; + const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`; + const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, { + requestKey: `note-content-${id}` + }); + const note = mapRecordToNote(record, true); + updateStoreWithNote(note); + loadedNoteDetailIds.add(id); + } catch (err) { + const status = (err as any)?.status; + if (status === 404) { + failedNoteDetailIds.add(id); + return; + } + console.error("Failed to load note content:", err); + } finally { + setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id)); + } +}; + +export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => { + try { + const formData = new FormData(); + formData.append("attachments+", file); + + const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData); + + if (record.attachments && record.attachments.length > 0) { + const latestFilename = record.attachments[record.attachments.length - 1]; + return { + url: pb.files.getURL(record, latestFilename), + filename: latestFilename + }; + } + + throw new Error("File uploaded but no attachment returned"); + } catch (err: any) { + console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2)); + toast.error("Failed to upload image to note."); + throw err; + } +}; + +export const cleanupNoteAttachments = async (noteId: string) => { + if (!pb.authStore.isValid) return; + + try { + const record = await pb.collection(NOTES_COLLECTION).getOne(noteId); + if (!record.attachments || record.attachments.length === 0) return; + + const content = record.content || ""; + const filesToDelete: string[] = []; + const decodedContent = decodeURIComponent(content); + + for (const filename of record.attachments) { + if (!content.includes(filename) && !decodedContent.includes(filename)) { + filesToDelete.push(filename); + } + } + + if (filesToDelete.length > 0) { + await pb.collection(NOTES_COLLECTION).update(noteId, { + "attachments-": filesToDelete + }); + console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`); + } + } catch (err) { + if ((err as any)?.status === 404) return; + console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err); + } +}; + +const removeDeletedNoteFromStore = (id: string) => { + if (activeNoteId() === id) { + setActiveNoteId(null); + } + 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); + } +}; + +export const updateNote = async (id: string, updates: Partial) => { + if (!pb.authStore.isValid) return; + + if (updates.content !== undefined) { + loadedNoteDetailIds.add(id); + } + + const optimisticUpdates = { + ...updates, + key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined), + updated: new Date().toISOString() + }; + setStore("notes", n => n.id === id, optimisticUpdates); + + try { + const pbUpdates: any = { ...updates }; + + if ("deletedAt" in updates) { + if (updates.deletedAt) { + pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString(); + } else { + pbUpdates.deletedAt = null; + } + } + + if (!pbUpdates.key && updates.title) { + pbUpdates.key = normalizeNoteKey(updates.title); + } + + await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null }); + } catch (err) { + console.error("Note update failed", err); + toast.error("Failed to update note."); + } +}; + +export const removeNote = (id: string) => { + updateNote(id, { deletedAt: Date.now() }); +}; + +export const restoreNote = (id: string) => { + updateNote(id, { deletedAt: null }); +}; + +export const deleteNotePermanently = async (id: string) => { + if (!pb.authStore.isValid) return; + + 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); + } catch (err) { + console.error("Note delete failed", err); + toast.error("Failed to delete note."); + } +}; + +export const loadTasksLinkedToNote = async (note: Pick) => { + if (!pb.authStore.isValid || !note?.id) return; + + const noteTag = buildNoteTag(note.key || note.title); + const escapedNoteTag = noteTag.replace(/"/g, '\\"'); + + try { + const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`, + sort: "-updated", + fields: getTaskListFields(), + requestKey: `note-linked-${note.id}` + }).catch(() => []); + + const recordMap = new Map(); + listRecords.forEach(record => { + if (!record?.id) return; + if (record.tags?.includes("__template__")) return; + recordMap.set(record.id, record); + }); + + if (recordMap.size === 0) return; + + setStore("tasks", currentTasks => { + const taskMap = new Map(currentTasks.map(task => [task.id, task])); + recordMap.forEach((record: any) => { + const mapped = mapRecordToTask(record); + const existing = taskMap.get(mapped.id); + if (existing?.content) { + mapped.content = existing.content; + } + taskMap.set(mapped.id, mapped); + }); + return Array.from(taskMap.values()); + }); + } catch (err: any) { + console.error("Failed to load note-linked tasks:", err?.status, err?.response || err); + } +}; diff --git a/src/store/preferences.ts b/src/store/preferences.ts new file mode 100644 index 0000000..575cd22 --- /dev/null +++ b/src/store/preferences.ts @@ -0,0 +1,176 @@ +import { pb } from "@/lib/pocketbase"; +import { toast } from "solid-sonner"; +import { + createDefaultFilter, + type Filter, + type FilterTemplate, + readScopedPrefs, + setStore, + store, + writeScopedPrefs +} from "./state"; + +export const syncPreferences = async () => { + const userId = pb.authStore.model?.id; + if (!userId) return; + + try { + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const prefs = { + pWeight: store.pWeight, + uWeight: store.uWeight, + matrixScaleDays: store.matrixScaleDays, + tagDefinitions: store.tagDefinitions.filter(def => !def.name.startsWith("@")), + filterTemplates: store.filterTemplates, + quickloadTasks: store.quickloadTasks, + dismissedTaskUpdateIndicators: store.dismissedTaskUpdateIndicators, + collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks, + subscribedBuckets: store.subscribedBuckets, + supervisorUserIds: store.personalContextSupervisorIds, + lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate + }; + + await pb.collection("users").update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...readScopedPrefs(rawPrefs), + ...prefs + }) + }, { requestKey: null }); + } catch (e: any) { + if (e.isAbort) return; + console.error("Failed to sync preferences", e); + } +}; + +export const setFilter = (update: Partial) => { + setStore("filter", f => ({ ...f, ...update })); +}; + +export const loadFilterTemplates = async () => { + if (!pb.authStore.isValid) return; + const userId = pb.authStore.model?.id; + if (!userId) return; + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); + setStore("filterTemplates", prefs.filterTemplates || []); +}; + +export const saveFilterTemplate = async (name: string) => { + if (!pb.authStore.isValid) return; + try { + const newTemplate: FilterTemplate = { + id: `filter-${Date.now()}`, + name, + filter: store.filter + }; + + setStore("filterTemplates", prev => [...prev, newTemplate]); + await syncPreferences(); + toast.success(`Filter template "${name}" saved`); + } catch (err) { + console.error("Failed to save filter template:", err); + toast.error("Failed to save filter template."); + } +}; + +export const applyFilterTemplate = (id: string) => { + const template = store.filterTemplates.find(t => t.id === id); + if (template) { + setFilter(template.filter); + toast.success(`Applied filter: ${template.name}`); + } +}; + +export const removeFilterTemplate = async (id: string) => { + if (!pb.authStore.isValid) return; + try { + setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); + await syncPreferences(); + toast.success("Filter template deleted"); + } catch (err) { + console.error("Failed to delete filter template:", err); + toast.error("Failed to delete filter template."); + } +}; + +export const clearFilter = () => { + setStore("filter", createDefaultFilter()); +}; + +export const setNoteFilter = (update: Partial) => { + setStore("noteFilter", f => ({ ...f, ...update })); +}; + +export const loadNoteFilterTemplates = async () => { + if (!pb.authStore.isValid) return; + const userId = pb.authStore.model?.id; + if (!userId) return; + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const prefs = readScopedPrefs(user.Taskgrid_pref || {}); + setStore("filterTemplates", prefs.noteFilterTemplates || []); +}; + +export const saveNoteFilterTemplate = async (name: string) => { + if (!pb.authStore.isValid) return; + try { + const newTemplate: FilterTemplate = { + id: `note-filter-${Date.now()}`, + name, + filter: store.noteFilter + }; + + setStore("filterTemplates", prev => [...prev, newTemplate]); + const userId = pb.authStore.model?.id; + if (userId) { + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + await pb.collection("users").update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...prefs, + noteFilterTemplates: [...(prefs.noteFilterTemplates || []), newTemplate] + }) + }, { requestKey: null }); + } + toast.success(`Note filter template "${name}" saved`); + } catch (err) { + console.error("Failed to save note filter template:", err); + toast.error("Failed to save note filter template."); + } +}; + +export const applyNoteFilterTemplate = (id: string) => { + const template = store.filterTemplates.find(t => t.id === id); + if (template) { + setNoteFilter(template.filter); + toast.success(`Applied note filter: ${template.name}`); + } +}; + +export const removeNoteFilterTemplate = async (id: string) => { + if (!pb.authStore.isValid) return; + try { + setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); + const userId = pb.authStore.model?.id; + if (userId) { + const user = await pb.collection("users").getOne(userId, { requestKey: null }); + const rawPrefs = user.Taskgrid_pref || {}; + const prefs = readScopedPrefs(rawPrefs); + await pb.collection("users").update(userId, { + Taskgrid_pref: writeScopedPrefs(rawPrefs, { + ...prefs, + noteFilterTemplates: (prefs.noteFilterTemplates || []).filter((template: FilterTemplate) => template.id !== id) + }) + }, { requestKey: null }); + } + toast.success("Note filter template deleted"); + } catch (err) { + console.error("Failed to delete note filter template:", err); + toast.error("Failed to delete note filter template."); + } +}; + +export const clearNoteFilter = () => { + setStore("noteFilter", createDefaultFilter()); +}; diff --git a/src/store/state.ts b/src/store/state.ts new file mode 100644 index 0000000..2e43881 --- /dev/null +++ b/src/store/state.ts @@ -0,0 +1,352 @@ +import { createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; +import { pb } from "@/lib/pocketbase"; +import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config"; + +// Helper to get the current user's personalized favorite tag +export const getFavoriteTag = () => { + const userId = pb.authStore.model?.id; + return userId ? `__${userId}_favorite__` : "__favorite__"; +}; + +export const [now, setNow] = createSignal(Date.now()); + +// Context: 'mine' is a temporary bootstrap alias for the current user's personal context bucket. +export type TaskContext = "mine" | { userId: string, name: string } | { bucketId: string, name: string, isPersonal?: boolean }; +export const [activeTaskId, setActiveTaskId] = createSignal(null); +export const [activeNoteId, setActiveNoteId] = createSignal(null); +export const [currentTaskContext, setCurrentTaskContext] = createSignal("mine"); + +export type UrgencyLevel = number; + +export interface TaskShareRef { + contextId: string; + key: string; + kind: "user" | "bucket" | "note"; + policy?: "collaborative" | "handoff"; +} + +export interface NoteRef { + noteId: string; + key: string; +} + +export interface Task { + id: string; + title: string; + startDate: string; + dueDate: string; + priority: number; + status: number; + completed: boolean; + tags: string[]; + labelTags: string[]; + shareRefs: TaskShareRef[]; + noteRefs: NoteRef[]; + ownerId: string | null; + createdBy: string | null; + updatedBy?: string | null; + content?: string; + deletedAt?: number | null; + created: string; + updated: string; + recurrence?: { + type: "daily" | "weekly" | "monthly"; + days?: number[]; + dayOfMonth?: number; + lastUncompleted?: string; + }; + size?: number; + sharedWith?: Array<{ userId: string; access: "view" | "edit" }>; + attachments?: string[]; +} + +export interface TaskTemplate { + id: string; + name: string; + title: string; + priority: number; + urgency: number; + tags: string[]; + content: string; +} + +export interface TagDefinition { + id: string; + name: string; + value: number; + color?: string; + theme?: "light" | "dark"; + isUser?: boolean; + isBucket?: boolean; +} + +export interface Note { + id: string; + title: string; + key: string; + content?: string; + tags: string[]; + isPrivate: boolean; + user: string; + tasks: string[]; + created: string; + updated: string; + deletedAt?: number | null; +} + +export interface FilterTag { + name: string; + excluded: boolean; +} + +export interface Filter { + query: string; + tags: FilterTag[]; + priorityMin: number; + priorityMax: number; + urgencyMin: number; + urgencyMax: number; + editedToday: boolean; + ownedByMe: boolean; + starred: boolean; + jobStatus: FilterTag[]; + jobDivision: FilterTag[]; +} + +export interface FilterTemplate { + id: string; + name: string; + filter: Filter; +} + +export interface ShareContext { + id: string; + key: string; + displayName: string; + kind: "user" | "bucket" | "note"; + policy: "collaborative" | "handoff"; + targetUserId?: string | null; + supervisorUserIds?: string[]; + color?: string; + description?: string; + deletedAt?: number | null; + deletedBy?: string | null; +} + +export interface ShareRule { + id: string; + ownerId: string; + targetUserId: string; + type: "tag" | "all"; + tagName?: string; + share_mode?: "ADD_USER" | "SEND_TASK"; +} + +export interface SupervisedContextAccess { + ownerUserId: string; + ownerName: string; + contextId: string; + contextName: string; +} + +export interface Bucket { + id: string; + name: string; + color: string; + description?: string; + policy?: "collaborative" | "handoff"; + key?: string; + deletedAt?: number | null; +} + +export interface TaskStore { + tasks: Task[]; + isInitializing: boolean; + quickloadFadeTaskIds: string[]; + dismissedTaskUpdateIndicators: Record; + collapsedUntilUpdatedTasks: Record; + isNotesLoading: boolean; + hasLoadedNotesMetadata: boolean; + loadingNoteContentIds: string[]; + pWeight: number; + uWeight: number; + matrixScaleDays: number; + prefId?: string; + tagDefinitions: TagDefinition[]; + templates: TaskTemplate[]; + filter: Filter; + shareRules: ShareRule[]; + contexts: ShareContext[]; + supervisedContexts: SupervisedContextAccess[]; + filterTemplates: FilterTemplate[]; + buckets: Bucket[]; + subscribedBuckets: string[]; + personalContextSupervisorIds: string[]; + notes: Note[]; + isNotepadMode: boolean; + quickloadTasks: string[]; + noteFilter: Filter; + lastCompletedTaskCleanupDate: string; +} + +export interface ScopedTaskgridPrefs { + pWeight?: number; + uWeight?: number; + matrixScaleDays?: number; + tagDefinitions?: TagDefinition[]; + filter?: Filter; + filterTemplates?: FilterTemplate[]; + noteFilterTemplates?: FilterTemplate[]; + quickloadTasks?: string[]; + noteFilter?: Filter; + subscribedBuckets?: string[]; + supervisorUserIds?: string[]; + dismissedTaskUpdateIndicators?: Record; + collapsedUntilUpdatedTasks?: Record; + migratedStructuredTagsV2?: boolean; + migratedPersonalContextRefsV1?: boolean; + migratedContextOwnershipV2?: boolean; + lastCompletedTaskCleanupDate?: string; +} + +export const createDefaultFilter = (): Filter => ({ + query: "", + tags: [], + priorityMin: 1, + priorityMax: 10, + urgencyMin: 1, + urgencyMax: 10, + editedToday: false, + ownedByMe: false, + starred: false, + jobStatus: [], + jobDivision: [] +}); + +export const [store, setStore] = createStore({ + tasks: [], + isInitializing: false, + quickloadFadeTaskIds: [], + dismissedTaskUpdateIndicators: {}, + collapsedUntilUpdatedTasks: {}, + isNotesLoading: false, + hasLoadedNotesMetadata: false, + loadingNoteContentIds: [], + pWeight: 1.0, + uWeight: 1.0, + matrixScaleDays: 30, + tagDefinitions: [], + templates: [], + filter: createDefaultFilter(), + shareRules: [], + contexts: [], + supervisedContexts: [], + filterTemplates: [], + buckets: [], + subscribedBuckets: [], + personalContextSupervisorIds: [], + notes: [], + isNotepadMode: false, + quickloadTasks: [], + lastCompletedTaskCleanupDate: "", + noteFilter: createDefaultFilter() +}); + +const PREF_ENVIRONMENTS_KEY = "environments"; + +export const getStorageKey = () => { + const userId = pb.authStore.model?.id; + return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null; +}; + +export const getPersistedStoreSnapshot = () => { + const { isInitializing, quickloadFadeTaskIds, isNotesLoading, hasLoadedNotesMetadata, loadingNoteContentIds, ...persisted } = store; + return persisted; +}; + +export const readScopedPrefs = (rawPrefs: any): ScopedTaskgridPrefs => { + const scoped = rawPrefs?.[PREF_ENVIRONMENTS_KEY]?.[TASGRID_DATA_MODE]; + return (scoped || rawPrefs || {}) as ScopedTaskgridPrefs; +}; + +export const writeScopedPrefs = (rawPrefs: any, scopedPrefs: ScopedTaskgridPrefs) => ({ + ...(rawPrefs || {}), + [PREF_ENVIRONMENTS_KEY]: { + ...(rawPrefs?.[PREF_ENVIRONMENTS_KEY] || {}), + [TASGRID_DATA_MODE]: scopedPrefs + } +}); + +export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => { + const currentUserId = pb.authStore.model?.id; + const personalContext = currentUserId + ? store.contexts.find(context => + !context.deletedAt && + context.kind === "user" && + context.targetUserId === currentUserId + ) || null + : null; + + setStore({ + pWeight: prefs.pWeight || 1.0, + uWeight: prefs.uWeight || 1.0, + matrixScaleDays: prefs.matrixScaleDays || 30, + prefId: currentUserId, + subscribedBuckets: prefs.subscribedBuckets || [], + personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [], + tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")), + filterTemplates: prefs.filterTemplates || [], + quickloadTasks: prefs.quickloadTasks || [], + dismissedTaskUpdateIndicators: prefs.dismissedTaskUpdateIndicators || {}, + collapsedUntilUpdatedTasks: prefs.collapsedUntilUpdatedTasks || {}, + noteFilter: store.noteFilter, + filter: store.filter + }); +}; + +export const unwrapRelationId = (value: unknown): string | null => { + if (typeof value === "string") { + return value || null; + } + + if (Array.isArray(value)) { + const first = value[0]; + return typeof first === "string" ? first : null; + } + + return null; +}; + +export const unwrapRelationIds = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .map(item => { + if (typeof item === "string") return item; + if (item && typeof item === "object" && "id" in item && typeof (item as any).id === "string") { + return (item as any).id; + } + return null; + }) + .filter((item): item is string => !!item); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + return unwrapRelationIds(parsed); + } catch { + return trimmed.includes(",") + ? trimmed.split(",").map(item => item.trim()).filter(Boolean) + : [trimmed]; + } + } + + if (value && typeof value === "object" && "id" in value && typeof (value as any).id === "string") { + return [(value as any).id]; + } + + return []; +}; diff --git a/src/store/tasks.ts b/src/store/tasks.ts new file mode 100644 index 0000000..50c407e --- /dev/null +++ b/src/store/tasks.ts @@ -0,0 +1,1219 @@ +import { toast } from "solid-sonner"; +import { pb } from "@/lib/pocketbase"; +import { SIZE_HOURS, TASGRID_COLLECTION, URGENCY_HOURS } from "@/lib/constants"; +import { buildNoteTag, buildShareTag, normalizeContextKey, parseTags } from "@/lib/tags"; +import { + activeTaskId, + currentTaskContext, + getFavoriteTag, + now, + setActiveTaskId, + setStore, + store, + type Note, + type NoteRef, + type ShareContext, + type TagDefinition, + type Task, + type TaskShareRef, + type TaskTemplate, + unwrapRelationId +} from "./state"; +import { + buildPersonalAccessFilter, + buildTaskShareRef, + buildTaskTags, + dedupeNoteRefs, + dedupeShareRefs, + deriveTaskRelationsFromTags, + getContextByRef, + getPersonalContext, + getPersonalContextUserIdFromRef, + getSubscribedBucketContexts, + getTaskPersonalRefs, + getTaskSharePolicy, + getVisibleTaskTags, + taskHasPersonalContextAccess, + withActiveContextTags, + withMirroredOwnerId +} from "./contexts"; +import { syncPreferences } from "./preferences"; + +const BASE_LIST_FIELDS = "id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,labelTags,shareRefs,noteRefs,createdBy,user,size,recurrence,deletedAt"; + +let updatedByFieldSupport: boolean | null = null; + +export const getTaskListFields = () => + updatedByFieldSupport ? `${BASE_LIST_FIELDS},updatedBy` : BASE_LIST_FIELDS; + +export const checkRecurringTasks = () => { + const tasks = store.tasks; + const nowObj = new Date(); + const todayStr = nowObj.toLocaleDateString("en-CA"); + + tasks.forEach(task => { + if (task.status !== 10 || !task.recurrence) return; + if (task.deletedAt) return; + + const { type, days, dayOfMonth, lastUncompleted } = task.recurrence; + let shouldReset = false; + + if (lastUncompleted) { + const lastDate = new Date(lastUncompleted).toLocaleDateString("en-CA"); + if (lastDate === todayStr) return; + } + + const updatedDate = new Date(task.updated).toLocaleDateString("en-CA"); + if (updatedDate === todayStr && type === "daily") { + return; + } + + if (type === "daily") { + if (updatedDate < todayStr) { + shouldReset = true; + } + } else if (type === "weekly") { + const currentDay = nowObj.getDay(); + if (days?.includes(currentDay) && updatedDate < todayStr) { + shouldReset = true; + } + } else if (type === "monthly") { + const currentDate = nowObj.getDate(); + if (dayOfMonth === currentDate && updatedDate < todayStr) { + shouldReset = true; + } + } + + if (shouldReset) { + console.log(`Resetting recurring task: ${task.title}`); + updateTask(task.id, { + status: 0, + completed: false, + recurrence: { + ...task.recurrence, + lastUncompleted: new Date().toISOString() + } + }); + } + }); +}; + +export const matchesFilter = (task: Task) => { + const currentUserId = pb.authStore.model?.id; + const ctx = currentTaskContext(); + const personalContext = getPersonalContext(currentUserId); + const rawBucketRefs = task.shareRefs.filter(ref => ref.kind === "bucket"); + const activeShareContexts = task.shareRefs + .map(ref => getContextByRef(ref)) + .filter((context): context is ShareContext => !!context && !context.deletedAt); + const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket"); + const isPersonalView = ctx === "mine" || ( + typeof ctx === "object" && + "bucketId" in ctx && + !!personalContext && + ctx.bucketId === personalContext.id + ); + + if (isPersonalView) { + if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return false; + } else if (typeof ctx === "object" && "bucketId" in ctx) { + const activeBucketContext = store.contexts.find(context => context.id === ctx.bucketId); + const activeBucketKey = activeBucketContext?.key || normalizeContextKey(ctx.name); + const inCurrentBucket = activeBucketContext?.kind === "user" + ? !!activeBucketContext.targetUserId && taskHasPersonalContextAccess(task, activeBucketContext.targetUserId) + : rawBucketRefs.some(ref => + ref.contextId === ctx.bucketId || + ref.key === activeBucketKey + ) || bucketRefs.some(context => context.id === ctx.bucketId); + if (!inCurrentBucket) return false; + } else { + const targetUserId = (ctx as { userId: string }).userId; + if (!taskHasPersonalContextAccess(task, targetUserId)) { + return false; + } + } + + if (task.tags?.includes("__template__")) return false; + + const f = store.filter; + + if (f.query) { + const q = f.query.toLowerCase(); + const inTitle = task.title.toLowerCase().includes(q); + const inContent = task.content?.toLowerCase().includes(q); + const inVisibleTags = getVisibleTaskTags(task, ctx).some(tag => tag.toLowerCase().includes(q)); + if (!inTitle && !inContent && !inVisibleTags) return false; + } + + if (f.tags.length > 0) { + const includedTags = f.tags.filter(t => !t.excluded).map(t => t.name); + const excludedTags = f.tags.filter(t => t.excluded).map(t => t.name); + + if (excludedTags.length > 0) { + const hasExcluded = excludedTags.some(tag => task.tags?.includes(tag)); + if (hasExcluded) return false; + } + + if (includedTags.length > 0) { + const hasIncluded = includedTags.some(tag => task.tags?.includes(tag)); + if (!hasIncluded) return false; + } + } + + if (task.priority < f.priorityMin || task.priority > f.priorityMax) return false; + + const urgency = calculateUrgencyFromDate(task.dueDate); + if (urgency < f.urgencyMin || urgency > f.urgencyMax) return false; + + if (f.editedToday) { + const today = new Date().toLocaleDateString("en-CA"); + const updated = new Date(task.updated).toLocaleDateString("en-CA"); + if (today !== updated) return false; + } + + if (f.ownedByMe && (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId))) { + return false; + } + + if (f.starred && !task.tags?.includes(getFavoriteTag())) { + return false; + } + + return true; +}; + +export const calculateUrgencyScore = (dueDateStr: string): number => { + const due = new Date(dueDateStr).getTime(); + const diffHours = (due - now()) / (1000 * 60 * 60); + + if (diffHours <= URGENCY_HOURS[10]) return 10; + if (diffHours >= URGENCY_HOURS[1]) return 1; + + const levels = Object.keys(URGENCY_HOURS).map(Number).sort((a, b) => b - a); + for (let i = 0; i < levels.length - 1; i += 1) { + const highLevel = levels[i]; + const lowLevel = levels[i + 1]; + const highHours = URGENCY_HOURS[highLevel]; + const lowHours = URGENCY_HOURS[lowLevel]; + + if (diffHours >= highHours && diffHours <= lowHours) { + const range = lowHours - highHours; + const progress = (diffHours - highHours) / range; + return highLevel - progress * (highLevel - lowLevel); + } + } + + return 1; +}; + +export const calculateDateFromUrgency = (level: number): string => { + const roundedLevel = Math.max(1, Math.min(10, Math.round(level))); + const hours = URGENCY_HOURS[roundedLevel] || 24; + return new Date(now() + hours * 60 * 60 * 1000).toISOString(); +}; + +export const calculateUrgencyFromDate = (dateStr: string): number => { + return Math.round(calculateUrgencyScore(dateStr)); +}; + +export const getCombinedScore = (task: Task): number => { + const size = task.size ?? 3; + const sizeHours = SIZE_HOURS[size] ?? 12; + const dueTime = new Date(task.dueDate).getTime(); + if (isNaN(dueTime)) return 0; + + const effectiveDueTime = dueTime - (sizeHours * 60 * 60 * 1000); + const effectiveDueDateStr = new Date(effectiveDueTime).toISOString(); + + const urgencyScore = calculateUrgencyScore(effectiveDueDateStr); + let baseScore = (task.priority * store.pWeight) + (urgencyScore * store.uWeight); + + if (task.tags && task.tags.length > 0) { + task.tags.forEach(tagName => { + const def = store.tagDefinitions.find(d => d.name === tagName); + if (def) { + baseScore += (def.value - 5) * 0.1; + } + }); + } + + const sizeScore = (10 - size) * 0.19; + baseScore += sizeScore; + + if (task.status < 10) { + const statusWeight = (task.status / 9) * 2.0; + baseScore += statusWeight; + } + + return baseScore; +}; + +const recordHasField = (record: any, field: string) => + !!record && Object.prototype.hasOwnProperty.call(record, field); + +const detectUpdatedBySupportFromRecord = (record: any) => { + if (updatedByFieldSupport !== true && recordHasField(record, "updatedBy")) { + updatedByFieldSupport = true; + } +}; + +const refreshLoadedTaskUpdatedBy = async () => { + if (updatedByFieldSupport !== true || !pb.authStore.isValid) return; + + const taskIds = store.tasks + .map(task => task.id) + .filter(id => !!id && !id.startsWith("temp-")); + + if (taskIds.length === 0) return; + + const chunkSize = 50; + for (let i = 0; i < taskIds.length; i += chunkSize) { + const ids = taskIds.slice(i, i + chunkSize); + try { + const records = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter: ids.map(id => `id="${id}"`).join(" || "), + fields: "id,updated,updatedBy", + requestKey: null + }); + + const updatesById = new Map( + records.map((record: any) => { + detectUpdatedBySupportFromRecord(record); + return [ + record.id, + { + updated: record.updated, + updatedBy: unwrapRelationId(record.updatedBy) + } + ] as const; + }) + ); + + setStore("tasks", tasks => tasks.map(task => { + const update = updatesById.get(task.id); + return update ? { ...task, ...update } : task; + })); + } catch (err) { + console.warn("Failed to refresh task update actors:", err); + break; + } + } +}; + +export const ensureUpdatedByFieldSupport = async () => { + if (updatedByFieldSupport !== null || !pb.authStore.isValid) return updatedByFieldSupport; + + const sampleTaskId = store.tasks.find(task => !task.id.startsWith("temp-"))?.id; + if (!sampleTaskId) return updatedByFieldSupport; + + try { + const record = await pb.collection(TASGRID_COLLECTION).getOne(sampleTaskId, { requestKey: null }); + updatedByFieldSupport = recordHasField(record, "updatedBy"); + detectUpdatedBySupportFromRecord(record); + + if (updatedByFieldSupport) { + await refreshLoadedTaskUpdatedBy(); + } + } catch (err) { + console.warn("Failed to detect task update actor support:", err); + updatedByFieldSupport = false; + } + + return updatedByFieldSupport; +}; + +export const mapRecordToTask = (r: any): Task => { + detectUpdatedBySupportFromRecord(r); + const legacyTags: string[] = r.tags || []; + const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__"); + const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs) + ? r.shareRefs.map((ref: any) => ({ + contextId: ref.contextId, + key: ref.key, + kind: ref.kind, + policy: ref.policy + })) + : parsedTags + .filter((tag): tag is Extract<(typeof parsedTags)[number], { kind: "share" | "note" }> => tag.kind === "share" || tag.kind === "note") + .map(tag => buildTaskShareRef(tag)); + const noteRefs: NoteRef[] = Array.isArray(r.noteRefs) + ? r.noteRefs + : parsedTags + .filter(tag => tag.kind === "note") + .map(tag => { + const note = store.notes.find(existing => !existing.deletedAt && existing.key === tag.key); + return { + noteId: note?.id || tag.key, + key: tag.key + }; + }); + const noteShareRefs: TaskShareRef[] = noteRefs.map(ref => ({ + contextId: ref.noteId || ref.key, + key: ref.key, + kind: "note", + policy: shareRefs.find(existing => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy + })); + const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]); + const labelTags: string[] = Array.isArray(r.labelTags) + ? r.labelTags + : parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); + const favoriteTag = legacyTags.find((tag: string) => tag.endsWith("_favorite__")); + const tags = buildTaskTags(labelTags, mergedShareRefs, noteRefs, favoriteTag); + return { + id: r.id, + title: r.title, + startDate: r.startDate, + dueDate: r.dueDate, + priority: r.priority, + status: r.status ?? (r.completed ? 10 : 0), + completed: r.status === 10 || r.completed, + tags, + labelTags, + shareRefs: mergedShareRefs, + noteRefs, + ownerId: unwrapRelationId(r.user), + createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user), + updatedBy: recordHasField(r, "updatedBy") + ? (unwrapRelationId(r.updatedBy) || (r.created === r.updated ? (unwrapRelationId(r.createdBy) || unwrapRelationId(r.user)) : null)) + : null, + content: r.content, + deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined, + created: r.created, + updated: r.updated, + recurrence: r.recurrence, + size: (r.size === null || r.size === undefined) ? undefined : r.size, + sharedWith: [], + attachments: r.attachments || [] + }; +}; + +export const buildNoteLinkMap = (notes: Note[]) => { + const links = new Map(); + for (const note of notes) { + for (const taskId of note.tasks || []) { + const existing = links.get(taskId) || []; + existing.push({ noteId: note.id, key: note.key }); + links.set(taskId, existing); + } + } + return links; +}; + +export const buildTaskMigrationPayload = ( + task: Task, + ownerContextByUserId: Map, + linkedNoteRefs: NoteRef[] = [], + fallbackUserId?: string +) => { + const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); + const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined; + let nextShareRefs = dedupeShareRefs(task.shareRefs); + let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]); + const shouldHideFromLegacyOwner = !!task.ownerId && + getTaskPersonalRefs(task).length === 0 && + nextShareRefs.some(ref => ref.kind !== "user" && getTaskSharePolicy(ref) === "handoff"); + + if (ownerContext) { + const matchesOwnerContext = (ref: TaskShareRef) => + ref.kind === "user" && ( + ref.contextId === ownerContext.id || + ref.key === ownerContext.key || + normalizeContextKey(ownerContext.displayName) === ref.key + ); + + if (shouldHideFromLegacyOwner) { + nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref)); + } else if (!nextShareRefs.some(matchesOwnerContext)) { + nextShareRefs = [ + { + contextId: ownerContext.id, + key: ownerContext.key, + kind: "user" as const, + policy: getTaskSharePolicy({ contextId: ownerContext.id, key: ownerContext.key, kind: "user" }) + }, + ...nextShareRefs + ]; + } + } + + nextShareRefs = dedupeShareRefs(nextShareRefs); + const mirroredOwnerId = (() => { + const personalUserIds = [...new Set( + nextShareRefs + .filter(ref => ref.kind === "user") + .map(ref => getPersonalContextUserIdFromRef(ref)) + .filter((value): value is string => !!value) + )]; + + if (personalUserIds.length === 1) { + return personalUserIds[0]; + } + + if (personalUserIds.length > 1) { + const preferredOwnerId = task.ownerId || fallbackUserId || null; + if (preferredOwnerId && personalUserIds.includes(preferredOwnerId)) { + return preferredOwnerId; + } + } + + return task.ownerId || fallbackUserId || pb.authStore.model?.id || null; + })(); + + return { + tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag), + labelTags: task.labelTags, + shareRefs: nextShareRefs, + noteRefs: nextNoteRefs, + ownerId: mirroredOwnerId, + createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || "" + }; +}; + +export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => { + try { + const formData = new FormData(); + formData.append("attachments+", file); + + const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData); + + if (record.attachments && record.attachments.length > 0) { + const latestFilename = record.attachments[record.attachments.length - 1]; + return { + url: pb.files.getURL(record, latestFilename), + filename: latestFilename + }; + } + + throw new Error("File uploaded but no attachment returned"); + } catch (err: any) { + console.error("Error uploading attachment. Validation details:", JSON.stringify(err.data, null, 2)); + toast.error("Failed to upload image."); + throw err; + } +}; + +export const cleanupTaskAttachments = async (taskId: string) => { + if (!pb.authStore.isValid) return; + + try { + const record = await pb.collection(TASGRID_COLLECTION).getOne(taskId); + if (!record.attachments || record.attachments.length === 0) return; + + const content = record.content || ""; + const filesToDelete: string[] = []; + const decodedContent = decodeURIComponent(content); + + for (const filename of record.attachments) { + if (!content.includes(filename) && !decodedContent.includes(filename)) { + filesToDelete.push(filename); + } + } + + if (filesToDelete.length > 0) { + await pb.collection(TASGRID_COLLECTION).update(taskId, { + "attachments-": filesToDelete + }); + console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for task ${taskId}`); + } + } catch (err) { + console.error("[CLEANUP] Failed to clean up orphaned task attachments:", err); + } +}; + +const removeDeletedTaskFromStore = (id: string) => { + if (activeTaskId() === id) { + setActiveTaskId(null); + } + setStore("tasks", tasks => tasks.filter(task => task.id !== id)); +}; + +export const isTaskUpdatedByAnotherUser = (task: Task) => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId || !task.updatedBy || task.updatedBy === currentUserId) return false; + return store.dismissedTaskUpdateIndicators[task.id] !== task.updated; +}; + +export const dismissTaskUpdateIndicator = (taskId: string, updatedAt: string) => { + setStore("dismissedTaskUpdateIndicators", taskId, updatedAt); + void syncPreferences(); +}; + +export const isTaskCollaborativelyShared = (task: Task) => + task.shareRefs.some(ref => { + const currentUserId = pb.authStore.model?.id; + const context = getContextByRef(ref); + const policy = getTaskSharePolicy(ref); + const targetUserId = context?.targetUserId || null; + return ref.kind === "user" && policy === "collaborative" && !!targetUserId && targetUserId !== currentUserId; + }); + +export const canHandoffCollaborativelySharedTask = (task: Pick) => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return false; + + return task.shareRefs.some(ref => { + if (ref.kind !== "user" || getTaskSharePolicy(ref) !== "collaborative") return false; + const targetUserId = getPersonalContextUserIdFromRef(ref); + return !!targetUserId && targetUserId !== currentUserId; + }); +}; + +export const isTaskCollapsedUntilUpdated = (task: Task) => + store.collapsedUntilUpdatedTasks[task.id] === task.updated; + +export const collapseTaskUntilUpdated = (taskId: string, updatedAt: string) => { + setStore("collapsedUntilUpdatedTasks", taskId, updatedAt); + void syncPreferences(); +}; + +export const expandCollapsedTask = (taskId: string) => { + setStore("collapsedUntilUpdatedTasks", taskId, ""); + void syncPreferences(); +}; + +export const handoffCollaborativelySharedTask = async (taskId: string) => { + const task = store.tasks.find(existing => existing.id === taskId); + const currentUserId = pb.authStore.model?.id; + const senderPersonalContext = currentUserId ? getPersonalContext(currentUserId) : null; + + if (!task || !currentUserId || !senderPersonalContext) return false; + if (!canHandoffCollaborativelySharedTask(task)) { + toast.info("This task needs another collaborative @user before you can hand it off."); + return false; + } + + const nextShareRefs = task.shareRefs.filter(ref => !( + ref.kind === "user" && + (ref.contextId === senderPersonalContext.id || ref.key === senderPersonalContext.key) + )); + + const didUpdate = await updateTask(taskId, { + shareRefs: nextShareRefs, + labelTags: task.labelTags, + noteRefs: task.noteRefs, + tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, getFavoriteTag()) + }); + + if (didUpdate) { + toast.success("Task handed off successfully."); + } + + return didUpdate; +}; + +export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number, shareModeByTag?: Record }) => { + if (!pb.authStore.isValid) return; + + let dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + if (options?.dueDate) { + dueDate = options.dueDate instanceof Date ? options.dueDate.toISOString() : options.dueDate; + } + + const startDate = new Date().toISOString(); + const priority = options?.priority ?? 5; + const tags = withActiveContextTags(options?.tags ?? ["work"]); + const content = options?.content ?? ""; + const size = options?.size ?? 3; + const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags(tags, [], options?.shareModeByTag || {}); + + const tempId = `temp-${Date.now()}`; + const initialTask: Task = { + id: tempId, + title, + startDate, + dueDate, + priority, + status: 0, + completed: false, + tags: buildTaskTags(labelTags, shareRefs, noteRefs), + labelTags, + shareRefs, + noteRefs, + ownerId: pb.authStore.model?.id || "", + createdBy: pb.authStore.model?.id || "", + updatedBy: pb.authStore.model?.id || "", + content, + size, + created: new Date().toISOString(), + updated: new Date().toISOString() + }; + + const handoffUpdates = checkForHandoffs(initialTask, initialTask); + const mirroredUpdates = withMirroredOwnerId( + initialTask.ownerId, + handoffUpdates.shareRefs || initialTask.shareRefs, + handoffUpdates, + handoffUpdates.ownerId || initialTask.ownerId + ); + const newTask = { ...initialTask, ...mirroredUpdates }; + + setStore("tasks", t => [newTask, ...t]); + + try { + const updatedBySupported = await ensureUpdatedByFieldSupport(); + const createPayload: any = { + user: newTask.ownerId, + createdBy: newTask.createdBy, + title, + startDate, + dueDate, + priority, + status: 0, + completed: false, + tags: newTask.tags, + labelTags: newTask.labelTags, + shareRefs: newTask.shareRefs, + noteRefs: newTask.noteRefs, + content, + size + }; + + if (updatedBySupported) { + createPayload.updatedBy = pb.authStore.model?.id || null; + } + + const record = await pb.collection(TASGRID_COLLECTION).create(createPayload); + + setStore("tasks", t => { + const realExists = t.some(x => x.id === record.id); + if (realExists) { + return t.filter(x => x.id !== tempId); + } + return t.map(task => task.id === tempId ? { + ...task, + id: record.id, + created: record.created, + updated: record.updated, + ownerId: unwrapRelationId(record.user), + createdBy: unwrapRelationId(record.createdBy) || unwrapRelationId(record.user), + updatedBy: recordHasField(record, "updatedBy") + ? (unwrapRelationId(record.updatedBy) || unwrapRelationId(record.createdBy) || unwrapRelationId(record.user)) + : task.updatedBy + } : task); + }); + } catch (err) { + console.error("create failed", err); + toast.error("Failed to save task."); + setStore("tasks", t => t.filter(task => task.id !== tempId)); + } +}; + +export const copyTask = async (id: string) => { + const original = store.tasks.find(t => t.id === id); + if (!original) return; + + const shareModeByTag = Object.fromEntries( + original.shareRefs.map(ref => { + const context = getContextByRef(ref); + const tag = ref.kind === "note" + ? buildNoteTag(context?.key || ref.key) + : buildShareTag(context?.displayName || ref.key); + return [tag.toLowerCase(), getTaskSharePolicy(ref)]; + }) + ) as Record; + + await addTask(`${original.title} (Copy)`, { + priority: original.priority, + dueDate: original.dueDate, + tags: [...(original.tags || [])], + content: original.content, + size: original.size, + shareModeByTag + }); + + toast.success("Task duplicated"); +}; + +const checkForHandoffs = (task: Task, updates: Partial): Partial => { + const currentUserId = pb.authStore.model?.id; + if (!currentUserId || !taskHasPersonalContextAccess(task, currentUserId)) return updates; + + const refs = updates.shareRefs || task.shareRefs; + const senderPersonalContext = getPersonalContext(currentUserId); + const stripSenderPersonalContext = (inputRefs: TaskShareRef[]) => + senderPersonalContext + ? inputRefs.filter(existingRef => !( + existingRef.kind === "user" && + (existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key) + )) + : inputRefs; + + for (const ref of refs) { + const context = getContextByRef(ref); + if (senderPersonalContext && ref.kind === "user" && ( + ref.contextId === senderPersonalContext.id || + ref.key === senderPersonalContext.key + )) { + continue; + } + if (!context || getTaskSharePolicy(ref) !== "handoff") continue; + const nextShareRefs = stripSenderPersonalContext(refs); + const labelTags = updates.labelTags || task.labelTags; + const noteRefs = updates.noteRefs || task.noteRefs; + const favoriteTag = (updates.tags || task.tags || []).find(tag => tag.endsWith("_favorite__")); + + if (context.kind === "user" && context.targetUserId) { + return { + ...updates, + ownerId: context.targetUserId, + shareRefs: nextShareRefs, + tags: buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag) + }; + } + + if (context.kind === "bucket" || context.kind === "note") { + return { + ...updates, + shareRefs: nextShareRefs, + tags: buildTaskTags(labelTags, nextShareRefs, noteRefs, favoriteTag) + }; + } + } + + return updates; +}; + +export const updateTask = async (id: string, updates: Partial) => { + if (!pb.authStore.isValid) return false; + + const currentTask = store.tasks.find(t => t.id === id); + let finalUpdates = { ...updates }; + + if (finalUpdates.status !== undefined) { + finalUpdates.completed = finalUpdates.status === 10; + } else if (finalUpdates.completed !== undefined) { + if (finalUpdates.completed) { + finalUpdates.status = 10; + } else if (currentTask && currentTask.status === 10) { + finalUpdates.status = 0; + } + } + + if (currentTask) { + let favoriteTagOverride: string | null | undefined; + + if (finalUpdates.tags) { + if (!finalUpdates.shareRefs || !finalUpdates.noteRefs || !finalUpdates.labelTags) { + const derived = deriveTaskRelationsFromTags(finalUpdates.tags, currentTask.shareRefs); + finalUpdates.labelTags = finalUpdates.labelTags || derived.labelTags; + finalUpdates.shareRefs = finalUpdates.shareRefs || derived.shareRefs; + finalUpdates.noteRefs = finalUpdates.noteRefs || derived.noteRefs; + } + favoriteTagOverride = finalUpdates.tags.find(tag => tag.endsWith("_favorite__")) || null; + } + + if (finalUpdates.labelTags || finalUpdates.shareRefs || finalUpdates.noteRefs) { + const favoriteTag = favoriteTagOverride !== undefined + ? favoriteTagOverride || undefined + : currentTask.tags.find(tag => tag.endsWith("_favorite__")); + finalUpdates.tags = buildTaskTags( + finalUpdates.labelTags || currentTask.labelTags, + finalUpdates.shareRefs || currentTask.shareRefs, + finalUpdates.noteRefs || currentTask.noteRefs, + favoriteTag + ); + } + finalUpdates = checkForHandoffs(currentTask, finalUpdates); + const mirroredShareRefs = finalUpdates.shareRefs || currentTask.shareRefs; + finalUpdates = withMirroredOwnerId( + finalUpdates.ownerId ?? currentTask.ownerId, + mirroredShareRefs, + finalUpdates, + finalUpdates.ownerId ?? currentTask.ownerId + ); + } + + const optimisticUpdates = { + ...finalUpdates, + updated: new Date().toISOString(), + updatedBy: pb.authStore.model?.id || null + }; + setStore("tasks", t => t.id === id, optimisticUpdates); + + try { + const pbUpdates: any = { ...finalUpdates }; + const updatedBySupported = await ensureUpdatedByFieldSupport(); + + if (finalUpdates.ownerId !== undefined) { + pbUpdates.user = finalUpdates.ownerId || null; + delete pbUpdates.ownerId; + } + if (finalUpdates.createdBy !== undefined) { + pbUpdates.createdBy = finalUpdates.createdBy; + } + + if ("deletedAt" in finalUpdates) { + if (finalUpdates.deletedAt) { + pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString(); + } else { + pbUpdates.deletedAt = null; + } + } + + if (updatedBySupported) { + pbUpdates.updatedBy = pb.authStore.model?.id || null; + } + + await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null }); + return true; + } catch (err) { + console.error("update failed", err); + toast.error("Failed to update task."); + return false; + } +}; + +export const updateTaskField = (id: string, field: keyof Task, value: any) => { + setStore("tasks", t => t.id === id, field, value); + updateTask(id, { [field]: value } as Partial); +}; + +export const setMatrixScaleDays = async (days: number) => { + setStore("matrixScaleDays", days); + await syncPreferences(); +}; + +export const toggleTask = (id: string) => { + const task = store.tasks.find(t => t.id === id); + if (task) { + const newCompleted = !task.completed; + updateTask(id, { + completed: newCompleted, + status: newCompleted ? 10 : 0 + }); + } +}; + +export const removeTask = (id: string) => { + updateTask(id, { deletedAt: Date.now() }); +}; + +export const restoreTask = (id: string) => { + updateTask(id, { deletedAt: null }); +}; + +export const deleteTaskPermanently = async (id: string) => { + if (!pb.authStore.isValid) return; + + removeDeletedTaskFromStore(id); + + try { + await pb.collection(TASGRID_COLLECTION).delete(id); + } catch (err) { + console.error("delete failed", err); + toast.error("Failed to delete task."); + } +}; + +export const upsertTagDefinition = async (name: string, value: number, color?: string, theme?: "light" | "dark") => { + if (!pb.authStore.isValid) return; + + if (name.startsWith("@")) { + toast.error("Shared @tags are managed from contexts."); + return; + } + + const existing = store.tagDefinitions.find(d => d.name === name); + const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light"); + + try { + if (existing) { + setStore("tagDefinitions", d => d.id === existing.id, { + value, + color: color !== undefined ? color : existing.color, + theme: theme !== undefined ? theme : existing.theme + }); + } else { + const nameLower = name.toLowerCase(); + const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === nameLower); + + if (duplicate) { + toast.error(`Tag "${duplicate.name}" already exists.`); + return; + } + + const record = { + id: `pref-${normalizeContextKey(name) || Date.now().toString()}`, + name, + value, + color: color || "#6366f1", + theme: finalTheme + }; + + const newDef: TagDefinition = { + id: record.id, + name: record.name, + value: record.value, + color: record.color, + theme: record.theme as "light" | "dark" + }; + setStore("tagDefinitions", prev => [...prev, newDef]); + } + await syncPreferences(); + } catch (err) { + console.error("Failed to upsert tag:", err); + toast.error("Failed to save tag definition."); + } +}; + +export const removeTagDefinition = async (name: string) => { + if (!pb.authStore.isValid) return; + const def = store.tagDefinitions.find(d => d.name === name); + if (!def) return; + + try { + setStore("tagDefinitions", prev => prev.filter(d => d.id !== def.id)); + + const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name)); + for (const task of tasksWithTag) { + const nextTags = (task.tags || []).filter(t => t !== name); + updateTask(task.id, { tags: nextTags }); + } + + await syncPreferences(); + toast.success(`Tag "${name}" deleted`); + } catch (err) { + console.error("Failed to delete tag:", err); + toast.error("Failed to delete tag."); + } +}; + +export const renameTagDefinition = async (oldName: string, newName: string) => { + if (!newName || !newName.trim() || newName === oldName) return; + if (!pb.authStore.isValid) return; + + const def = store.tagDefinitions.find(d => d.name === oldName); + if (!def) return; + + const finalName = newName.trim(); + const finalNameLower = finalName.toLowerCase(); + const duplicate = store.tagDefinitions.find(d => d.name.toLowerCase() === finalNameLower && d.id !== def.id); + if (duplicate) { + toast.error(`Tag "${duplicate.name}" already exists.`); + return; + } + + try { + setStore("tagDefinitions", d => d.id === def.id, { name: finalName }); + + const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName)); + for (const task of tasksWithTag) { + const newTags = task.tags.map(t => t === oldName ? finalName : t); + const uniqueTags = [...new Set(newTags)]; + updateTask(task.id, { tags: uniqueTags }); + } + + await syncPreferences(); + } catch (err) { + console.error("Failed to rename tag:", err); + toast.error("Failed to rename tag."); + } +}; + +export const loadAllHistory = async () => { + if (!pb.authStore.isValid) return; + const userId = pb.authStore.model?.id; + if (!userId) return; + + try { + toast.promise( + (async () => { + const bucketTags = getSubscribedBucketContexts() + .map(context => buildShareTag(context.displayName)); + const supervisedUserIds = store.supervisedContexts.map(context => context.ownerUserId); + const userAccessFilter = buildPersonalAccessFilter([userId]); + const supervisedAccessFilter = buildPersonalAccessFilter(supervisedUserIds); + + const promises = [ + pb.collection(TASGRID_COLLECTION).getFullList({ filter: userAccessFilter, sort: "-created", fields: getTaskListFields(), requestKey: null }) + ]; + + if (bucketTags.length > 0) { + promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ + filter: bucketTags.map(tag => `tags ~ "${tag}"`).join(" || "), + sort: "-created", + fields: getTaskListFields(), + requestKey: null + }).catch(() => [])); + } + + if (supervisedUserIds.length > 0) { + promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ + filter: supervisedAccessFilter, + sort: "-created", + fields: getTaskListFields(), + requestKey: null + }).catch(() => [])); + } + + const results = await Promise.all(promises); + const allRecords = results.flat(); + + setStore("tasks", currentTasks => { + const taskMap = new Map(currentTasks.map(t => [t.id, t])); + allRecords.forEach((r: any) => { + if (r.tags?.includes("__template__")) return; + if (!taskMap.has(r.id)) { + taskMap.set(r.id, mapRecordToTask(r)); + } + }); + return Array.from(taskMap.values()); + }); + + return "Loaded history."; + })(), + { + loading: "Loading full history...", + success: msg => msg, + error: "Failed to load history" + } + ); + } catch (err) { + console.error("Failed to load history:", err); + } +}; + +export const loadTaskContent = async (id: string) => { + if (!pb.authStore.isValid) return; + try { + const record = await pb.collection(TASGRID_COLLECTION).getOne(id, { requestKey: null }); + setStore("tasks", t => t.id === id && t.content === undefined, { content: record.content }); + } catch (err) { + console.error("Failed to load task content:", err); + } +}; + +export const addTemplate = async (template: Omit) => { + if (!pb.authStore.isValid) return; + + const meta = { + title: template.title, + urgency: template.urgency, + tags: template.tags, + content: template.content + }; + + try { + const record = await pb.collection(TASGRID_COLLECTION).create({ + user: pb.authStore.model?.id, + title: template.name, + priority: template.priority, + tags: ["__template__"], + content: JSON.stringify(meta), + completed: false, + startDate: new Date().toISOString(), + dueDate: new Date().toISOString() + }, { requestKey: null }); + + const newTemplate = { ...template, id: record.id }; + setStore("templates", prev => { + if (prev?.some(t => t.id === record.id)) return prev; + return [...(prev || []), newTemplate]; + }); + return newTemplate; + } catch (err) { + console.error("Failed to add template:", err); + toast.error("Failed to save template."); + } +}; + +export const updateTemplate = async (id: string, updates: Partial) => { + if (!pb.authStore.isValid) return; + + setStore("templates", t => t!.id === id, updates); + + const template = store.templates?.find(t => t.id === id); + if (!template) return; + + const meta = { + title: template.title, + urgency: template.urgency, + tags: template.tags, + content: template.content + }; + + try { + await pb.collection(TASGRID_COLLECTION).update(id, { + title: template.name, + priority: template.priority, + content: JSON.stringify(meta) + }, { requestKey: null }); + } catch (err) { + console.error("Failed to update template:", err); + toast.error("Failed to update template."); + } +}; + +export const removeTemplate = async (id: string) => { + if (!pb.authStore.isValid) return; + + setStore("templates", t => (t || []).filter(tmpl => tmpl.id !== id)); + + try { + await pb.collection(TASGRID_COLLECTION).delete(id); + } catch (err) { + console.error("Failed to delete template:", err); + toast.error("Failed to delete template."); + } +}; + +export const saveTaskAsTemplate = async (taskId: string, name: string) => { + const task = store.tasks.find(t => t.id === taskId); + if (!task) return; + + const template: Omit = { + name, + title: task.title, + priority: task.priority, + urgency: calculateUrgencyFromDate(task.dueDate), + tags: [...(task.tags || [])], + content: task.content || "" + }; + + return await addTemplate(template); +}; + +export const shareTask = async (_taskId: string, _userId: string, _access: "view" | "edit" = "view") => { + if (!pb.authStore.isValid) return; + toast.info("Direct task sharing has been replaced by @user tags."); +}; + +export const loadTasksForOwner = async (userId: string) => { + if (!pb.authStore.isValid || !userId) return; + + try { + const filter = buildPersonalAccessFilter([userId]); + const records = await pb.collection(TASGRID_COLLECTION).getFullList({ + filter, + sort: "-created", + fields: getTaskListFields(), + requestKey: `owner-context-${userId}` + }); + + setStore("tasks", currentTasks => { + const taskMap = new Map(currentTasks.map(task => [task.id, task])); + records.forEach((record: any) => { + if (record.tags?.includes("__template__")) return; + const mapped = mapRecordToTask(record); + const existing = taskMap.get(mapped.id); + if (existing && existing.content) { + mapped.content = existing.content; + } + taskMap.set(mapped.id, mapped); + }); + return Array.from(taskMap.values()); + }); + } catch (err) { + console.error("Failed to load owner context tasks:", err); + } +}; + +export const revokeShare = async (_taskId: string, _userId: string) => { + if (!pb.authStore.isValid) return; + toast.info("Remove the matching @user tag from the task instead."); +}; + +export const splitTask = async (_taskId: string, _userId: string) => { + if (!pb.authStore.isValid) return; + toast.info("Task split is no longer supported. Duplicate the task and retag it."); +};