diff --git a/src/lib/components/Checklist.svelte b/src/lib/components/Checklist.svelte index 18ce54d..bddf394 100644 --- a/src/lib/components/Checklist.svelte +++ b/src/lib/components/Checklist.svelte @@ -24,6 +24,18 @@ let doneExpanded = $state(true); const flipDurationMs = 150; + // Track the last content we emitted so we can distinguish self-originated + // changes from external ones (sync, history restore, etc.) + let lastEmitted = content; + + // Re-parse items when content changes externally (not from our own emitChange) + $effect(() => { + if (content !== lastEmitted) { + items = parseChecklist(content); + lastEmitted = content; + } + }); + let activeItems = $derived(items.filter((i) => !i.checked)); let doneItems = $derived(items.filter((i) => i.checked)); let doneCount = $derived(doneItems.length); @@ -49,7 +61,9 @@ } function emitChange() { - onChange(serializeChecklist(items)); + const serialized = serializeChecklist(items); + lastEmitted = serialized; + onChange(serialized); } function toggleItem(id: string) { diff --git a/src/lib/stores/notes.test.ts b/src/lib/stores/notes.test.ts new file mode 100644 index 0000000..637fe0f --- /dev/null +++ b/src/lib/stores/notes.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { mergeNotesByVersion } from './notes.js'; +import type { Note } from '$lib/types/index.js'; + +function makeNote(overrides: Partial = {}): Note { + return { + id: 'note-1', + title: 'Test', + content: '- [ ] Item', + color: 'default', + pinned: false, + archived: false, + trashed: false, + trashedAt: null, + checklistMode: true, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + version: 1, + tags: [], + attachments: [], + collaborators: [], + isOwner: true, + isShared: false, + ...overrides + }; +} + +describe('mergeNotesByVersion', () => { + it('keeps the local note when it has a higher version than the incoming note', () => { + const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' }); + const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' }); + + const result = mergeNotesByVersion([local], [incoming]); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe('- [x] Checked'); + expect(result[0].version).toBe(3); + }); + + it('uses the incoming note when it has a higher or equal version', () => { + const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' }); + const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' }); + + const result = mergeNotesByVersion([local], [incoming]); + + expect(result).toHaveLength(1); + expect(result[0].content).toBe('- [x] New'); + expect(result[0].version).toBe(3); + }); + + it('uses the incoming note when versions are equal', () => { + const local = makeNote({ id: 'n1', version: 2, content: 'local' }); + const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' }); + + const result = mergeNotesByVersion([local], [incoming]); + + expect(result[0].content).toBe('incoming'); + }); + + it('handles notes not present in the local store', () => { + const incoming = makeNote({ id: 'n2', version: 1 }); + + const result = mergeNotesByVersion([], [incoming]); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('n2'); + }); + + it('preserves multiple notes correctly during a race condition', () => { + const localNotes = [ + makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }), + makeNote({ id: 'n2', version: 1, content: 'unchanged' }) + ]; + const incomingNotes = [ + makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }), + makeNote({ id: 'n2', version: 2, content: 'updated on server' }) + ]; + + const result = mergeNotesByVersion(localNotes, incomingNotes); + + expect(result).toHaveLength(2); + // n1: local version 5 > incoming version 4 → keep local + expect(result[0].content).toBe('- [x] Just saved'); + // n2: local version 1 < incoming version 2 → use incoming + expect(result[1].content).toBe('updated on server'); + }); +}); diff --git a/src/lib/stores/notes.ts b/src/lib/stores/notes.ts index a6c1c64..e45b008 100644 --- a/src/lib/stores/notes.ts +++ b/src/lib/stores/notes.ts @@ -53,12 +53,27 @@ function isNetworkError(err: unknown): boolean { return err instanceof TypeError; } +/** + * Merge incoming notes with current store, keeping the higher-version note + * when a conflict exists. This prevents a stale loadNotes response from + * overwriting a just-saved note (race between sync interval and updateNote). + */ +export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] { + const currentMap = new Map(current.map((n) => [n.id, n])); + const merged = incoming.map((incomingNote) => { + const local = currentMap.get(incomingNote.id); + if (local && local.version > incomingNote.version) return local; + return incomingNote; + }); + return merged; +} + export async function loadNotes(filter: NoteFilter = 'all') { // Load from IDB first for instant display try { const cached = await getAllNotes(); if (cached.length > 0) { - notes.set(cached); + notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached); } } catch { // IDB unavailable — continue with server fetch @@ -71,7 +86,7 @@ export async function loadNotes(filter: NoteFilter = 'all') { const res = await fetch(`/api/notes?filter=${filter}`); if (res.ok) { const data: Note[] = await res.json(); - notes.set(data); + notes.update((current) => mergeNotesByVersion(current, data)); currentFilter.set(filter); // Update IDB with server state diff --git a/tests/e2e/checklist.spec.ts b/tests/e2e/checklist.spec.ts index d7a2a47..a402d5e 100644 --- a/tests/e2e/checklist.spec.ts +++ b/tests/e2e/checklist.spec.ts @@ -60,6 +60,26 @@ test.describe('Checklist', () => { await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked(); }); + test('Scenario: Multiple checked items persist after closing and reopening', async ({ authenticatedPage: page }) => { + // Given a checklist note with three items exists + await createChecklistNote(page, 'Groceries', ['Milk', 'Eggs', 'Bread']); + + // When the user reopens and checks two items + await noteCard(page, 'Groceries').click(); + await page.getByTestId('checklist-checkbox').nth(0).click(); // Milk → done + await page.getByTestId('checklist-checkbox').nth(0).click(); // Eggs (now first) → done + + // Then the done section shows two checked items + await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done'); + await page.getByTestId('close-editor-btn').click(); + + // And the checked state persists after reopening + await noteCard(page, 'Groceries').click(); + await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done'); + await expect(page.getByTestId('checklist-input')).toHaveCount(1); // Only Bread remains active + await expect(page.getByTestId('checklist-input').first()).toHaveValue('Bread'); + }); + test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => { // Given a checklist with one item await page.getByTestId('new-note-btn').click();