diff --git a/packages/landing/public/sw.js b/packages/landing/public/sw.js index d38484e27..9ba3b9999 100644 --- a/packages/landing/public/sw.js +++ b/packages/landing/public/sw.js @@ -74,7 +74,7 @@ self.addEventListener('install', event => { } const buildAssets = Array.from(discovered); - console.log('[SW] Caching', buildAssets.length, 'build assets'); + console.info('[SW] Caching', buildAssets.length, 'build assets'); for (const url of buildAssets) { try { diff --git a/packages/landing/scripts/version-sw.js b/packages/landing/scripts/version-sw.js index 4467d21bc..34c897b58 100644 --- a/packages/landing/scripts/version-sw.js +++ b/packages/landing/scripts/version-sw.js @@ -14,7 +14,7 @@ try { const buildTime = Date.now().toString(36); // Short, unique identifier const updated = content.replace(/__BUILD_TIME__/g, buildTime); writeFileSync(swPath, updated); - console.log(`[version-sw] Updated sw.js with build version: ${buildTime}`); + console.info(`[version-sw] Updated sw.js with build version: ${buildTime}`); } catch (error) { console.error('[version-sw] Failed to update sw.js:', error.message); process.exit(1); diff --git a/packages/web/package.json b/packages/web/package.json index 1e42c3177..97617bbd1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,8 +20,10 @@ "@corates/shared": "workspace:*", "@corates/ui": "workspace:*", "@solidjs/router": "^0.15.4", + "@tanstack/solid-query": "^5.90.18", "better-auth": "^1.4.9", "d3": "^7.9.0", + "idb": "^8.0.3", "pdfjs-dist": "^5.4.530", "solid-icons": "^1.1.0", "solid-js": "^1.9.10", diff --git a/packages/web/src/api/better-auth-store.js b/packages/web/src/api/better-auth-store.js index c3d4f302c..c03b81574 100644 --- a/packages/web/src/api/better-auth-store.js +++ b/packages/web/src/api/better-auth-store.js @@ -1,6 +1,7 @@ import { createSignal, createRoot, createEffect } from 'solid-js'; import { authClient, useSession } from '@api/auth-client.js'; -import projectStore from '@/stores/projectStore.js'; +import { queryClient } from '@lib/queryClient.js'; +import { queryKeys } from '@lib/queryKeys.js'; import { API_BASE, BASEPATH } from '@config/api.js'; import { saveLastLoginMethod, LOGIN_METHODS } from '@lib/lastLoginMethod.js'; import { @@ -204,21 +205,20 @@ function createBetterAuthStore() { // Wait a bit for session to update await new Promise(resolve => setTimeout(resolve, 100)); - // Validate and refresh project list if user is authenticated + // Invalidate project list query if user is authenticated const currentUser = user(); if (currentUser?.id) { - // Validate project list cache against current user - projectStore.validateProjectListCache(currentUser.id); - - // Refresh project list to ensure it's current + // Invalidate and refetch project list query to ensure it's current try { - await projectStore.refreshProjectList(currentUser.id); + await queryClient.invalidateQueries({ + queryKey: queryKeys.projects.list(currentUser.id), + }); } catch (err) { console.warn('[auth] Failed to refresh project list after visibility change:', err); } } else { - // User is not authenticated, clear project list - projectStore.clearProjectList(); + // User is not authenticated, clear query cache for all projects + queryClient.removeQueries({ queryKey: queryKeys.projects.all }); } } catch (err) { console.warn('[auth] Failed to refresh session on visibility change:', err); @@ -398,8 +398,8 @@ function createBetterAuthStore() { // Clear cached avatar from IndexedDB clearAvatarCache(); - // Clear cached project data on logout - projectStore.clearProjectList(); + // Clear query cache for all projects on logout + queryClient.removeQueries({ queryKey: queryKeys.projects.all }); // Refetch session to immediately clear it in current tab // This ensures session().data becomes null right away, preventing components @@ -704,7 +704,7 @@ function createBetterAuthStore() { } // Clear local data - projectStore.clearProjectList(); + queryClient.removeQueries({ queryKey: queryKeys.projects.all }); localStorage.removeItem('pendingEmail'); saveCachedAuth(null); setCachedUser(null); diff --git a/packages/web/src/api/google-drive.js b/packages/web/src/api/google-drive.js index 839dc346d..6f2aace86 100644 --- a/packages/web/src/api/google-drive.js +++ b/packages/web/src/api/google-drive.js @@ -49,7 +49,6 @@ export async function disconnectGoogleDrive() { * @returns {Promise<{success: boolean, file: Object}>} */ export async function importFromGoogleDrive(fileId, projectId, studyId) { - console.log('importing from google drive', fileId, projectId, studyId); const response = await fetch(`${API_BASE}/api/google-drive/import`, { method: 'POST', credentials: 'include', diff --git a/packages/web/src/components/admin/AdminDashboard.jsx b/packages/web/src/components/admin/AdminDashboard.jsx index 7cbf06f37..a3ab3ac75 100644 --- a/packages/web/src/components/admin/AdminDashboard.jsx +++ b/packages/web/src/components/admin/AdminDashboard.jsx @@ -2,7 +2,7 @@ * Admin Dashboard - Main admin panel page */ -import { createSignal, createResource, Show, onMount } from 'solid-js'; +import { createSignal, Show, onMount } from 'solid-js'; import { useNavigate, A } from '@solidjs/router'; import { FiUsers, @@ -16,13 +16,8 @@ import { FiAlertCircle, FiDatabase, } from 'solid-icons/fi'; -import { - isAdmin, - isAdminChecked, - checkAdminStatus, - fetchStats, - fetchUsers, -} from '@/stores/adminStore.js'; +import { isAdmin, isAdminChecked, checkAdminStatus } from '@/stores/adminStore.js'; +import { useAdminStats, useAdminUsers } from '@primitives/useAdminQueries.js'; import UserTable from './UserTable.jsx'; import StatsCard from './StatsCard.jsx'; @@ -40,14 +35,17 @@ export default function AdminDashboard() { } }); - // Fetch stats - const [stats] = createResource(fetchStats); + // Fetch stats using TanStack Query + const statsQuery = useAdminStats(); + const stats = () => statsQuery.data; - // Fetch users with pagination and search - const [usersData, { refetch: refetchUsers }] = createResource( - () => ({ page: page(), search: debouncedSearch() }), - fetchUsers, - ); + // Fetch users with pagination and search using TanStack Query + const usersDataQuery = useAdminUsers(() => ({ + page: page(), + limit: 20, + search: debouncedSearch(), + })); + const usersData = () => usersDataQuery.data; // Debounce search let searchTimeout; @@ -61,7 +59,7 @@ export default function AdminDashboard() { }; const handleRefresh = () => { - refetchUsers(); + usersDataQuery.refetch(); }; return ( @@ -111,28 +109,28 @@ export default function AdminDashboard() { value={stats()?.users ?? '-'} icon={FiUsers} color='blue' - loading={stats.loading} + loading={statsQuery.isLoading} /> diff --git a/packages/web/src/components/admin/StorageManagement.jsx b/packages/web/src/components/admin/StorageManagement.jsx index d4b4afa5a..654ba3f41 100644 --- a/packages/web/src/components/admin/StorageManagement.jsx +++ b/packages/web/src/components/admin/StorageManagement.jsx @@ -2,7 +2,7 @@ * Storage Management - Admin interface for managing R2 documents */ -import { createSignal, createResource, Show, For, onMount } from 'solid-js'; +import { createSignal, Show, For, onMount } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { FiTrash2, @@ -18,9 +18,9 @@ import { isAdmin, isAdminChecked, checkAdminStatus, - fetchStorageDocuments, deleteStorageDocuments, } from '@/stores/adminStore.js'; +import { useStorageDocuments } from '@primitives/useAdminQueries.js'; import { Dialog, showToast } from '@corates/ui'; function formatFileSize(bytes) { @@ -63,11 +63,14 @@ export default function StorageManagement() { } }); - // Fetch documents with cursor-based pagination - const [documentsData, { refetch: refetchDocuments }] = createResource( - () => ({ cursor: cursor(), limit, prefix: prefix(), search: debouncedSearch() }), - fetchStorageDocuments, - ); + // Fetch documents with cursor-based pagination using TanStack Query + const documentsDataQuery = useStorageDocuments(() => ({ + cursor: cursor(), + limit, + prefix: prefix(), + search: debouncedSearch(), + })); + const documentsData = () => documentsDataQuery.data; // Debounce search let searchTimeout; @@ -158,7 +161,7 @@ export default function StorageManagement() { showToast.success('Documents Deleted', `Successfully deleted ${result.deleted} documents.`); } - refetchDocuments(); + documentsDataQuery.refetch(); } catch (error) { showToast.error('Delete Failed', error.message || 'Failed to delete documents'); } finally { @@ -336,7 +339,7 @@ export default function StorageManagement() { diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx index 82ba508e1..8984305ee 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.jsx @@ -336,7 +336,6 @@ export default function ChecklistYjsWrapper() { // Determine back button navigation from tab query param const getBackTab = () => { - // console.log('location', location.search); const tabFromUrl = new URLSearchParams(location.search).get('tab'); return tabFromUrl || 'overview'; }; diff --git a/packages/web/src/components/profile/ProfilePage.jsx b/packages/web/src/components/profile/ProfilePage.jsx index 30d0dbb28..0b0939089 100644 --- a/packages/web/src/components/profile/ProfilePage.jsx +++ b/packages/web/src/components/profile/ProfilePage.jsx @@ -153,7 +153,7 @@ export default function ProfilePage() { maxSize: AVATAR_MAX_SIZE, quality: AVATAR_QUALITY, }); - console.log( + console.info( `Image compressed: ${(file.size / 1024).toFixed(1)}KB -> ${(compressedFile.size / 1024).toFixed(1)}KB`, ); diff --git a/packages/web/src/components/project/ProjectDashboard.jsx b/packages/web/src/components/project/ProjectDashboard.jsx index ec9459329..6ae6dbc94 100644 --- a/packages/web/src/components/project/ProjectDashboard.jsx +++ b/packages/web/src/components/project/ProjectDashboard.jsx @@ -7,6 +7,9 @@ import projectActionsStore from '@/stores/projectActionsStore'; import { useConfirmDialog, showToast } from '@corates/ui'; import { useBetterAuth } from '@api/better-auth-store.js'; import { useSubscription } from '@primitives/useSubscription.js'; +import { useProjectList } from '@primitives/useProjectList.js'; +import { useQueryClient } from '@tanstack/solid-query'; +import { queryKeys } from '@lib/queryKeys.js'; import { isUnlimitedQuota } from '@corates/shared/plans'; import CreateProjectForm from './CreateProjectForm.jsx'; import ProjectCard from './ProjectCard.jsx'; @@ -26,16 +29,22 @@ export default function ProjectDashboard(props) { const { hasEntitlement, hasQuota, quotas, loading: subscriptionLoading } = useSubscription(); const userId = () => props.userId; + const queryClient = useQueryClient(); - // Read from store - const projects = () => projectStore.getProjectList(); + // Use TanStack Query for project list + const projectListQuery = useProjectList(userId); + const projects = () => projectListQuery.projects(); const projectCount = () => projects()?.length || 0; - const isLoading = () => projectStore.isProjectListLoading(); - const isLoaded = () => projectStore.isProjectListLoaded(); - const error = () => projectStore.getProjectListError(); + const isLoading = () => projectListQuery.isLoading(); + // isLoaded is true when we have successfully fetched data (not just when not loading) + const isLoaded = () => projectListQuery.query.isSuccess; + const error = () => projectListQuery.error(); // Check both entitlement and quota + // Return null while loading to avoid UI flicker (neither show button nor ContactPrompt) const canCreateProject = () => { + // While loading, return null to indicate indeterminate state + if (subscriptionLoading()) return null; return ( hasEntitlement('project.create') && hasQuota('projects.max', { used: projectCount(), requested: 1 }) @@ -43,7 +52,10 @@ export default function ProjectDashboard(props) { }; // Determine restriction type and quota limit for ContactPrompt + // Only show ContactPrompt if subscription has loaded and user doesn't have entitlement const restrictionType = () => { + // Don't show prompt while loading (prevents showing default 'free' tier data) + if (subscriptionLoading()) return null; return !hasEntitlement('project.create') ? 'entitlement' : 'quota'; }; @@ -58,19 +70,14 @@ export default function ProjectDashboard(props) { return err && (err.includes('No internet connection') || err.includes('connection error')); }; - // Fetch on mount if not already loaded - createEffect(() => { - if (userId()) { - projectStore.fetchProjectList(userId()); - } - }); + // Project list is automatically fetched by useProjectList hook // Connect to notifications for real-time project updates const { connect, disconnect } = useNotifications(userId(), { onNotification: async notification => { if (notification.type === 'project-invite') { - // Refresh to get the new project - projectStore.refreshProjectList(userId()); + // Invalidate project list query to refetch + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(userId()) }); } else if (notification.type === 'removed-from-project') { // Clean up local data for the project we were removed from await cleanupProjectLocalData(notification.projectId); @@ -107,7 +114,8 @@ export default function ProjectDashboard(props) { pendingRefs = [], driveFiles = [], ) => { - projectStore.addProjectToList(newProject); + // Invalidate project list query to refetch with new project + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(userId()) }); // Store non-serializable data in projectStore instead of router state if (pendingPdfs.length > 0 || pendingRefs.length > 0 || driveFiles.length > 0) { projectStore.setPendingProjectData(newProject.id, { pendingPdfs, pendingRefs, driveFiles }); @@ -151,35 +159,41 @@ export default function ProjectDashboard(props) {

My Projects

Manage your research projects

+ {/* Show loading skeleton while subscription loads, then show button or ContactPrompt */} - - - } + when={canCreateProject() !== null} + fallback={
} > -
+ } > - + - New Project - + +
{/* Error display */}
- {error()} -
diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx index b1b8cde71..ba17843b6 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.jsx @@ -137,7 +137,6 @@ export default function GoogleDrivePickerLauncher(props) { const picked = await openPicker({ multiselect: !!props.multiselect }); if (!picked || picked.length === 0) return; - console.log('picked', picked, studyId); await props.onPick?.(picked, studyId); } catch { // primitive sets error diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.jsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.jsx index 977fbe3ea..2ab22df18 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.jsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.jsx @@ -27,7 +27,6 @@ export default function GoogleDrivePickerModal(props) { (async () => { try { - console.log('importing from google drive', file.id, projectId(), studyId); setImporting(true); const result = await importFromGoogleDrive(file.id, projectId(), studyId); showToast.success( diff --git a/packages/web/src/components/sidebar/Sidebar.jsx b/packages/web/src/components/sidebar/Sidebar.jsx index 44e2bda0e..996788b3d 100644 --- a/packages/web/src/components/sidebar/Sidebar.jsx +++ b/packages/web/src/components/sidebar/Sidebar.jsx @@ -1,8 +1,8 @@ -import { Show, For, createSignal, createEffect } from 'solid-js'; +import { Show, For, createSignal } from 'solid-js'; import { useNavigate, useLocation } from '@solidjs/router'; import { useBetterAuth } from '@api/better-auth-store.js'; import { useLocalChecklists } from '@primitives/useLocalChecklists.js'; -import projectStore from '@/stores/projectStore.js'; +import { useProjectList } from '@primitives/useProjectList.js'; import { useConfirmDialog } from '@corates/ui'; import { AiOutlineFolder } from 'solid-icons/ai'; import { AiOutlineCloud } from 'solid-icons/ai'; @@ -26,24 +26,17 @@ export default function Sidebar(props) { const [expandedProjects, setExpandedProjects] = createSignal({}); const [expandedStudies, setExpandedStudies] = createSignal({}); - // Read cloud projects from the store (same data as dashboard) - const cloudProjects = () => projectStore.getProjectList(); - const isProjectsLoading = () => projectStore.isProjectListLoading(); + // Use TanStack Query for project list + const projectListQuery = useProjectList(currentUserId, { + enabled: () => isLoggedIn(), + }); + const cloudProjects = () => projectListQuery.projects(); + const isProjectsLoading = () => projectListQuery.isLoading(); // Confirm dialog for delete actions const confirmDialog = useConfirmDialog(); const [_pendingDeleteId, setPendingDeleteId] = createSignal(null); - // Fetch projects if user is logged in - createEffect(() => { - const userId = user()?.id; - // Only fetch if logged in AND userId exists - if (!isLoggedIn() || !userId) { - return; - } - projectStore.fetchProjectList(userId); - }); - const toggleProject = projectId => { setExpandedProjects(prev => ({ ...prev, diff --git a/packages/web/src/lib/bfcache-handler.js b/packages/web/src/lib/bfcache-handler.js index e8d5c5106..fa148006c 100644 --- a/packages/web/src/lib/bfcache-handler.js +++ b/packages/web/src/lib/bfcache-handler.js @@ -6,7 +6,8 @@ */ import { useBetterAuth } from '@api/better-auth-store.js'; -import projectStore from '@/stores/projectStore.js'; +import { queryClient } from '@lib/queryClient.js'; +import { queryKeys } from '@lib/queryKeys.js'; /** * Initialize bfcache restoration handler @@ -22,7 +23,7 @@ export function initBfcacheHandler() { // event.persisted === true means the page was restored from bfcache if (!event.persisted) return; - console.log('[bfcache] Page restored from back-forward cache, refreshing state...'); + console.info('[bfcache] Page restored from back-forward cache, refreshing state...'); // Wait for auth to finish loading if it's currently loading // This ensures we have the current user before validating project cache @@ -58,21 +59,18 @@ export function initBfcacheHandler() { // Wait a bit for session to update await new Promise(resolve => setTimeout(resolve, 100)); - // Validate and refresh project list if user is authenticated + // Invalidate project list query if user is authenticated const currentUser = auth.user(); if (currentUser?.id) { - // Validate project list cache against current user - projectStore.validateProjectListCache(currentUser.id); - - // Refresh project list to ensure it's current + // Invalidate and refetch project list query to ensure it's current try { - await projectStore.refreshProjectList(currentUser.id); + await queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(currentUser.id) }); } catch (err) { console.warn('[bfcache] Failed to refresh project list:', err); } } else { - // User is not authenticated, clear project list - projectStore.clearProjectList(); + // User is not authenticated, clear query cache for all projects + queryClient.removeQueries({ queryKey: queryKeys.projects.all }); } }; diff --git a/packages/web/src/lib/queryClient.js b/packages/web/src/lib/queryClient.js new file mode 100644 index 000000000..369e66daf --- /dev/null +++ b/packages/web/src/lib/queryClient.js @@ -0,0 +1,238 @@ +/** + * TanStack Query Client Configuration + * Configured with offline-first defaults and IndexedDB persistence + */ + +import { QueryClient } from '@tanstack/solid-query'; +import { createIDBPersister } from './queryPersister.js'; + +let queryClientInstance = null; + +// Maximum age for persisted cache data (24 hours) +const MAX_CACHE_AGE_MS = 24 * 60 * 60 * 1000; + +// LocalStorage key for critical cache state (fallback for beforeunload) +const CACHE_SNAPSHOT_KEY = 'corates-query-cache-snapshot'; + +/** + * Initialize persistence for the query client + * Sets up automatic persistence of the query cache to IndexedDB + * @param {QueryClient} queryClient - The QueryClient instance to persist + * @returns {Function} Cleanup function (intentionally not called since client is singleton) + */ +async function setupPersistence(queryClient) { + const persister = createIDBPersister(); + + // Restore cache on initialization + try { + const persistedClient = await persister.restoreClient(); + if (persistedClient) { + const now = Date.now(); + const cacheTimestamp = persistedClient.timestamp || 0; + + // Validate cache age - skip if older than 24 hours + if (now - cacheTimestamp > MAX_CACHE_AGE_MS) { + console.info('[queryClient] Persisted cache expired, skipping restoration'); + await persister.removeClient(); + } else if (persistedClient.clientState?.queries) { + // Restore queries, validating each query's data age + for (const query of persistedClient.clientState.queries) { + const queryAge = now - (query.state?.dataUpdatedAt || 0); + + // Skip queries older than max age or with error status + if (queryAge > MAX_CACHE_AGE_MS || query.state?.status === 'error') { + continue; + } + + // Only restore if query doesn't already have fresher data + const existingQuery = queryClient.getQueryData(query.queryKey); + if (!existingQuery) { + queryClient.setQueryData(query.queryKey, query.state.data); + } + } + console.info( + '[queryClient] Restored persisted cache from', + new Date(cacheTimestamp).toISOString(), + ); + } + } + } catch (error) { + console.warn('Failed to restore persisted query cache:', error); + } + + // Set up periodic persistence (debounced) + let persistTimeout = null; + const persistCache = async () => { + if (persistTimeout) { + clearTimeout(persistTimeout); + } + persistTimeout = setTimeout(async () => { + try { + const queryCache = queryClient.getQueryCache(); + const mutationCache = queryClient.getMutationCache(); + + // Build persisted client state + const persistedClient = { + clientState: { + queries: Array.from(queryCache.getAll()).map(query => ({ + queryKey: query.queryKey, + queryHash: query.queryHash, + state: { + data: query.state.data, + dataUpdatedAt: query.state.dataUpdatedAt, + error: query.state.error, + errorUpdatedAt: query.state.errorUpdatedAt, + status: query.state.status, + fetchStatus: query.state.fetchStatus, + }, + })), + mutations: Array.from(mutationCache.getAll()).map(mutation => ({ + mutationKey: mutation.options.mutationKey, + state: { + status: mutation.state.status, + data: mutation.state.data, + error: mutation.state.error, + }, + })), + }, + timestamp: Date.now(), + }; + + await persister.persistClient(persistedClient); + } catch (error) { + console.error('Failed to persist query cache:', error); + } + }, 1000); // Debounce by 1 second + }; + + // Persist on cache updates + const unsubscribeQueries = queryClient.getQueryCache().subscribe(() => { + persistCache(); + }); + + const unsubscribeMutations = queryClient.getMutationCache().subscribe(() => { + persistCache(); + }); + + // Persist on window unload + // Use synchronous localStorage as fallback since async IndexedDB may not complete + const handleBeforeUnload = () => { + persistTimeout && clearTimeout(persistTimeout); + + // Synchronous localStorage write as fallback for critical data + // IndexedDB async write may not complete before page unload + try { + const queryCache = queryClient.getQueryCache(); + const criticalQueries = Array.from(queryCache.getAll()) + .filter(q => q.state.status === 'success' && q.state.data) + .slice(0, 10) // Limit to avoid localStorage quota issues + .map(q => ({ + queryKey: q.queryKey, + data: q.state.data, + dataUpdatedAt: q.state.dataUpdatedAt, + })); + + localStorage.setItem( + CACHE_SNAPSHOT_KEY, + JSON.stringify({ queries: criticalQueries, timestamp: Date.now() }), + ); + } catch { + // Silently fail - localStorage may be full or unavailable + } + + // Still try async persist (may complete if unload is slow) + persistCache(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', handleBeforeUnload); + + // Try to restore from localStorage snapshot on init (covers cases where IndexedDB didn't persist) + try { + const snapshot = localStorage.getItem(CACHE_SNAPSHOT_KEY); + if (snapshot) { + const { queries, timestamp } = JSON.parse(snapshot); + const now = Date.now(); + if (now - timestamp < MAX_CACHE_AGE_MS) { + for (const q of queries) { + if (!queryClient.getQueryData(q.queryKey)) { + queryClient.setQueryData(q.queryKey, q.data); + } + } + } + // Clear snapshot after restoration + localStorage.removeItem(CACHE_SNAPSHOT_KEY); + } + } catch { + // Silently fail + } + } + + // Return cleanup function + // Note: This cleanup is intentionally not called since queryClient is a singleton + // that lives for the entire app lifecycle. The subscriptions and event listeners + // are only cleaned up when the browser tab/window is closed. + return () => { + unsubscribeQueries(); + unsubscribeMutations(); + if (persistTimeout) { + clearTimeout(persistTimeout); + } + if (typeof window !== 'undefined') { + window.removeEventListener('beforeunload', handleBeforeUnload); + } + }; +} + +/** + * Create and configure QueryClient instance (singleton) + * @returns {QueryClient} Configured QueryClient + */ +export function getQueryClient() { + if (queryClientInstance) { + return queryClientInstance; + } + + queryClientInstance = new QueryClient({ + defaultOptions: { + queries: { + // Offline-first: try cache first, then network + networkMode: 'offlineFirst', + // Stale time: data is considered fresh for 5 minutes + staleTime: 1000 * 60 * 5, + // Cache time: unused data is kept in cache for 10 minutes + gcTime: 1000 * 60 * 10, + // Retry failed requests up to 3 times + retry: 3, + // Retry delay with exponential backoff + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + // Refetch on window focus (helps keep data fresh) + refetchOnWindowFocus: true, + // Refetch on reconnect (important for offline support) + refetchOnReconnect: true, + // Refetch on mount if data is stale + refetchOnMount: true, + }, + mutations: { + // Retry mutations once + retry: 1, + // Network mode for mutations: always try network + networkMode: 'online', + }, + }, + }); + + // Set up persistence (async, but don't block) + if (typeof window !== 'undefined') { + setupPersistence(queryClientInstance).catch(err => { + console.warn('Failed to set up query persistence:', err); + }); + } + + return queryClientInstance; +} + +/** + * Export the singleton queryClient instance + */ +export const queryClient = getQueryClient(); diff --git a/packages/web/src/lib/queryKeys.js b/packages/web/src/lib/queryKeys.js new file mode 100644 index 000000000..cf4ace061 --- /dev/null +++ b/packages/web/src/lib/queryKeys.js @@ -0,0 +1,37 @@ +/** + * Centralized Query Key Factory + * + * Provides consistent query keys across the application to prevent + * cache invalidation bugs from inconsistent key usage. + */ + +export const queryKeys = { + // Project queries + projects: { + /** All projects (for removal operations) */ + all: ['projects'], + /** Projects for a specific user */ + list: userId => ['projects', userId], + }, + + // Subscription queries + subscription: { + /** Current user's subscription */ + current: ['subscription'], + }, + + // Admin queries + admin: { + stats: ['adminStats'], + users: (page, limit, search) => ['adminUsers', page, limit, search], + userDetails: userId => ['adminUserDetails', userId], + storageDocuments: (cursor, limit, prefix, search) => [ + 'storageDocuments', + cursor, + limit, + prefix, + search, + ], + storageStats: ['storageStats'], + }, +}; diff --git a/packages/web/src/lib/queryPersister.js b/packages/web/src/lib/queryPersister.js new file mode 100644 index 000000000..9a37cedb0 --- /dev/null +++ b/packages/web/src/lib/queryPersister.js @@ -0,0 +1,80 @@ +/** + * TanStack Query IndexedDB Persister + * + * Persists query cache to IndexedDB for local-first offline support. + */ + +import { openDB } from 'idb'; + +const DB_NAME = 'corates-query-cache'; +const DB_VERSION = 1; +const STORE_NAME = 'queryCache'; +const CACHE_KEY = 'queryClient'; + +// Shared database instance promise +let dbPromise = null; + +/** + * Get or create the IndexedDB database + */ +function getDB() { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }, + }); + } + return dbPromise; +} + +/** + * Create an IndexedDB persister for TanStack Query + * Implements the persister interface expected by TanStack Query + * @returns {Object} Persister object with persistClient, restoreClient, removeClient methods + */ +export function createIDBPersister() { + return { + /** + * Persist query client state to IndexedDB + * @param {Object} client - The persisted client state + */ + persistClient: async client => { + try { + const db = await getDB(); + await db.put(STORE_NAME, client, CACHE_KEY); + } catch (error) { + console.error('Failed to persist query client to IndexedDB:', error); + } + }, + + /** + * Restore query client state from IndexedDB + * @returns {Promise} The persisted client state or null + */ + restoreClient: async () => { + try { + const db = await getDB(); + const client = await db.get(STORE_NAME, CACHE_KEY); + return client || null; + } catch (error) { + console.error('Failed to restore query client from IndexedDB:', error); + return null; + } + }, + + /** + * Remove persisted query client state from IndexedDB + */ + removeClient: async () => { + try { + const db = await getDB(); + await db.delete(STORE_NAME, CACHE_KEY); + } catch (error) { + console.error('Failed to remove query client from IndexedDB:', error); + } + }, + }; +} diff --git a/packages/web/src/main.jsx b/packages/web/src/main.jsx index 7bffb8506..5b5e54f0b 100644 --- a/packages/web/src/main.jsx +++ b/packages/web/src/main.jsx @@ -4,6 +4,8 @@ import Routes from './Routes.jsx'; import { cleanupExpiredStates } from '@lib/formStatePersistence.js'; import { initBfcacheHandler } from '@lib/bfcache-handler.js'; import AppErrorBoundary from './components/ErrorBoundary.jsx'; +import { QueryClientProvider } from '@tanstack/solid-query'; +import { queryClient } from '@lib/queryClient.js'; // Clean up any expired form state entries from IndexedDB on app load cleanupExpiredStates().catch(() => { @@ -17,9 +19,11 @@ initBfcacheHandler(); function Root() { return ( - - - + + + + + ); } diff --git a/packages/web/src/primitives/useAdminQueries.js b/packages/web/src/primitives/useAdminQueries.js new file mode 100644 index 000000000..9964f21a5 --- /dev/null +++ b/packages/web/src/primitives/useAdminQueries.js @@ -0,0 +1,114 @@ +/** + * Admin queries using TanStack Query + * Provides hooks for admin dashboard data fetching + */ + +import { useQuery } from '@tanstack/solid-query'; +import { API_BASE } from '@config/api.js'; +import { queryKeys } from '@lib/queryKeys.js'; + +/** + * Helper for admin fetch calls + */ +async function adminFetch(path, options = {}) { + const response = await fetch(`${API_BASE}/api/admin/${path}`, { + credentials: 'include', + ...options, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || `Failed to fetch ${path}`); + } + return response.json(); +} + +/** + * Hook to fetch admin dashboard stats + */ +export function useAdminStats() { + return useQuery(() => ({ + queryKey: queryKeys.admin.stats, + queryFn: () => adminFetch('stats'), + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 5, // 5 minutes + })); +} + +/** + * Hook to fetch users with pagination and search + * @param {() => {page: number, limit: number, search: string}} getParams - Function returning params + */ +export function useAdminUsers(getParams) { + return useQuery(() => { + const params = typeof getParams === 'function' ? getParams() : getParams; + const page = params?.page ?? 1; + const limit = params?.limit ?? 20; + const search = params?.search ?? ''; + return { + queryKey: queryKeys.admin.users(page, limit, search), + queryFn: () => { + const searchParams = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + if (search) searchParams.set('search', search); + return adminFetch(`users?${searchParams.toString()}`); + }, + staleTime: 1000 * 60 * 1, // 1 minute + gcTime: 1000 * 60 * 5, // 5 minutes + }; + }); +} + +/** + * Hook to fetch single user details + */ +export function useAdminUserDetails(userId) { + return useQuery(() => ({ + queryKey: queryKeys.admin.userDetails(userId), + queryFn: () => adminFetch(`users/${userId}`), + enabled: !!userId, + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 5, // 5 minutes + })); +} + +/** + * Hook to fetch storage documents with cursor-based pagination + * @param {() => {cursor: string|null, limit: number, prefix: string, search: string}} getParams - Function returning params + */ +export function useStorageDocuments(getParams) { + return useQuery(() => { + const params = typeof getParams === 'function' ? getParams() : getParams; + const cursor = params?.cursor ?? null; + const limit = params?.limit ?? 50; + const prefix = params?.prefix ?? ''; + const search = params?.search ?? ''; + return { + queryKey: queryKeys.admin.storageDocuments(cursor, limit, prefix, search), + queryFn: () => { + const searchParams = new URLSearchParams({ + limit: limit.toString(), + }); + if (cursor) searchParams.set('cursor', cursor); + if (prefix) searchParams.set('prefix', prefix); + if (search) searchParams.set('search', search); + return adminFetch(`storage/documents?${searchParams.toString()}`); + }, + staleTime: 1000 * 60 * 1, // 1 minute + gcTime: 1000 * 60 * 5, // 5 minutes + }; + }); +} + +/** + * Hook to fetch storage statistics + */ +export function useStorageStats() { + return useQuery(() => ({ + queryKey: queryKeys.admin.storageStats, + queryFn: () => adminFetch('storage/stats'), + staleTime: 1000 * 60 * 2, // 2 minutes + gcTime: 1000 * 60 * 5, // 5 minutes + })); +} diff --git a/packages/web/src/primitives/useProject/index.js b/packages/web/src/primitives/useProject/index.js index 61b4bdd5e..e97b70dfd 100644 --- a/packages/web/src/primitives/useProject/index.js +++ b/packages/web/src/primitives/useProject/index.js @@ -7,7 +7,9 @@ import { createEffect, onCleanup, createMemo } from 'solid-js'; import * as Y from 'yjs'; import { IndexeddbPersistence } from 'y-indexeddb'; -import projectStore, { registerStaleProjectCleanup } from '@/stores/projectStore.js'; +import projectStore from '@/stores/projectStore.js'; +import { queryClient } from '@lib/queryClient.js'; +import { queryKeys } from '@lib/queryKeys.js'; import projectActionsStore from '@/stores/projectActionsStore'; import useOnlineStatus from '../useOnlineStatus.js'; import { createConnectionManager } from './connection.js'; @@ -131,14 +133,12 @@ export async function cleanupProjectLocalData(projectId) { console.error('Failed to delete IndexedDB for project:', projectId, err); } - // 3. Clear from projectStore (in-memory + localStorage cache) + // 3. Clear from projectStore (in-memory cache) projectStore.clearProject(projectId); - projectStore.removeProjectFromList(projectId); -} -// Register the cleanup function with projectStore for stale project reconciliation -// This happens at module load time to avoid circular dependency issues -registerStaleProjectCleanup(cleanupProjectLocalData); + // 4. Invalidate project list query + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); +} /** * Hook to connect to a project's Y.Doc and manage studies/checklists diff --git a/packages/web/src/primitives/useProject/studies.js b/packages/web/src/primitives/useProject/studies.js index 17cc7077d..b1b924c71 100644 --- a/packages/web/src/primitives/useProject/studies.js +++ b/packages/web/src/primitives/useProject/studies.js @@ -5,6 +5,8 @@ import * as Y from 'yjs'; import { API_BASE } from '@config/api.js'; import projectStore from '@/stores/projectStore.js'; +import { queryClient } from '@lib/queryClient.js'; +import { queryKeys } from '@lib/queryKeys.js'; /** * Creates study operations @@ -174,10 +176,8 @@ export function createStudyOperations(projectId, getYDoc, isSynced) { projectStore.setProjectData(projectId, { meta: { ...existingMeta, name: trimmed, updatedAt: now }, }); - projectStore.updateProjectInList(projectId, { - name: trimmed, - updatedAt: new Date(now), - }); + // Invalidate project list query to refetch with updated name + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); return trimmed; } diff --git a/packages/web/src/primitives/useProjectList.js b/packages/web/src/primitives/useProjectList.js new file mode 100644 index 000000000..4ea1d22ef --- /dev/null +++ b/packages/web/src/primitives/useProjectList.js @@ -0,0 +1,118 @@ +/** + * useProjectList hook - Fetches and manages project list with TanStack Query + * Includes IndexedDB persistence for offline support + */ + +import { useQuery } from '@tanstack/solid-query'; +import { API_BASE } from '@config/api.js'; +import { queryKeys } from '@lib/queryKeys.js'; +import projectStore from '@/stores/projectStore.js'; + +// Track failed cleanup attempts for retry on subsequent fetches +const failedCleanupQueue = new Set(); + +let cleanupProjectLocalData = null; +async function getCleanupFunction() { + if (!cleanupProjectLocalData) { + const module = await import('@/primitives/useProject/index.js'); + cleanupProjectLocalData = module.cleanupProjectLocalData; + } + return cleanupProjectLocalData; +} + +/** + * Attempt to clean up a stale project, tracking failures for retry + * @param {string} projectId - Project ID to clean up + * @param {Function} cleanupFn - Cleanup function + */ +async function attemptCleanup(projectId, cleanupFn) { + try { + await cleanupFn(projectId); + // Remove from failed queue on success (in case it was a retry) + failedCleanupQueue.delete(projectId); + } catch (err) { + console.error('Failed to clean up stale project:', projectId, err); + // Track for retry on next fetch + failedCleanupQueue.add(projectId); + } +} + +/** + * Fetch projects from API + * @param {string} userId - User ID + * @returns {Promise} Array of projects + */ +async function fetchProjects(userId) { + if (!userId) { + return []; + } + + const response = await fetch(`${API_BASE}/api/users/${userId}/projects`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + + const projects = await response.json(); + + // Reconcile: clean up local data for projects no longer in the server list + // This handles cases where user was removed or project was deleted while offline + const serverProjectIds = new Set(projects.map(p => p.id)); + const cachedProjectIds = Object.keys(projectStore.store.projects); + + const cleanupFn = await getCleanupFunction(); + + // Retry previously failed cleanups first + for (const failedId of failedCleanupQueue) { + if (!serverProjectIds.has(failedId) && cleanupFn) { + attemptCleanup(failedId, cleanupFn); + } else { + // Project is now in server list or cleanup not available, remove from queue + failedCleanupQueue.delete(failedId); + } + } + + // Clean up newly stale projects + for (const cachedId of cachedProjectIds) { + if (!serverProjectIds.has(cachedId) && cleanupFn && !failedCleanupQueue.has(cachedId)) { + attemptCleanup(cachedId, cleanupFn); + } + } + + return projects; +} + +/** + * Hook to fetch and manage project list with TanStack Query + * @param {() => string | null | undefined} userId - Reactive user ID signal/function + * @param {Object} options - Query options + * @param {boolean | (() => boolean)} options.enabled - Whether the query should be enabled (default: true if userId exists) + * @returns {Object} Query state and helpers + */ +export function useProjectList(userId, options = {}) { + const query = useQuery(() => { + const currentUserId = typeof userId === 'function' ? userId() : userId; + // Support both static boolean and getter function for enabled option + const enabledOption = + typeof options.enabled === 'function' ? options.enabled() : options.enabled; + return { + queryKey: queryKeys.projects.list(currentUserId), + queryFn: () => fetchProjects(currentUserId), + enabled: enabledOption !== false && !!currentUserId, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes + }; + }); + + return { + projects: () => query.data || [], + isLoading: () => query.isLoading || query.isFetching, + isError: () => query.isError, + error: () => query.error, + refetch: () => query.refetch(), + query, + }; +} diff --git a/packages/web/src/primitives/useSubscription.js b/packages/web/src/primitives/useSubscription.js index c33b06399..24bb526bc 100644 --- a/packages/web/src/primitives/useSubscription.js +++ b/packages/web/src/primitives/useSubscription.js @@ -3,8 +3,10 @@ * Manages subscription state and provides permission helpers */ -import { createResource, createMemo } from 'solid-js'; +import { createMemo } from 'solid-js'; +import { useQuery, useQueryClient } from '@tanstack/solid-query'; import { API_BASE } from '@config/api.js'; +import { queryKeys } from '@lib/queryKeys.js'; import { handleFetchError } from '@/lib/error-utils.js'; import { hasActiveAccess as checkActiveAccess } from '@/lib/access.js'; import { @@ -55,14 +57,19 @@ async function getSubscriptionSafe() { export function useSubscription() { const { isLoggedIn } = useBetterAuth(); - // Only fetch subscription when user is logged in - // This prevents errors during signout when component is still mounted - const [subscription, { refetch, mutate }] = createResource( - () => (isLoggedIn() ? getSubscriptionSafe() : null), - { - initialValue: DEFAULT_SUBSCRIPTION, - }, - ); + // Use TanStack Query for subscription data with persistence + const query = useQuery(() => ({ + queryKey: queryKeys.subscription.current, + queryFn: getSubscriptionSafe, + enabled: isLoggedIn(), + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes + // Use placeholderData so components can check loading state + // placeholderData is only used while loading, not as initial data + placeholderData: undefined, + })); + + const subscription = () => query.data || DEFAULT_SUBSCRIPTION; /** * Current subscription tier @@ -131,13 +138,18 @@ export function useSubscription() { }); }); + const queryClient = useQueryClient(); + return { - // Resource + // Query state subscription, - loading: () => subscription.loading, - error: () => subscription.error, - refetch, - mutate, + loading: () => query.isLoading || query.isFetching, + error: () => query.error, + refetch: () => query.refetch(), + mutate: data => { + // Optimistic update - set query data via queryClient + queryClient.setQueryData(queryKeys.subscription.current, data); + }, // Tier info tier, diff --git a/packages/web/src/stores/projectActionsStore/members.js b/packages/web/src/stores/projectActionsStore/members.js index ccdc79873..985bcfb8c 100644 --- a/packages/web/src/stores/projectActionsStore/members.js +++ b/packages/web/src/stores/projectActionsStore/members.js @@ -3,7 +3,8 @@ */ import { API_BASE } from '@config/api.js'; -import projectStore from '../projectStore.js'; +import { queryClient } from '@lib/queryClient.js'; +import { queryKeys } from '@lib/queryKeys.js'; /** * Creates member operations @@ -33,7 +34,8 @@ export function createMemberActions(getActiveProjectId, getCurrentUserId) { } if (isSelf) { - projectStore.removeProjectFromList(projectId); + // Invalidate project list query since user was removed + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(currentUserId) }); } return { isSelf }; diff --git a/packages/web/src/stores/projectActionsStore/project.js b/packages/web/src/stores/projectActionsStore/project.js index 05a0a8b00..af9c26087 100644 --- a/packages/web/src/stores/projectActionsStore/project.js +++ b/packages/web/src/stores/projectActionsStore/project.js @@ -4,7 +4,8 @@ import { showToast } from '@corates/ui'; import { API_BASE } from '@config/api.js'; -import projectStore from '../projectStore.js'; +import { queryClient } from '@lib/queryClient.js'; +import { queryKeys } from '@lib/queryKeys.js'; /** * Creates project operations @@ -63,7 +64,8 @@ export function createProjectActions(getActiveConnection, getActiveProjectId) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'Failed to delete project'); } - projectStore.removeProjectFromList(targetProjectId); + // Invalidate project list query to refetch without deleted project + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); } catch (err) { console.error('Error deleting project:', err); throw err; diff --git a/packages/web/src/stores/projectStore.js b/packages/web/src/stores/projectStore.js index 72f351eea..7c969466e 100644 --- a/packages/web/src/stores/projectStore.js +++ b/packages/web/src/stores/projectStore.js @@ -6,85 +6,12 @@ */ import { createStore, produce } from 'solid-js/store'; -import { API_BASE } from '@config/api.js'; - -// LocalStorage keys for offline caching -const PROJECT_LIST_CACHE_KEY = 'corates-project-list-cache'; -const PROJECT_LIST_CACHE_TIMESTAMP_KEY = 'corates-project-list-cache-timestamp'; -const PROJECT_LIST_CACHE_USER_ID_KEY = 'corates-project-list-cache-user-id'; -const PROJECT_LIST_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days // Temporary in-memory storage for pending uploads during project creation // This avoids passing non-serializable data through router state const pendingProjectData = new Map(); -// Callback for cleaning up local data when a project is no longer accessible -// Set by useProject module to avoid circular dependency -let onStaleProjectCleanup = null; - function createProjectStore() { - // Load cached project list from localStorage - function loadCachedProjectList() { - if (typeof window === 'undefined') return null; - try { - const cached = localStorage.getItem(PROJECT_LIST_CACHE_KEY); - const timestamp = localStorage.getItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); - const cachedUserId = localStorage.getItem(PROJECT_LIST_CACHE_USER_ID_KEY); - if (!cached || !timestamp) return null; - - const age = Date.now() - parseInt(timestamp, 10); - if (age > PROJECT_LIST_CACHE_MAX_AGE) { - // Cache expired, clear it - localStorage.removeItem(PROJECT_LIST_CACHE_KEY); - localStorage.removeItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); - localStorage.removeItem(PROJECT_LIST_CACHE_USER_ID_KEY); - return null; - } - - return { projects: JSON.parse(cached), userId: cachedUserId }; - } catch (err) { - console.error('Error loading cached project list:', err); - return null; - } - } - - // Save project list to localStorage - function saveCachedProjectList(projects, userId) { - if (typeof window === 'undefined') return; - try { - if (projects && Array.isArray(projects) && userId) { - localStorage.setItem(PROJECT_LIST_CACHE_KEY, JSON.stringify(projects)); - localStorage.setItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY, Date.now().toString()); - localStorage.setItem(PROJECT_LIST_CACHE_USER_ID_KEY, userId); - } else { - localStorage.removeItem(PROJECT_LIST_CACHE_KEY); - localStorage.removeItem(PROJECT_LIST_CACHE_TIMESTAMP_KEY); - localStorage.removeItem(PROJECT_LIST_CACHE_USER_ID_KEY); - } - } catch (err) { - console.error('Error saving cached project list:', err); - } - } - - // Initialize with cached data if available - const cachedData = loadCachedProjectList(); - const initialProjectList = - cachedData?.projects ? - { - items: cachedData.projects, - loaded: false, // Don't mark as loaded - we'll check userId and fetch if needed - loading: false, - error: null, - cachedUserId: cachedData.userId, // Track which user this cache belongs to - } - : { - items: [], - loaded: false, - loading: false, - error: null, - cachedUserId: null, - }; - const [store, setStore] = createStore({ // Cached project data by projectId (Y.js data: studies, members, meta) projects: {}, @@ -92,8 +19,6 @@ function createProjectStore() { activeProjectId: null, // Connection states by projectId connections: {}, - // Project list from API (for dashboard) - projectList: initialProjectList, }); /** @@ -290,259 +215,6 @@ function createProjectStore() { return pdfs.find(pdf => pdf.id === pdfId) || null; } - // ============ Project List (Dashboard) ============ - - /** - * Get the project list - */ - function getProjectList() { - return store.projectList.items; - } - - /** - * Check if project list is loaded - */ - function isProjectListLoaded() { - return store.projectList.loaded; - } - - /** - * Check if project list is loading - */ - function isProjectListLoading() { - return store.projectList.loading; - } - - /** - * Add a project to the list - */ - function addProjectToList(project) { - setStore( - produce(s => { - s.projectList.items.push(project); - }), - ); - // Update cache (preserve userId) - saveCachedProjectList(store.projectList.items, store.projectList.cachedUserId); - } - - /** - * Update a project in the list - */ - function updateProjectInList(projectId, updates) { - setStore( - produce(s => { - const index = s.projectList.items.findIndex(p => p.id === projectId); - if (index !== -1) { - Object.assign(s.projectList.items[index], updates); - } - }), - ); - // Update cache (preserve userId) - saveCachedProjectList(store.projectList.items, store.projectList.cachedUserId); - } - - /** - * Remove a project from the list - */ - function removeProjectFromList(projectId) { - setStore( - produce(s => { - s.projectList.items = s.projectList.items.filter(p => p.id !== projectId); - }), - ); - // Update cache (preserve userId) - saveCachedProjectList(store.projectList.items, store.projectList.cachedUserId); - } - - /** - * Clear the project list (e.g., on logout) - */ - function clearProjectList() { - setStore('projectList', { - items: [], - loaded: false, - loading: false, - error: null, - cachedUserId: null, - }); - // Clear cached data - saveCachedProjectList(null, null); - } - - /** - * Get project list error - */ - function getProjectListError() { - return store.projectList.error; - } - - /** - * Fetch projects from API for a user - * Returns early if already loaded or loading - * Falls back to cached data when offline - */ - async function fetchProjectList(userId, options = {}) { - const { force = false } = options; - - // Check if cached data belongs to a different user - if so, force refresh and clear cache - const cachedUserId = store.projectList.cachedUserId; - const shouldForceRefresh = force || (cachedUserId && cachedUserId !== userId); - - // If userId changed, clear the cached items - if (cachedUserId && cachedUserId !== userId) { - setStore('projectList', { - items: [], - loaded: false, - cachedUserId: null, - }); - } - - // Skip if already loaded and userId matches (unless forcing refresh) - if (!shouldForceRefresh && store.projectList.loaded && cachedUserId === userId) { - return store.projectList.items; - } - - // Skip if already loading - if (store.projectList.loading) { - return null; - } - - if (!userId) { - return []; - } - - // Check if we're offline - if so, try to use cached data (only if it's for the same user) - const isOnline = navigator.onLine; - if (!isOnline) { - const cached = loadCachedProjectList(); - if (cached?.projects && cached.userId === userId) { - setStore('projectList', { - items: cached.projects, - loaded: true, - loading: false, - error: null, - cachedUserId: userId, - }); - return cached.projects; - } - // No cached data available offline - setStore('projectList', { - loading: false, - error: 'No internet connection and no cached data available', - }); - return null; - } - - setStore('projectList', { loading: true, error: null }); - - try { - const response = await fetch(`${API_BASE}/api/users/${userId}/projects`, { - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch projects'); - } - - const projects = await response.json(); - - // Reconcile: clean up local data for projects no longer in the server list - // This handles cases where user was removed or project was deleted while offline - const serverProjectIds = new Set(projects.map(p => p.id)); - const cachedProjectIds = Object.keys(store.projects); - - for (const cachedId of cachedProjectIds) { - if (!serverProjectIds.has(cachedId) && onStaleProjectCleanup) { - // User no longer has access to this project - clean up local data - // Run async but don't block the fetch - onStaleProjectCleanup(cachedId).catch(err => { - console.error('Failed to clean up stale project:', cachedId, err); - }); - } - } - - setStore('projectList', { - items: projects, - loaded: true, - loading: false, - error: null, - cachedUserId: userId, - }); - - // Save to cache for offline access - saveCachedProjectList(projects, userId); - - return projects; - } catch (err) { - console.error('Error fetching projects:', err); - - // If fetch failed, try to use cached data as fallback (only if it's for the same user) - const cached = loadCachedProjectList(); - if (cached?.projects && cached.userId === userId) { - setStore('projectList', { - items: cached.projects, - loaded: true, - loading: false, - error: 'Using cached data - connection error', - cachedUserId: userId, - }); - return cached.projects; - } - - setStore('projectList', { - loading: false, - error: err.message, - }); - return null; - } - } - - /** - * Refresh the project list (force re-fetch) - */ - function refreshProjectList(userId) { - return fetchProjectList(userId, { force: true }); - } - - /** - * Validate cached project list against current user ID - * Clears cache if user ID doesn't match - * @param {string} currentUserId - The current authenticated user's ID - */ - function validateProjectListCache(currentUserId) { - if (!currentUserId) { - // No user ID, clear the cache - setStore('projectList', { - items: [], - loaded: false, - loading: false, - error: null, - cachedUserId: null, - }); - // Clear localStorage cache - saveCachedProjectList(null, null); - return; - } - - const cachedUserId = store.projectList.cachedUserId; - - // If cached user ID doesn't match current user, clear the cache - if (cachedUserId && cachedUserId !== currentUserId) { - console.log('[projectStore] Cached project list belongs to different user, clearing cache'); - setStore('projectList', { - items: [], - loaded: false, - loading: false, - error: null, - cachedUserId: null, - }); - // Clear localStorage cache - saveCachedProjectList(null, null); - } - } - /** * Temporarily store pending project data during creation * This avoids passing non-serializable data through router state @@ -586,21 +258,6 @@ function createProjectStore() { getSecondaryPdfs, getPdf, - // Getters - Project List (Dashboard) - getProjectList, - isProjectListLoaded, - isProjectListLoading, - getProjectListError, - - // Actions - Project List (Dashboard) - fetchProjectList, - refreshProjectList, - validateProjectListCache, - addProjectToList, - updateProjectInList, - removeProjectFromList, - clearProjectList, - // Temporary storage for project creation setPendingProjectData, getPendingProjectData, @@ -617,13 +274,4 @@ function createProjectStore() { // createStore doesn't need a reactive owner/root context const projectStore = createProjectStore(); -/** - * Register a callback for cleaning up stale project local data. - * This is called by useProject module to avoid circular dependency. - * @param {Function} callback - Async function that takes projectId and cleans up local data - */ -export function registerStaleProjectCleanup(callback) { - onStaleProjectCleanup = callback; -} - export default projectStore; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a2e13f3..58f44256a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,12 +172,18 @@ importers: '@solidjs/router': specifier: ^0.15.4 version: 0.15.4(solid-js@1.9.10) + '@tanstack/solid-query': + specifier: ^5.90.18 + version: 5.90.18(solid-js@1.9.10) better-auth: specifier: ^1.4.9 version: 1.4.9(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20251229.0)(gel@2.2.0)(kysely@0.28.9))(solid-js@1.9.10)(vitest@4.0.16)(vue@3.5.26(typescript@5.9.3)) d3: specifier: ^7.9.0 version: 7.9.0 + idb: + specifier: ^8.0.3 + version: 8.0.3 pdfjs-dist: specifier: ^5.4.530 version: 5.4.530 @@ -3504,6 +3510,12 @@ packages: peerDependencies: vite: '>=6.0.0' + '@tanstack/query-core@5.90.15': + resolution: + { + integrity: sha512-mInIZNUZftbERE+/Hbtswfse49uUQwch46p+27gP9DWJL927UjnaWEF2t3RMOqBcXbfMdcNkPe06VyUIAZTV1g==, + } + '@tanstack/router-utils@1.143.11': resolution: { @@ -3518,6 +3530,14 @@ packages: } engines: { node: '>=12' } + '@tanstack/solid-query@5.90.18': + resolution: + { + integrity: sha512-ee1b1zmCndDehTHtLGl0+JeaV0+N3BYYD3464udWfzH3PAbIgjAUm5dtUpLOse9253XVyGpB5X3sMn6frWepMA==, + } + peerDependencies: + solid-js: ^1.6.0 + '@testing-library/dom@10.4.1': resolution: { @@ -7374,6 +7394,12 @@ packages: } engines: { node: '>=0.10.0' } + idb@8.0.3: + resolution: + { + integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==, + } + ieee754@1.2.1: resolution: { @@ -12858,6 +12884,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@tanstack/query-core@5.90.15': {} + '@tanstack/router-utils@1.143.11': dependencies: '@babel/core': 7.28.5 @@ -12886,6 +12914,11 @@ snapshots: - supports-color - vite + '@tanstack/solid-query@5.90.18(solid-js@1.9.10)': + dependencies: + '@tanstack/query-core': 5.90.15 + solid-js: 1.9.10 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -15675,6 +15708,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@8.0.3: {} + ieee754@1.2.1: {} ignore@5.3.2: {}