diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index bb50ad9..4720a28 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -63,6 +63,77 @@ // svelte-ignore state_referenced_locally let currentlyNew = $state(isNew); + // Auto-save: track last-saved state to detect real changes + let lastSavedTitle = note?.title ?? ''; + let lastSavedContent = note?.content ?? ''; + let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor; + let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode; + let isSaving = false; + let savingPromise: Promise | null = null; + let autoSaveTimer: ReturnType | undefined; + + function hasUnsavedChanges(): boolean { + return ( + title !== lastSavedTitle || + content !== lastSavedContent || + color !== lastSavedColor || + checklistMode !== lastSavedChecklistMode + ); + } + + async function performSave() { + if (isSaving) return; + if (!title.trim() && !content.trim()) return; + if (!hasUnsavedChanges()) return; + + isSaving = true; + try { + if (currentlyNew) { + const created = await createNote({ title, content, color, checklistMode }); + if (created) { + noteId = created.id; + currentlyNew = false; + } + } else if (noteId) { + await updateNote(noteId, { title, content, color, checklistMode }); + } + lastSavedTitle = title; + lastSavedContent = content; + lastSavedColor = color; + lastSavedChecklistMode = checklistMode; + } finally { + isSaving = false; + } + } + + // Debounced auto-save: triggers 2s after the user stops editing + $effect(() => { + const _t = title; + const _c = content; + const _col = color; + const _cm = checklistMode; + + if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return; + if (!_t.trim() && !_c.trim()) return; + + autoSaveTimer = setTimeout(() => { + savingPromise = performSave(); + }, 2000); + + return () => clearTimeout(autoSaveTimer); + }); + + // Safety net: save on tab close / navigation + $effect(() => { + function handleBeforeUnload() { + if (hasUnsavedChanges() && (title.trim() || content.trim())) { + performSave(); + } + } + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }); + // svelte-ignore state_referenced_locally let attachmentsList = $state(note?.attachments ?? []); @@ -242,15 +313,18 @@ } async function saveAndClose() { + clearTimeout(autoSaveTimer); + // Wait for any in-flight auto-save to finish + if (savingPromise) await savingPromise; + if (!title.trim() && !content.trim()) { onClose(); return; } - if (currentlyNew) { - await createNote({ title, content, color, checklistMode }); - } else if (noteId) { - await updateNote(noteId, { title, content, color, checklistMode }); + // Final save if auto-save hasn't caught up yet + if (hasUnsavedChanges()) { + await performSave(); } onClose(); } @@ -258,18 +332,14 @@ /** Auto-save a new note without closing, returns the new note ID */ async function autoSave(): Promise { if (!currentlyNew) return noteId; - const created = await createNote({ - title: title || 'Untitled', - content, - color, - checklistMode - }); - if (created) { - noteId = created.id; - currentlyNew = false; - return created.id; - } - return null; + clearTimeout(autoSaveTimer); + if (savingPromise) await savingPromise; + // For feature-triggered saves (image upload, share), use 'Untitled' fallback + const prevTitle = title; + if (!title.trim()) title = 'Untitled'; + await performSave(); + if (!prevTitle.trim()) title = prevTitle; + return noteId; } async function toggleImageUpload() { diff --git a/tests/e2e/auto-save.spec.ts b/tests/e2e/auto-save.spec.ts new file mode 100644 index 0000000..4b46646 --- /dev/null +++ b/tests/e2e/auto-save.spec.ts @@ -0,0 +1,83 @@ +import { test, expect, noteCard, createNote } from './helpers/fixtures.js'; + +test.describe('Auto-save', () => { + test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => { + // When the user starts a new note and types a title and content + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Auto-saved Note'); + const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); + await editor.click(); + await editor.pressSequentially('This should be saved automatically'); + + // Then the note is persisted after the auto-save delay (2s debounce) + await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 }); + + // And closing the editor preserves the note + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, 'Auto-saved Note')).toBeVisible(); + }); + + test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => { + // Given a note titled "Edit Me" exists + await createNote(page, 'Edit Me', 'Original content'); + + // When the user opens the note and changes the title + await noteCard(page, 'Edit Me').click(); + await page.getByTestId('note-title-input').clear(); + await page.getByTestId('note-title-input').fill('Edited Title'); + + // Then the updated title is auto-saved after the debounce delay + // Wait for the API call that confirms the save + await page.waitForResponse( + (res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH', + { timeout: 5000 } + ); + + // And the note card reflects the change while editor is still open + await expect(noteCard(page, 'Edited Title')).toBeVisible(); + + // Cleanup: close the editor + await page.getByTestId('close-editor-btn').click(); + }); + + test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => { + // When the user opens a new note but leaves it empty + await page.getByTestId('new-note-btn').click(); + + // And waits longer than the auto-save delay + await page.waitForTimeout(3000); + + // Then no note card appears in the list + const noteCards = page.locator('[data-testid="note-card"]'); + const countBefore = await noteCards.count(); + + // Close the empty editor + await page.getByTestId('close-editor-btn').click(); + + // And no new note was created + const countAfter = await noteCards.count(); + expect(countAfter).toBe(countBefore); + }); + + test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => { + // When the user creates a note and waits for auto-save + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Survive Reload'); + const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); + await editor.click(); + await editor.pressSequentially('Content that survives'); + + // Wait for auto-save to fire + await page.waitForResponse( + (res) => res.url().includes('/api/notes') && res.request().method() === 'POST', + { timeout: 5000 } + ); + + // When the page is reloaded without closing the editor + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Then the note is still visible + await expect(noteCard(page, 'Survive Reload')).toBeVisible(); + }); +});