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={}
>
-
+
+
{/* 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 |