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 } 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 LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,labelTags,shareRefs,noteRefs,createdBy,user,size,recurrence,deletedAt'; const getStorageKey = () => { const userId = pb.authStore.model?.id; return userId ? `${STORAGE_KEY_PREFIX}_data_${userId}` : null; }; 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; 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 records = await pb.collection(TASGRID_COLLECTION).getFullList({ filter: `${relationFilter("user", personalOrUserId)} && updated >= "${timeStr}"`, sort: '-updated', fields: LIST_FIELDS, 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[]; migratedStructuredTagsV2?: boolean; migratedPersonalContextRefsV1?: boolean; } 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 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'; 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'; } 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; 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; } // Initial empty state export const [store, setStore] = createStore({ tasks: [], isInitializing: false, 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: [], 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) => 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.id === ref.noteId) || store.notes.find(note => note.key === ref.key); const buildTaskTags = ( labelTags: string[], shareRefs: TaskShareRef[], noteRefs: NoteRef[], favoriteTag?: string ) => { const tags = [ ...labelTags, ...shareRefs.map(ref => { const context = getContextByRef(ref); return buildShareTag(context?.displayName || 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(Boolean))]; }; 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))); const labelDefinitions = store.tagDefinitions.filter(def => !def.name.startsWith("@")); const mergedDefs = [...labelDefinitions, ...deriveSystemTagDefinitions(contexts)]; setStore("tagDefinitions", reconcile(mergedDefs)); }; const getPersonalContext = (userId = pb.authStore.model?.id) => userId ? store.contexts.find(context => !context.deletedAt && context.kind === "user" && context.targetUserId === userId ) || null : null; 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 collaborativeUserShare = activeShareContexts.some(context => context.kind === "user" && context.policy === "collaborative" && context.targetUserId === currentUserId ); const bucketRefs = activeShareContexts.filter(context => context.kind === "bucket"); const hasHandoffBucket = rawBucketRefs.some(ref => { const context = getContextByRef(ref); return !context || context.policy === "handoff"; }); const inPinnedBucket = rawBucketRefs.some(ref => store.subscribedBuckets.includes(ref.contextId) || store.subscribedBuckets.some(bucketId => store.contexts.find(context => context.id === bucketId)?.key === ref.key) ); const inAnyBucket = rawBucketRefs.length > 0; const isPersonalView = ctx === "mine" || ( typeof ctx === "object" && "bucketId" in ctx && !!personalContext && ctx.bucketId === personalContext.id ); const inPersonalContext = !!personalContext && task.shareRefs.some(ref => ref.kind === "user" && (ref.contextId === personalContext.id || ref.key === personalContext.key) ); // 0. Context Filter if (isPersonalView) { if (hasHandoffBucket) return false; const isOwner = task.ownerId === currentUserId; if (!isOwner && !collaborativeUserShare && !inPersonalContext) 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 = 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; const targetPersonalContext = getPersonalContext(targetUserId); const inTargetPersonalContext = !!targetPersonalContext && task.shareRefs.some(ref => ref.kind === "user" && ( ref.contextId === targetPersonalContext.id || ref.key === targetPersonalContext.key || normalizeContextKey(targetPersonalContext.displayName) === ref.key ) ); if (targetPersonalContext) { if (!inTargetPersonalContext) return false; } else if (task.ownerId !== targetUserId) { return false; } } if (isPersonalView && inAnyBucket && !inPinnedBucket && task.ownerId !== currentUserId) return false; // Hide templates from all regular views if (task.tags?.includes("__template__")) return false; const f = store.filter; // Query search (title or content) if (f.query) { const q = f.query.toLowerCase(); const inTitle = task.title.toLowerCase().includes(q); const inContent = task.content?.toLowerCase().includes(q); if (!inTitle && !inContent) 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 && task.ownerId !== currentUserId) { return false; } // Starred if (f.starred && !task.tags?.includes(getFavoriteTag())) { return false; } return true; }; // Auto-persist changes to localStorage (wrapped in root to avoid console warning) createRoot(() => { createEffect(() => { const key = getStorageKey(); if (key) { localStorage.setItem(key, JSON.stringify(store)); } }); 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 && t.ownerId === pb.authStore.model?.id && !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; const mapRecordToTask = (r: any): Task => { const legacyTags: string[] = r.tags || []; const parsedTags = parseTags(legacyTags).filter(tag => tag.raw !== "__template__"); const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs) ? r.shareRefs : parsedTags .filter(tag => tag.kind === "share") .map(tag => { const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, key: tag.key, kind: context?.kind || "bucket" }; }); const noteRefs: NoteRef[] = Array.isArray(r.noteRefs) ? r.noteRefs : parsedTags .filter(tag => tag.kind === "note") .map(tag => { const note = store.notes.find(existing => existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key }; }); 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, shareRefs, 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, noteRefs, ownerId: unwrapRelationId(r.user), createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user), 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 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): Note => { return { id: r.id, title: r.title, key: r.key || normalizeNoteKey(r.title), content: r.content, 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 }; }; 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) => { 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]; }); }; // 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 (mappedTask.ownerId === currentUserId) return true; if (store.supervisedContexts.some(context => context.ownerUserId === mappedTask.ownerId)) return true; if (mappedTask.shareRefs.some(ref => ref.kind === "bucket")) return true; const contexts = mappedTask.shareRefs .map(ref => getContextByRef(ref)) .filter((context): context is ShareContext => !!context && !context.deletedAt); return contexts.some(context => context.kind === "user" && context.policy === "collaborative" && context.targetUserId === currentUserId ); }; export const subscribeToRealtime = async () => { // 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 updatedNote = mapRecordToNote(e.record); setStore("notes", prev => { const exists = prev.find(n => n.id === updatedNote.id); if (exists) return prev.map(n => n.id === updatedNote.id ? updatedNote : 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(); }); }; const runLegacyTagMigration = async () => { 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 (prefs.migratedStructuredTagsV2) { return; // Already migrated } console.log("[MIGRATION] Starting structured tag migration..."); await syncUserContexts(); 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); await pb.collection(TASGRID_COLLECTION).update(task.id, { tags: task.tags, labelTags: task.labelTags, shareRefs: task.shareRefs, noteRefs: task.noteRefs, createdBy: task.createdBy || task.ownerId || currentUserId }, { requestKey: null }); } const notes = await pb.collection(NOTES_COLLECTION).getFullList({ requestKey: null }); for (const noteRecord of notes) { const note = mapRecordToNote(noteRecord); await pb.collection(NOTES_COLLECTION).update(note.id, { key: note.key, tags: note.tags }, { 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) { const nextContextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ sort: 'displayName,key', requestKey: null }).catch(() => []); const nextContexts = nextContextRecords.map(mapRecordToContext); setContexts(nextContexts); setStore("shareRules", nextContexts.map(mapContextToShareRule)); } }; 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 runPersonalContextMigration = async () => { 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 (prefs.migratedPersonalContextRefsV1) { return; } const contexts = store.contexts.length > 0 ? store.contexts : (await pb.collection(CONTEXTS_COLLECTION).getFullList({ requestKey: null })).map(mapRecordToContext); const ownerContextByUserId = new Map( contexts .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) .map(context => [context.targetUserId as string, context]) ); const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ fields: LIST_FIELDS, requestKey: null }); for (const taskRecord of taskRecords) { if (taskRecord.tags?.includes("__template__")) continue; const task = mapRecordToTask(taskRecord); if (!task.ownerId) continue; const ownerContext = ownerContextByUserId.get(task.ownerId); if (!ownerContext) continue; const alreadyHasOwnerContext = task.shareRefs.some(ref => ref.kind === "user" && (ref.contextId === ownerContext.id || ref.key === ownerContext.key) ); if (alreadyHasOwnerContext) continue; const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); const nextShareRefs = [ { contextId: ownerContext.id, key: ownerContext.key, kind: "user" as const }, ...task.shareRefs ]; await pb.collection(TASGRID_COLLECTION).update(task.id, { shareRefs: nextShareRefs, tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, favoriteTag) }, { requestKey: null }); } await pb.collection('users').update(currentUserId, { Taskgrid_pref: writeScopedPrefs(rawPrefs, { ...prefs, migratedPersonalContextRefsV1: true }) }, { requestKey: null }); } catch (err) { console.error("[MIGRATION] Failed personal context backfill:", err); } }; export const initStore = async () => { if (!pb.authStore.isValid || store.isInitializing) return; setStore("isInitializing", true); const currentUserId = pb.authStore.model?.id; // 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 { focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, { filter: `${relationFilter("user", currentUserId)} && 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); } 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 { setStore(JSON.parse(saved)); } 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 || {}); const personalContext = getPersonalContext(userId); setStore({ pWeight: prefs.pWeight || 1.0, uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, prefId: userId, subscribedBuckets: prefs.subscribedBuckets || user.subscribedBuckets || [], personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [], tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")), filterTemplates: prefs.filterTemplates || [], quickloadTasks: prefs.quickloadTasks || [], noteFilter: prefs.noteFilter || { query: "", tags: [], priorityMin: 1, priorityMax: 10, urgencyMin: 1, urgencyMax: 10, editedToday: false, ownedByMe: false, starred: false, jobStatus: [], jobDivision: [] }, filter: prefs.filter || store.filter }); } } 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); } // 1.3 Fetch Notes try { const currentUserId = pb.authStore.model?.id; const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({ filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`, sort: '-created', requestKey: null }); setStore("notes", notesRecords.map(mapRecordToNote)); } catch (notesErr) { console.warn("Failed to load notes (collection might not exist yet):", notesErr); } 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 collaborativeKeys = store.contexts .filter(context => !context.deletedAt && context.kind === "user" && context.policy === "collaborative" && context.targetUserId === currentUserId ) .map(context => buildShareTag(context.displayName)); // 2a. Fetch ALL Incomplete Tasks (Metadata Only) to ensure availability const incompletePromises = [ pb.collection(TASGRID_COLLECTION).getFullList({ filter: `${relationFilter("user", currentUserId)} && completed = false`, sort: '-created', fields: LIST_FIELDS, requestKey: null }), (subscribedBucketTags.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ filter: `(${subscribedBucketTags.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => []) : Promise.resolve([]), (collaborativeKeys.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ filter: `(${collaborativeKeys.map(tag => `tags ~ "${tag}"`).join(' || ')}) && completed = false`, sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => []) : Promise.resolve([]), (supervisedUserIds.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ filter: `(${supervisedUserIds.map(id => relationFilter("user", id)).join(' || ')}) && completed = false`, sort: '-created', fields: LIST_FIELDS, 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; 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); }); 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).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 recentCompleted = await pb.collection(TASGRID_COLLECTION).getList(1, remainingSlots, { filter: `${relationFilter("user", currentUserId)} && completed = true`, sort: '-updated', fields: LIST_FIELDS, 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__") 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(); // Run one-time migration for legacy tags await runLegacyTagMigration(); await runPersonalContextMigration(); // 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(); } 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: LIST_FIELDS }); 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") => { try { await pb.collection(CONTEXTS_COLLECTION).update(contextId, { policy }, { requestKey: null }); toast.success("User context updated"); } catch (err) { console.error("Failed to update user context:", err); toast.error("Failed to update user context"); } }; 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 }) => { 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 parsedTags = parseTags(tags); const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); const shareRefs = parsedTags .filter(tag => tag.kind === "share") .map(tag => { const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, key: tag.key, kind: context?.kind || "bucket" } satisfies TaskShareRef; }); const noteRefs = parsedTags .filter(tag => tag.kind === "note") .map(tag => { const note = store.notes.find(existing => existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key } satisfies NoteRef; }); 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 || "", 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 newTask = { ...initialTask, ...handoffUpdates }; // Optimistic UI update setStore("tasks", (t) => [newTask, ...t]); try { const record = await pb.collection(TASGRID_COLLECTION).create({ 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 }); // 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) } : 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; // 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 }); toast.success("Task duplicated"); }; const checkForHandoffs = (task: Task, updates: Partial): Partial => { const currentUserId = pb.authStore.model?.id; if (!currentUserId || task.ownerId !== 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 (!context || context.policy !== "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") { 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; // 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) { if (finalUpdates.tags) { const parsedTags = parseTags(finalUpdates.tags); finalUpdates.labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); finalUpdates.shareRefs = parsedTags .filter(tag => tag.kind === "share") .map(tag => { const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, key: tag.key, kind: context?.kind || "bucket" } satisfies TaskShareRef; }); finalUpdates.noteRefs = parsedTags .filter(tag => tag.kind === "note") .map(tag => { const note = store.notes.find(existing => existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key } satisfies NoteRef; }); } if (finalUpdates.labelTags || finalUpdates.shareRefs || finalUpdates.noteRefs) { const favoriteTag = 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); } // Optimistic update with timestamp to prevent stale realtime events const optimisticUpdates = { ...finalUpdates, updated: new Date().toISOString() }; 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 }; 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 } } // Disable auto-cancellation for updates to prevent aborted errors during rapid typing await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null }); } catch (err) { console.error("update failed", err); toast.error("Failed to update task."); } }; // 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 setStore("tasks", (t) => t.filter((t) => t.id !== 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; // 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; // Optimistic setStore("notes", (n) => n.filter((n) => n.id !== 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 collaborativeTags = store.contexts .filter(context => !context.deletedAt && context.kind === "user" && context.policy === "collaborative" && context.targetUserId === userId ) .map(context => buildShareTag(context.displayName)); const promises = [ pb.collection(TASGRID_COLLECTION).getFullList({ filter: relationFilter("user", userId), sort: '-created', fields: LIST_FIELDS, requestKey: null }) ]; if (bucketTags.length > 0) { promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter: bucketTags.map(tag => `tags ~ "${tag}"`).join(' || '), sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => [])); } if (collaborativeTags.length > 0) { promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter: collaborativeTags.map(tag => `tags ~ "${tag}"`).join(' || '), sort: '-created', fields: LIST_FIELDS, requestKey: null }).catch(() => [])); } if (supervisedUserIds.length > 0) { promises.push(pb.collection(TASGRID_COLLECTION).getFullList({ filter: supervisedUserIds.map(id => relationFilter("user", id)).join(' || '), sort: '-created', fields: LIST_FIELDS, 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); } }; // -- 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("@")), filter: store.filter, filterTemplates: store.filterTemplates, quickloadTasks: store.quickloadTasks, noteFilter: store.noteFilter, subscribedBuckets: store.subscribedBuckets, supervisorUserIds: store.personalContextSupervisorIds }; 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 targetPersonalContext = getPersonalContext(userId); const filter = targetPersonalContext ? `tags ~ "${buildShareTag(targetPersonalContext.displayName)}"` : relationFilter("user", userId); const records = await pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', fields: LIST_FIELDS, 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' = 'ADD_USER') => { 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 })); syncPreferences(); }; 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: [] }); syncPreferences(); }; // 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(); };