Files
taskplaner/.planning/phases/05-search/05-RESEARCH.md
Thomas Richter 9d4f9bb67f docs(05): research phase domain
Phase 05: Search
- Client-side filtering recommended (small dataset)
- Svelte 5 $effect cleanup for debouncing
- HTML <mark> for text highlighting
- svelte-persisted-store for recent searches
- Native event listener for "/" shortcut (Svelte bug workaround)
2026-01-31 14:12:26 +01:00

17 KiB

Phase 5: Search - Research

Researched: 2026-01-31 Domain: Search/filtering for SvelteKit + SQLite application Confidence: HIGH

Summary

This phase implements text search and filtering for the TaskPlaner entries list. The research covers six key areas: SQLite search strategy, client vs server filtering, search state management, debouncing in Svelte 5, text highlighting, and recent searches storage.

The recommended approach is client-side filtering since all entries are already loaded on the page (small dataset). This provides instant feedback without network latency and keeps filters session-only as specified. The existing svelte-persisted-store package handles recent searches storage, and native Svelte 5 $effect with cleanup handles debouncing elegantly.

Primary recommendation: Filter entries client-side using $derived with a search/filter state object, debounce the search input with $effect, and use the HTML <mark> element for text highlighting.

Standard Stack

The established libraries/tools for this domain:

Core (Already Installed - No New Dependencies)

Library Version Purpose Why Standard
Svelte 5 ^5.48.2 $state, $effect, $derived for reactive filtering Native reactivity, already in project
svelte-persisted-store ^0.12.0 Recent searches in localStorage Already used for preferences

Supporting (No Installation Needed)

Library Version Purpose When to Use
Native HTML <mark> N/A Text highlighting Semantic, no JS needed
Native $effect cleanup N/A Debouncing Built into Svelte 5

Alternatives Considered

Instead of Could Use Tradeoff
Client filtering Server-side with URL params Server adds latency; client is instant for small datasets
Native <mark> mark.js library Library adds weight; <mark> sufficient for simple highlighting
svelte-persisted-store Custom localStorage Already have the dependency, consistent with existing code
Native $effect debounce lodash.debounce Extra dependency; native pattern is clean in Svelte 5
svelte-focus-key Manual keydown listener Known Svelte 5 bug with svelte:window keydown and preventDefault()

Installation:

# No new dependencies required
npm install svelte-focus-key  # OPTIONAL: Only if manual approach proves buggy

Architecture Patterns

src/lib/components/
├── SearchBar.svelte          # Search input with debounce + "/" shortcut
├── FilterBar.svelte          # Tags, type, date range filters
├── EntryList.svelte          # Modified to accept filtered entries
└── SearchableEntryList.svelte # Wrapper combining search + filters + list

Pattern 1: Client-Side Filtering with $derived

What: All filtering logic in a single $derived expression that reacts to search/filter state changes.

When to use: Small to medium datasets already loaded on the client (< 1000 entries).

Example:

// Source: Svelte 5 reactivity docs
let searchQuery = $state('');
let selectedTags: string[] = $state([]);
let typeFilter: 'task' | 'thought' | 'all' = $state('all');
let dateRange = $state<{ start: string | null; end: string | null }>({ start: null, end: null });

let filteredEntries = $derived(() => {
  let result = entries;

  // Text search (title + content)
  if (searchQuery.length >= 2) {
    const query = searchQuery.toLowerCase();
    result = result.filter(e =>
      e.title?.toLowerCase().includes(query) ||
      e.content.toLowerCase().includes(query)
    );
  }

  // Tag filter (AND logic - must have ALL selected tags)
  if (selectedTags.length > 0) {
    result = result.filter(e =>
      selectedTags.every(tagName =>
        e.tags.some(t => t.name === tagName)
      )
    );
  }

  // Type filter
  if (typeFilter !== 'all') {
    result = result.filter(e => e.type === typeFilter);
  }

  // Date range filter
  if (dateRange.start) {
    result = result.filter(e => e.createdAt >= dateRange.start);
  }
  if (dateRange.end) {
    result = result.filter(e => e.createdAt <= dateRange.end);
  }

  return result;
});

Pattern 2: Debounced Search with $effect Cleanup

What: Delay search execution until user stops typing, using $effect return function for cleanup.

When to use: Any text input that triggers computation or API calls.

Example:

<!-- Source: Svelte 5 $effect docs + minimalistdjango.com -->
<script lang="ts">
  let inputValue = $state('');
  let debouncedValue = $state('');

  $effect(() => {
    const timeout = setTimeout(() => {
      debouncedValue = inputValue;
    }, 300); // 300ms debounce

    return () => clearTimeout(timeout);
  });
</script>

<input bind:value={inputValue} placeholder="Search..." />

Pattern 3: "/" Key Focus with Manual Event Listener

What: Focus search input when "/" is pressed, avoiding known Svelte 5 bug.

When to use: GitHub-style keyboard shortcuts for search.

Example:

<!-- Source: Svelte GitHub issue #15474 workaround -->
<script lang="ts">
  import { onMount } from 'svelte';

  let searchInput: HTMLInputElement;

  onMount(() => {
    function handleKeydown(e: KeyboardEvent) {
      // Skip if already in an input/textarea
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
        return;
      }

      if (e.key === '/') {
        e.preventDefault();
        searchInput?.focus();
      }
    }

    document.addEventListener('keydown', handleKeydown);
    return () => document.removeEventListener('keydown', handleKeydown);
  });
</script>

<input bind:this={searchInput} type="text" placeholder='Press "/" to search' />

Note: There is a known bug (Svelte issue #15474, March 2025) where svelte:window onkeydown with e.preventDefault() does not prevent the "/" character from being typed into the focused input. The workaround is to use native document.addEventListener.

Pattern 4: Text Highlighting with <mark>

What: Wrap matching text in semantic <mark> element for highlighting.

When to use: Displaying search results with highlighted matches.

Example:

// Source: bitsofco.de, MDN <mark> docs
function highlightMatch(text: string, query: string): string {
  if (!query || query.length < 2) return text;

  // Escape regex special characters in query
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`(${escaped})`, 'gi');

  return text.replace(regex, '<mark class="font-bold bg-transparent">$1</mark>');
}
<!-- Usage in component -->
<p>{@html highlightMatch(entry.content, searchQuery)}</p>

Security note: Use {@html} carefully. The highlightMatch function only wraps matched text, but ensure the original content is already sanitized if it comes from user input.

Anti-Patterns to Avoid

  • Re-fetching from server on every keystroke: Use debouncing and client-side filtering for session-only filters
  • Storing filter state in URL: Context specifies "session-only, not persisted in URL, reset on reload"
  • Using $derived for debounce: $derived cannot be debounced directly; use $effect to update a debounced $state
  • Using svelte:window onkeydown for "/" focus: Known bug prevents preventDefault() from working correctly

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Recent searches persistence Custom localStorage wrapper svelte-persisted-store Already in project, handles sync/serialization
Debounce function Custom debounce util Native $effect cleanup Svelte 5 pattern, cleaner than external utility
Text highlighting Custom DOM manipulation HTML <mark> + regex replace Semantic, accessible, simple
Tag filtering UI Custom multi-select Existing svelecte component Already used in TagInput.svelte

Key insight: The project already has all necessary dependencies. New search features can be built entirely with existing tools.

Common Pitfalls

Pitfall 1: Forgetting Minimum Character Check

What goes wrong: Search triggers on every character, including single letters that match almost everything. Why it happens: Not implementing the "minimum 2 characters" requirement. How to avoid: Guard search filter with if (query.length >= 2). Warning signs: Poor performance, too many results on first keystroke.

What goes wrong: Pinned entries remain separated when searching/filtering. Why it happens: Not flattening the list during active search. How to avoid: Check isFiltering state and skip pinned separation when true. Warning signs: Pinned items don't match search but still appear at top.

Pitfall 3: "/" Key Typing Into Input

What goes wrong: When pressing "/" to focus search, the "/" character appears in the input. Why it happens: Known Svelte 5 bug with svelte:window onkeydown and preventDefault(). How to avoid: Use native document.addEventListener instead of svelte:window. Warning signs: "/" appears in search box after pressing hotkey.

Pitfall 4: Tag Filter AND vs OR Confusion

What goes wrong: Selecting multiple tags shows entries with ANY tag instead of ALL tags. Why it happens: Using some() instead of every() in filter logic. How to avoid: Context specifies AND logic: selectedTags.every(tag => entry.tags.includes(tag)). Warning signs: Results include entries missing some selected tags.

Pitfall 5: Date Comparison with ISO Strings

What goes wrong: Date range filter doesn't work correctly. Why it happens: ISO string comparison works for dates but needs consistent formatting. How to avoid: Ensure all dates are in ISO format (YYYY-MM-DD) for string comparison. The schema already uses ISO strings. Warning signs: Entries from edge dates (start/end of range) incorrectly included/excluded.

Pitfall 6: Memory Leak from Event Listeners

What goes wrong: Keyboard shortcut handler accumulates on navigation. Why it happens: Not cleaning up document.addEventListener on component unmount. How to avoid: Return cleanup function from onMount. Warning signs: Multiple key handlers fire on single keypress.

Code Examples

Verified patterns from research:

Complete Search Bar Component

<!-- SearchBar.svelte -->
<script lang="ts">
  import { onMount } from 'svelte';

  interface Props {
    value: string;
    onSearch: (query: string) => void;
    recentSearches: string[];
    onSelectRecent: (query: string) => void;
  }

  let { value = $bindable(), onSearch, recentSearches, onSelectRecent }: Props = $props();

  let inputValue = $state(value);
  let searchInput: HTMLInputElement;
  let showRecent = $state(false);

  // Debounce input -> search
  $effect(() => {
    const timeout = setTimeout(() => {
      if (inputValue.length >= 2 || inputValue.length === 0) {
        value = inputValue;
        onSearch(inputValue);
      }
    }, 300);
    return () => clearTimeout(timeout);
  });

  // "/" keyboard shortcut (native listener to avoid Svelte bug)
  onMount(() => {
    function handleKeydown(e: KeyboardEvent) {
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
      if (e.key === '/') {
        e.preventDefault();
        searchInput?.focus();
        showRecent = true;
      }
    }
    document.addEventListener('keydown', handleKeydown);
    return () => document.removeEventListener('keydown', handleKeydown);
  });
</script>

<div class="relative">
  <input
    bind:this={searchInput}
    bind:value={inputValue}
    type="text"
    placeholder='Search entries... (press "/")'
    class="w-full px-4 py-2 border rounded-lg"
    onfocus={() => showRecent = true}
    onblur={() => setTimeout(() => showRecent = false, 200)}
  />

  {#if showRecent && recentSearches.length > 0 && inputValue.length === 0}
    <div class="absolute top-full left-0 right-0 bg-white border rounded-lg shadow-lg mt-1 z-10">
      <div class="px-3 py-2 text-sm text-gray-500">Recent searches</div>
      {#each recentSearches as search}
        <button
          type="button"
          class="w-full px-3 py-2 text-left hover:bg-gray-100"
          onmousedown={() => onSelectRecent(search)}
        >
          {search}
        </button>
      {/each}
    </div>
  {/if}
</div>

Recent Searches Store

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

const MAX_RECENT = 5;

export const recentSearches = persisted<string[]>('taskplaner-recent-searches', []);

export function addRecentSearch(query: string): void {
  if (!query || query.length < 2) return;

  recentSearches.update(searches => {
    // Remove if already exists (will re-add at front)
    const filtered = searches.filter(s => s.toLowerCase() !== query.toLowerCase());
    // Add to front, limit to MAX_RECENT
    return [query, ...filtered].slice(0, MAX_RECENT);
  });
}

Date Range Presets

// Date range preset helper
function getDatePreset(preset: 'today' | 'week' | 'month'): { start: string; end: string } {
  const now = new Date();
  const end = now.toISOString().split('T')[0]; // Today

  let start: Date;
  switch (preset) {
    case 'today':
      start = now;
      break;
    case 'week':
      start = new Date(now);
      start.setDate(start.getDate() - 7);
      break;
    case 'month':
      start = new Date(now);
      start.setMonth(start.getMonth() - 1);
      break;
  }

  return {
    start: start.toISOString().split('T')[0],
    end
  };
}

Filter State Type

// src/lib/types/search.ts
export interface SearchFilters {
  query: string;
  tags: string[];
  type: 'task' | 'thought' | 'all';
  dateRange: {
    start: string | null;
    end: string | null;
  };
}

export const defaultFilters: SearchFilters = {
  query: '',
  tags: [],
  type: 'all',
  dateRange: { start: null, end: null }
};

export function hasActiveFilters(filters: SearchFilters): boolean {
  return (
    filters.query.length >= 2 ||
    filters.tags.length > 0 ||
    filters.type !== 'all' ||
    filters.dateRange.start !== null ||
    filters.dateRange.end !== null
  );
}

State of the Art

Old Approach Current Approach When Changed Impact
URL query params for filters Reactive $state for session-only Svelte 5 (2024) Simpler, no URL pollution
External debounce libraries Native $effect cleanup Svelte 5 (2024) No dependency needed
on:keydown directive onkeydown attribute Svelte 5 (2024) New syntax, same behavior
mark.js for highlighting Native <mark> + regex Always available Lighter, semantic

Deprecated/outdated:

  • svelte:window on:keydown for "/" focus: Has known bug, use native listener instead
  • Svelte 4 reactive statements ($:) for derived values: Use $derived in Svelte 5

Open Questions

Things that couldn't be fully resolved:

  1. Custom date range picker UI

    • What we know: Need quick presets (Today, This week, This month) + custom range
    • What's unclear: Best UX pattern for custom date input on mobile
    • Recommendation: Use native <input type="date"> for start/end; works well on mobile
  2. svelte-focus-key compatibility with Svelte 5

    • What we know: Package exists specifically for "/" focus use case
    • What's unclear: Whether it's updated for Svelte 5 runes syntax
    • Recommendation: Start with native listener approach; add package if issues arise

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence - verified against official sources)

Metadata

Confidence breakdown:

  • Standard stack: HIGH - all tools already in project, verified with official docs
  • Architecture: HIGH - patterns verified with Svelte 5 docs and working examples
  • Pitfalls: MEDIUM - based on GitHub issues and community reports

Research date: 2026-01-31 Valid until: 2026-03-01 (30 days - Svelte 5 stable, patterns unlikely to change)