From c2094173811ceaabc7a07d8bab2ca84e734ec9b9 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 7 Oct 2025 07:54:49 -0700 Subject: [PATCH] feat(admin): add prime() and auto-idle to autosave controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhances autosave controller with missing features: - prime(payload): Sets initial hash baseline to prevent autosaves on page load - idleResetMs option: Auto-transitions from 'saved' → 'idle' status (default 2s) - onSaved callback: Now receives { prime } helper for re-priming after server response - Cleanup: destroy() now properly clears idle reset timer All existing tests pass. Backward compatible - forms not using new features yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/task-6-autosave-store-plan.md | 212 +++++++++++++++++++++++++++++ src/lib/admin/autoSave.ts | 29 +++- 2 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 docs/task-6-autosave-store-plan.md diff --git a/docs/task-6-autosave-store-plan.md b/docs/task-6-autosave-store-plan.md new file mode 100644 index 0000000..00562cc --- /dev/null +++ b/docs/task-6-autosave-store-plan.md @@ -0,0 +1,212 @@ +# Task 6: Autosave Store Implementation Plan + +## Goal +Modernize autosave to use Svelte 5 runes while fixing existing bugs. Ensure data integrity through incremental implementation with validation points. + +--- + +## Overview + +**Current State:** +- `createAutoSaveController()` uses manual subscriptions (Svelte 4 pattern) +- Works in ProjectForm and partially in posts editor +- Has known bugs: autosaves on load, broken navigation guard, status doesn't reset to idle + +**Target State:** +- `createAutoSaveStore()` using Svelte 5 `$state()` runes +- Fixes known bugs (prime baseline, auto-idle, navigation guard) +- Clean API: `autoSave.status` instead of `autoSave.status.subscribe(...)` +- Reusable across all admin forms + +--- + +## Implementation Steps + +### Step 1: Add Missing Features to Current Controller +**Why first:** Existing tests already expect these features. Fix bugs before converting to runes. + +**Changes to `src/lib/admin/autoSave.ts`:** +- Add `prime(payload)` method to set initial hash baseline (prevents autosave on load) +- Add `idleResetMs` option for auto-transition: 'saved' → 'idle' (default 2000ms) +- Enhance `onSaved` callback to receive `{ prime }` helper for re-priming after server response + +**Validation:** +```bash +node --test --loader tsx tests/autoSaveController.test.ts +``` +All 3 tests should pass. + +**Quick Manual Test:** +- Open browser console on ProjectForm +- Verify no PUT request fires on initial load +- Make an edit, verify save triggers after 2s + +--- + +### Step 2: Convert to Runes-Based Store +**Why separate:** Proves the rune conversion without complicating Step 1's bug fixes. + +**Changes:** +1. Rename: `src/lib/admin/autoSave.ts` → `src/lib/admin/autoSave.svelte.ts` +2. Replace manual subscriptions with rune-based state: + ```typescript + let status = $state('idle') + let lastError = $state(null) + + return { + get status() { return status }, + get lastError() { return lastError }, + schedule, + flush, + destroy, + prime + } + ``` +3. Export types: `AutoSaveStore`, `AutoSaveStoreOptions` + +**Validation:** +```bash +npm run check # Should pass (ignore pre-existing errors) +``` + +Create minimal test component: +```svelte + + +
Status: {store.status}
+``` +Verify status updates reactively without manual subscription. + +--- + +### Step 3: Update ProjectForm (Pilot) +**Why ProjectForm first:** It's the most complex form. If it works here, others will be easier. + +**Changes to `src/lib/components/admin/ProjectForm.svelte`:** +1. Import new store: `import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'` +2. Remove subscription code (if any exists) +3. Add `hasLoaded` flag: + ```typescript + let hasLoaded = $state(false) + ``` +4. After `populateFormData()` completes: + ```typescript + formData = { ...loadedData } + autoSave?.prime(buildPayload()) + hasLoaded = true + ``` +5. Update `$effect` that schedules autosave: + ```typescript + $effect(() => { + formData // establish dependency + if (mode === 'edit' && hasLoaded && autoSave) { + autoSave.schedule() + if (draftKey) saveDraft(draftKey, buildPayload()) + } + }) + ``` +6. Use lifecycle helper (if not already): + ```typescript + import { initAutoSaveLifecycle } from '$lib/admin/autoSaveLifecycle' + + if (mode === 'edit' && autoSave) { + initAutoSaveLifecycle(autoSave, { + isReady: () => hasLoaded, + onFlushError: (error) => console.error('Autosave flush failed:', error) + }) + } + ``` + +**Critical Validation Checklist:** +- [ ] Open existing project → no autosave fires +- [ ] Edit title → autosave triggers after 2s +- [ ] Status shows: idle → saving → saved → idle +- [ ] Make edit, navigate away → save completes first +- [ ] Press Cmd/Ctrl+S → immediate save +- [ ] Make edit, refresh page → draft prompt appears +- [ ] Restore draft, make manual save → draft clears + +**Debugging:** +- Network tab: Watch for PUT requests to `/api/projects/{id}` +- Console: Add `console.log('Saving:', payload)` in save function +- Console: Add `console.log('Status:', store.status)` to watch transitions + +--- + +### Step 4: Update Posts Editor +**Apply same pattern to `src/routes/admin/posts/[id]/edit/+page.svelte`** + +Key differences: +- Simpler structure (no case study) +- Add missing `restoreDraft()` and `dismissDraft()` functions (currently referenced but not defined) + +**Validation:** Same checklist as ProjectForm + +--- + +### Step 5: Update Remaining Forms (Optional) +If EssayForm, PhotoPostForm, SimplePostForm use autosave, apply same pattern. + +**Validation:** Quick smoke test (edit, save, verify no errors) + +--- + +### Step 6: Update Tests & Cleanup +1. Rename test file: `tests/autoSaveController.test.ts` → `tests/autoSaveStore.test.ts` +2. Update imports in test file +3. Run tests: `node --test --loader tsx tests/autoSaveStore.test.ts` +4. Update `docs/autosave-completion-guide.md` to reflect new API + +--- + +## Data Integrity Safeguards + +### Hash-Based Deduplication +✓ Only saves when payload changes (via JSON hash comparison) + +### Concurrency Control +✓ `updatedAt` field prevents overwriting newer server data + +### Request Cancellation +✓ AbortController cancels in-flight requests when new save triggered + +### Navigation Guard +✓ Waits for flush to complete before allowing route change + +### Draft Recovery +✓ localStorage backup in case of crash/accidental navigation + +--- + +## Rollback Strategy + +**If issues in Step 1:** Revert `autoSave.ts` changes +**If issues in Step 2:** Keep Step 1 fixes, revert rune conversion +**If issues in Step 3:** Only ProjectForm affected, other forms unchanged +**If issues in Step 4+:** Revert individual forms independently + +--- + +## Success Criteria + +- ✅ No autosaves on initial page load +- ✅ Saves trigger correctly on edits (2s debounce) +- ✅ Status indicator cycles properly (idle → saving → saved → idle) +- ✅ Navigation guard prevents data loss +- ✅ Draft recovery works reliably +- ✅ All unit tests pass +- ✅ Zero duplicate save requests +- ✅ Manual QA checklist passes + +--- + +## Notes + +- Keep old `autoSave.ts` until all forms migrate (backward compatibility) +- Test with slow network (Chrome DevTools → Network → Slow 3G) +- Test offline mode (DevTools → Network → Offline) +- Each step is independently testable +- Stop at any step if issues arise diff --git a/src/lib/admin/autoSave.ts b/src/lib/admin/autoSave.ts index 431f592..4b37f5e 100644 --- a/src/lib/admin/autoSave.ts +++ b/src/lib/admin/autoSave.ts @@ -2,16 +2,19 @@ export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline' interface CreateAutoSaveControllerOptions { debounceMs?: number + idleResetMs?: number getPayload: () => TPayload | null | undefined save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise - onSaved?: (res: TResponse) => void + onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void } export function createAutoSaveController( opts: CreateAutoSaveControllerOptions ) { const debounceMs = opts.debounceMs ?? 2000 + const idleResetMs = opts.idleResetMs ?? 2000 let timer: ReturnType | null = null + let idleResetTimer: ReturnType | null = null let controller: AbortController | null = null let lastSentHash: string | null = null @@ -21,8 +24,26 @@ export function createAutoSaveController( const errorSubs = new Set<(v: string | null) => void>() function setStatus(next: AutoSaveStatus) { + if (idleResetTimer) { + clearTimeout(idleResetTimer) + idleResetTimer = null + } + _status = next statusSubs.forEach((fn) => fn(_status)) + + // Auto-transition from 'saved' to 'idle' after idleResetMs + if (next === 'saved') { + idleResetTimer = setTimeout(() => { + _status = 'idle' + statusSubs.forEach((fn) => fn(_status)) + idleResetTimer = null + }, idleResetMs) + } + } + + function prime(payload: TPayload) { + lastSentHash = safeHash(payload) } function schedule() { @@ -51,7 +72,7 @@ export function createAutoSaveController( const res = await opts.save(payload, { signal: controller.signal }) lastSentHash = hash setStatus('saved') - if (opts.onSaved) opts.onSaved(res) + if (opts.onSaved) opts.onSaved(res, { prime }) } catch (e: any) { if (e?.name === 'AbortError') { // Newer save superseded this one @@ -73,6 +94,7 @@ export function createAutoSaveController( function destroy() { if (timer) clearTimeout(timer) + if (idleResetTimer) clearTimeout(idleResetTimer) if (controller) controller.abort() } @@ -93,7 +115,8 @@ export function createAutoSaveController( }, schedule, flush, - destroy + destroy, + prime } }