forked from mirror/crumbs-original
fix: handle offline note editing with sync queue, toasts, and live sync indicator
- Extract toast store to standalone module so notes store can show toasts - Add try/catch to all note mutations with optimistic updates + IDB queue on network error - Wire up startSync/stopSync in layout to enable online/offline event listeners - Reload notes from server after successful sync push to prevent data loss - Connect SyncIndicator to real sync status with Lucide cloud icons - Add 300ms minimum display time for status transitions - Add e2e tests for offline create, edit, trash, and sync-on-reconnect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
df3a65ab08
commit
f684e38134
@@ -1,32 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'info' | 'success' | 'error';
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
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));
|
||||
}
|
||||
import { toasts, dismiss } from '$lib/stores/toast.js';
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
<script lang="ts">
|
||||
// Placeholder for Phase 6 - will be connected to sync store
|
||||
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
|
||||
|
||||
let status: SyncStatus = $state(getSyncStatus());
|
||||
let displayStatus: SyncStatus = $state(getSyncStatus());
|
||||
let minDisplayTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingStatus: SyncStatus | undefined;
|
||||
|
||||
function applyStatus(s: SyncStatus) {
|
||||
if (displayStatus === s) return;
|
||||
|
||||
if (minDisplayTimeout) {
|
||||
pendingStatus = s;
|
||||
return;
|
||||
}
|
||||
|
||||
displayStatus = s;
|
||||
minDisplayTimeout = setTimeout(() => {
|
||||
minDisplayTimeout = undefined;
|
||||
if (pendingStatus !== undefined && pendingStatus !== displayStatus) {
|
||||
applyStatus(pendingStatus);
|
||||
pendingStatus = undefined;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribe = onSyncStatusChange((s) => {
|
||||
status = s;
|
||||
applyStatus(s);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
if (minDisplayTimeout) clearTimeout(minDisplayTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
|
||||
{#if status === 'synced'}
|
||||
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else if status === 'syncing'}
|
||||
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{:else if status === 'offline'}
|
||||
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</svg>
|
||||
{#if displayStatus === 'synced'}
|
||||
<CloudCheck class="h-5 w-5 text-green-500" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<CloudSync class="h-5 w-5 text-blue-500" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<CloudOff class="h-5 w-5 text-yellow-500" />
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<CloudAlert class="h-5 w-5 text-red-500" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+107
-30
@@ -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<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('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<Note | null> {
|
||||
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<Note | null> {
|
||||
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<boolean> {
|
||||
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<Note | null> {
|
||||
|
||||
@@ -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<ToastMessage[]>([]);
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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<void> {
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user