From e6cfda471ac2d0218d3076f788d93c3841d5d038 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Tue, 10 Mar 2026 10:33:44 +0100 Subject: [PATCH] docs: add user preferences implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-10-user-preferences.md | 851 ++++++++++++++++++++++ 1 file changed, 851 insertions(+) create mode 100644 docs/plans/2026-03-10-user-preferences.md diff --git a/docs/plans/2026-03-10-user-preferences.md b/docs/plans/2026-03-10-user-preferences.md new file mode 100644 index 0000000..2337649 --- /dev/null +++ b/docs/plans/2026-03-10-user-preferences.md @@ -0,0 +1,851 @@ +# User Preferences Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add user-customizable preferences (default note mode, default note color, hide footer, sidebar default state) with offline-first localStorage + server sync. + +**Architecture:** Key-value `user_preferences` table on the server, synced via dedicated API endpoints. Client reads from localStorage immediately (offline-first), hydrates from server when online, and pushes changes on update. Preferences exposed via a Svelte `$state` runes store. + +**Tech Stack:** SvelteKit, Svelte 5 runes, Drizzle ORM (SQLite), localStorage, Vitest, Playwright + +--- + +### Task 1: Database Schema — `userPreferences` table + +**Files:** +- Modify: `src/lib/server/db/schema.ts` (add table after `loginAttempts`, ~line 177) +- Modify: `src/lib/server/db/test-helpers.ts` (add CREATE TABLE to test schema) + +**Step 1: Add the Drizzle table definition** + +In `src/lib/server/db/schema.ts`, add after `loginAttempts`: + +```typescript +export const userPreferences = sqliteTable( + 'user_preferences', + { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id') + .references(() => users.id) + .notNull(), + key: text('key').notNull(), + value: text('value').notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull() + }, + (table) => [ + uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key), + index('user_preferences_user_id_idx').on(table.userId) + ] +); +``` + +**Step 2: Add the table to test-helpers.ts** + +In `src/lib/server/db/test-helpers.ts`, add inside the `sqlite.exec` block before the closing backtick: + +```sql +CREATE TABLE user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key); +CREATE INDEX user_preferences_user_id_idx ON user_preferences(user_id); +``` + +**Step 3: Generate migration** + +Run: `make db-generate` + +**Step 4: Push schema to dev DB** + +Run: `make db-push` + +**Step 5: Verify existing tests still pass** + +Run: `make test-unit` + +**Step 6: Commit** + +``` +feat: add userPreferences table schema +``` + +--- + +### Task 2: Preferences Types & Defaults + +**Files:** +- Create: `src/lib/types/preferences.ts` +- Modify: `src/lib/types/index.ts` (re-export) + +**Step 1: Create the preferences type file** + +Create `src/lib/types/preferences.ts`: + +```typescript +import type { NoteColor } from './index.js'; + +export interface UserPreferences { + defaultNoteMode: 'richtext' | 'markdown'; + defaultNoteColor: NoteColor; + hideFooter: boolean; + sidebarDefaultState: 'open' | 'collapsed'; +} + +export const DEFAULT_PREFERENCES: UserPreferences = { + defaultNoteMode: 'richtext', + defaultNoteColor: 'default', + hideFooter: false, + sidebarDefaultState: 'open' +}; + +/** Keys that are boolean values (stored as "true"/"false" strings in DB) */ +export const BOOLEAN_PREF_KEYS: (keyof UserPreferences)[] = ['hideFooter']; +``` + +**Step 2: Re-export from index** + +In `src/lib/types/index.ts`, add: + +```typescript +export type { UserPreferences } from './preferences.js'; +export { DEFAULT_PREFERENCES } from './preferences.js'; +``` + +**Step 3: Commit** + +``` +feat: add UserPreferences types and defaults +``` + +--- + +### Task 3: Server-side Preferences Service + +**Files:** +- Create: `src/lib/server/preferences.ts` +- Test: `src/lib/server/preferences.test.ts` + +**Step 1: Write the failing tests** + +Create `src/lib/server/preferences.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { users } from './db/schema.js'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schema from './db/schema.js'; +import { getPreferences, upsertPreferences } from './preferences.js'; + +let db: BetterSQLite3Database; +const USER_ID = 1; + +beforeEach(() => { + const testDb = createTestDb(); + db = testDb.db; + db.insert(users).values({ + email: 'test@test.com', + displayName: 'Test', + role: 'user', + authProvider: 'password', + createdAt: new Date() + }).run(); +}); + +describe('getPreferences', () => { + it('should return empty object when no preferences set', () => { + const prefs = getPreferences(db, USER_ID); + expect(prefs).toEqual({}); + }); + + it('should return all preferences for user', () => { + upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' }); + const prefs = getPreferences(db, USER_ID); + expect(prefs).toEqual({ defaultNoteMode: 'markdown' }); + }); +}); + +describe('upsertPreferences', () => { + it('should insert new preferences', () => { + upsertPreferences(db, USER_ID, { + defaultNoteMode: 'markdown', + hideFooter: 'true' + }); + const prefs = getPreferences(db, USER_ID); + expect(prefs.defaultNoteMode).toBe('markdown'); + expect(prefs.hideFooter).toBe('true'); + }); + + it('should update existing preferences', () => { + upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' }); + upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' }); + const prefs = getPreferences(db, USER_ID); + expect(prefs.defaultNoteMode).toBe('richtext'); + }); + + it('should only update specified keys, leaving others unchanged', () => { + upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown', hideFooter: 'true' }); + upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' }); + const prefs = getPreferences(db, USER_ID); + expect(prefs.defaultNoteMode).toBe('richtext'); + expect(prefs.hideFooter).toBe('true'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run src/lib/server/preferences.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement the service** + +Create `src/lib/server/preferences.ts`: + +```typescript +import { eq, and } from 'drizzle-orm'; +import { userPreferences } from './db/schema.js'; +import type { Db } from './db/index.js'; + +/** + * Get all preferences for a user as a key-value record. + */ +export function getPreferences(db: Db, userId: number): Record { + const rows = db + .select({ key: userPreferences.key, value: userPreferences.value }) + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .all(); + + return Object.fromEntries(rows.map((r) => [r.key, r.value])); +} + +/** + * Upsert preferences for a user. Only the provided keys are created/updated. + */ +export function upsertPreferences( + db: Db, + userId: number, + prefs: Record +): void { + const now = new Date(); + for (const [key, value] of Object.entries(prefs)) { + const existing = db + .select({ id: userPreferences.id }) + .from(userPreferences) + .where(and(eq(userPreferences.userId, userId), eq(userPreferences.key, key))) + .get(); + + if (existing) { + db.update(userPreferences) + .set({ value, updatedAt: now }) + .where(eq(userPreferences.id, existing.id)) + .run(); + } else { + db.insert(userPreferences) + .values({ userId, key, value, updatedAt: now }) + .run(); + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run src/lib/server/preferences.test.ts` +Expected: PASS + +**Step 5: Commit** + +``` +feat: add preferences server service with tests +``` + +--- + +### Task 4: Preferences API Endpoints + +**Files:** +- Create: `src/routes/api/preferences/+server.ts` + +**Step 1: Create the API endpoint** + +Create `src/routes/api/preferences/+server.ts`: + +```typescript +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getUserId } from '$lib/server/api-utils.js'; +import { getPreferences, upsertPreferences } from '$lib/server/preferences.js'; +import { db } from '$lib/server/db/index.js'; + +export const GET: RequestHandler = async (event) => { + const userId = getUserId(event); + const prefs = getPreferences(db, userId); + return json(prefs); +}; + +export const PUT: RequestHandler = async ({ request, ...event }) => { + const userId = getUserId(event); + const body: Record = await request.json(); + + // Validate: only accept string key-value pairs + const cleaned: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (typeof key === 'string' && typeof value === 'string') { + cleaned[key] = value; + } + } + + upsertPreferences(db, userId, cleaned); + const updated = getPreferences(db, userId); + return json(updated); +}; +``` + +**Step 2: Verify build passes** + +Run: `make check` + +**Step 3: Commit** + +``` +feat: add GET/PUT /api/preferences endpoints +``` + +--- + +### Task 5: Client-side Preferences Store + +**Files:** +- Create: `src/lib/stores/preferences.ts` + +**Step 1: Create the preferences store** + +Create `src/lib/stores/preferences.ts`: + +```typescript +import { browser } from '$app/environment'; +import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js'; +import type { UserPreferences } from '$lib/types/preferences.js'; + +const STORAGE_KEY = 'crumbs-preferences'; + +let preferences = $state({ ...DEFAULT_PREFERENCES }); +let initialized = false; + +/** Parse a server record into typed UserPreferences */ +function parsePreferences(raw: Record): Partial { + const result: Partial = {}; + for (const [key, value] of Object.entries(raw)) { + if (key in DEFAULT_PREFERENCES) { + if (BOOLEAN_PREF_KEYS.includes(key as keyof UserPreferences)) { + (result as Record)[key] = value === 'true'; + } else { + (result as Record)[key] = value; + } + } + } + return result; +} + +/** Serialize typed preferences to string record for the server */ +function serializePreferences(prefs: Partial): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(prefs)) { + result[key] = String(value); + } + return result; +} + +/** Load from localStorage (instant, offline-first) */ +function loadFromStorage(): void { + if (!browser) return; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + preferences = { ...DEFAULT_PREFERENCES, ...parsed }; + } + } catch { + // Corrupted storage — use defaults + } +} + +/** Save to localStorage */ +function saveToStorage(): void { + if (!browser) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch { + // Storage full or unavailable + } +} + +/** Fetch from server and merge (server wins for sync) */ +export async function syncPreferencesFromServer(): Promise { + try { + const res = await fetch('/api/preferences'); + if (!res.ok) return; + const raw: Record = await res.json(); + const serverPrefs = parsePreferences(raw); + if (Object.keys(serverPrefs).length > 0) { + preferences = { ...DEFAULT_PREFERENCES, ...serverPrefs }; + saveToStorage(); + } + } catch { + // Offline — localStorage values are fine + } +} + +/** Push a preference change to the server */ +async function pushToServer(updates: Partial): Promise { + try { + await fetch('/api/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serializePreferences(updates)) + }); + } catch { + // Offline — will sync on next pull + } +} + +/** Initialize the store (call once on app mount) */ +export function initPreferences(): void { + if (initialized) return; + initialized = true; + loadFromStorage(); + syncPreferencesFromServer(); +} + +/** Get the reactive preferences object */ +export function getPreferences(): UserPreferences { + return preferences; +} + +/** Update one or more preferences */ +export function updatePreference( + key: K, + value: UserPreferences[K] +): void { + preferences = { ...preferences, [key]: value }; + saveToStorage(); + pushToServer({ [key]: value } as Partial); +} + +/** Reset preferences to defaults */ +export function resetPreferences(): void { + preferences = { ...DEFAULT_PREFERENCES }; + saveToStorage(); + pushToServer(DEFAULT_PREFERENCES); +} +``` + +**Step 2: Verify build passes** + +Run: `make check` + +**Step 3: Commit** + +``` +feat: add client-side preferences store with localStorage + server sync +``` + +--- + +### Task 6: Initialize Preferences in App Layout + +**Files:** +- Modify: `src/routes/(app)/+layout.svelte` + +**Step 1: Wire up preferences initialization and footer toggle** + +In `src/routes/(app)/+layout.svelte`: + +1. Add imports: +```typescript +import { initPreferences, getPreferences } from '$lib/stores/preferences.js'; +``` + +2. In `onMount`, after `startSync()`, add: +```typescript +initPreferences(); +``` + +3. Add a derived reference: +```typescript +let prefs = $derived(getPreferences()); +``` + +4. Replace the sidebar initial state line: +```typescript +// Before: +sidebarOpen = window.matchMedia('(min-width: 1024px)').matches; +// After: +const isDesktop = window.matchMedia('(min-width: 1024px)').matches; +sidebarOpen = isDesktop && getPreferences().sidebarDefaultState !== 'collapsed'; +``` + +5. Wrap the footer conditionally: +```svelte +{#if !prefs.hideFooter} +
+ Crumbs by Bretzel — made with 🥨 in Strasbourg +
+{/if} +``` + +**Step 2: Verify build passes** + +Run: `make check` + +**Step 3: Commit** + +``` +feat: wire preferences into app layout (footer toggle, sidebar default) +``` + +--- + +### Task 7: Wire Default Note Mode & Color into NoteEditor + +**Files:** +- Modify: `src/lib/components/NoteEditor.svelte` (~lines 35, 41) + +**Step 1: Apply preferences for new notes** + +In `src/lib/components/NoteEditor.svelte`: + +1. Add import: +```typescript +import { getPreferences } from '$lib/stores/preferences.js'; +``` + +2. Change the `color` default (line 35): +```typescript +// Before: +let color = $state(note?.color ?? 'default'); +// After: +let color = $state(note?.color ?? getPreferences().defaultNoteColor); +``` + +3. Change the `rawMarkdownMode` default (line 41): +```typescript +// Before: +let rawMarkdownMode = $state(note?.checklistMode ?? false); +// After: +let rawMarkdownMode = $state( + note ? (note.checklistMode ?? false) : getPreferences().defaultNoteMode === 'markdown' +); +``` + +Note: For existing notes, we keep the existing behavior. Only new notes (`note === null`) use the preference. + +**Step 2: Verify build passes** + +Run: `make check` + +**Step 3: Commit** + +``` +feat: apply default note mode and color preferences to new notes +``` + +--- + +### Task 8: Settings UI — Preferences Tab + +**Files:** +- Create: `src/routes/(app)/settings/preferences/+page.svelte` +- Modify: `src/routes/(app)/settings/+layout.svelte` (add tab, reorder) + +**Step 1: Update the settings layout to add Preferences tab first** + +In `src/routes/(app)/settings/+layout.svelte`: + +1. Add import: +```typescript +import { SlidersHorizontal } from 'lucide-svelte'; +``` + +2. Add `startsWith` check for active state: +```typescript +let isPreferencesActive = $derived(pathname === '/settings/preferences'); +``` + +3. Insert new `
  • ` as the first item in the `
      `: +```svelte +
    • + + + Preferences + +
    • +``` + +4. Update `/settings` route to redirect to `/settings/preferences` as the default tab (or keep Profile as the `/settings` route — depends on preference). Since Preferences is first, update the Profile link's active state to still use `pathname === '/settings'`. + +**Step 2: Create the Preferences page** + +Create `src/routes/(app)/settings/preferences/+page.svelte`: + +```svelte + + +
      +
      +

      Preferences

      +

      Customize your Crumbs experience

      +
      + + +
      + +

      Choose the editor mode for new notes

      +
      + + +
      +
      + + +
      + +

      New notes will start with this color

      + updatePreference('defaultNoteColor', color)} + /> +
      + + +
      +
      + +

      Hide the "Crumbs by Bretzel" footer

      +
      + updatePreference('hideFooter', !prefs.hideFooter)} + class="h-5 w-5 cursor-pointer" + data-testid="pref-hide-footer" + /> +
      + + +
      +
      + +

      Sidebar state when opening the app on desktop

      +
      + updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'open' ? 'collapsed' : 'open')} + class="h-5 w-5 cursor-pointer" + data-testid="pref-sidebar-default" + /> +
      +
      +``` + +**Step 3: Verify build passes** + +Run: `make check` + +**Step 4: Commit** + +``` +feat: add Preferences settings tab with all four preference controls +``` + +--- + +### Task 9: E2E Tests — Preferences + +**Files:** +- Modify: `tests/e2e/settings.spec.ts` (add preferences test block) + +**Step 1: Write the E2E tests** + +Add to `tests/e2e/settings.spec.ts`: + +```typescript +test.describe('Settings — Preferences', () => { + test('Scenario: Preferences tab is the first settings tab', async ({ + authenticatedPage: page + }) => { + // When the user navigates to settings + await page.goto('/settings'); + + // Then the Preferences tab is visible in the navigation + const prefsLink = page.getByRole('link', { name: 'Preferences' }); + await expect(prefsLink).toBeVisible(); + }); + + test('Scenario: Default note mode can be changed to markdown', async ({ + authenticatedPage: page + }) => { + // Given the user is on the Preferences settings page + await page.goto('/settings/preferences'); + + // When the user selects Markdown as default note mode + await page.getByTestId('pref-mode-markdown').click(); + + // Then the Markdown button shows as selected + await expect(page.getByTestId('pref-mode-markdown')).toHaveClass(/font-medium/); + + // And when the user creates a new note, it opens in markdown mode + await page.goto('/'); + await page.getByTestId('new-note-btn').click(); + await expect(page.getByTestId('markdown-toggle')).toBeVisible(); + // The markdown toggle should show the "switch to rich text" icon (FileText) + await expect(page.getByTestId('note-content-input')).toBeVisible(); + }); + + test('Scenario: Footer visibility can be toggled', async ({ + authenticatedPage: page + }) => { + // Given the footer is visible + await page.goto('/'); + await expect(page.locator('footer')).toBeVisible(); + + // When the user hides the footer via preferences + await page.goto('/settings/preferences'); + await page.getByTestId('pref-hide-footer').check(); + + // Then the footer is hidden on the main page + await page.goto('/'); + await expect(page.locator('footer')).not.toBeVisible(); + }); + + test('Scenario: Preferences persist across page reloads', async ({ + authenticatedPage: page + }) => { + // Given the user sets markdown as default mode + await page.goto('/settings/preferences'); + await page.getByTestId('pref-mode-markdown').click(); + + // When the page is reloaded + await page.reload(); + await page.goto('/settings/preferences'); + + // Then the preference is still set + await expect(page.getByTestId('pref-mode-markdown')).toHaveClass(/font-medium/); + }); +}); +``` + +**Step 2: Run E2E tests** + +Run: `make test-e2e` + +**Step 3: Commit** + +``` +test: add E2E tests for user preferences +``` + +--- + +### Task 10: Unit Tests — Preferences Store Serialization + +**Files:** +- Create: `src/lib/stores/preferences.test.ts` + +**Step 1: Write unit tests for serialization/parsing logic** + +Create `src/lib/stores/preferences.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { DEFAULT_PREFERENCES } from '$lib/types/preferences.js'; + +// Test the defaults and type structure +describe('UserPreferences defaults', () => { + it('should have correct default values', () => { + expect(DEFAULT_PREFERENCES.defaultNoteMode).toBe('richtext'); + expect(DEFAULT_PREFERENCES.defaultNoteColor).toBe('default'); + expect(DEFAULT_PREFERENCES.hideFooter).toBe(false); + expect(DEFAULT_PREFERENCES.sidebarDefaultState).toBe('open'); + }); + + it('should have all four preference keys', () => { + const keys = Object.keys(DEFAULT_PREFERENCES); + expect(keys).toHaveLength(4); + expect(keys).toContain('defaultNoteMode'); + expect(keys).toContain('defaultNoteColor'); + expect(keys).toContain('hideFooter'); + expect(keys).toContain('sidebarDefaultState'); + }); +}); +``` + +**Step 2: Run unit tests** + +Run: `pnpm vitest run src/lib/stores/preferences.test.ts` + +**Step 3: Commit** + +``` +test: add unit tests for preferences defaults +``` + +--- + +### Task 11: Final Verification & Cleanup + +**Step 1: Run full test suite** + +Run: `make test` + +**Step 2: Run type check** + +Run: `make check` + +**Step 3: Run build** + +Run: `make build` + +**Step 4: Manual smoke test** + +Run: `make preview` and verify: +- [ ] Navigate to Settings — Preferences tab appears first +- [ ] Toggle each preference and verify it takes effect +- [ ] Reload page — preferences persist +- [ ] Create new note — uses chosen default mode and color +- [ ] Footer hides/shows based on setting +- [ ] Sidebar opens/collapses based on setting on page load + +**Step 5: Final commit if any cleanup needed** + +``` +chore: cleanup and finalize user preferences feature +```