import { createStore, reconcile, produce } from "solid-js/store"; import { createSignal, createEffect, createRoot } from "solid-js"; import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase"; import { toast } from "solid-sonner"; const getStorageKey = () => { const userId = pb.authStore.model?.id; return userId ? `tasgrid_data_${userId}` : null; }; export const [now, setNow] = createSignal(Date.now()); export const [activeTaskId, setActiveTaskId] = createSignal(null); export type UrgencyLevel = number; export interface Task { id: string; title: string; startDate: string; dueDate: string; priority: number; // 1-10 completed: boolean; tags: string[]; content?: string; // HTML content from Tiptap deletedAt?: number; // Timestamp of soft delete } export interface Filter { query: string; tags: string[]; priorityMin: number; priorityMax: number; urgencyMin: number; urgencyMax: number; } interface TaskStore { tasks: Task[]; pWeight: number; uWeight: number; matrixScaleDays: number; prefId?: string; // ID of the user_preferences record tagDefinitions?: Record; filter: Filter; } // Initial empty state export const [store, setStore] = createStore({ tasks: [], pWeight: 1.0, uWeight: 1.0, matrixScaleDays: 30, tagDefinitions: {}, // Map filter: { query: "", tags: [], priorityMin: 1, priorityMax: 10, urgencyMin: 1, urgencyMax: 10 } }); export const matchesFilter = (task: Task) => { 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 (OR logic if multiple selected?) if (f.tags.length > 0) { const hasTag = f.tags.some(tag => task.tags?.includes(tag)); if (!hasTag) 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; 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); // Geometric formulation // Base is derived so that urgency 1 ~ matrixScaleDays const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9); // Reverse formula: Level = 10 - (ln(hours/24) / ln(base)) // If hours <= 24, log(<=1) is negative or 0, so Level >= 10. if (diffHours <= 24) return 10; const level = 10 - (Math.log(diffHours / 24) / Math.log(base)); return Math.max(1, Math.min(10, level)); }; export const calculateDateFromUrgency = (level: number): string => { // Inverse of above if (level >= 10) { return new Date(now() + 24 * 60 * 60 * 1000).toISOString(); } const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9); const hours = 24 * Math.pow(base, 10 - level); 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 defVal = store.tagDefinitions?.[tagName]; if (defVal !== undefined) { // Formula: (Value - 5) * 0.1 // 5 -> 0, 10 -> 0.5, 0 -> -0.5 baseScore += (defVal - 5) * 0.1; } }); } return baseScore; }; // -- Persistence & Sync -- let heartbeatInterval: number | undefined; 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()), 60000); try { // 1. Fetch Preferences from User Profile try { const userId = pb.authStore.model?.id; if (userId) { // Fetch fresh user record const user = await pb.collection('users').getOne(userId, { requestKey: null }); // Field: Taskgrid_pref (JSON) const prefs = user.Taskgrid_pref || {}; setStore({ pWeight: prefs.pWeight || 1.0, uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, tagDefinitions: prefs.tagDefinitions || {}, prefId: userId // The ID to update is the User ID }); } } catch (prefErr) { // Non-blocking error for preferences console.warn("Failed to load preferences from user profile:", prefErr); } // 2. Fetch Tasks - Explicitly filtered by user and sorted const records = await pb.collection(TASGRID_COLLECTION).getFullList({ filter: `user = "${pb.authStore.model?.id}"`, sort: '-created', }); // Map PB records to Task interface const loadedTasks: Task[] = records.map(r => ({ id: r.id, title: r.title, startDate: r.startDate, dueDate: r.dueDate, priority: r.priority, completed: r.completed, tags: r.tags || [], content: r.content, deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined })); // Use reconcile for efficient store update (keeps observers happy) setStore("tasks", reconcile(loadedTasks)); } catch (err) { // If we fail but have cached data, don't toast an error unless it's a hard failure if (store.tasks.length === 0) { console.error("Failed to load data:", err); toast.error("Failed to sync with server."); } else { console.warn("Sync failed, using cached data", err); } } }; // -- Actions -- export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string }) => { 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 tempId = "temp-" + Date.now(); const newTask: Task = { id: tempId, title, startDate, dueDate, priority, completed: false, tags, content }; // 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, completed: false, tags, content }); // Replace temp task with real record setStore("tasks", (t) => t.map(task => task.id === tempId ? { ...task, id: record.id } : 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 }); toast.success("Task duplicated"); }; export const updateTask = async (id: string, updates: Partial) => { if (!pb.authStore.isValid) return; // Optimistic setStore("tasks", (t) => t.id === id, updates); 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 = { ...updates }; if (updates.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 (updates.deletedAt) { pbUpdates.deletedAt = new Date(updates.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); // Sync to users collection if (store.prefId) { try { const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: days }; await pb.collection('users').update(store.prefId, { Taskgrid_pref: prefs }); } catch (err) { console.error("Failed to update preferences", err); } } }; export const toggleTask = (id: string) => { const task = store.tasks.find(t => t.id === id); if (task) { updateTask(id, { completed: !task.completed }); } }; export const removeTask = (id: string) => { // Soft delete const nowTs = Date.now(); updateTask(id, { deletedAt: nowTs }); }; export const restoreTask = (id: string) => { // This requires passing undefined or null to updateTask // createStore handles undefined ok. setStore("tasks", (t) => t.id === id, "deletedAt", undefined); // For PB, we need to send null explicitly for date clear? // updateTask handles this check. 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) => { // Optimistic update setStore("tagDefinitions", name, value); // Persist const map = { ...store.tagDefinitions }; const userId = pb.authStore.model?.id; if (userId) { try { const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, tagDefinitions: map }; await pb.collection('users').update(userId, { Taskgrid_pref: prefs }, { requestKey: null }); } catch (e: any) { // Ignore abort errors if (e.isAbort) return; console.error("Failed to persist tag definition", e); toast.error("Failed to save tag definition"); } } }; export const upsertMultipleTagDefinitions = async (updates: Record) => { // Optimistic update setStore("tagDefinitions", (defs) => ({ ...(defs || {}), ...updates })); // Persist const map = { ...store.tagDefinitions }; const userId = pb.authStore.model?.id; if (userId) { try { const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, tagDefinitions: map }; await pb.collection('users').update(userId, { Taskgrid_pref: prefs }, { requestKey: null }); } catch (e: any) { // Ignore abort errors if (e.isAbort) return; console.error("Failed to persist tag definitions", e); toast.error("Failed to save tag definitions"); } } }; export const removeTagDefinition = async (name: string) => { // 1. Remove from all tasks const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name)); for (const task of tasksWithTag) { const newTags = task.tags.filter(t => t !== name); updateTask(task.id, { tags: newTags }); } // 2. Remove definition locally setStore("tagDefinitions", produce((defs) => { if (defs) delete defs[name]; })); // 3. Persist const map = { ...store.tagDefinitions }; const userId = pb.authStore.model?.id; if (userId) { try { const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, tagDefinitions: map }; await pb.collection('users').update(userId, { Taskgrid_pref: prefs }); toast.success(`Tag "${name}" deleted`); } catch (e) { console.error("Failed to delete tag definition", e); toast.error("Failed to delete tag"); } } }; export const renameTagDefinition = async (oldName: string, newName: string) => { if (!newName || !newName.trim() || newName === oldName) return; const finalName = newName.trim(); // 1. Get current value const val = store.tagDefinitions?.[oldName] ?? 5; // 2. Update all tasks 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); // Dedupe const uniqueTags = [...new Set(newTags)]; updateTask(task.id, { tags: uniqueTags }); } // 3. Update definition locally setStore("tagDefinitions", produce((defs) => { if (defs) { defs[finalName] = val; delete defs[oldName]; } })); // 4. Persist const map = { ...store.tagDefinitions }; const userId = pb.authStore.model?.id; if (userId) { try { const prefs = { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, tagDefinitions: map }; await pb.collection('users').update(userId, { Taskgrid_pref: prefs }); toast.success(`Tag renamed to "${finalName}"`); } catch (e) { console.error("Failed to rename tag", e); toast.error("Failed to rename tag"); } } }; export const setFilter = (update: Partial) => { setStore("filter", (f) => ({ ...f, ...update })); }; export const clearFilter = () => { setStore("filter", { query: "", tags: [], priorityMin: 1, priorityMax: 10, urgencyMin: 1, urgencyMax: 10 }); }; // 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(); };