aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/atoms
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/atoms')
-rw-r--r--src/client/atoms/auth.ts57
-rw-r--r--src/client/atoms/cards.ts31
-rw-r--r--src/client/atoms/decks.ts37
-rw-r--r--src/client/atoms/index.ts42
-rw-r--r--src/client/atoms/noteTypes.ts22
-rw-r--r--src/client/atoms/study.ts59
-rw-r--r--src/client/atoms/sync.ts274
-rw-r--r--src/client/atoms/utils.ts81
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();
+ }
+}