Files
taskplaner/.planning/phases/02-core-crud/02-RESEARCH.md
Thomas Richter 4036457391 docs(02): research phase domain
Phase 02: Core CRUD
- Standard stack identified (SvelteKit form actions, svelte-gestures)
- Architecture patterns documented (progressive enhancement, inline editing)
- Pitfalls catalogued (reactivity, debounce, mobile-first)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 05:22:10 +01:00

19 KiB

Phase 2: Core CRUD - Research

Researched: 2026-01-29 Domain: SvelteKit CRUD UI, Mobile-First Design, Accessibility Confidence: HIGH

Summary

This phase builds a fully functional CRUD interface for entries (tasks and thoughts) on top of the existing foundation. The research covers SvelteKit form actions, Svelte 5 runes for reactive UI, mobile-first responsive design with Tailwind v4, swipe gestures for mobile delete, debounced auto-save, and accessibility requirements for touch targets and font sizes.

The standard approach is to use SvelteKit form actions with progressive enhancement (use:enhance) for all CRUD operations, Svelte 5 runes ($state, $derived) for component state, and Tailwind v4's mobile-first breakpoints. Swipe gestures can be implemented with the svelte-gestures library (Svelte 5 compatible), and user preferences (like last-used entry type) should persist via svelte-persisted-store or a simple localStorage wrapper.

Primary recommendation: Build progressively enhanced forms with use:enhance, use inline editing with debounced auto-save (300-500ms), implement swipe-to-delete with svelte-gestures, and ensure 44x44px minimum touch targets with 16px+ base font size.

Standard Stack

Core (Already Installed)

Library Version Purpose Why Standard
SvelteKit ^2.50.1 Full-stack framework Form actions, SSR, routing
Svelte ^5.48.2 UI framework Runes for reactivity
Tailwind CSS ^4.1.18 Styling Mobile-first, utility classes
Drizzle ORM ^0.45.1 Database queries Type-safe filters, orderBy

Supporting (To Add)

Library Version Purpose When to Use
svelte-gestures ^5.x Touch gestures Swipe-to-delete on mobile
svelte-persisted-store latest localStorage persistence Sticky user preferences

Alternatives Considered

Instead of Could Use Tradeoff
svelte-gestures @svelte-put/swipeable Less comprehensive but simpler for just swipe
svelte-persisted-store Custom localStorage wrapper More control but more code
use:enhance fetch API Progressive enhancement lost

Installation:

npm install svelte-gestures svelte-persisted-store

Architecture Patterns

src/
├── lib/
│   ├── components/
│   │   ├── EntryList.svelte       # Entry list with ordering
│   │   ├── EntryCard.svelte       # Single entry (expandable)
│   │   ├── QuickCapture.svelte    # Bottom capture bar
│   │   └── CompletedToggle.svelte # Show/hide completed
│   ├── stores/
│   │   └── preferences.svelte.ts  # User preferences (last type, show completed)
│   └── server/
│       └── db/                    # Existing repository
├── routes/
│   ├── +page.svelte               # Main view
│   ├── +page.server.ts            # Load + form actions
│   └── +layout.svelte             # Global layout
└── app.css                        # Tailwind + base styles

Pattern 1: SvelteKit Form Actions with Progressive Enhancement

What: Server-side form handlers that work without JavaScript, enhanced with use:enhance. When to use: All create, update, delete operations.

// +page.server.ts
import { fail } from '@sveltejs/kit';
import { entryRepository } from '$lib/server/db/repository';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  const entries = entryRepository.getAll();
  return { entries };
};

export const actions: Actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const title = data.get('title')?.toString() || '';
    const content = data.get('content')?.toString() || '';
    const type = data.get('type')?.toString() as 'task' | 'thought' || 'thought';

    if (!content.trim()) {
      return fail(400, { error: 'Content is required', title, type });
    }

    const entry = entryRepository.create({ title, content, type });
    return { success: true, entryId: entry.id };
  },

  update: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id')?.toString();
    if (!id) return fail(400, { error: 'Missing entry ID' });

    const updates: Record<string, unknown> = {};
    for (const [key, value] of data.entries()) {
      if (key !== 'id') updates[key] = value;
    }

    const entry = entryRepository.update(id, updates);
    if (!entry) return fail(404, { error: 'Entry not found' });
    return { success: true };
  },

  delete: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id')?.toString();
    if (!id) return fail(400, { error: 'Missing entry ID' });

    const deleted = entryRepository.delete(id);
    if (!deleted) return fail(404, { error: 'Entry not found' });
    return { success: true };
  },

  toggleComplete: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id')?.toString();
    const currentStatus = data.get('status')?.toString();
    if (!id) return fail(400, { error: 'Missing entry ID' });

    const newStatus = currentStatus === 'done' ? 'open' : 'done';
    entryRepository.update(id, { status: newStatus });
    return { success: true };
  }
};
<!-- +page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';

  let { data, form } = $props();
</script>

<form method="POST" action="?/create" use:enhance>
  <input name="title" placeholder="Title (optional)" />
  <textarea name="content" required></textarea>
  <select name="type">
    <option value="thought">Thought</option>
    <option value="task">Task</option>
  </select>
  <button type="submit">Add</button>
</form>

Pattern 2: Inline Expand with Svelte 5 Runes

What: Clicking an entry expands it in place for editing. When to use: Entry editing flow.

<script lang="ts">
  import type { Entry } from '$lib/server/db/schema';
  import { slide } from 'svelte/transition';

  interface Props {
    entry: Entry;
    onUpdate: (id: string, updates: Partial<Entry>) => void;
    onDelete: (id: string) => void;
  }

  let { entry, onUpdate, onDelete }: Props = $props();

  let expanded = $state(false);
  let editTitle = $state(entry.title || '');
  let editContent = $state(entry.content);

  // Debounced auto-save
  let saveTimeout: ReturnType<typeof setTimeout>;

  function handleInput() {
    clearTimeout(saveTimeout);
    saveTimeout = setTimeout(() => {
      onUpdate(entry.id, { title: editTitle, content: editContent });
    }, 400);
  }
</script>

<article class="border-b border-gray-200">
  <button
    onclick={() => expanded = !expanded}
    class="w-full text-left p-4"
  >
    <h3 class="font-medium text-gray-900">{entry.title || 'Untitled'}</h3>
    {#if !expanded}
      <p class="text-gray-600 truncate">{entry.content}</p>
    {/if}
  </button>

  {#if expanded}
    <div transition:slide={{ duration: 200 }} class="px-4 pb-4">
      <input
        bind:value={editTitle}
        oninput={handleInput}
        class="w-full text-lg font-medium mb-2"
        placeholder="Title"
      />
      <textarea
        bind:value={editContent}
        oninput={handleInput}
        class="w-full min-h-32"
      ></textarea>
      <button onclick={() => onDelete(entry.id)} class="text-red-600">
        Delete
      </button>
    </div>
  {/if}
</article>

Pattern 3: User Preferences with Persisted Store

What: Remember last-used entry type and completed filter state. When to use: Sticky preferences across sessions.

// src/lib/stores/preferences.svelte.ts
import { persisted } from 'svelte-persisted-store';

export const preferences = persisted('taskplaner-preferences', {
  lastEntryType: 'thought' as 'task' | 'thought',
  showCompleted: false
});
<script lang="ts">
  import { preferences } from '$lib/stores/preferences.svelte';

  // Use in quick capture
  let selectedType = $state($preferences.lastEntryType);

  function handleSubmit() {
    // Update preference when creating entry
    $preferences.lastEntryType = selectedType;
    // ... submit form
  }
</script>

Pattern 4: Swipe-to-Delete with svelte-gestures

What: Mobile-friendly delete via horizontal swipe. When to use: Entry deletion on touch devices.

<script lang="ts">
  import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';

  let swipeOffset = $state(0);
  let isDeleting = $state(false);

  function handleSwipe(event: SwipeCustomEvent) {
    if (event.detail.direction === 'left') {
      isDeleting = true;
      // Trigger delete after animation
      setTimeout(() => onDelete(entry.id), 300);
    }
  }
</script>

<div
  {...useSwipe(handleSwipe, () => ({
    minSwipeDistance: 100,
    timeframe: 300,
    touchAction: 'pan-y'
  }))}
  class="relative overflow-hidden"
  style:transform="translateX({swipeOffset}px)"
>
  <!-- Entry content -->
</div>

Anti-Patterns to Avoid

  • Direct database calls in components: Always go through form actions or API routes.
  • Non-debounced auto-save: Will flood the database with writes.
  • Pixel-based font sizes: Use rem for accessibility (user can scale).
  • Small touch targets: Under 44x44px causes usability issues on mobile.
  • Missing form progressive enhancement: Forms should work without JS.

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Debounce function Custom debounce lodash.debounce or inline setTimeout Edge cases with rapid input
Touch gestures Pointer event handlers svelte-gestures Cross-browser/device complexity
localStorage sync Custom wrapper svelte-persisted-store Tab sync, SSR safety, error handling
Form validation display Custom error state SvelteKit form prop Built-in, progressive enhancement
CSS transitions JS animations Svelte transitions (slide, fade) CSS-based, non-blocking

Key insight: Form handling and state persistence have many edge cases. Libraries handle SSR hydration, race conditions, and browser quirks that custom code misses.

Common Pitfalls

Pitfall 1: Breaking Reactivity with Destructuring

What goes wrong: Destructuring reactive objects breaks the proxy. Why it happens: Values are evaluated at destructure time, not read time. How to avoid: Access properties directly or use $derived.

// BAD - loses reactivity
let { title, content } = entry;

// GOOD - stays reactive
let title = $derived(entry.title);
let content = $derived(entry.content);

Warning signs: UI not updating when data changes.

Pitfall 2: Auto-Save Without Debounce

What goes wrong: Every keystroke triggers a database write. Why it happens: Input events fire on every character. How to avoid: Always debounce auto-save (300-500ms recommended).

let timeout: ReturnType<typeof setTimeout>;
function debouncedSave(data: FormData) {
  clearTimeout(timeout);
  timeout = setTimeout(() => saveToServer(data), 400);
}

Warning signs: High CPU usage, sluggish UI, database locks.

Pitfall 3: Mobile-First Confusion

What goes wrong: Styles only apply on larger screens, not mobile. Why it happens: Using sm: prefix for mobile styles. How to avoid: Unprefixed = mobile, breakpoint prefixes = larger screens.

<!-- BAD: sm:text-center only applies at 640px+ -->
<div class="sm:text-center">

<!-- GOOD: center on mobile, left-align on larger -->
<div class="text-center md:text-left">

Warning signs: Layout broken on mobile devices.

Pitfall 4: localStorage on Server

What goes wrong: Server-side render fails with "localStorage is not defined". Why it happens: SvelteKit renders on server first. How to avoid: Use browser check or svelte-persisted-store.

import { browser } from '$app/environment';

const stored = browser ? localStorage.getItem('key') : null;

Warning signs: Hydration errors, SSR crashes.

Pitfall 5: Touch Targets Too Small

What goes wrong: Users can't tap buttons reliably on mobile. Why it happens: Desktop-sized click targets. How to avoid: Minimum 44x44px (WCAG AAA) or at least 24x24px (AA).

<!-- BAD: icon-only button -->
<button class="p-1"><Icon /></button>

<!-- GOOD: adequate padding -->
<button class="p-3 min-h-11 min-w-11"><Icon /></button>

Warning signs: User complaints, high error rates on mobile.

Code Examples

Quick Capture Component

<!-- src/lib/components/QuickCapture.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { preferences } from '$lib/stores/preferences.svelte';

  let title = $state('');
  let content = $state('');
  let type = $state($preferences.lastEntryType);

  function handleSubmit() {
    $preferences.lastEntryType = type;
  }
</script>

<form
  method="POST"
  action="?/create"
  use:enhance={() => {
    return async ({ result, update }) => {
      if (result.type === 'success') {
        title = '';
        content = '';
        // Scroll to new entry, highlight it
      }
      await update();
    };
  }}
  onsubmit={handleSubmit}
  class="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg p-4"
  style="padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);"
>
  <div class="max-w-2xl mx-auto flex flex-col gap-2">
    <input
      name="title"
      bind:value={title}
      placeholder="Title (optional)"
      class="w-full px-3 py-2 border rounded-lg text-base"
    />
    <div class="flex gap-2">
      <textarea
        name="content"
        bind:value={content}
        placeholder="What's on your mind?"
        required
        class="flex-1 px-3 py-2 border rounded-lg text-base min-h-12 resize-none"
      ></textarea>
      <div class="flex flex-col gap-1">
        <select
          name="type"
          bind:value={type}
          class="px-2 py-1 border rounded text-sm"
        >
          <option value="thought">Thought</option>
          <option value="task">Task</option>
        </select>
        <button
          type="submit"
          class="px-4 py-2 bg-blue-600 text-white rounded-lg min-h-11"
        >
          Add
        </button>
      </div>
    </div>
  </div>
</form>

Entry List with Ordering

// Extended repository method for phase 2
// src/lib/server/db/repository.ts

import { eq, desc, asc, and, ne } from 'drizzle-orm';

// Add to EntryRepository interface:
getOrdered(options?: {
  showCompleted?: boolean;
}): Entry[] {
  const conditions = options?.showCompleted
    ? undefined
    : ne(entries.status, 'done');

  // Tasks first (type='task'), then thoughts, newest first within each group
  return db.select()
    .from(entries)
    .where(conditions)
    .orderBy(
      // Tasks before thoughts (asc puts 'task' before 'thought' alphabetically)
      asc(entries.type),
      // Newest first within each type
      desc(entries.createdAt)
    )
    .all();
}

Mobile-Responsive Entry Card

<!-- Compact on mobile, card on desktop -->
<article class="
  p-4 border-b border-gray-100
  md:border md:rounded-lg md:shadow-sm md:mb-3
">
  <div class="flex items-start gap-3">
    {#if entry.type === 'task'}
      <input
        type="checkbox"
        checked={entry.status === 'done'}
        class="mt-1 w-5 h-5 rounded"
      />
    {:else}
      <span class="mt-1 w-5 h-5 rounded-full bg-purple-100 flex items-center justify-center">
        <span class="text-purple-600 text-xs">T</span>
      </span>
    {/if}

    <div class="flex-1 min-w-0">
      <h3 class="font-medium text-gray-900 text-base md:text-lg">
        {entry.title || 'Untitled'}
      </h3>
      <p class="text-gray-600 text-sm md:text-base line-clamp-2">
        {entry.content}
      </p>
    </div>
  </div>
</article>

Base Font Size Configuration

/* src/app.css */
@import 'tailwindcss';

/* Ensure minimum 16px base for accessibility */
html {
  font-size: 100%; /* 16px default, respects user browser settings */
}

/* All text uses rem, so user can scale */
body {
  font-size: 1rem; /* 16px minimum */
  line-height: 1.5;
}

/* Safe area padding for bottom capture bar */
.safe-bottom {
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

State of the Art

Old Approach Current Approach When Changed Impact
Svelte stores for component state $state rune Svelte 5 (Oct 2024) Simpler, more explicit reactivity
$: reactive statements $derived rune Svelte 5 Computed values are memoized
export let for props $props() rune Svelte 5 Better TypeScript inference
tailwind.config.js breakpoints CSS @theme variables Tailwind v4 Configuration in CSS
createEventDispatcher Callback props Svelte 5 Direct function passing

Deprecated/outdated:

  • Svelte stores for local state: Use $state rune instead
  • $: reactive declarations: Use $derived for computed values
  • Tailwind v3 config files: v4 uses CSS-based @theme configuration

Open Questions

  1. svelte-persisted-store Svelte 5 compatibility

    • What we know: Library is actively maintained (22 releases, TypeScript support)
    • What's unclear: Explicit Svelte 5 runes compatibility not documented
    • Recommendation: Test during implementation. Fallback: simple custom wrapper with browser check
  2. Swipe gesture on desktop with mouse

    • What we know: svelte-gestures supports pointer events
    • What's unclear: How swipe-to-delete should work with mouse drag
    • Recommendation: Show delete button on hover for desktop, swipe for touch
  3. Optimistic UI updates

    • What we know: use:enhance can return custom handlers
    • What's unclear: Best pattern for optimistic delete with rollback
    • Recommendation: Use simple approach first (wait for server), add optimistic if UX suffers

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Using existing dependencies, well-documented
  • Architecture: HIGH - SvelteKit form actions are official pattern
  • Pitfalls: HIGH - Common issues well-documented in community
  • Gesture library: MEDIUM - Confirmed Svelte 5 support, less community usage data

Research date: 2026-01-29 Valid until: 2026-02-28 (30 days - stable technologies)