mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
feat: auto-save notes with debounced persistence
Notes are now automatically saved 2 seconds after the user stops editing, preventing data loss when the browser tab is closed without closing the editor first. A beforeunload handler provides an additional safety net. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
4f4cc69b9f
commit
07b27c01e7
@@ -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<void> | null = null;
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | 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<Attachment[]>(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<string | null> {
|
||||
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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user