diff --git a/src/lib/components/Layout/Toast.svelte b/src/lib/components/Layout/Toast.svelte index c6ec051..e17656e 100644 --- a/src/lib/components/Layout/Toast.svelte +++ b/src/lib/components/Layout/Toast.svelte @@ -1,32 +1,5 @@
diff --git a/src/lib/components/SyncIndicator.svelte b/src/lib/components/SyncIndicator.svelte index 6e2a169..5b92f19 100644 --- a/src/lib/components/SyncIndicator.svelte +++ b/src/lib/components/SyncIndicator.svelte @@ -1,24 +1,57 @@
- {#if status === 'synced'} - - - - {:else if status === 'syncing'} - - - - {:else if status === 'offline'} - - - + {#if displayStatus === 'synced'} + + {:else if displayStatus === 'syncing'} + + {:else if displayStatus === 'offline'} + {:else} - - - + {/if}
diff --git a/src/lib/stores/notes.ts b/src/lib/stores/notes.ts index e8e740b..b04c83a 100644 --- a/src/lib/stores/notes.ts +++ b/src/lib/stores/notes.ts @@ -1,5 +1,7 @@ import { writable, derived } from 'svelte/store'; import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js'; +import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes } from '$lib/sync/idb.js'; +import { showToast } from '$lib/stores/toast.js'; export const notes = writable([]); export const currentFilter = writable('all'); @@ -37,50 +39,125 @@ export const allTags = derived(notes, ($notes) => { return [...tagSet].sort(); }); +function isNetworkError(err: unknown): boolean { + return err instanceof TypeError; +} + export async function loadNotes(filter: NoteFilter = 'all') { - const res = await fetch(`/api/notes?filter=${filter}`); - if (res.ok) { - const data = await res.json(); - notes.set(data); - currentFilter.set(filter); + try { + const res = await fetch(`/api/notes?filter=${filter}`); + if (res.ok) { + const data = await res.json(); + notes.set(data); + currentFilter.set(filter); + } + } catch (err) { + if (isNetworkError(err)) { + const cached = await getAllNotes(); + if (cached.length > 0) { + notes.set(cached); + currentFilter.set(filter); + } + } } } export async function createNote(note: NoteCreate): Promise { - const res = await fetch('/api/notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(note) - }); - if (res.ok) { - const created = await res.json(); - notes.update((list) => [created, ...list]); - return created; + try { + const res = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(note) + }); + if (res.ok) { + const created = await res.json(); + notes.update((list) => [created, ...list]); + return created; + } + return null; + } catch (err) { + if (isNetworkError(err)) { + const now = new Date(); + const optimistic: Note = { + id: crypto.randomUUID(), + title: note.title ?? '', + content: note.content ?? '', + color: note.color ?? 'default', + pinned: note.pinned ?? false, + archived: false, + trashed: false, + trashedAt: null, + checklistMode: note.checklistMode ?? false, + sortOrder: 0, + createdAt: now, + updatedAt: now, + version: 1 + }; + notes.update((list) => [optimistic, ...list]); + await putNote(optimistic); + await addToSyncQueue({ noteId: optimistic.id, operation: 'create', data: optimistic, timestamp: Date.now() }); + showToast('Saved offline — will sync when reconnected', 'info'); + return optimistic; + } + return null; } - return null; } export async function updateNote(id: string, updates: NoteUpdate): Promise { - const res = await fetch(`/api/notes/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates) - }); - if (res.ok) { - const updated = await res.json(); - notes.update((list) => list.map((n) => (n.id === id ? updated : n))); - return updated; + try { + const res = await fetch(`/api/notes/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + if (res.ok) { + const updated = await res.json(); + notes.update((list) => list.map((n) => (n.id === id ? updated : n))); + return updated; + } + showToast('Failed to save note', 'error'); + return null; + } catch (err) { + if (isNetworkError(err)) { + let optimistic: Note | null = null; + notes.update((list) => + list.map((n) => { + if (n.id === id) { + optimistic = { ...n, ...updates, updatedAt: new Date() }; + return optimistic; + } + return n; + }) + ); + if (optimistic) { + await putNote(optimistic); + await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() }); + showToast('Saved offline — will sync when reconnected', 'info'); + } + return optimistic; + } + return null; } - return null; } export async function deleteNote(id: string): Promise { - const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' }); - if (res.ok) { - notes.update((list) => list.filter((n) => n.id !== id)); - return true; + try { + const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' }); + if (res.ok) { + notes.update((list) => list.filter((n) => n.id !== id)); + return true; + } + return false; + } catch (err) { + if (isNetworkError(err)) { + notes.update((list) => list.filter((n) => n.id !== id)); + await deleteNoteFromIdb(id); + await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() }); + showToast('Saved offline — will sync when reconnected', 'info'); + return true; + } + return false; } - return false; } export async function trashNote(id: string): Promise { diff --git a/src/lib/stores/toast.ts b/src/lib/stores/toast.ts new file mode 100644 index 0000000..fada8f4 --- /dev/null +++ b/src/lib/stores/toast.ts @@ -0,0 +1,28 @@ +import { writable } from 'svelte/store'; + +export interface ToastMessage { + id: number; + text: string; + type: 'info' | 'success' | 'error'; + action?: { label: string; handler: () => void }; +} + +let counter = 0; +export const toasts = writable([]); + +export function showToast( + text: string, + type: 'info' | 'success' | 'error' = 'info', + action?: { label: string; handler: () => void }, + duration = 4000 +) { + const id = ++counter; + toasts.update((t) => [...t, { id, text, type, action }]); + if (!action) { + setTimeout(() => dismiss(id), duration); + } +} + +export function dismiss(id: number) { + toasts.update((t) => t.filter((toast) => toast.id !== id)); +} diff --git a/src/lib/sync/client.ts b/src/lib/sync/client.ts index 3213a50..3c80cb8 100644 --- a/src/lib/sync/client.ts +++ b/src/lib/sync/client.ts @@ -2,6 +2,8 @@ import { browser } from '$app/environment'; import type { Note } from '$lib/types/index.js'; import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js'; import { mergeNotes } from './crdt.js'; +import { notes as notesStore, currentFilter } from '$lib/stores/notes.js'; +import { get } from 'svelte/store'; export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error'; @@ -98,6 +100,14 @@ export async function sync(): Promise { await pullChanges(); setStatus('synced'); + + // Reload notes from server to reflect synced state + const filter = get(currentFilter); + const res = await fetch(`/api/notes?filter=${filter}`); + if (res.ok) { + const data = await res.json(); + notesStore.set(data); + } } catch { setStatus('error'); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index eac7e98..92b090c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,13 +4,19 @@ import Sidebar from '$lib/components/Layout/Sidebar.svelte'; import Toast from '$lib/components/Layout/Toast.svelte'; import { loadNotes } from '$lib/stores/notes.js'; - import { onMount } from 'svelte'; + import { startSync, stopSync } from '$lib/sync/client.js'; + import { onMount, onDestroy } from 'svelte'; let { data, children } = $props(); let sidebarOpen = $state(false); onMount(() => { loadNotes(); + startSync(); + }); + + onDestroy(() => { + stopSync(); }); diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts index da5de80..8f17529 100644 --- a/tests/e2e/offline.spec.ts +++ b/tests/e2e/offline.spec.ts @@ -16,4 +16,87 @@ test.describe('Offline Support', () => { // Then the note appears in the list await expect(page.getByText('Offline Test Note')).toBeVisible(); }); + + test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => { + // Given a note titled "Offline Edit" exists + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Offline Edit'); + await page.getByTestId('close-editor-btn').click(); + await expect(page.getByText('Offline Edit')).toBeVisible(); + + // When the network is unavailable + await page.context().setOffline(true); + + // And the user edits the note title to "Offline Edited" + await page.getByTestId('note-card').click(); + await page.getByTestId('note-title-input').clear(); + await page.getByTestId('note-title-input').fill('Offline Edited'); + await page.getByTestId('close-editor-btn').click(); + + // Then the note "Offline Edited" appears in the list + await expect(page.getByText('Offline Edited')).toBeVisible(); + + // And a "Saved offline" toast is shown + await expect(page.getByText('Saved offline')).toBeVisible(); + }); + + test('Scenario: New note appears in the UI when created offline', async ({ authenticatedPage: page }) => { + // When the network is unavailable + await page.context().setOffline(true); + + // And the user creates a note titled "Offline New" + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Offline New'); + await page.getByTestId('close-editor-btn').click(); + + // Then the note "Offline New" appears in the list + await expect(page.getByText('Offline New')).toBeVisible(); + + // And a "Saved offline" toast is shown + await expect(page.getByText('Saved offline')).toBeVisible(); + }); + + test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => { + // When the network is unavailable + await page.context().setOffline(true); + + // And the user creates a note titled "Survive Reload" + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Survive Reload'); + await page.getByTestId('close-editor-btn').click(); + await expect(page.getByText('Survive Reload')).toBeVisible(); + + // When the network is restored (triggers 'online' event + navigator.onLine = true) + await page.context().setOffline(false); + + // And the sync completes + await page.waitForResponse('**/api/sync'); + + // And the user reloads the page + await page.reload(); + + // Then the note is still visible + await expect(page.getByText('Survive Reload')).toBeVisible(); + }); + + test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => { + // Given a note titled "Offline Trash" exists + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Offline Trash'); + await page.getByTestId('close-editor-btn').click(); + await expect(page.getByText('Offline Trash')).toBeVisible(); + + // When the network is unavailable + await page.context().setOffline(true); + + // And the user trashes the note + await page.getByTestId('note-card').hover(); + await page.getByTestId('trash-btn').click(); + + // Then the note is no longer visible + await expect(page.getByText('Offline Trash')).not.toBeVisible(); + + // And a "Saved offline" toast is shown + await expect(page.getByText('Saved offline')).toBeVisible(); + }); });