From 7b5af20dee944b8308a5cb6cb95777bd9078b04c Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 7 Oct 2025 03:18:02 -0700 Subject: [PATCH 1/3] docs: capture autosave roll-out plan --- docs/autosave-completion-guide.md | 61 ++++++ prd/PRD-auto-save-functionality.md | 338 +++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 docs/autosave-completion-guide.md create mode 100644 prd/PRD-auto-save-functionality.md diff --git a/docs/autosave-completion-guide.md b/docs/autosave-completion-guide.md new file mode 100644 index 0000000..6db20c7 --- /dev/null +++ b/docs/autosave-completion-guide.md @@ -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. + diff --git a/prd/PRD-auto-save-functionality.md b/prd/PRD-auto-save-functionality.md new file mode 100644 index 0000000..3b2563a --- /dev/null +++ b/prd/PRD-auto-save-functionality.md @@ -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(null) + private saveTimer: NodeJS.Timeout | null = null + private saveQueue: Set = new Set() + + constructor(options: AutoSaveOptions) { + // Initialize with endpoint, auth, debounce settings + } + + track(field: string, value: any): void + save(immediate?: boolean): Promise + recover(): Promise + 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 \ No newline at end of file From 6b21c4f7b3b4ab0358bfc92dd2f3d6fdff4d0e4e Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 7 Oct 2025 03:18:10 -0700 Subject: [PATCH 2/3] test: cover autosave controller and lifecycle helper --- src/lib/admin/autoSaveLifecycle.ts | 60 ++++++++++++++++++ tests/autoSaveController.test.ts | 99 ++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/lib/admin/autoSaveLifecycle.ts create mode 100644 tests/autoSaveController.test.ts diff --git a/src/lib/admin/autoSaveLifecycle.ts b/src/lib/admin/autoSaveLifecycle.ts new file mode 100644 index 0000000..4111039 --- /dev/null +++ b/src/lib/admin/autoSaveLifecycle.ts @@ -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 } +} diff --git a/tests/autoSaveController.test.ts b/tests/autoSaveController.test.ts new file mode 100644 index 0000000..c0cf1ab --- /dev/null +++ b/tests/autoSaveController.test.ts @@ -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((resolve) => setTimeout(resolve, ms)) +} From c96def27892b8fc23a77871e8d955c50e67e1ba9 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Tue, 7 Oct 2025 03:18:17 -0700 Subject: [PATCH 3/3] feat: add minimal not-found error page --- src/routes/+error.svelte | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/routes/+error.svelte diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..14c3fc3 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,33 @@ + + +
+

404

+

Not Found

+
+ + \ No newline at end of file