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 { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, URGENCY_HOURS } from "@/lib/constants"; const getStorageKey = () => { const userId = pb.authStore.model?.id; return userId ? `tasgrid_data_${userId}` : null; }; export const [now, setNow] = createSignal(Date.now()); // Context: 'mine' = My Workspace (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string }; export const [activeTaskId, setActiveTaskId] = 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[]; ownerId: string | null; // Add ownerId to interface content?: string; // HTML content from Tiptap deletedAt?: number; // Timestamp of soft delete 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' }>; // Users this task is shared with } 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; // New flag to identify user-based tags isBucket?: boolean; // New flag to identify bucket-based tags } export interface FilterTag { name: string; excluded: boolean; } export interface Filter { query: string; tags: FilterTag[]; priorityMin: number; priorityMax: number; urgencyMin: number; urgencyMax: number; editedToday: boolean; } export interface FilterTemplate { id: string; name: string; filter: Filter; } export interface ShareRule { id: string; ownerId: string; targetUserId: string; type: 'tag' | 'all'; tagName?: string; share_mode?: 'ADD_USER' | 'SEND_TASK'; // Default to ADD_USER if undefined } export interface Bucket { id: string; name: string; color: string; description?: string; } interface TaskStore { tasks: Task[]; pWeight: number; uWeight: number; matrixScaleDays: number; prefId?: string; // ID of the user record tagDefinitions: TagDefinition[]; templates: TaskTemplate[]; filter: Filter; shareRules: ShareRule[]; filterTemplates: FilterTemplate[]; buckets: Bucket[]; subscribedBuckets: string[]; // IDs of buckets I'm subscribed to } // Initial empty state export const [store, setStore] = createStore({ tasks: [], pWeight: 1.0, uWeight: 1.0, matrixScaleDays: 30, tagDefinitions: [], templates: [], filter: { query: "", tags: [], priorityMin: 1, priorityMax: 10, urgencyMin: 1, urgencyMax: 10, editedToday: false }, shareRules: [], filterTemplates: [], buckets: [], subscribedBuckets: [] }); export const matchesFilter = (task: Task) => { const currentUserId = pb.authStore.model?.id; const ctx = currentTaskContext(); // 0. Context Filter if (ctx === 'mine') { const isOwner = task.ownerId === currentUserId; const isExplicitlyShared = task.sharedWith?.some(s => s.userId === currentUserId); // Tag-based share rules targeting me const isTagRuleShared = store.shareRules.some(r => r.targetUserId === currentUserId && r.ownerId === task.ownerId && r.type === 'tag' && task.tags?.includes(r.tagName || "") ); // Bucket visibility logic for "My Workspace": // Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users." // AND "In settings a user should be able to see and edit all buckets... They should be able to add any bucket to their workspaces list." // When in 'mine', do we show bucket tasks? // Typically 'My Workspace' is just MY stuff + stuff shared explicitly with me. // Buckets seem to be separate "Contexts" in the sidebar. // However, if the user wants them mixed in, they didn't explicitly say "mix into my main list", // they said "add that bucket to their workspaces list". This usually implies a separate view. // BUT, if I create a task and tag it "Shop", I expect to see it. // If I own the task, I see it. // If it's shared with me, I see it. if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false; } else if (typeof ctx === 'object' && 'bucketId' in ctx) { // Bucket View const bucketProxy = store.buckets.find(b => b.id === (ctx as { bucketId: string }).bucketId); if (!bucketProxy) return false; // Show tasks that have the bucket name as a tag // The requirement: "if any user adds a tag that is the name of a bucket to a task that task is then made public and moved to the bucket." if (!task.tags?.includes(bucketProxy.name)) return false; } else { // Context is Oversight for specific user // Show ONLY tasks owned by that user // Note: We might want to EXCLUDE bucket tasks from oversight if they are purely bucket tasks, // but typically oversight means "everything that user owns". if (task.ownerId !== (ctx as { userId: string }).userId) 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; } 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)); } }); }); // -- 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 => { const urgencyScore = calculateUrgencyScore(task.dueDate); 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: (size - 5) * -0.4 // Small tasks (0-4) get a slight boost, large tasks (6-10) get a slight penalty const sizeScore = ((task.size ?? 5) - 5) * -0.4; 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 => { // Check if it's a template? The caller handles filtering templates usually, // but here we just map fields. const tags = r.tags || []; 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, ownerId: r.user || 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: r.sharedWith }; }; const mapRecordToShareRule = (r: any): ShareRule => { return { id: r.id, ownerId: r.ownerId, targetUserId: r.targetUserId, type: r.type as 'tag' | 'all', tagName: r.tagName, share_mode: r.share_mode || 'ADD_USER' }; }; // Helper to check if a task should be visible based on ownership, shares, or rules const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => { // Own task if (task.user === currentUserId) return true; // Individually shared const sharedWith = task.sharedWith || []; if (sharedWith.some((s: any) => s.userId === currentUserId)) return true; // ShareRule match const matchingRule = store.shareRules.find(rule => { if (rule.targetUserId !== currentUserId) return false; if (task.user !== rule.ownerId) return false; if (rule.type === 'all') return true; if (rule.type === 'tag') { const tags = task.tags || []; return tags.includes(rule.tagName); } return false; }); // Bucket check if (isTaskInSubscribedBucket(task)) return true; return !!matchingRule; }; // Check if task is visible because it belongs to a subscribed bucket const isTaskInSubscribedBucket = (task: any): boolean => { const taskTags = task.tags || []; if (taskTags.length === 0) return false; // Check if any of the task's tags match a bucket we are subscribed to // We need to look up bucket names from our subscribed bucket IDs // This is slightly expensive if we iterate. // Optimization: Pre-calculate subscribed bucket names? // For now, simple find. return store.subscribedBuckets.some(subId => { const bucket = store.buckets.find(b => b.id === subId); return bucket && taskTags.includes(bucket.name); }); }; 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 = 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 = 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 Buckets // We need to know if buckets are added/removed/renamed await pb.collection(BUCKETS_COLLECTION).unsubscribe('*'); pb.collection(BUCKETS_COLLECTION).subscribe('*', async (e) => { if (e.action === 'create' || e.action === 'update') { const bucket = { id: e.record.id, name: e.record.name, color: e.record.color, description: e.record.description }; setStore("buckets", (prev) => { const exists = prev.find(b => b.id === bucket.id); if (exists) return prev.map(b => b.id === bucket.id ? bucket : b); return [...prev, bucket]; }); } if (e.action === 'delete') { setStore("buckets", (prev) => prev.filter(b => b.id !== e.record.id)); // Also remove from subscribed if present? // The user record might still have it, but we can filter display. } }); // Subscribe to Share Rules await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*'); pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => { const currentUserId = pb.authStore.model?.id; if (e.action === 'create') { const newRule = mapRecordToShareRule(e.record); // Add to shareRules store setStore("shareRules", (rules) => { if (rules.some(r => r.id === newRule.id)) return rules; return [...rules, newRule]; }); // If I'm the target, fetch matching tasks from the owner if (newRule.targetUserId === currentUserId) { try { let filter = `user = "${newRule.ownerId}"`; if (newRule.type === 'tag' && newRule.tagName) { filter += ` && tags ~ "${newRule.tagName}"`; } const records = await pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', requestKey: null }); records.forEach((r: any) => { if (!r.tags?.includes("__template__") && !store.tasks.find(t => t.id === r.id)) { const newTask = mapRecordToTask(r); setStore("tasks", t => [newTask, ...t]); } }); } catch (err) { console.warn("Failed to fetch tasks for new share rule:", err); } } } if (e.action === 'delete') { const deletedRuleId = e.record.id; const deletedRule = store.shareRules.find(r => r.id === deletedRuleId); // Remove from shareRules store setStore("shareRules", (rules) => rules.filter(r => r.id !== deletedRuleId)); // If I was the target, remove tasks that are no longer visible if (deletedRule && deletedRule.targetUserId === currentUserId) { setStore("tasks", tasks => tasks.filter(task => shouldTaskBeVisible(task, currentUserId!)) ); } } if (e.action === 'update') { const updatedRule = mapRecordToShareRule(e.record); setStore("shareRules", (rules) => rules.map(r => r.id === updatedRule.id ? updatedRule : r)); } }); }; export const initStore = async () => { if (!pb.authStore.isValid) return; // 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 = user.Taskgrid_pref || {}; setStore({ pWeight: prefs.pWeight || 1.0, uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, prefId: userId, subscribedBuckets: user.subscribedBuckets || [] }); } } catch (prefErr) { console.warn("Failed to load preferences:", prefErr); } // 1.2 Fetch Buckets try { const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name' }); setStore("buckets", buckets.map((b: any) => ({ id: b.id, name: b.name, color: b.color, description: b.description }))); } catch (bucketErr) { console.warn("Failed to load buckets (collection might not exist yet):", bucketErr); } // 1.5. Fetch Tag Definitions try { const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({ filter: `user = "${pb.authStore.model?.id}"`, }); const loadedTags: TagDefinition[] = tagRecords.map(r => ({ id: r.id, name: r.name, value: r.value, color: r.color, theme: r.theme, isUser: r.isUser || false, isBucket: r.isBucket || false })); setStore("tagDefinitions", reconcile(loadedTags)); } catch (tagErr) { console.warn("Failed to load tags:", tagErr); } // 1.8 Sync System Tags & Share Rules await syncSystemTagsAndRules(); // 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL const currentUserId = pb.authStore.model?.id; // Prepare parallel fetch promises (requestKey: null prevents auto-cancellation) const ownTasksPromise = pb.collection(TASGRID_COLLECTION).getFullList({ filter: `user = "${currentUserId}"`, sort: '-created', requestKey: null }); const individuallySharedPromise = currentUserId ? pb.collection(TASGRID_COLLECTION).getFullList({ filter: `sharedWith ~ "${currentUserId}"`, sort: '-created', requestKey: null }).catch(err => { console.warn("Failed to load individually shared tasks:", err); return []; }) : Promise.resolve([]); const shareRulesPromise = currentUserId ? pb.collection(SHARE_RULES_COLLECTION).getFullList({ filter: `targetUserId = "${currentUserId}"`, requestKey: null }).catch(err => { console.warn("Failed to load share rules:", err); return []; }) : Promise.resolve([]); // 1.9 Fetch Tasks for Subscribed Buckets const subscribedBucketNames = store.subscribedBuckets .map(id => store.buckets.find(b => b.id === id)?.name) .filter(Boolean); const bucketTasksPromise = (subscribedBucketNames.length > 0) ? pb.collection(TASGRID_COLLECTION).getFullList({ filter: subscribedBucketNames.map(name => `tags ~ "${name}"`).join(' || '), sort: '-created', requestKey: null }).catch(err => { console.warn("Failed to load bucket tasks:", err); return []; }) : Promise.resolve([]); // Wait for all initial fetches const [ownRecords, individuallySharedRecords, incomingRules, bucketRecords] = await Promise.all([ ownTasksPromise, individuallySharedPromise, shareRulesPromise, bucketTasksPromise ]); const allTasks: Task[] = []; const loadedTemplates: TaskTemplate[] = []; const seenIds = new Set(); // Process own tasks & templates ownRecords.forEach(r => { const tags = r.tags || []; if (tags.includes("__template__")) { let meta: any = {}; try { meta = JSON.parse(r.content || "{}"); } catch (e) { } loadedTemplates.push({ id: r.id, name: r.title, title: meta.title || "", priority: r.priority, urgency: meta.urgency || 5, tags: meta.tags || [], content: meta.content || "" }); } else { allTasks.push(mapRecordToTask(r)); seenIds.add(r.id); } }); // Process individually shared tasks individuallySharedRecords.forEach((r: any) => { if (!seenIds.has(r.id)) { const tags = r.tags || []; if (!tags.includes("__template__")) { allTasks.push(mapRecordToTask(r)); seenIds.add(r.id); } } }); // Process ShareRules - fetch tasks from each owner in parallel if (incomingRules.length > 0) { // Group rules by owner const ownerRules = new Map(); incomingRules.forEach((rule: any) => { const existing = ownerRules.get(rule.ownerId) || []; existing.push({ type: rule.type, tagName: rule.tagName }); ownerRules.set(rule.ownerId, existing); }); // Fetch from all owners in parallel const ownerFetchPromises = Array.from(ownerRules.entries()).map(async ([ownerId, rules]) => { const hasAllRule = rules.some(r => r.type === 'all'); const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean); let filter = `user = "${ownerId}"`; if (!hasAllRule && tagRules.length > 0) { const tagFilters = tagRules.map(t => `tags ~ "${t}"`).join(' || '); filter += ` && (${tagFilters})`; } try { return await pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', requestKey: null }); } catch (err) { console.warn(`Failed to fetch tasks from owner ${ownerId}:`, err); return []; } }); const ownerResults = await Promise.all(ownerFetchPromises); ownerResults.flat().forEach((r: any) => { if (!seenIds.has(r.id)) { const tags = r.tags || []; if (!tags.includes("__template__")) { allTasks.push(mapRecordToTask(r)); seenIds.add(r.id); } } }); } // Process Bucket Tasks if (bucketRecords && bucketRecords.length > 0) { bucketRecords.forEach((r: any) => { if (!seenIds.has(r.id)) { const tags = r.tags || []; if (!tags.includes("__template__")) { allTasks.push(mapRecordToTask(r)); seenIds.add(r.id); } } }); } // Set store ONCE with all tasks and rules setStore("tasks", reconcile(allTasks)); setStore("templates", loadedTemplates); // Make sure we include incomingRules in the store so ContextSwitcher can see them setStore("shareRules", incomingRules.map(mapRecordToShareRule)); // Check recurring tasks after load checkRecurringTasks(); // 2.7. Fetch Share Rules (owned by current user) await loadShareRules(); // 2.8 Fetch Filter Templates await loadFilterTemplates(); // 3. Subscribe to Realtime Updates await subscribeToRealtime(); // 4. Reconstructive Migration: Verify all tags in tasks have definitions const foundTagNames = new Set(); allTasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag))); loadedTemplates.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag))); for (const tagName of foundTagNames) { if (tagName === "__template__") continue; const exists = store.tagDefinitions.find(d => d.name === tagName); if (!exists) { console.log(`Reconstructing missing tag: ${tagName}`); // Default to dark mode for reconstruction if we can't tell const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light"; await upsertTagDefinition(tagName, 5, undefined, currentTheme); } } } catch (err) { if (store.tasks.length === 0) { console.error("Failed to load data:", err); toast.error("Failed to sync with server."); } } }; // -- 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 { await pb.collection('users').update(userId, { subscribedBuckets: newSubscribed }); // If we just subscribed, we should fetch the tasks for this bucket immediately if (!isSubscribed) { const bucketName = store.buckets.find(b => b.id === bucketId)?.name; if (bucketName) { const records = await pb.collection(TASGRID_COLLECTION).getFullList({ filter: `tags ~ "${bucketName}"`, sort: '-created' }); 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 { await pb.collection(BUCKETS_COLLECTION).create({ name, color, description }); await syncSystemTagsAndRules(); 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 }) => { try { await pb.collection(BUCKETS_COLLECTION).update(id, data); if (data.name) { await syncSystemTagsAndRules(); } 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(BUCKETS_COLLECTION).delete(id); toast.success("Bucket deleted"); } catch (err) { console.error("Failed to delete bucket:", err); toast.error("Failed to delete bucket"); } }; 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 = options?.tags ?? ["work"]; const content = options?.content ?? ""; const size = options?.size ?? 5; const tempId = "temp-" + Date.now(); const newTask: Task = { id: tempId, title, startDate, dueDate, priority, status: 0, completed: false, tags, ownerId: pb.authStore.model?.id || "", content, size, created: new Date().toISOString(), updated: new Date().toISOString() }; // Optimistic UI update setStore("tasks", (t) => [newTask, ...t]); try { const record = await pb.collection(TASGRID_COLLECTION).create({ user: pb.authStore.model?.id, title, startDate, dueDate, priority, status: 0, completed: false, tags, 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: record.user // Add missing ownerId from record } : 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 => { // Only check if tags are being modified if (!updates.tags) return updates; const currentUserId = pb.authStore.model?.id; if (!currentUserId || task.ownerId !== currentUserId) return updates; // Check for any tag that triggers a SEND_TASK rule for (const tag of updates.tags) { const rule = store.shareRules.find(r => r.ownerId === currentUserId && r.type === 'tag' && r.tagName === tag && r.share_mode === 'SEND_TASK' ); if (rule) { console.log(`Handoff triggered by tag "${tag}" -> Transferring to ${rule.targetUserId}`); // Check if it's a bucket tag (target is self, but tag is a bucket) const isBucketRule = rule.targetUserId === currentUserId && store.tagDefinitions.find(t => t.name === tag)?.isBucket; if (isBucketRule) { return { ...updates, ownerId: null, // Unassign from current user (null is better for PB relations) // Remove current user from sharedWith to ensure it disappears from their view sharedWith: (task.sharedWith || []).filter(s => s.userId !== currentUserId) }; } return { ...updates, ownerId: rule.targetUserId, // Also clear sharedWith for the specific target user if they were already shared sharedWith: (task.sharedWith || []).filter(s => s.userId !== rule.targetUserId) }; } } 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) { 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.deletedAt !== undefined) { // PB expects a date string or null for date fields usually, // but if we defined deletedAt as a 'date' field in PB. // If we defined it as a number in PB? User didn't specify. // Assuming it's a 'Date' field in PB based on previous conversation // "deletedAt | Date | No" if (finalUpdates.deletedAt) { pbUpdates.deletedAt = new Date(finalUpdates.deletedAt).toISOString(); } else { pbUpdates.deletedAt = null; // restore } } // 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); // Debounce this? // For now, fire and forget update 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 undefined for deletedAt, updateTask will convert to null for PB updateTask(id, { deletedAt: undefined }); }; 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; const existing = store.tagDefinitions.find(d => d.name === name); const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light"); try { if (existing) { const updateData: any = { value }; if (color !== undefined) updateData.color = color; if (theme !== undefined) updateData.theme = theme; await pb.collection(TAGS_COLLECTION).update(existing.id, updateData, { requestKey: null }); setStore("tagDefinitions", (d) => d.id === existing.id, { value, color: color !== undefined ? color : existing.color, theme: theme !== undefined ? theme : existing.theme }); } else { const record = await pb.collection(TAGS_COLLECTION).create({ user: pb.authStore.model?.id, 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", isBucket: record.isBucket || false }; setStore("tagDefinitions", (prev) => [...prev, newDef]); } } 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 { await pb.collection(TAGS_COLLECTION).delete(def.id); 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 }); } toast.success(`Tag "${name}" deleted`); } catch (err) { console.error("Failed to delete tag:", err); toast.error("Failed to delete tag."); } }; export const syncSystemTagsAndRules = async () => { if (!pb.authStore.isValid) return; const currentUserId = pb.authStore.model?.id; try { // 1. Fetch data const [users, pbRules, tagDefinitions] = await Promise.all([ pb.collection('users').getFullList({ filter: 'verified = true', fields: 'id,name,email,verified', requestKey: null }), pb.collection(SHARE_RULES_COLLECTION).getFullList({ filter: `ownerId = "${currentUserId}"`, requestKey: null }), pb.collection(TAGS_COLLECTION).getFullList({ filter: `user = "${currentUserId}"`, requestKey: null }) ]); const normalizedRules = pbRules.map(r => ({ id: r.id, ownerId: r.ownerId, targetUserId: r.targetUserId, type: r.type, tagName: r.tagName })); // --- Sync Bucket Tags --- for (const bucket of store.buckets) { const tagExists = tagDefinitions.find(t => t.name === bucket.name); if (!tagExists) { try { const record = await pb.collection(TAGS_COLLECTION).create({ user: currentUserId, name: bucket.name, value: 5, color: bucket.color || "#64748b", theme: "dark", isBucket: true }); setStore("tagDefinitions", prev => [...prev, { id: record.id, name: record.name, value: record.value, color: record.color, theme: record.theme, isBucket: true }]); } catch (e) { console.error(`Failed to create bucket tag ${bucket.name}`, e); } } else if (!tagExists.isBucket) { try { await pb.collection(TAGS_COLLECTION).update(tagExists.id, { isBucket: true }); setStore("tagDefinitions", d => d.id === tagExists.id, { isBucket: true }); } catch (e) { console.error(`Failed to flag tag ${bucket.name} as bucket`, e); } } // --- Sync Bucket System Rule --- // Ensure a ShareRule exists for this bucket so it appears in "System Rules" const bucketRuleExists = normalizedRules.some(r => r.ownerId === currentUserId && r.targetUserId === currentUserId && r.type === 'tag' && r.tagName === bucket.name ); if (!bucketRuleExists) { try { await pb.collection(SHARE_RULES_COLLECTION).create({ ownerId: currentUserId, targetUserId: currentUserId, type: 'tag', tagName: bucket.name, share_mode: 'SEND_TASK' }); } catch (e) { console.error(`Failed to ensure system rule for bucket ${bucket.name}`, e); } } } // --- Sync User Tags --- for (const user of users) { const userName = user.name || user.email; if (!userName) continue; const tagExists = tagDefinitions.find(t => t.name === userName); if (!tagExists) { try { const record = await pb.collection(TAGS_COLLECTION).create({ user: currentUserId, name: userName, value: 5, color: "#64748b", theme: "dark", isUser: true }); setStore("tagDefinitions", prev => [...prev, { id: record.id, name: record.name, value: record.value, color: record.color, theme: record.theme, isUser: true }]); } catch (e) { // console.error(`Failed to create user tag ${userName}`, e); } } else if (!tagExists.isUser) { try { await pb.collection(TAGS_COLLECTION).update(tagExists.id, { isUser: true }); setStore("tagDefinitions", d => d.id === tagExists.id, { isUser: true }); } catch (e) { console.error(`Failed to flag tag ${userName} as user`, e); } } // --- User Share Rule Sync --- if (user.id !== currentUserId) { const matchingRules = normalizedRules.filter(r => r.targetUserId === user.id && r.type === 'tag' && r.tagName === userName ); if (matchingRules.length === 0) { await addShareRule('tag', user.id, userName, 'ADD_USER'); } else if (matchingRules.length > 1) { const [, ...remove] = matchingRules; for (const rule of remove) { try { await pb.collection(SHARE_RULES_COLLECTION).delete(rule.id); setStore("shareRules", list => list.filter(r => r.id !== rule.id)); } catch (e) { console.error("Failed to delete duplicate rule:", e); } } } } } } catch (err) { console.error("Failed to sync system tags/rules:", err); } }; 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(); try { await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName }); 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 }); } } catch (err) { console.error("Failed to rename tag:", err); toast.error("Failed to rename tag."); } }; // -- Templates -- export const syncPreferences = async () => { const userId = pb.authStore.model?.id; if (!userId) return; try { const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, tagDefinitions: store.tagDefinitions }; await pb.collection('users').update(userId, { Taskgrid_pref: 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() }); 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; const task = store.tasks.find(t => t.id === taskId); if (!task) return; const existingShares = task.sharedWith || []; if (existingShares.some(s => s.userId === userId)) { toast.info(`Task is already shared with this user`); return; } const newShares = [...existingShares, { userId, access }]; // Update task with new sharedWith await updateTask(taskId, { sharedWith: newShares }); toast.success(`Task shared successfully`); }; export const revokeShare = async (taskId: string, userId: string) => { if (!pb.authStore.isValid) return; const task = store.tasks.find(t => t.id === taskId); if (!task) return; const existingShares = task.sharedWith || []; const newShares = existingShares.filter(s => s.userId !== userId); await updateTask(taskId, { sharedWith: newShares }); toast.success(`Share revoked`); }; export const splitTask = async (taskId: string, userId: string) => { if (!pb.authStore.isValid) return; const task = store.tasks.find(t => t.id === taskId); if (!task) return; try { // Create a duplicate task owned by the target user const duplicateData = { user: userId, title: task.title, content: task.content, priority: task.priority, size: task.size, tags: task.tags, startDate: task.startDate, dueDate: task.dueDate, status: task.status, completed: task.completed, deletedAt: null, sharedWith: [], // Start fresh with no shares recurrence: task.recurrence }; await pb.collection(TASGRID_COLLECTION).create(duplicateData, { requestKey: null }); // Remove the user from the original task's sharedWith const existingShares = task.sharedWith || []; const newShares = existingShares.filter(s => s.userId !== userId); await updateTask(taskId, { sharedWith: newShares }); toast.success(`Task split - user now has their own copy`); } catch (err: any) { console.error("Failed to split task:", err); console.error("Error details:", err.response?.data || err.data || err.message); toast.error("Failed to split task: " + (err.response?.data?.message || err.message || "Unknown error")); } }; // -- Share Rules (for tag-based and all-tasks sharing) -- export const loadShareRules = async () => { if (!pb.authStore.isValid) return; try { const currentUserId = pb.authStore.model?.id; const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({ filter: `ownerId = "${currentUserId}" || targetUserId = "${currentUserId}"` }); const rules: ShareRule[] = records.map(r => ({ id: r.id, ownerId: r.ownerId, targetUserId: r.targetUserId, type: r.type as 'tag' | 'all', tagName: r.tagName, share_mode: r.share_mode || 'ADD_USER' })); setStore("shareRules", reconcile(rules)); } catch (err) { console.error("Failed to load share rules:", err); } }; export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string, share_mode: 'ADD_USER' | 'SEND_TASK' = 'ADD_USER') => { if (!pb.authStore.isValid) return; try { await pb.collection(SHARE_RULES_COLLECTION).create({ ownerId: pb.authStore.model?.id, targetUserId, type, tagName: type === 'tag' ? tagName : null, share_mode: share_mode || 'ADD_USER' }); // Realtime subscription will handle adding to store toast.success(`Share rule created`); } catch (err) { console.error("Failed to create share rule:", err); toast.error("Failed to create share rule. Make sure the collection exists in PocketBase."); } }; export const updateShareRule = async (ruleId: string, updates: Partial) => { if (!pb.authStore.isValid) return; try { await pb.collection(SHARE_RULES_COLLECTION).update(ruleId, updates); setStore("shareRules", (rules) => rules.map(r => r.id === ruleId ? { ...r, ...updates } : r) ); toast.success("Share rule 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(SHARE_RULES_COLLECTION).delete(ruleId); setStore("shareRules", (rules) => rules.filter(r => r.id !== ruleId)); toast.success("Share rule removed"); } catch (err) { console.error("Failed to delete share rule:", err); toast.error("Failed to delete share rule."); } }; // -- Filter Templates -- const FILTER_TEMPLATES_COLLECTION = 'TasGrid_FilterTemplates'; export const loadFilterTemplates = async () => { if (!pb.authStore.isValid) return; try { const records = await pb.collection(FILTER_TEMPLATES_COLLECTION).getFullList({ filter: `user = "${pb.authStore.model?.id}"`, requestKey: null }); const templates: FilterTemplate[] = records.map(r => ({ id: r.id, name: r.name, filter: r.filter as Filter })); setStore("filterTemplates", templates); } catch (err) { console.error("Failed to load filter templates:", err); } }; export const saveFilterTemplate = async (name: string) => { if (!pb.authStore.isValid) return; try { const record = await pb.collection(FILTER_TEMPLATES_COLLECTION).create({ user: pb.authStore.model?.id, name, filter: store.filter }, { requestKey: null }); const newTemplate: FilterTemplate = { id: record.id, name: record.name, filter: record.filter as Filter }; setStore("filterTemplates", prev => [...prev, newTemplate]); 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 { await pb.collection(FILTER_TEMPLATES_COLLECTION).delete(id); setStore("filterTemplates", prev => prev.filter(t => t.id !== id)); 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 }); }; // 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(); };