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:
parent
66d5240240
commit
c209417381
2 changed files with 238 additions and 3 deletions
212
docs/task-6-autosave-store-plan.md
Normal file
212
docs/task-6-autosave-store-plan.md
Normal 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
|
||||||
|
|
@ -2,16 +2,19 @@ export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
|
||||||
|
|
||||||
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
|
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
|
||||||
debounceMs?: number
|
debounceMs?: number
|
||||||
|
idleResetMs?: number
|
||||||
getPayload: () => TPayload | null | undefined
|
getPayload: () => TPayload | null | undefined
|
||||||
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
|
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>(
|
export function createAutoSaveController<TPayload, TResponse = unknown>(
|
||||||
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
|
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
|
||||||
) {
|
) {
|
||||||
const debounceMs = opts.debounceMs ?? 2000
|
const debounceMs = opts.debounceMs ?? 2000
|
||||||
|
const idleResetMs = opts.idleResetMs ?? 2000
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let controller: AbortController | null = null
|
let controller: AbortController | null = null
|
||||||
let lastSentHash: string | 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>()
|
const errorSubs = new Set<(v: string | null) => void>()
|
||||||
|
|
||||||
function setStatus(next: AutoSaveStatus) {
|
function setStatus(next: AutoSaveStatus) {
|
||||||
|
if (idleResetTimer) {
|
||||||
|
clearTimeout(idleResetTimer)
|
||||||
|
idleResetTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
_status = next
|
_status = next
|
||||||
statusSubs.forEach((fn) => fn(_status))
|
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() {
|
function schedule() {
|
||||||
|
|
@ -51,7 +72,7 @@ export function createAutoSaveController<TPayload, TResponse = unknown>(
|
||||||
const res = await opts.save(payload, { signal: controller.signal })
|
const res = await opts.save(payload, { signal: controller.signal })
|
||||||
lastSentHash = hash
|
lastSentHash = hash
|
||||||
setStatus('saved')
|
setStatus('saved')
|
||||||
if (opts.onSaved) opts.onSaved(res)
|
if (opts.onSaved) opts.onSaved(res, { prime })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') {
|
if (e?.name === 'AbortError') {
|
||||||
// Newer save superseded this one
|
// Newer save superseded this one
|
||||||
|
|
@ -73,6 +94,7 @@ export function createAutoSaveController<TPayload, TResponse = unknown>(
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
|
if (idleResetTimer) clearTimeout(idleResetTimer)
|
||||||
if (controller) controller.abort()
|
if (controller) controller.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +115,8 @@ export function createAutoSaveController<TPayload, TResponse = unknown>(
|
||||||
},
|
},
|
||||||
schedule,
|
schedule,
|
||||||
flush,
|
flush,
|
||||||
destroy
|
destroy,
|
||||||
|
prime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue