Merge pull request #15 from jedmund/jedmund/autosave

complete autosave implementation
This commit is contained in:
Justin Edmund 2025-10-07 03:25:12 -07:00 committed by GitHub
commit ed906b6c75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 591 additions and 0 deletions

View file

@ -0,0 +1,61 @@
# Admin Autosave Completion Guide
## Objectives
- Eliminate redundant save requests triggered on initial page load.
- Restore reliable local draft recovery, including clear-up of stale backups.
- Deliver autosave status feedback that visibly transitions back to `idle` after successful saves.
- Ensure navigation/unload flows wait for pending autosaves instead of cancelling them mid-flight.
## Key Problem Areas
### Missing Draft Handlers
- `src/routes/admin/posts/[id]/edit/+page.svelte:425` references `restoreDraft` and `dismissDraft`, but the functions are never defined. Draft recovery buttons therefore break compilation and runtime behavior.
### Immediate Autosaves on Load
- Effects in `src/routes/admin/posts/[id]/edit/+page.svelte:307` and `src/lib/components/admin/ProjectForm.svelte:157` call `autoSave.schedule()` as soon as the component mounts. Because the payload hash includes `updatedAt`, each mount triggers redundant PUTs until the server response realigns the hash.
### Ineffective Navigation Guard
- `beforeNavigate(() => autoSave.flush())` (posts + project form) does not cancel the outbound navigation, so the flush typically aborts when the route unloads. Result: unsaved work if the user navigates away during a pending autosave.
### Controller Lifecycle Gaps
- `createAutoSaveController` timers/AbortController persist after leaving the page because callers never invoke `destroy()`.
- Post editor imports `clearDraft` but never clears the draft after successful saves or when dismissing the prompt, so stale backups reappear.
## Controller Enhancements (`src/lib/admin/autoSave.ts`)
- **Baseline priming**: Add a `prime(initialPayload)` (or allow `onSaved` to pass the response payload) to set `lastSentHash` immediately after fetching server data. This prevents an automatic save when the user has not made changes.
- **Auto-idle transition**: When status becomes `'saved'`, set a timeout (e.g., 2s) that reverts status to `'idle'`. Cancel the timeout on any new state change.
- **Robust destroy**: Ensure `destroy()` clears pending timers and aborts the current request; expose and require callers to invoke it on component teardown.
- Consider optional helper flags (e.g., `autoResetStatus`) so forms do not reimplement timing logic.
## Shared Lifecycle Helper
Create a utility (e.g., `initAutoSaveLifecycle`) that accepts the controller plus configuration:
- Registers keyboard shortcut (`Cmd/Ctrl+S`) to `flush()` once the page has loaded.
- Provides a real navigation guard that cancels the navigation event, awaits `flush()`, then resumes or surfaces an error.
- Hooks into `onDestroy` to remove listeners and call `controller.destroy()`.
- Optionally wires window unload handling if needed.
## Form Integration Checklist
### Posts Editor (`src/routes/admin/posts/[id]/edit/+page.svelte`)
1. Implement `restoreDraft` / `dismissDraft` and handle `clearDraft` after autosave or manual save success.
2. Introduce a `hasLoaded` flag set after `loadPost()` (and controller `prime`) before scheduling autosave.
3. Adopt the shared lifecycle helper for navigation, keyboard shortcuts, and cleanup.
### Project Form (`src/lib/components/admin/ProjectForm.svelte`)
1. Mirror baseline priming and `hasLoaded` gating before scheduling.
2. Clear drafts on success or dismissal, and reuse the lifecycle helper.
3. Ensure autosave only starts after the initial project data populates `formData`.
### Other Forms (Simple Post, Essay, Photo, etc.)
- Audit each admin form to ensure they use the shared lifecycle helper, seed baselines, clear drafts, and transition status back to `idle`.
## Testing & Verification
- **Unit Tests**: Cover controller state transitions, baseline priming, abort handling, and auto-idle timeout (`tests/autoSaveController.test.ts`). Run with `node --test --loader tsx tests/autoSaveController.test.ts`.
- **Component Tests**: Verify autosave does not fire on initial mount, drafts restore/clear correctly, and navigation waits for flush.
- **Manual QA**: Confirm keyboard shortcut behavior, offline fallback, and that UI returns to `idle` after showing “saved”.
## Structural Considerations
- Factor shared autosave wiring into reusable modules to avoid copy/paste drift.
- Ensure server response payloads used in `prime()` reflect the canonical representation (including normalized fields) so hashes stay in sync.
- Document the lifecycle helper so new admin screens adopt the proven pattern without regression.

View file

@ -0,0 +1,338 @@
# Product Requirements Document: Auto-Save Functionality
## Executive Summary
Implement an intelligent auto-save system for all admin forms and editors to prevent data loss and improve the content creation experience.
## Problem Statement
Currently, users must manually save their work in the admin interface, which can lead to:
- Data loss if the browser crashes or connection is interrupted
- Anxiety about losing work during long editing sessions
- Inefficient workflow with frequent manual saves
- No recovery mechanism for unsaved changes
## Goals & Success Metrics
### Primary Goals
1. Prevent data loss during content creation
2. Provide seamless, unobtrusive saving experience
3. Enable recovery from unexpected interruptions
4. Maintain data consistency and integrity
### Success Metrics
- 0% data loss from browser crashes or network issues
- <3 second save latency for typical content
- 95% of saves complete without user intervention
- User satisfaction with editing experience improvement
## User Stories
### As a content creator
- I want my work to be automatically saved so I don't lose progress
- I want to see clear feedback about save status
- I want to recover my work if something goes wrong
- I want control over when auto-save is active
### As a site administrator
- I want to ensure data integrity across all saves
- I want to minimize server load from frequent saves
- I want to track save patterns for optimization
## Functional Requirements
### Core Auto-Save System
#### 1. Smart Debouncing
- **Content changes**: 2-second delay after user stops typing
- **Metadata changes**: Immediate save for critical fields
- **Navigation events**: Immediate save before leaving page
- **Keyboard shortcut**: Cmd/Ctrl+S for manual save
#### 2. Save States & Feedback
- **Idle**: No pending changes
- **Saving**: Active save in progress with spinner
- **Saved**: Confirmation with timestamp
- **Error**: Clear error message with retry option
- **Conflict**: Detection and resolution UI
#### 3. Data Persistence
- **Server-first**: Primary storage in database
- **Local backup**: IndexedDB for offline/recovery
- **Conflict detection**: Version tracking with timestamps
- **Partial saves**: Only send changed fields
### Visual Design
#### Status Indicator
```
States:
- Idle: No indicator (clean UI)
- Saving: "Saving..." with subtle spinner
- Saved: "All changes saved" (fades after 2s)
- Error: Red indicator with retry button
- Offline: "Working offline" badge
```
#### Positioning
- Fixed position in editor header
- Non-intrusive, doesn't shift content
- Responsive to different screen sizes
- Accessible color contrast
### API Design
#### New Endpoints
```typescript
// Auto-save endpoint
POST /api/posts/[id]/autosave
Body: {
content?: JSONContent,
title?: string,
metadata?: object,
lastModified: timestamp
}
Response: {
success: boolean,
lastModified: timestamp,
conflict?: {
serverVersion: object,
serverModified: timestamp
}
}
// Recovery endpoint
GET /api/posts/[id]/recover
Response: {
localDraft?: object,
serverVersion: object,
timestamps: {
local?: timestamp,
server: timestamp
}
}
```
### Integration Points
#### Form Components to Update
1. **EssayForm.svelte** - Blog posts and essays
2. **ProjectForm.svelte** - Project case studies
3. **AlbumForm.svelte** - Album descriptions
4. **SimplePostForm.svelte** - Simple text posts
5. **PhotoPostForm.svelte** - Photo posts with captions
#### Composer Integration
- Hook into TipTap editor's `onUpdate` event
- Track content changes separately from metadata
- Handle rich media embeds appropriately
## Technical Requirements
### Frontend Architecture
#### Auto-Save Hook (`useAutoSave.svelte.ts`)
```typescript
class AutoSave {
private state = $state<'idle' | 'saving' | 'saved' | 'error'>('idle')
private lastSaved = $state<Date | null>(null)
private saveTimer: NodeJS.Timeout | null = null
private saveQueue: Set<string> = new Set()
constructor(options: AutoSaveOptions) {
// Initialize with endpoint, auth, debounce settings
}
track(field: string, value: any): void
save(immediate?: boolean): Promise<void>
recover(): Promise<RecoveryData>
reset(): void
}
```
#### Svelte 5 Integration
- Use `$state` rune for reactive state
- Use `$effect` for side effects and cleanup
- Use `$derived` for computed values
- Maintain compatibility with existing stores
### Backend Requirements
#### Database Schema Updates
```sql
-- Add version tracking
ALTER TABLE posts ADD COLUMN version INTEGER DEFAULT 1;
ALTER TABLE posts ADD COLUMN last_auto_save TIMESTAMP;
-- Auto-save drafts table
CREATE TABLE auto_save_drafts (
id SERIAL PRIMARY KEY,
entity_type VARCHAR(50),
entity_id INTEGER,
user_id INTEGER,
content JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
```
#### Performance Optimizations
- Implement request coalescing for rapid changes
- Use database transactions for consistency
- Add Redis caching for conflict detection
- Implement rate limiting per user
### Security Considerations
- Validate user ownership before auto-save
- Sanitize content to prevent XSS
- Rate limit to prevent abuse
- Encrypt local storage data
- Audit trail for all saves
## Non-Functional Requirements
### Performance
- Save latency <500ms for text content
- <2MB memory overhead per form
- Debounce efficiency >90% reduction in requests
- Support 100+ concurrent editors
### Reliability
- 99.9% save success rate
- Graceful degradation on network issues
- Automatic retry with exponential backoff
- Data recovery from last 24 hours
### Usability
- Zero configuration for basic use
- Clear, non-technical error messages
- Intuitive conflict resolution
- Keyboard accessible
### Compatibility
- Chrome 90+, Firefox 88+, Safari 14+
- Mobile responsive
- Works with screen readers
- Progressive enhancement
## Implementation Plan
### Phase 1: Core Infrastructure (Week 1-2)
- [ ] Create `useAutoSave` hook
- [ ] Implement debouncing logic
- [ ] Add basic status component
- [ ] Create auto-save API endpoint
### Phase 2: Form Integration (Week 2-3)
- [ ] Integrate with EssayForm
- [ ] Integrate with ProjectForm
- [ ] Add keyboard shortcuts
- [ ] Implement local storage backup
### Phase 3: Advanced Features (Week 3-4)
- [ ] Conflict detection and resolution
- [ ] Offline support with service worker
- [ ] Recovery interface
- [ ] Performance monitoring
### Phase 4: Polish & Testing (Week 4-5)
- [ ] UI/UX refinements
- [ ] Comprehensive testing
- [ ] Documentation
- [ ] Performance optimization
## Testing Strategy
### Unit Tests
- Debounce logic validation
- State management correctness
- API error handling
- Local storage operations
### Integration Tests
- Form component integration
- API endpoint validation
- Conflict resolution flow
- Recovery scenarios
### E2E Tests
- Complete save flow
- Network interruption handling
- Multi-tab scenarios
- Mobile experience
### Performance Tests
- Load testing with concurrent users
- Memory leak detection
- Network bandwidth usage
- Database query optimization
## Rollout Strategy
1. **Beta Testing**: Deploy to staging with select users
2. **Gradual Rollout**: Enable for 10% → 50% → 100% of forms
3. **Monitoring**: Track save success rates and user feedback
4. **Iteration**: Refine based on real-world usage
## Future Enhancements
### Version 2.0
- Real-time collaboration indicators
- Revision history with diff view
- Auto-save templates and drafts
- AI-powered content suggestions
### Version 3.0
- Multi-device sync
- Offline-first architecture
- Advanced merge conflict resolution
- Team collaboration features
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| Data corruption | High | Implement checksums and validation |
| Performance degradation | Medium | Rate limiting and request batching |
| User confusion | Low | Clear UI feedback and documentation |
| Storage limits | Low | Implement cleanup and quotas |
## Dependencies
### External Libraries
- None required (uses native Svelte/SvelteKit features)
### Internal Systems
- Existing authentication system
- Toast notification system
- TipTap editor integration
- Prisma database client
## Acceptance Criteria
- [ ] Auto-save activates within 2 seconds of changes
- [ ] Visual feedback appears for all save states
- [ ] Manual save button remains functional
- [ ] Recovery works after browser crash
- [ ] No data loss in normal operation
- [ ] Performance metrics meet targets
- [ ] Accessibility standards met
- [ ] Documentation complete
## Appendix
### Competitive Analysis
- **Notion**: Instant save with "Saving..." indicator
- **Google Docs**: Real-time with conflict resolution
- **WordPress**: Auto-save drafts every 60 seconds
- **Medium**: Continuous save with version history
### User Research Insights
- Users expect auto-save in modern editors
- Visual feedback reduces anxiety
- Recovery options increase trust
- Performance is critical for user satisfaction
---
**Document Version**: 1.0
**Last Updated**: 2025-01-30
**Author**: System Architecture Team
**Status**: Ready for Implementation

View file

@ -0,0 +1,60 @@
import { beforeNavigate } from '$app/navigation'
import { onDestroy } from 'svelte'
import type { AutoSaveController } from './autoSave'
interface AutoSaveLifecycleOptions {
isReady?: () => boolean
onFlushError?: (error: unknown) => void
enableShortcut?: boolean
}
export function initAutoSaveLifecycle(
controller: AutoSaveController,
options: AutoSaveLifecycleOptions = {}
) {
const { isReady = () => true, onFlushError, enableShortcut = true } = options
if (typeof window === 'undefined') {
onDestroy(() => controller.destroy())
return
}
function handleKeydown(event: KeyboardEvent) {
if (!enableShortcut) return
if (!isReady()) return
const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey
if (!isModifier || key !== 's') return
event.preventDefault()
controller.flush().catch((error) => {
onFlushError?.(error)
})
}
if (enableShortcut) {
document.addEventListener('keydown', handleKeydown)
}
const stopNavigating = beforeNavigate(async (navigation) => {
if (!isReady()) return
navigation.cancel()
try {
await controller.flush()
navigation.retry()
} catch (error) {
onFlushError?.(error)
}
})
const stop = () => {
if (enableShortcut) {
document.removeEventListener('keydown', handleKeydown)
}
stopNavigating?.()
controller.destroy()
}
onDestroy(stop)
return { stop }
}

33
src/routes/+error.svelte Normal file
View file

@ -0,0 +1,33 @@
<script>
import { page } from '$app/stores';
</script>
<div class="error-container">
<h1 class="error-code">404</h1>
<p class="error-message">Not Found</p>
</div>
<style lang="scss">
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
}
.error-code {
font-size: 120px;
font-weight: 500;
line-height: 1;
margin: 0;
color: var(--text-primary);
}
.error-message {
font-size: 24px;
margin-top: $unit-2x;
color: var(--text-secondary);
}
</style>

View file

@ -0,0 +1,99 @@
import { describe, it, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { createAutoSaveController } from '../src/lib/admin/autoSave'
describe('createAutoSaveController', () => {
beforeEach(() => {
if (typeof navigator === 'undefined') {
// @ts-expect-error add minimal navigator shim for tests
global.navigator = { onLine: true }
}
})
it('skips save when payload matches primed baseline', async () => {
let value = 0
let saveCalls = 0
const controller = createAutoSaveController<{ value: number }>({
debounceMs: 5,
getPayload: () => ({ value }),
save: async () => {
saveCalls += 1
return { value }
}
})
controller.prime({ value })
controller.schedule()
await wait(15)
assert.equal(saveCalls, 0)
await controller.flush()
assert.equal(saveCalls, 0)
controller.destroy()
})
it('saves when payload changes and returns to idle after success', async () => {
let value = 0
let updatedAt = 0
let saveCalls = 0
const statuses: string[] = []
const controller = createAutoSaveController<{ value: number; updatedAt: number }, { updatedAt: number }>({
debounceMs: 5,
idleResetMs: 10,
getPayload: () => ({ value, updatedAt }),
save: async (payload) => {
saveCalls += 1
return { updatedAt: payload.updatedAt + 1 }
},
onSaved: (response, { prime }) => {
updatedAt = response.updatedAt
prime({ value, updatedAt })
}
})
const unsubscribe = controller.status.subscribe((status) => {
statuses.push(status)
})
controller.prime({ value, updatedAt })
value = 1
controller.schedule()
await wait(15)
assert.equal(saveCalls, 1)
assert.ok(statuses.includes('saving'))
assert.ok(statuses.includes('saved'))
await wait(20)
assert.equal(statuses.at(-1), 'idle')
unsubscribe()
controller.destroy()
})
it('cancels pending work on destroy', async () => {
let saveCalls = 0
const controller = createAutoSaveController<{ foo: string }>({
debounceMs: 20,
getPayload: () => ({ foo: 'bar' }),
save: async () => {
saveCalls += 1
}
})
controller.schedule()
controller.destroy()
await wait(30)
assert.equal(saveCalls, 0)
})
})
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}