diff options
Diffstat (limited to 'src/client/atoms')
| -rw-r--r-- | src/client/atoms/auth.ts | 57 | ||||
| -rw-r--r-- | src/client/atoms/cards.ts | 31 | ||||
| -rw-r--r-- | src/client/atoms/decks.ts | 37 | ||||
| -rw-r--r-- | src/client/atoms/index.ts | 42 | ||||
| -rw-r--r-- | src/client/atoms/noteTypes.ts | 22 | ||||
| -rw-r--r-- | src/client/atoms/study.ts | 59 | ||||
| -rw-r--r-- | src/client/atoms/sync.ts | 274 | ||||
| -rw-r--r-- | src/client/atoms/utils.ts | 81 |
8 files changed, 603 insertions, 0 deletions
diff --git a/src/client/atoms/auth.ts b/src/client/atoms/auth.ts new file mode 100644 index 0000000..f618ccf --- /dev/null +++ b/src/client/atoms/auth.ts @@ -0,0 +1,57 @@ +import { atom, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { apiClient, type User } from "../api/client"; + +// Primitive atoms +export const userAtom = atom<User | null>(null); +export const authLoadingAtom = atom<boolean>(true); + +// Derived atom - checks if user is authenticated via apiClient +export const isAuthenticatedAtom = atom<boolean>((get) => { + // We need to trigger re-evaluation when user changes + get(userAtom); + return apiClient.isAuthenticated(); +}); + +// Action atom - login +export const loginAtom = atom( + null, + async ( + _get, + set, + { username, password }: { username: string; password: string }, + ) => { + const response = await apiClient.login(username, password); + set(userAtom, response.user); + }, +); + +// Action atom - logout +export const logoutAtom = atom(null, (_get, set) => { + apiClient.logout(); + set(userAtom, null); +}); + +// Hook to initialize auth state and subscribe to session expiration +export function useAuthInit() { + const setAuthLoading = useSetAtom(authLoadingAtom); + const setUser = useSetAtom(userAtom); + + useEffect(() => { + // Check for existing auth on mount + const tokens = apiClient.getTokens(); + if (tokens) { + // We have tokens stored, but we don't have user info cached + // For now, just set authenticated state. User info will be fetched when needed. + } + setAuthLoading(false); + + // Subscribe to session expired events from the API client + const unsubscribe = apiClient.onSessionExpired(() => { + apiClient.logout(); + setUser(null); + }); + + return unsubscribe; + }, [setAuthLoading, setUser]); +} diff --git a/src/client/atoms/cards.ts b/src/client/atoms/cards.ts new file mode 100644 index 0000000..f053ab9 --- /dev/null +++ b/src/client/atoms/cards.ts @@ -0,0 +1,31 @@ +import { apiClient } from "../api/client"; +import { createReloadableAtomFamily } from "./utils"; + +export interface Card { + id: string; + deckId: string; + noteId: string; + isReversed: boolean; + front: string; + back: string; + state: number; + due: string; + reps: number; + lapses: number; + createdAt: string; + updatedAt: string; +} + +// ===================== +// Cards by Deck - Suspense-compatible +// ===================== + +export const cardsByDeckAtomFamily = createReloadableAtomFamily( + async (deckId: string) => { + const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({ + param: { deckId }, + }); + const data = await apiClient.handleResponse<{ cards: Card[] }>(res); + return data.cards; + }, +); diff --git a/src/client/atoms/decks.ts b/src/client/atoms/decks.ts new file mode 100644 index 0000000..57abef4 --- /dev/null +++ b/src/client/atoms/decks.ts @@ -0,0 +1,37 @@ +import { apiClient } from "../api/client"; +import { createReloadableAtom, createReloadableAtomFamily } from "./utils"; + +export interface Deck { + id: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: string; + updatedAt: string; +} + +// ===================== +// Decks List - Suspense-compatible +// ===================== + +export const decksAtom = createReloadableAtom(async () => { + const res = await apiClient.rpc.api.decks.$get(undefined, { + headers: apiClient.getAuthHeader(), + }); + const data = await apiClient.handleResponse<{ decks: Deck[] }>(res); + return data.decks; +}); + +// ===================== +// Single Deck by ID - Suspense-compatible +// ===================== + +export const deckByIdAtomFamily = createReloadableAtomFamily( + async (deckId: string) => { + const res = await apiClient.rpc.api.decks[":id"].$get({ + param: { id: deckId }, + }); + const data = await apiClient.handleResponse<{ deck: Deck }>(res); + return data.deck; + }, +); diff --git a/src/client/atoms/index.ts b/src/client/atoms/index.ts new file mode 100644 index 0000000..1e13222 --- /dev/null +++ b/src/client/atoms/index.ts @@ -0,0 +1,42 @@ +// Auth atoms +export { SyncStatus } from "../sync"; +export { + authLoadingAtom, + isAuthenticatedAtom, + loginAtom, + logoutAtom, + useAuthInit, + userAtom, +} from "./auth"; + +// Cards atoms +export { type Card, cardsByDeckAtomFamily } from "./cards"; + +// Decks atoms +export { type Deck, deckByIdAtomFamily, decksAtom } from "./decks"; + +// NoteTypes atoms +export { type NoteType, noteTypesAtom } from "./noteTypes"; + +// Study atoms +export { + type StudyCard, + type StudyData, + type StudyDeck, + studyDataAtomFamily, +} from "./study"; + +// Sync atoms +export { + isOnlineAtom, + isSyncingAtom, + lastErrorAtom, + lastSyncAtAtom, + pendingCountAtom, + syncActionAtom, + syncStatusAtom, + useSyncInit, +} from "./sync"; + +// Utilities +export { createReloadableAtom, createReloadableAtomFamily } from "./utils"; diff --git a/src/client/atoms/noteTypes.ts b/src/client/atoms/noteTypes.ts new file mode 100644 index 0000000..adc9d44 --- /dev/null +++ b/src/client/atoms/noteTypes.ts @@ -0,0 +1,22 @@ +import { apiClient } from "../api/client"; +import { createReloadableAtom } from "./utils"; + +export interface NoteType { + id: string; + name: string; + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + createdAt: string; + updatedAt: string; +} + +// ===================== +// NoteTypes List - Suspense-compatible +// ===================== + +export const noteTypesAtom = createReloadableAtom(async () => { + const res = await apiClient.rpc.api["note-types"].$get(); + const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>(res); + return data.noteTypes; +}); diff --git a/src/client/atoms/study.ts b/src/client/atoms/study.ts new file mode 100644 index 0000000..2e3e1ea --- /dev/null +++ b/src/client/atoms/study.ts @@ -0,0 +1,59 @@ +import { apiClient } from "../api/client"; +import { shuffle } from "../utils/shuffle"; +import { createReloadableAtomFamily } from "./utils"; + +export interface StudyCard { + id: string; + deckId: string; + noteId: string; + isReversed: boolean; + front: string; + back: string; + state: number; + due: string; + stability: number; + difficulty: number; + reps: number; + lapses: number; + noteType: { + frontTemplate: string; + backTemplate: string; + }; + fieldValuesMap: Record<string, string>; +} + +export interface StudyDeck { + id: string; + name: string; +} + +export interface StudyData { + deck: StudyDeck; + cards: StudyCard[]; +} + +// ===================== +// Study Session - Suspense-compatible +// ===================== + +export const studyDataAtomFamily = createReloadableAtomFamily( + async (deckId: string): 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, + ); + + return { + deck: deckData.deck, + cards: shuffle(cardsData.cards), + }; + }, +); diff --git a/src/client/atoms/sync.ts b/src/client/atoms/sync.ts new file mode 100644 index 0000000..91395d8 --- /dev/null +++ b/src/client/atoms/sync.ts @@ -0,0 +1,274 @@ +import { atom, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { apiClient } from "../api/client"; +import { + conflictResolver, + createPullService, + createPushService, + createSyncManager, + type SyncManagerEvent, + type SyncQueueState, + type SyncResult, + SyncStatus, + syncQueue, +} from "../sync"; +import type { + ServerCard, + ServerDeck, + ServerNote, + ServerNoteFieldType, + ServerNoteFieldValue, + ServerNoteType, + ServerReviewLog, + SyncPullResult, +} from "../sync/pull"; +import type { SyncPushData, SyncPushResult } from "../sync/push"; + +// ===================== +// Sync Services Setup +// ===================== + +interface PullResponse { + decks: Array< + Omit<ServerDeck, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + cards: Array< + Omit< + ServerCard, + "due" | "lastReview" | "createdAt" | "updatedAt" | "deletedAt" + > & { + due: string; + lastReview: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + reviewLogs: Array< + Omit<ServerReviewLog, "reviewedAt"> & { + reviewedAt: string; + } + >; + noteTypes: Array< + Omit<ServerNoteType, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + noteFieldTypes: Array< + Omit<ServerNoteFieldType, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + notes: Array< + Omit<ServerNote, "createdAt" | "updatedAt" | "deletedAt"> & { + createdAt: string; + updatedAt: string; + deletedAt: string | null; + } + >; + noteFieldValues: Array< + Omit<ServerNoteFieldValue, "createdAt" | "updatedAt"> & { + createdAt: string; + updatedAt: string; + } + >; + currentSyncVersion: number; +} + +async function pushToServer(data: SyncPushData): Promise<SyncPushResult> { + const res = await apiClient.authenticatedFetch("/api/sync/push", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(errorBody.error || `Push failed with status ${res.status}`); + } + + return res.json() as Promise<SyncPushResult>; +} + +async function pullFromServer( + lastSyncVersion: number, +): Promise<SyncPullResult> { + const res = await apiClient.authenticatedFetch( + `/api/sync/pull?lastSyncVersion=${lastSyncVersion}`, + ); + + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(errorBody.error || `Pull failed with status ${res.status}`); + } + + const data = (await res.json()) as PullResponse; + + return { + decks: data.decks.map((d) => ({ + ...d, + createdAt: new Date(d.createdAt), + updatedAt: new Date(d.updatedAt), + deletedAt: d.deletedAt ? new Date(d.deletedAt) : null, + })), + cards: data.cards.map((c) => ({ + ...c, + due: new Date(c.due), + lastReview: c.lastReview ? new Date(c.lastReview) : null, + createdAt: new Date(c.createdAt), + updatedAt: new Date(c.updatedAt), + deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, + })), + reviewLogs: data.reviewLogs.map((r) => ({ + ...r, + reviewedAt: new Date(r.reviewedAt), + })), + noteTypes: data.noteTypes.map((n) => ({ + ...n, + createdAt: new Date(n.createdAt), + updatedAt: new Date(n.updatedAt), + deletedAt: n.deletedAt ? new Date(n.deletedAt) : null, + })), + noteFieldTypes: data.noteFieldTypes.map((f) => ({ + ...f, + createdAt: new Date(f.createdAt), + updatedAt: new Date(f.updatedAt), + deletedAt: f.deletedAt ? new Date(f.deletedAt) : null, + })), + notes: data.notes.map((n) => ({ + ...n, + createdAt: new Date(n.createdAt), + updatedAt: new Date(n.updatedAt), + deletedAt: n.deletedAt ? new Date(n.deletedAt) : null, + })), + noteFieldValues: data.noteFieldValues.map((v) => ({ + ...v, + createdAt: new Date(v.createdAt), + updatedAt: new Date(v.updatedAt), + })), + currentSyncVersion: data.currentSyncVersion, + }; +} + +const pushService = createPushService({ + syncQueue, + pushToServer, +}); + +const pullService = createPullService({ + syncQueue, + pullFromServer, +}); + +const syncManager = createSyncManager({ + syncQueue, + pushService, + pullService, + conflictResolver, +}); + +// ===================== +// Sync State Atoms +// ===================== + +export const isOnlineAtom = atom<boolean>( + typeof navigator !== "undefined" ? navigator.onLine : true, +); +export const isSyncingAtom = atom<boolean>(false); +export const pendingCountAtom = atom<number>(0); +export const lastSyncAtAtom = atom<Date | null>(null); +export const lastErrorAtom = atom<string | null>(null); +export const syncStatusAtom = atom<SyncQueueState["status"]>(SyncStatus.Idle); + +// Action atom - trigger sync +export const syncActionAtom = atom(null, async (): Promise<SyncResult> => { + return syncManager.sync(); +}); + +// Hook to initialize sync subscriptions +export function useSyncInit() { + const setIsOnline = useSetAtom(isOnlineAtom); + const setIsSyncing = useSetAtom(isSyncingAtom); + const setPendingCount = useSetAtom(pendingCountAtom); + const setLastSyncAt = useSetAtom(lastSyncAtAtom); + const setLastError = useSetAtom(lastErrorAtom); + const setStatus = useSetAtom(syncStatusAtom); + + useEffect(() => { + syncManager.start(); + + const unsubscribeManager = syncManager.subscribe( + (event: SyncManagerEvent) => { + switch (event.type) { + case "online": + setIsOnline(true); + break; + case "offline": + setIsOnline(false); + break; + case "sync_start": + setIsSyncing(true); + setLastError(null); + setStatus(SyncStatus.Syncing); + break; + case "sync_complete": + setIsSyncing(false); + setLastSyncAt(new Date()); + setStatus(SyncStatus.Idle); + break; + case "sync_error": + setIsSyncing(false); + setLastError(event.error); + setStatus(SyncStatus.Error); + break; + } + }, + ); + + const unsubscribeQueue = syncQueue.subscribe((state: SyncQueueState) => { + setPendingCount(state.pendingCount); + if (state.lastSyncAt) { + setLastSyncAt(state.lastSyncAt); + } + if (state.lastError) { + setLastError(state.lastError); + } + setStatus(state.status); + }); + + // Initialize state from queue + syncQueue.getState().then((state) => { + setPendingCount(state.pendingCount); + setLastSyncAt(state.lastSyncAt); + setLastError(state.lastError); + setStatus(state.status); + }); + + return () => { + unsubscribeManager(); + unsubscribeQueue(); + syncManager.stop(); + }; + }, [ + setIsOnline, + setIsSyncing, + setPendingCount, + setLastSyncAt, + setLastError, + setStatus, + ]); +} diff --git a/src/client/atoms/utils.ts b/src/client/atoms/utils.ts new file mode 100644 index 0000000..e7af288 --- /dev/null +++ b/src/client/atoms/utils.ts @@ -0,0 +1,81 @@ +import { atom, type Getter, type WritableAtom } from "jotai"; + +// Symbol to identify reload action +const RELOAD = Symbol("reload"); + +/** + * A WritableAtom that returns T (or Promise<T> before hydration) and accepts + * an optional T value for hydration, or undefined to trigger reload. + */ +export type ReloadableAtom<T> = WritableAtom<T | Promise<T>, [T?], void>; + +/** + * Creates an async atom that can be reloaded by calling its setter. + * Read the atom to get the data (suspends while loading). + * Set the atom with no args to trigger a reload. + * Set the atom with a value to hydrate (useful for testing). + */ +export function createReloadableAtom<T>( + getter: (get: Getter) => Promise<T>, +): ReloadableAtom<T> { + const refetchKeyAtom = atom(0); + // Stores hydrated value - undefined means not hydrated + const hydratedValueAtom = atom<{ value: T } | undefined>(undefined); + + return atom( + // Not using async here - returns T synchronously when hydrated, Promise<T> when fetching + (get): T | Promise<T> => { + // Check for hydrated value first (sync path - avoids Suspense) + const hydrated = get(hydratedValueAtom); + if (hydrated !== undefined) { + return hydrated.value; + } + // Async path - will trigger Suspense + get(refetchKeyAtom); + return getter(get); + }, + (_get, set, action?: T | typeof RELOAD) => { + if (action === undefined || action === RELOAD) { + // Trigger reload: clear hydrated value and bump refetch key + set(hydratedValueAtom, undefined); + set(refetchKeyAtom, (k) => k + 1); + } else { + // Hydrate with value + set(hydratedValueAtom, { value: action }); + } + }, + ); +} + +// Track all atom family caches for test cleanup +const atomFamilyCaches: Map<unknown, unknown>[] = []; + +/** + * Creates a reloadable atom family for parameterized async data. + * Each unique parameter gets its own cached atom with reload capability. + */ +export function createReloadableAtomFamily<T, P extends string | number>( + getter: (param: P, get: Getter) => Promise<T>, +): (param: P) => ReloadableAtom<T> { + const cache = new Map<P, ReloadableAtom<T>>(); + atomFamilyCaches.push(cache); + + return (param: P): ReloadableAtom<T> => { + let reloadableAtom = cache.get(param); + if (!reloadableAtom) { + reloadableAtom = createReloadableAtom((get) => getter(param, get)); + cache.set(param, reloadableAtom); + } + return reloadableAtom; + }; +} + +/** + * Clears all atom family caches. Call this in test beforeEach/afterEach + * to ensure tests don't share cached atoms. + */ +export function clearAtomFamilyCaches() { + for (const cache of atomFamilyCaches) { + cache.clear(); + } +} |
