learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: local card management

* a UI to display local sync data in settings

+738 -60
+36
docs/core-user-journeys.md
··· 239 239 240 240 - Help page displays prominent notice that Malfestio is in active development 241 241 - Links to Bluesky and GitHub for community support 242 + 243 + ## 8. Offline-First Sync 244 + 245 + **Goal**: Users can work seamlessly online or offline with automatic data synchronization. 246 + 247 + ### High-Level Workflow 248 + 249 + 1. **Local Storage**: All decks, notes, and cards stored in IndexedDB 250 + 2. **Online**: Changes sync immediately to PDS 251 + 3. **Offline**: Changes queued locally, sync when reconnected 252 + 4. **Conflict**: Visual indicator when local and remote diverge 253 + 254 + ### Detailed Flows 255 + 256 + #### Creating Content (Offline-Capable) 257 + 258 + 1. User creates/edits deck, note, or card 259 + 2. Content saved to IndexedDB immediately 260 + 3. If online: queued for sync → pushed to PDS 261 + 4. If offline: marked "local_only" or "pending_push" 262 + 5. SyncIndicator in header shows current status 263 + 264 + #### Viewing Sync Status 265 + 266 + 1. Header → "Settings" 267 + 2. Scroll to "Local Sync Data" section 268 + 3. View tabs: Records (all local data) / Queue (pending sync) 269 + 4. Actions: Refresh, Clear All, Sync individual items 270 + 271 + #### Handling Conflicts 272 + 273 + 1. System detects conflict (HTTP 409 from PDS) 274 + 2. Card marked with "conflict" status 275 + 3. User sees conflict count in SyncIndicator 276 + 4. Settings → Local Sync Data → "Keep Local" to resolve 277 + 5. Or API: `POST /api/sync/resolve/:type/:id`
+56 -20
docs/local-dev.md
··· 29 29 30 30 1. **Start PostgreSQL** 31 31 32 - ```bash 33 - # Using Docker 34 - docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:14 32 + ```bash 33 + # Using Docker 34 + docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:14 35 35 36 - # Or use your local PostgreSQL installation 37 - ``` 36 + # Or use your local PostgreSQL installation 37 + ``` 38 38 39 39 2. **Run migrations** 40 40 41 - ```bash 42 - just migrate 43 - ``` 41 + ```bash 42 + just migrate 43 + ``` 44 44 45 45 3. **Start backend** 46 46 47 - ```bash 48 - just start 49 - ``` 47 + ```bash 48 + just start 49 + ``` 50 50 51 - Server runs on <http://localhost:8080> 51 + Server runs on <http://localhost:8080> 52 52 53 53 4. **Start frontend** 54 54 55 - ```bash 56 - just web-dev 57 - ``` 55 + ```bash 56 + just web-dev 57 + ``` 58 58 59 - Frontend runs on <http://localhost:3000> 59 + Frontend runs on <http://localhost:3000> 60 60 61 61 5. **Test OAuth login** 62 - - Navigate to <http://localhost:3000/login> 63 - - Enter your Bluesky handle (e.g., `thunderbot.bsky.social`) 64 - - Authorize the application on bsky.social 65 - - Verify redirect back to app with successful login 62 + - Navigate to <http://localhost:3000/login> 63 + - Enter your Bluesky handle (e.g., `thunderbot.bsky.social`) 64 + - Authorize the application on bsky.social 65 + - Verify redirect back to app with successful login 66 66 67 67 ### OAuth Flow Details 68 68 ··· 83 83 2. Click "Publish" to publish to your PDS 84 84 3. Check your Bluesky profile at <https://bsky.app> to see the published record 85 85 4. Verify record appears in your AT Protocol repository 86 + 87 + ## Testing Sync Flow 88 + 89 + The app supports offline-first editing with automatic sync when online. 90 + 91 + ### Local Storage (IndexedDB) 92 + 93 + All decks, notes, and cards are stored locally in IndexedDB via Dexie.js. You can view this data: 94 + 95 + 1. Navigate to Settings page 96 + 2. Scroll to "Local Sync Data" section 97 + 3. Use the Records/Queue tabs to view stored data 98 + 99 + ### Testing Offline Mode 100 + 101 + 1. Create or edit a deck while online → syncs immediately 102 + 2. Disconnect network (DevTools → Network → Offline) 103 + 3. Create/edit content → stored locally with "local_only" or "pending_push" status 104 + 4. Reconnect → content auto-syncs when online status changes 105 + 106 + ### Sync Statuses 107 + 108 + | Status | Meaning | 109 + | -------------- | -------------------------------- | 110 + | `local_only` | New content, never synced | 111 + | `synced` | Content matches PDS | 112 + | `pending_push` | Local changes waiting to sync | 113 + | `conflict` | Local and remote versions differ | 114 + 115 + ### Conflict Resolution 116 + 117 + When conflicts occur: 118 + 119 + 1. Settings → Local Sync Data shows records with "conflict" status 120 + 2. Click "Keep Local" to overwrite remote with local version 121 + 3. Or use the API: `POST /api/sync/resolve/:type/:id` with strategy 86 122 87 123 ## Verifying Your Setup 88 124
+30 -18
web/src/components/NoteEditor.tsx
··· 2 2 import { EditorToolbar } from "$components/notes/EditorToolbar"; 3 3 import { MarkdownEditor, type MarkdownEditorAPI } from "$components/notes/MarkdownEditor"; 4 4 import { api } from "$lib/api"; 5 - import type { Note } from "$lib/model"; 5 + import type { Note, Visibility } from "$lib/model"; 6 + import { authStore } from "$lib/store"; 7 + import { syncStore } from "$lib/sync-store"; 6 8 import { toast } from "$lib/toast"; 7 9 import { Button } from "$ui/Button"; 8 10 import rehypeShiki from "@shikijs/rehype"; ··· 116 118 const handleSubmit = async (e: Event) => { 117 119 e.preventDefault(); 118 120 try { 121 + const user = authStore.user(); 122 + if (!user) { 123 + toast.error("Not authenticated"); 124 + return; 125 + } 126 + 119 127 let visibility; 120 128 if (visibilityType() === "SharedWith") { 121 129 visibility = { type: "SharedWith", content: sharedWith().split(",").map((s) => s.trim()).filter((s) => s) }; ··· 123 131 visibility = { type: visibilityType() }; 124 132 } 125 133 126 - const payload = { 134 + const parsedTags = tags().split(",").map((t) => t.trim()).filter((t) => t); 135 + 136 + const localNote = await syncStore.saveNoteLocally({ 137 + id: props.noteId, 138 + ownerDid: user.did, 127 139 title: title(), 128 140 body: content(), 129 - tags: tags().split(",").map((t) => t.trim()).filter((t) => t), 130 - visibility, 131 - }; 141 + tags: parsedTags, 142 + visibility: visibility as Visibility, 143 + links: [], 144 + }); 132 145 133 - const res = props.noteId ? await api.updateNote(props.noteId, payload) : await api.post("/notes", payload); 146 + if (syncStore.isOnline()) { 147 + const payload = { title: title(), body: content(), tags: parsedTags, visibility }; 148 + const res = props.noteId ? await api.updateNote(props.noteId, payload) : await api.post("/notes", payload); 134 149 135 - if (res.ok) { 136 - toast.success("Note saved!"); 137 - if (props.noteId) { 138 - navigate(`/notes/${props.noteId}`); 139 - } else { 150 + if (res.ok) { 151 + toast.success("Note saved and synced!"); 140 152 try { 141 - const newNote = await res.json(); 142 - navigate(`/notes/${newNote.id}`); 153 + const serverNote = await res.json(); 154 + navigate(`/notes/${serverNote.id}`); 143 155 } catch { 144 - navigate("/notes"); 156 + navigate(`/notes/${localNote.id}`); 145 157 } 158 + return; 146 159 } 147 - } else { 148 - const errorText = await res.text(); 149 - console.error("Failed to save note:", res.status, errorText); 150 - toast.error("Failed to save note"); 151 160 } 161 + 162 + toast.success("Note saved locally"); 163 + navigate(`/notes/${localNote.id}`); 152 164 } catch (e) { 153 165 console.error(e); 154 166 toast.error("Failed to save note");
+212
web/src/components/SyncDataTable.tsx
··· 1 + import { api } from "$lib/api"; 2 + import type { LocalCard, LocalDeck, LocalNote, SyncQueueItem } from "$lib/db"; 3 + import { syncStore } from "$lib/sync-store"; 4 + import type { Column } from "$ui/DataTable"; 5 + import { DataTable } from "$ui/DataTable"; 6 + import { createResource, createSignal, Show } from "solid-js"; 7 + 8 + type SyncRecord = { 9 + id: string; 10 + type: "deck" | "note" | "card"; 11 + title: string; 12 + status: string; 13 + version: number; 14 + updatedAt: string; 15 + }; 16 + 17 + type QueueRecord = { 18 + id: string; 19 + entityType: string; 20 + entityId: string; 21 + operation: string; 22 + retryCount: number; 23 + createdAt: string; 24 + lastError?: string; 25 + }; 26 + 27 + export function SyncDataTable() { 28 + const [activeTab, setActiveTab] = createSignal<"records" | "queue">("records"); 29 + const [refreshKey, setRefreshKey] = createSignal(0); 30 + 31 + const [data, { refetch }] = createResource(refreshKey, async () => { 32 + const result = await syncStore.getAllLocalData(); 33 + return result; 34 + }); 35 + 36 + const syncRecords = (): SyncRecord[] => { 37 + const d = data(); 38 + if (!d) return []; 39 + 40 + const decks: SyncRecord[] = d.decks.map((deck: LocalDeck) => ({ 41 + id: deck.id, 42 + type: "deck" as const, 43 + title: deck.title, 44 + status: deck.syncStatus, 45 + version: deck.localVersion, 46 + updatedAt: deck.updatedAt, 47 + })); 48 + 49 + const notes: SyncRecord[] = d.notes.map((note: LocalNote) => ({ 50 + id: note.id, 51 + type: "note" as const, 52 + title: note.title, 53 + status: note.syncStatus, 54 + version: note.localVersion, 55 + updatedAt: note.updatedAt, 56 + })); 57 + 58 + const cards: SyncRecord[] = d.cards.map((card: LocalCard) => ({ 59 + id: card.id, 60 + type: "card" as const, 61 + title: card.front.slice(0, 50) + (card.front.length > 50 ? "..." : ""), 62 + status: card.syncStatus, 63 + version: card.localVersion, 64 + updatedAt: "", 65 + })); 66 + 67 + return [...decks, ...notes, ...cards]; 68 + }; 69 + 70 + const queueRecords = (): QueueRecord[] => { 71 + const d = data(); 72 + if (!d) return []; 73 + 74 + return d.queue.map((item: SyncQueueItem) => ({ 75 + id: String(item.id || ""), 76 + entityType: item.entityType, 77 + entityId: item.entityId, 78 + operation: item.operation, 79 + retryCount: item.retryCount, 80 + createdAt: item.createdAt, 81 + lastError: item.lastError, 82 + })); 83 + }; 84 + 85 + const handleSync = async (type: string, id: string) => { 86 + await syncStore.queueForSync(type as "deck" | "card" | "note", id, "push"); 87 + await syncStore.processQueue(); 88 + setRefreshKey((k) => k + 1); 89 + }; 90 + 91 + const handleResolve = async ( 92 + type: string, 93 + id: string, 94 + strategy: "last_write_wins" | "keep_local" | "keep_remote", 95 + ) => { 96 + await api.resolveConflict(type, id, strategy); 97 + setRefreshKey((k) => k + 1); 98 + }; 99 + 100 + const handleClear = async () => { 101 + if (confirm("Clear all local sync data? This cannot be undone.")) { 102 + await syncStore.clearAll(); 103 + setRefreshKey((k) => k + 1); 104 + } 105 + }; 106 + 107 + const recordColumns: Column<SyncRecord>[] = [ 108 + { key: "type", header: "Type", sortable: true, width: "80px" }, 109 + { key: "title", header: "Title", sortable: true }, 110 + { 111 + key: "status", 112 + header: "Status", 113 + sortable: true, 114 + width: "120px", 115 + render: (row) => ( 116 + <span 117 + class={`px-2 py-1 rounded text-xs ${ 118 + row.status === "synced" 119 + ? "bg-green-900 text-green-300" 120 + : row.status === "conflict" 121 + ? "bg-red-900 text-red-300" 122 + : row.status === "pending_push" 123 + ? "bg-blue-900 text-blue-300" 124 + : "bg-gray-700 text-gray-300" 125 + }`}> 126 + {row.status} 127 + </span> 128 + ), 129 + }, 130 + { key: "version", header: "Ver", sortable: true, width: "60px" }, 131 + { 132 + key: "actions", 133 + header: "Actions", 134 + width: "150px", 135 + render: (row) => ( 136 + <div class="flex gap-2"> 137 + <Show when={row.status === "conflict"}> 138 + <button 139 + onClick={() => handleResolve(row.type, row.id, "keep_local")} 140 + class="text-xs text-blue-400 hover:underline"> 141 + Keep Local 142 + </button> 143 + </Show> 144 + <Show when={row.status !== "synced"}> 145 + <button onClick={() => handleSync(row.type, row.id)} class="text-xs text-green-400 hover:underline"> 146 + Sync 147 + </button> 148 + </Show> 149 + </div> 150 + ), 151 + }, 152 + ]; 153 + 154 + const queueColumns: Column<QueueRecord>[] = [ 155 + { key: "entityType", header: "Type", sortable: true, width: "80px" }, 156 + { key: "entityId", header: "Entity ID", sortable: true }, 157 + { key: "operation", header: "Op", width: "60px" }, 158 + { key: "retryCount", header: "Retries", sortable: true, width: "80px" }, 159 + { key: "lastError", header: "Error" }, 160 + ]; 161 + 162 + return ( 163 + <div class="space-y-4"> 164 + <div class="flex items-center justify-between"> 165 + <div class="flex gap-2"> 166 + <button 167 + onClick={() => setActiveTab("records")} 168 + class={`px-3 py-1.5 text-sm rounded ${ 169 + activeTab() === "records" ? "bg-blue-600 text-white" : "bg-gray-700 text-gray-300" 170 + }`}> 171 + Records ({syncRecords().length}) 172 + </button> 173 + <button 174 + onClick={() => setActiveTab("queue")} 175 + class={`px-3 py-1.5 text-sm rounded ${ 176 + activeTab() === "queue" ? "bg-blue-600 text-white" : "bg-gray-700 text-gray-300" 177 + }`}> 178 + Queue ({queueRecords().length}) 179 + </button> 180 + </div> 181 + <div class="flex gap-2"> 182 + <button 183 + onClick={() => refetch()} 184 + class="px-3 py-1.5 text-sm bg-gray-700 text-gray-300 rounded hover:bg-gray-600"> 185 + Refresh 186 + </button> 187 + <button onClick={handleClear} class="px-3 py-1.5 text-sm bg-red-900 text-red-300 rounded hover:bg-red-800"> 188 + Clear All 189 + </button> 190 + </div> 191 + </div> 192 + 193 + <Show when={data.loading}> 194 + <div class="text-gray-400 text-sm">Loading...</div> 195 + </Show> 196 + 197 + <Show when={!data.loading && activeTab() === "records"}> 198 + <Show when={syncRecords().length > 0} fallback={<div class="text-gray-500 text-sm">No local records</div>}> 199 + <DataTable columns={recordColumns} data={syncRecords()} getRowId={(r) => r.id} /> 200 + </Show> 201 + </Show> 202 + 203 + <Show when={!data.loading && activeTab() === "queue"}> 204 + <Show 205 + when={queueRecords().length > 0} 206 + fallback={<div class="text-gray-500 text-sm">No pending queue items</div>}> 207 + <DataTable columns={queueColumns} data={queueRecords()} getRowId={(r) => r.id} /> 208 + </Show> 209 + </Show> 210 + </div> 211 + ); 212 + }
+90
web/src/components/tests/SyncDataTable.test.tsx
··· 1 + import "fake-indexeddb/auto"; 2 + import { db } from "$lib/db"; 3 + import { cleanup, render, screen } from "@solidjs/testing-library"; 4 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 5 + 6 + const mockSyncStore = vi.hoisted(() => ({ 7 + getAllLocalData: vi.fn(), 8 + queueForSync: vi.fn(), 9 + processQueue: vi.fn(), 10 + clearAll: vi.fn(), 11 + })); 12 + 13 + vi.mock("$lib/sync-store", () => ({ syncStore: mockSyncStore })); 14 + vi.mock("$lib/api", () => ({ api: { resolveConflict: vi.fn().mockResolvedValue({ ok: true }) } })); 15 + 16 + import { SyncDataTable } from "../SyncDataTable"; 17 + 18 + describe("SyncDataTable", () => { 19 + beforeEach(async () => { 20 + vi.clearAllMocks(); 21 + mockSyncStore.getAllLocalData.mockResolvedValue({ decks: [], notes: [], cards: [], queue: [] }); 22 + await db.decks.clear(); 23 + await db.notes.clear(); 24 + await db.cards.clear(); 25 + await db.syncQueue.clear(); 26 + }); 27 + 28 + afterEach(cleanup); 29 + 30 + it("renders with empty state", async () => { 31 + render(() => <SyncDataTable />); 32 + 33 + await new Promise((r) => setTimeout(r, 100)); 34 + 35 + expect(screen.getByText("Records (0)")).toBeInTheDocument(); 36 + expect(screen.getByText("Queue (0)")).toBeInTheDocument(); 37 + }); 38 + 39 + it("renders records tab with data", async () => { 40 + mockSyncStore.getAllLocalData.mockResolvedValue({ 41 + decks: [{ 42 + id: "deck-1", 43 + title: "Test Deck", 44 + syncStatus: "synced", 45 + localVersion: 1, 46 + updatedAt: new Date().toISOString(), 47 + }], 48 + notes: [], 49 + cards: [], 50 + queue: [], 51 + }); 52 + 53 + render(() => <SyncDataTable />); 54 + 55 + await new Promise((r) => setTimeout(r, 100)); 56 + 57 + expect(screen.getByText("Records (1)")).toBeInTheDocument(); 58 + }); 59 + 60 + it("renders queue tab with pending items", async () => { 61 + mockSyncStore.getAllLocalData.mockResolvedValue({ 62 + decks: [], 63 + notes: [], 64 + cards: [], 65 + queue: [{ 66 + id: 1, 67 + entityType: "deck", 68 + entityId: "deck-1", 69 + operation: "push", 70 + retryCount: 0, 71 + createdAt: new Date().toISOString(), 72 + }], 73 + }); 74 + 75 + render(() => <SyncDataTable />); 76 + 77 + await new Promise((r) => setTimeout(r, 100)); 78 + 79 + expect(screen.getByText("Queue (1)")).toBeInTheDocument(); 80 + }); 81 + 82 + it("has refresh and clear buttons", async () => { 83 + render(() => <SyncDataTable />); 84 + 85 + await new Promise((r) => setTimeout(r, 100)); 86 + 87 + expect(screen.getByText("Refresh")).toBeInTheDocument(); 88 + expect(screen.getByText("Clear All")).toBeInTheDocument(); 89 + }); 90 + });
+55 -1
web/src/lib/sync-store.ts
··· 3 3 */ 4 4 import { createRoot, createSignal } from "solid-js"; 5 5 import { api } from "./api"; 6 - import { db, generateLocalId, type LocalDeck, type LocalNote, type SyncStatus } from "./db"; 6 + import { 7 + db, 8 + generateLocalId, 9 + type LocalCard, 10 + type LocalDeck, 11 + type LocalNote, 12 + type SyncQueueItem, 13 + type SyncStatus, 14 + } from "./db"; 7 15 import { authStore } from "./store"; 8 16 9 17 export type SyncState = "idle" | "syncing" | "error" | "offline"; ··· 99 107 return localNote; 100 108 } 101 109 110 + async function saveCardLocally( 111 + card: Omit<LocalCard, "id" | "syncStatus" | "localVersion"> & { id?: string }, 112 + ): Promise<LocalCard> { 113 + const existing = card.id ? await db.cards.get(card.id) : null; 114 + 115 + const localCard: LocalCard = { 116 + id: card.id || generateLocalId(), 117 + deckId: card.deckId, 118 + front: card.front, 119 + back: card.back, 120 + mediaUrl: card.mediaUrl, 121 + cardType: card.cardType, 122 + hints: card.hints, 123 + syncStatus: existing ? "pending_push" : "local_only", 124 + localVersion: existing ? existing.localVersion + 1 : 1, 125 + pdsCid: existing?.pdsCid, 126 + }; 127 + 128 + await db.cards.put(localCard); 129 + return localCard; 130 + } 131 + 132 + async function getLocalCards(deckId: string): Promise<LocalCard[]> { 133 + return db.cards.where("deckId").equals(deckId).toArray(); 134 + } 135 + 136 + async function deleteLocalCard(id: string): Promise<void> { 137 + await db.cards.delete(id); 138 + } 139 + 102 140 async function queueForSync(entityType: "deck" | "card" | "note", entityId: string, operation: "push" | "delete") { 103 141 const existing = await db.syncQueue.where({ entityType, entityId, operation }).first(); 104 142 ··· 189 227 return db.notes.get(id); 190 228 } 191 229 230 + async function getAllLocalData(): Promise< 231 + { decks: LocalDeck[]; notes: LocalNote[]; cards: LocalCard[]; queue: SyncQueueItem[] } 232 + > { 233 + const [decks, notes, cards, queue] = await Promise.all([ 234 + db.decks.toArray(), 235 + db.notes.toArray(), 236 + db.cards.toArray(), 237 + db.syncQueue.toArray(), 238 + ]); 239 + return { decks, notes, cards, queue }; 240 + } 241 + 192 242 async function clearAll() { 193 243 await db.decks.clear(); 194 244 await db.cards.clear(); ··· 207 257 isOnline, 208 258 saveDeckLocally, 209 259 saveNoteLocally, 260 + saveCardLocally, 261 + getLocalCards, 262 + deleteLocalCard, 210 263 queueForSync, 211 264 processQueue, 212 265 refreshCounts, ··· 214 267 getLocalNotes, 215 268 getLocalDeck, 216 269 getLocalNote, 270 + getAllLocalData, 217 271 clearAll, 218 272 }; 219 273 }
+101
web/src/lib/tests/sync-store.test.ts
··· 215 215 expect(await db.syncQueue.count()).toBe(0); 216 216 }); 217 217 }); 218 + 219 + describe("saveCardLocally", () => { 220 + it("should save a new card with local_only status", async () => { 221 + const card = await syncStore.saveCardLocally({ 222 + deckId: "deck-1", 223 + front: "Question", 224 + back: "Answer", 225 + cardType: "basic", 226 + hints: [], 227 + }); 228 + 229 + expect(card.id).toMatch(/^local_/); 230 + expect(card.syncStatus).toBe("local_only"); 231 + expect(card.localVersion).toBe(1); 232 + 233 + const stored = await db.cards.get(card.id); 234 + expect(stored?.front).toBe("Question"); 235 + }); 236 + 237 + it("should update existing card with pending_push status", async () => { 238 + const card = await syncStore.saveCardLocally({ 239 + deckId: "deck-1", 240 + front: "Original", 241 + back: "Answer", 242 + cardType: "basic", 243 + hints: [], 244 + }); 245 + 246 + const updated = await syncStore.saveCardLocally({ 247 + id: card.id, 248 + deckId: "deck-1", 249 + front: "Updated", 250 + back: "Answer", 251 + cardType: "basic", 252 + hints: [], 253 + }); 254 + 255 + expect(updated.id).toBe(card.id); 256 + expect(updated.front).toBe("Updated"); 257 + expect(updated.syncStatus).toBe("pending_push"); 258 + expect(updated.localVersion).toBe(2); 259 + }); 260 + }); 261 + 262 + describe("getLocalCards", () => { 263 + it("should return cards for a specific deck", async () => { 264 + await syncStore.saveCardLocally({ deckId: "deck-1", front: "Q1", back: "A1", cardType: "basic", hints: [] }); 265 + await syncStore.saveCardLocally({ deckId: "deck-1", front: "Q2", back: "A2", cardType: "basic", hints: [] }); 266 + await syncStore.saveCardLocally({ deckId: "deck-2", front: "Q3", back: "A3", cardType: "basic", hints: [] }); 267 + 268 + const cards = await syncStore.getLocalCards("deck-1"); 269 + expect(cards).toHaveLength(2); 270 + expect(cards.map((c) => c.front)).toContain("Q1"); 271 + expect(cards.map((c) => c.front)).toContain("Q2"); 272 + }); 273 + }); 274 + 275 + describe("deleteLocalCard", () => { 276 + it("should delete a card by id", async () => { 277 + const card = await syncStore.saveCardLocally({ 278 + deckId: "deck-1", 279 + front: "To Delete", 280 + back: "Answer", 281 + cardType: "basic", 282 + hints: [], 283 + }); 284 + 285 + await syncStore.deleteLocalCard(card.id); 286 + 287 + const stored = await db.cards.get(card.id); 288 + expect(stored).toBeUndefined(); 289 + }); 290 + }); 291 + 292 + describe("getAllLocalData", () => { 293 + it("should return all local decks, notes, cards, and queue items", async () => { 294 + await syncStore.saveDeckLocally({ 295 + ownerDid: "did:test", 296 + title: "Deck", 297 + description: "", 298 + tags: [], 299 + visibility: { type: "Private" }, 300 + }); 301 + await syncStore.saveNoteLocally({ 302 + ownerDid: "did:test", 303 + title: "Note", 304 + body: "Body", 305 + tags: [], 306 + visibility: { type: "Private" }, 307 + links: [], 308 + }); 309 + await syncStore.saveCardLocally({ deckId: "deck-1", front: "Q", back: "A", cardType: "basic", hints: [] }); 310 + 311 + const data = await syncStore.getAllLocalData(); 312 + 313 + expect(data.decks).toHaveLength(1); 314 + expect(data.notes).toHaveLength(1); 315 + expect(data.cards).toHaveLength(1); 316 + expect(data.queue.length).toBeGreaterThanOrEqual(0); 317 + }); 318 + }); 218 319 });
+45 -13
web/src/pages/DeckNew.tsx
··· 2 2 import { TutorialOverlay } from "$components/TutorialOverlay"; 3 3 import { api } from "$lib/api"; 4 4 import type { CreateDeckPayload } from "$lib/model"; 5 - import { prefStore } from "$lib/store"; 5 + import { authStore, prefStore } from "$lib/store"; 6 + import { syncStore } from "$lib/sync-store"; 6 7 import { toast } from "$lib/toast"; 7 8 import { TutorialProvider, useTutorial } from "$lib/TutorialProvider"; 8 9 import { Button } from "$ui/Button"; ··· 29 30 30 31 const handleSave = async (data: CreateDeckPayload) => { 31 32 try { 32 - const res = await api.createDeck(data); 33 - if (res.ok) { 34 - const deck = await res.json(); 35 - if (!prefStore.prefs()?.tutorial_deck_completed) { 36 - await api.updatePreferences({ tutorial_deck_completed: true }); 37 - prefStore.fetchPrefs(); 33 + const user = authStore.user(); 34 + if (!user) { 35 + toast.error("Not authenticated"); 36 + return; 37 + } 38 + 39 + const localDeck = await syncStore.saveDeckLocally({ 40 + ownerDid: user.did, 41 + title: data.title, 42 + description: data.description ?? "", 43 + tags: data.tags ?? [], 44 + visibility: data.visibility ?? { type: "Private" }, 45 + }); 46 + 47 + for (const card of data.cards ?? []) { 48 + await syncStore.saveCardLocally({ 49 + deckId: localDeck.id, 50 + front: card.front, 51 + back: card.back, 52 + cardType: card.cardType ?? "basic", 53 + hints: card.hints ?? [], 54 + }); 55 + } 56 + 57 + if (syncStore.isOnline()) { 58 + const res = await api.createDeck(data); 59 + if (res.ok) { 60 + const serverDeck = await res.json(); 61 + if (!prefStore.prefs()?.tutorial_deck_completed) { 62 + await api.updatePreferences({ tutorial_deck_completed: true }); 63 + prefStore.fetchPrefs(); 64 + } 65 + toast.success("Deck created and synced"); 66 + navigate(`/decks/${serverDeck.id}`); 67 + return; 38 68 } 39 - toast.success("Deck created successfully"); 40 - navigate(`/decks/${deck.id}`); 41 - } else { 42 - const err = await res.json(); 43 - toast.error(err.error || "Failed to create deck"); 44 69 } 70 + 71 + if (!prefStore.prefs()?.tutorial_deck_completed) { 72 + await api.updatePreferences({ tutorial_deck_completed: true }).catch(() => {}); 73 + prefStore.fetchPrefs(); 74 + } 75 + toast.success("Deck saved locally"); 76 + navigate(`/decks/${localDeck.id}`); 45 77 } catch (e) { 46 78 console.error(e); 47 - toast.error("Network error"); 79 + toast.error("Failed to save deck"); 48 80 } 49 81 }; 50 82
+37 -3
web/src/pages/Home.tsx
··· 4 4 import { Tag } from "$components/ui/Tag"; 5 5 import { api } from "$lib/api"; 6 6 import type { Deck, Persona } from "$lib/model"; 7 - import { prefStore } from "$lib/store"; 7 + import { authStore, prefStore } from "$lib/store"; 8 + import { syncStore } from "$lib/sync-store"; 8 9 import { Button } from "$ui/Button"; 9 10 import { A } from "@solidjs/router"; 10 11 import type { Component, JSX } from "solid-js"; ··· 140 141 141 142 const Home: Component = () => { 142 143 const [decks] = createResource(async () => { 143 - const res = await api.getDecks(); 144 - return res.ok ? ((await res.json()) as Deck[]) : []; 144 + const user = authStore.user(); 145 + const remoteDecks: Deck[] = []; 146 + const localDecks: Deck[] = []; 147 + 148 + try { 149 + const res = await api.getDecks(); 150 + if (res.ok) { 151 + remoteDecks.push(...((await res.json()) as Deck[])); 152 + } 153 + } catch { 154 + console.log("Offline - continuing with local only"); 155 + } 156 + 157 + if (user) { 158 + const locals = await syncStore.getLocalDecks(user.did); 159 + for (const local of locals) { 160 + if ( 161 + local.syncStatus === "local_only" || local.syncStatus === "pending_push" || local.syncStatus === "conflict" 162 + ) { 163 + localDecks.push( 164 + { 165 + id: local.id, 166 + owner_did: local.ownerDid, 167 + title: local.title, 168 + description: local.description, 169 + tags: local.tags, 170 + visibility: local.visibility, 171 + updated_at: local.updatedAt, 172 + } as Deck, 173 + ); 174 + } 175 + } 176 + } 177 + 178 + return [...localDecks, ...remoteDecks]; 145 179 }); 146 180 147 181 const currentTip = createMemo(() => {
+36 -3
web/src/pages/Notes.tsx
··· 4 4 import { EmptyState } from "$components/ui/EmptyState"; 5 5 import { api } from "$lib/api"; 6 6 import type { Note } from "$lib/model"; 7 + import { authStore } from "$lib/store"; 8 + import { syncStore } from "$lib/sync-store"; 7 9 import { A, useLocation } from "@solidjs/router"; 8 10 import type { Component } from "solid-js"; 9 11 import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; 10 12 11 13 const fetchNotes = async (): Promise<Note[]> => { 12 - const res = await api.getNotes(); 13 - if (!res.ok) return []; 14 - return res.json(); 14 + const user = authStore.user(); 15 + const remoteNotes: Note[] = []; 16 + const localNotes: Note[] = []; 17 + 18 + try { 19 + const res = await api.getNotes(); 20 + if (res.ok) { 21 + remoteNotes.push(...(await res.json())); 22 + } 23 + } catch { 24 + console.log("Offline - continuing with local only"); 25 + } 26 + 27 + if (user) { 28 + const locals = await syncStore.getLocalNotes(user.did); 29 + for (const local of locals) { 30 + if (local.syncStatus === "local_only" || local.syncStatus === "pending_push" || local.syncStatus === "conflict") { 31 + localNotes.push( 32 + { 33 + id: local.id, 34 + owner_did: local.ownerDid, 35 + title: local.title, 36 + body: local.body, 37 + tags: local.tags, 38 + visibility: local.visibility, 39 + updated_at: local.updatedAt, 40 + links: local.links ?? [], 41 + } as Note, 42 + ); 43 + } 44 + } 45 + } 46 + 47 + return [...localNotes, ...remoteNotes]; 15 48 }; 16 49 17 50 type ViewMode = "grid" | "list" | "graph";
+9
web/src/pages/Settings.tsx
··· 3 3 import { prefStore } from "$lib/store"; 4 4 import type { Component } from "solid-js"; 5 5 import { createSignal, For, Show } from "solid-js"; 6 + import { SyncDataTable } from "../components/SyncDataTable"; 6 7 7 8 type DensityOption = { value: DensityMode; label: string; description: string }; 8 9 ··· 132 133 {exportingNotes() ? "Exporting..." : "Export Notes"} 133 134 </button> 134 135 </div> 136 + </section> 137 + 138 + <section class="p-6 bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700"> 139 + <h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-4">Local Sync Data</h2> 140 + <p class="text-sm text-slate-600 dark:text-slate-400 mb-6"> 141 + View and manage locally cached data and pending sync operations. 142 + </p> 143 + <SyncDataTable /> 135 144 </section> 136 145 </div> 137 146 </div>
+16 -1
web/src/pages/tests/DeckNew.test.tsx
··· 1 + import "fake-indexeddb/auto"; 1 2 import { cleanup, render, screen } from "@solidjs/testing-library"; 2 3 import { JSX } from "solid-js"; 3 4 import { afterEach, describe, expect, it, vi } from "vitest"; ··· 9 10 10 11 vi.mock( 11 12 "$lib/store", 12 - () => ({ prefStore: { prefs: vi.fn(() => ({ tutorial_deck_completed: true })), fetchPrefs: vi.fn() } }), 13 + () => ({ 14 + prefStore: { prefs: vi.fn(() => ({ tutorial_deck_completed: true })), fetchPrefs: vi.fn() }, 15 + authStore: { user: vi.fn(() => ({ did: "did:plc:test" })) }, 16 + }), 17 + ); 18 + 19 + vi.mock( 20 + "$lib/sync-store", 21 + () => ({ 22 + syncStore: { 23 + saveDeckLocally: vi.fn().mockResolvedValue({ id: "local_123" }), 24 + saveCardLocally: vi.fn().mockResolvedValue({ id: "card_123" }), 25 + isOnline: vi.fn(() => true), 26 + }, 27 + }), 13 28 ); 14 29 15 30 vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+3 -1
web/src/pages/tests/Notes.test.tsx
··· 1 + import "fake-indexeddb/auto"; 1 2 import { api } from "$lib/api"; 2 3 import type { Note } from "$lib/model"; 3 4 import { MemoryRouter, Route } from "@solidjs/router"; ··· 6 7 import Notes from "../Notes"; 7 8 8 9 vi.mock("$lib/api", () => ({ api: { getNotes: vi.fn() } })); 9 - 10 + vi.mock("$lib/store", () => ({ authStore: { user: vi.fn(() => ({ did: "did:plc:test" })) } })); 11 + vi.mock("$lib/sync-store", () => ({ syncStore: { getLocalNotes: vi.fn().mockResolvedValue([]) } })); 10 12 vi.mock("$lib/density-context", () => ({ useDensity: vi.fn(() => "comfortable") })); 11 13 12 14 const mockNotes: Note[] = [{
+12
web/src/pages/tests/Settings.test.tsx
··· 1 + import "fake-indexeddb/auto"; 1 2 import { api } from "$lib/api"; 2 3 import { prefStore } from "$lib/store"; 3 4 import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; ··· 6 7 7 8 vi.mock("$lib/api", () => ({ api: { exportData: vi.fn() } })); 8 9 vi.mock("$lib/store", () => ({ prefStore: { densityMode: vi.fn(() => "comfortable"), updatePreferences: vi.fn() } })); 10 + vi.mock( 11 + "$lib/sync-store", 12 + () => ({ 13 + syncStore: { 14 + getAllLocalData: vi.fn().mockResolvedValue({ decks: [], notes: [], cards: [], queue: [] }), 15 + queueForSync: vi.fn(), 16 + processQueue: vi.fn(), 17 + clearAll: vi.fn(), 18 + }, 19 + }), 20 + ); 9 21 10 22 vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:test"), revokeObjectURL: vi.fn() }); 11 23