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();
+ });
});