aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/atoms
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/atoms')
-rw-r--r--src/client/atoms/cards.ts51
-rw-r--r--src/client/atoms/decks.ts124
-rw-r--r--src/client/atoms/noteTypes.ts49
-rw-r--r--src/client/atoms/study.ts60
-rw-r--r--src/client/atoms/sync.ts50
5 files changed, 270 insertions, 64 deletions
diff --git a/src/client/atoms/cards.ts b/src/client/atoms/cards.ts
index 4a6e72e..7771cdb 100644
--- a/src/client/atoms/cards.ts
+++ b/src/client/atoms/cards.ts
@@ -1,7 +1,8 @@
import { atomFamily } from "jotai-family";
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
-import { apiClient } from "../api/client";
-import type { CardStateType } from "../db";
+import type { CardStateType, LocalCard } from "../db";
+import { localCardRepository } from "../db/repositories";
+import { ensureBootstrap } from "./sync";
export interface Card {
id: string;
@@ -25,19 +26,51 @@ export interface Card {
syncVersion: number;
}
+function localCardToView(card: LocalCard): Card {
+ return {
+ id: card.id,
+ deckId: card.deckId,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
+ front: card.front,
+ back: card.back,
+ state: card.state,
+ due: card.due.toISOString(),
+ stability: card.stability,
+ difficulty: card.difficulty,
+ elapsedDays: card.elapsedDays,
+ scheduledDays: card.scheduledDays,
+ reps: card.reps,
+ lapses: card.lapses,
+ lastReview: card.lastReview ? card.lastReview.toISOString() : null,
+ createdAt: card.createdAt.toISOString(),
+ updatedAt: card.updatedAt.toISOString(),
+ deletedAt: card.deletedAt ? card.deletedAt.toISOString() : null,
+ syncVersion: card.syncVersion,
+ };
+}
+
+async function loadCardsByDeck(deckId: string): Promise<Card[]> {
+ const cards = await localCardRepository.findByDeckId(deckId);
+ cards.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+ return cards.map(localCardToView);
+}
+
// =====================
-// Cards by Deck - Suspense-compatible
+// Cards by Deck - Suspense-compatible, IndexedDB-first
// =====================
export const cardsByDeckAtomFamily = atomFamily((deckId: string) =>
atomWithSuspenseQuery(() => ({
queryKey: ["decks", deckId, "cards"],
- queryFn: async () => {
- const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({
- param: { deckId },
- });
- const data = await apiClient.handleResponse<{ cards: Card[] }>(res);
- return data.cards;
+ queryFn: async (): Promise<Card[]> => {
+ const cards = await loadCardsByDeck(deckId);
+ if (cards.length > 0) {
+ ensureBootstrap();
+ return cards;
+ }
+ await ensureBootstrap();
+ return loadCardsByDeck(deckId);
},
})),
);
diff --git a/src/client/atoms/decks.ts b/src/client/atoms/decks.ts
index 0d20586..d62227c 100644
--- a/src/client/atoms/decks.ts
+++ b/src/client/atoms/decks.ts
@@ -1,6 +1,9 @@
import { atomFamily } from "jotai-family";
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
-import { apiClient } from "../api/client";
+import { getEndOfStudyDayBoundary } from "../../shared/date";
+import { CardState, db, type LocalDeck } from "../db";
+import { localDeckRepository } from "../db/repositories";
+import { ensureBootstrap } from "./sync";
export interface Deck {
id: string;
@@ -15,34 +18,125 @@ export interface Deck {
updatedAt: string;
}
+async function loadCurrentUserId(): Promise<string | null> {
+ const stored = localStorage.getItem("kioku_user");
+ if (!stored) return null;
+ try {
+ const user = JSON.parse(stored) as { id?: string } | null;
+ return user?.id ?? null;
+ } catch {
+ return null;
+ }
+}
+
+interface DeckCardCounts {
+ dueCardCount: number;
+ newCardCount: number;
+ totalCardCount: number;
+ reviewCardCount: number;
+}
+
+async function computeDeckCounts(
+ deckId: string,
+ dueBoundary: Date,
+): Promise<DeckCardCounts> {
+ const cards = await db.cards.where("deckId").equals(deckId).toArray();
+ let due = 0;
+ let news = 0;
+ let total = 0;
+ let review = 0;
+ for (const card of cards) {
+ if (card.deletedAt !== null) continue;
+ total++;
+ if (card.due < dueBoundary) due++;
+ if (card.state === CardState.New) news++;
+ if (card.state === CardState.Review) review++;
+ }
+ return {
+ dueCardCount: due,
+ newCardCount: news,
+ totalCardCount: total,
+ reviewCardCount: review,
+ };
+}
+
+function localDeckToView(deck: LocalDeck, counts: DeckCardCounts): Deck {
+ return {
+ id: deck.id,
+ name: deck.name,
+ description: deck.description,
+ defaultNoteTypeId: deck.defaultNoteTypeId,
+ dueCardCount: counts.dueCardCount,
+ newCardCount: counts.newCardCount,
+ totalCardCount: counts.totalCardCount,
+ reviewCardCount: counts.reviewCardCount,
+ createdAt: deck.createdAt.toISOString(),
+ updatedAt: deck.updatedAt.toISOString(),
+ };
+}
+
+async function loadDecksFromIndexedDb(): Promise<Deck[]> {
+ const userId = await loadCurrentUserId();
+ if (!userId) return [];
+ const decks = await localDeckRepository.findByUserId(userId);
+ const boundary = getEndOfStudyDayBoundary(new Date());
+ decks.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+ return Promise.all(
+ decks.map(async (deck) => {
+ const counts = await computeDeckCounts(deck.id, boundary);
+ return localDeckToView(deck, counts);
+ }),
+ );
+}
+
// =====================
-// Decks List - Suspense-compatible
+// Decks List - Suspense-compatible, IndexedDB-first
// =====================
export const decksAtom = atomWithSuspenseQuery(() => ({
queryKey: ["decks"],
- queryFn: async () => {
- const res = await apiClient.rpc.api.decks.$get(undefined, {
- headers: apiClient.getAuthHeader(),
- });
- const data = await apiClient.handleResponse<{ decks: Deck[] }>(res);
- return data.decks;
+ queryFn: async (): Promise<Deck[]> => {
+ const decks = await loadDecksFromIndexedDb();
+ if (decks.length > 0) {
+ // Stale-while-revalidate: kick a background pull so the next
+ // invalidation reflects upstream changes.
+ ensureBootstrap();
+ return decks;
+ }
+ // IndexedDB is empty — wait for the initial pull to populate it
+ // before deciding there really are no decks.
+ await ensureBootstrap();
+ return loadDecksFromIndexedDb();
},
}));
// =====================
-// Single Deck by ID - Suspense-compatible
+// Single Deck by ID - Suspense-compatible, IndexedDB-first
// =====================
+async function loadDeckById(deckId: string): Promise<Deck | null> {
+ const deck = await localDeckRepository.findById(deckId);
+ if (!deck || deck.deletedAt !== null) return null;
+ const boundary = getEndOfStudyDayBoundary(new Date());
+ const counts = await computeDeckCounts(deck.id, boundary);
+ return localDeckToView(deck, counts);
+}
+
export const deckByIdAtomFamily = atomFamily((deckId: string) =>
atomWithSuspenseQuery(() => ({
queryKey: ["decks", deckId],
- queryFn: async () => {
- const res = await apiClient.rpc.api.decks[":id"].$get({
- param: { id: deckId },
- });
- const data = await apiClient.handleResponse<{ deck: Deck }>(res);
- return data.deck;
+ queryFn: async (): Promise<Deck> => {
+ let deck = await loadDeckById(deckId);
+ if (deck) {
+ ensureBootstrap();
+ return deck;
+ }
+ await ensureBootstrap();
+ deck = await loadDeckById(deckId);
+ if (!deck) {
+ throw new Error(`Deck not found: ${deckId}`);
+ }
+ return deck;
},
})),
);
diff --git a/src/client/atoms/noteTypes.ts b/src/client/atoms/noteTypes.ts
index fb99b14..1fde8f5 100644
--- a/src/client/atoms/noteTypes.ts
+++ b/src/client/atoms/noteTypes.ts
@@ -1,5 +1,7 @@
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
-import { apiClient } from "../api/client";
+import type { LocalNoteType } from "../db";
+import { localNoteTypeRepository } from "../db/repositories";
+import { ensureBootstrap } from "./sync";
export interface NoteType {
id: string;
@@ -11,15 +13,50 @@ export interface NoteType {
updatedAt: string;
}
+async function loadCurrentUserId(): Promise<string | null> {
+ const stored = localStorage.getItem("kioku_user");
+ if (!stored) return null;
+ try {
+ const user = JSON.parse(stored) as { id?: string } | null;
+ return user?.id ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function localNoteTypeToView(noteType: LocalNoteType): NoteType {
+ return {
+ id: noteType.id,
+ name: noteType.name,
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ isReversible: noteType.isReversible,
+ createdAt: noteType.createdAt.toISOString(),
+ updatedAt: noteType.updatedAt.toISOString(),
+ };
+}
+
+async function loadNoteTypes(): Promise<NoteType[]> {
+ const userId = await loadCurrentUserId();
+ if (!userId) return [];
+ const noteTypes = await localNoteTypeRepository.findByUserId(userId);
+ noteTypes.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
+ return noteTypes.map(localNoteTypeToView);
+}
+
// =====================
-// NoteTypes List - Suspense-compatible
+// NoteTypes List - Suspense-compatible, IndexedDB-first
// =====================
export const noteTypesAtom = atomWithSuspenseQuery(() => ({
queryKey: ["noteTypes"],
- queryFn: async () => {
- const res = await apiClient.rpc.api["note-types"].$get();
- const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>(res);
- return data.noteTypes;
+ queryFn: async (): Promise<NoteType[]> => {
+ const noteTypes = await loadNoteTypes();
+ if (noteTypes.length > 0) {
+ ensureBootstrap();
+ return noteTypes;
+ }
+ await ensureBootstrap();
+ return loadNoteTypes();
},
}));
diff --git a/src/client/atoms/study.ts b/src/client/atoms/study.ts
index 17519de..115bbcd 100644
--- a/src/client/atoms/study.ts
+++ b/src/client/atoms/study.ts
@@ -1,19 +1,12 @@
import { atomFamily } from "jotai-family";
import { atomWithSuspenseQuery } from "jotai-tanstack-query";
import { getStartOfStudyDayBoundary } from "../../shared/date";
-import { apiClient } from "../api/client";
-import type { CardStateType } from "../db";
-import { cacheStudyCards, type ServerStudyCard } from "../sync";
+import { localDeckRepository } from "../db/repositories";
+import { buildStudyCards, type StudyCardView } from "../db/study-builder";
import { createSeededRandom, shuffle } from "../utils/random";
+import { ensureBootstrap } from "./sync";
-export interface StudyCard extends ServerStudyCard {
- state: CardStateType;
- noteType: {
- frontTemplate: string;
- backTemplate: string;
- };
- fieldValuesMap: Record<string, string>;
-}
+export type StudyCard = StudyCardView;
export interface StudyDeck {
id: string;
@@ -25,35 +18,36 @@ export interface StudyData {
cards: StudyCard[];
}
+async function loadStudyData(deckId: string): Promise<StudyData | null> {
+ const deck = await localDeckRepository.findById(deckId);
+ if (!deck || deck.deletedAt !== null) return null;
+ const cards = await buildStudyCards(deckId);
+ const seed = getStartOfStudyDayBoundary().getTime();
+ return {
+ deck: { id: deck.id, name: deck.name },
+ cards: shuffle(cards, createSeededRandom(seed)),
+ };
+}
+
// =====================
-// Study Session - Suspense-compatible
+// Study Session - Suspense-compatible, IndexedDB-first
// =====================
export const studyDataAtomFamily = atomFamily((deckId: string) =>
atomWithSuspenseQuery(() => ({
queryKey: ["decks", deckId, "study"],
queryFn: async (): Promise<StudyData> => {
- // Fetch deck and due cards in parallel
- const [deckRes, cardsRes] = await Promise.all([
- apiClient.rpc.api.decks[":id"].$get({ param: { id: deckId } }),
- apiClient.rpc.api.decks[":deckId"].study.$get({ param: { deckId } }),
- ]);
-
- const deckData = await apiClient.handleResponse<{ deck: StudyDeck }>(
- deckRes,
- );
- const cardsData = await apiClient.handleResponse<{
- cards: StudyCard[];
- }>(cardsRes);
-
- // Cache cards in IndexedDB so reviews can be submitted offline.
- await cacheStudyCards(cardsData.cards);
-
- const seed = getStartOfStudyDayBoundary().getTime();
- return {
- deck: deckData.deck,
- cards: shuffle(cardsData.cards, createSeededRandom(seed)),
- };
+ let data = await loadStudyData(deckId);
+ if (data) {
+ ensureBootstrap();
+ return data;
+ }
+ await ensureBootstrap();
+ data = await loadStudyData(deckId);
+ if (!data) {
+ throw new Error(`Deck not found: ${deckId}`);
+ }
+ return data;
},
})),
);
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
@@ -182,6 +184,34 @@ const syncManager = createSyncManager({
});
// =====================
+// 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<void> | null = null;
+
+export function ensureBootstrap(): Promise<void> {
+ 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);