diff --git a/src/App.tsx b/src/App.tsx index 34eb022..3ec851c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,12 +30,16 @@ const App: Component = () => { window.addEventListener('popstate', handleLocChange); window.addEventListener('hashchange', handleLocChange); + let isFirstRun = true; const removeListener = pb.authStore.onChange((token) => { console.log('[DEBUG] pb.authStore.onChange fired, token present:', !!token); + const wasAuthenticated = isAuthenticated(); setIsAuthenticated(!!token); - if (token) { + + if (token && (isFirstRun || !wasAuthenticated)) { initStore(); } + isFirstRun = false; }, true); onCleanup(() => { diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index def21cd..93f672f 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -11,6 +11,7 @@ const Toaster = (props: ToasterProps) => { theme={theme() as ToasterProps["theme"]} class="toaster group" position="top-center" + closeButton toastOptions={{ classes: { toast: diff --git a/src/store/index.ts b/src/store/index.ts index dea4297..6c1f5e8 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -30,7 +30,7 @@ export interface Task { tags: string[]; ownerId: string | null; // Add ownerId to interface content?: string; // HTML content from Tiptap - deletedAt?: number; // Timestamp of soft delete + deletedAt?: number | null; // Timestamp of soft delete or null if restored created: string; // ISO string from PB updated: string; // ISO string from PB recurrence?: { @@ -202,6 +202,7 @@ interface TaskStore { subscribedBuckets: string[]; // IDs of buckets I'm subscribed to notes: Note[]; isNotepadMode: boolean; + quickloadTasks: string[]; } // Initial empty state @@ -227,7 +228,8 @@ export const [store, setStore] = createStore({ buckets: [], subscribedBuckets: [], notes: [], - isNotepadMode: false + isNotepadMode: false, + quickloadTasks: [] }); export const matchesFilter = (task: Task) => { @@ -336,6 +338,38 @@ createRoot(() => { 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 -- @@ -762,6 +796,45 @@ 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 = 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: `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) { @@ -795,7 +868,8 @@ export const initStore = async () => { uWeight: prefs.uWeight || 1.0, matrixScaleDays: prefs.matrixScaleDays || 30, prefId: userId, - subscribedBuckets: user.subscribedBuckets || [] + subscribedBuckets: user.subscribedBuckets || [], + quickloadTasks: prefs.quickloadTasks || [] }); } } catch (prefErr) { @@ -859,24 +933,6 @@ export const initStore = async () => { // --- WINDOWED SYNC STRATEGY --- - // STAGE 1: Instant Focus (Top 30 Incomplete - User Only) - // Goal: < 100ms First Paint. Uses 'idx_user_focused'. - try { - const focusRecords = await pb.collection(TASGRID_COLLECTION).getList(1, 30, { - filter: `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); - } // STAGE 2 & 3: Background Sync const backgroundSync = async () => { @@ -1387,16 +1443,13 @@ export const updateTask = async (id: string, updates: Partial) => { 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 ("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 + pbUpdates.deletedAt = null; // restore task } } @@ -1478,8 +1531,8 @@ export const removeTask = (id: string) => { 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 }); + // Pass null for deletedAt to explicitly clear it in PB + updateTask(id, { deletedAt: null }); }; export const deleteTaskPermanently = async (id: string) => { @@ -1956,7 +2009,8 @@ export const syncPreferences = async () => { pWeight: store.pWeight, uWeight: store.uWeight, matrixScaleDays: store.matrixScaleDays, - tagDefinitions: store.tagDefinitions + tagDefinitions: store.tagDefinitions, + quickloadTasks: store.quickloadTasks }; await pb.collection('users').update(userId, {