From 7ca9941982a7d7a4c126d215770ce71ad2f7f427 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 11:11:53 +0900 Subject: feat(client): read decks/cards/study from IndexedDB first Switch deckAtom, cardsByDeckAtomFamily, noteTypesAtom, and studyDataAtom to a stale-while-revalidate pattern: read from IndexedDB synchronously, trigger sync in the background, and refetch on sync_complete. When local is empty, await a single bootstrap pull before deciding there's no data. Add study-builder to assemble StudyCards from LocalCard + Note + NoteType + field values, replacing the server /study endpoint dependency. The study session can now run end-to-end offline. Disable submit on all write modals when offline since writes still require the server. Add a "Showing cached data" hint to the sync status indicator. Drop cacheStudyCards (cards arrive via regular sync pull now) and update page tests to reflect that lists no longer refresh by hitting the GET API. --- src/client/atoms/sync.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) (limited to 'src/client/atoms/sync.ts') diff --git a/src/client/atoms/sync.ts b/src/client/atoms/sync.ts index 91395d8..60c8fae 100644 --- a/src/client/atoms/sync.ts +++ b/src/client/atoms/sync.ts @@ -1,6 +1,7 @@ -import { atom, useSetAtom } from "jotai"; +import { atom, useAtomValue, useSetAtom } from "jotai"; import { useEffect } from "react"; import { apiClient } from "../api/client"; +import { queryClient } from "../queryClient"; import { conflictResolver, createPullService, @@ -23,6 +24,7 @@ import type { SyncPullResult, } from "../sync/pull"; import type { SyncPushData, SyncPushResult } from "../sync/push"; +import { userAtom } from "./auth"; // ===================== // Sync Services Setup @@ -181,6 +183,34 @@ const syncManager = createSyncManager({ conflictResolver, }); +// ===================== +// Bootstrap (initial sync) coordination +// ===================== +// +// The first sync after app load is responsible for populating IndexedDB +// from the server. SWR-style atoms (decks, cards, noteTypes, study) check +// whether bootstrap is in flight and await it if their local data is empty. +// If we are offline or already bootstrapped, the promise resolves immediately. + +let bootstrapPromise: Promise | null = null; + +export function ensureBootstrap(): Promise { + if (bootstrapPromise) return bootstrapPromise; + if (typeof navigator !== "undefined" && !navigator.onLine) { + bootstrapPromise = Promise.resolve(); + return bootstrapPromise; + } + bootstrapPromise = syncManager + .sync() + .then(() => undefined) + .catch(() => undefined); + return bootstrapPromise; +} + +function resetBootstrap(): void { + bootstrapPromise = null; +} + // ===================== // Sync State Atoms // ===================== @@ -207,6 +237,17 @@ export function useSyncInit() { const setLastSyncAt = useSetAtom(lastSyncAtAtom); const setLastError = useSetAtom(lastErrorAtom); const setStatus = useSetAtom(syncStatusAtom); + const user = useAtomValue(userAtom); + + useEffect(() => { + // Bootstrap pulls user-scoped data, so wait for an authenticated user. + // Reset on logout so the next sign-in re-pulls. + if (user) { + ensureBootstrap(); + } else { + resetBootstrap(); + } + }, [user]); useEffect(() => { syncManager.start(); @@ -229,6 +270,13 @@ export function useSyncInit() { setIsSyncing(false); setLastSyncAt(new Date()); setStatus(SyncStatus.Idle); + // Refetch SWR atoms so the UI reflects the freshly pulled + // IndexedDB data. Suspense queries with cached data refetch + // in the background without re-suspending. + if (event.result.success) { + queryClient.invalidateQueries({ queryKey: ["decks"] }); + queryClient.invalidateQueries({ queryKey: ["noteTypes"] }); + } break; case "sync_error": setIsSyncing(false); -- cgit v1.3.1