feat(admin): add prime() and auto-idle to autosave controller

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 <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-10-07 07:54:49 -07:00
parent 66d5240240
commit c209417381
2 changed files with 238 additions and 3 deletions

View file

@ -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<AutoSaveStatus>('idle')
let lastError = $state<string | null>(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
<script>
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
const store = createAutoSaveStore({ ... })
</script>
<div>Status: {store.status}</div>
```
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

View file

@ -2,16 +2,19 @@ export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
debounceMs?: number
idleResetMs?: number
getPayload: () => TPayload | null | undefined
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
onSaved?: (res: TResponse) => void
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
}
export function createAutoSaveController<TPayload, TResponse = unknown>(
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
) {
const debounceMs = opts.debounceMs ?? 2000
const idleResetMs = opts.idleResetMs ?? 2000
let timer: ReturnType<typeof setTimeout> | null = null
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null
let lastSentHash: string | null = null
@ -21,8 +24,26 @@ export function createAutoSaveController<TPayload, TResponse = unknown>(
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<TPayload, TResponse = unknown>(
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<TPayload, TResponse = unknown>(
function destroy() {
if (timer) clearTimeout(timer)
if (idleResetTimer) clearTimeout(idleResetTimer)
if (controller) controller.abort()
}
@ -93,7 +115,8 @@ export function createAutoSaveController<TPayload, TResponse = unknown>(
},
schedule,
flush,
destroy
destroy,
prime
}
}