# 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 `` 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 `` | 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.js library | Library adds weight; `` 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:** ```bash # No new dependencies required npm install svelte-focus-key # OPTIONAL: Only if manual approach proves buggy ``` ## Architecture Patterns ### Recommended Component Structure ``` 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:** ```typescript // 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:** ```svelte ``` ### 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:** ```svelte ``` **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 `` **What:** Wrap matching text in semantic `` element for highlighting. **When to use:** Displaying search results with highlighted matches. **Example:** ```typescript // Source: bitsofco.de, MDN 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, '$1'); } ``` ```svelte

{@html highlightMatch(entry.content, searchQuery)}

``` **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 `` + 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. ### Pitfall 2: Pinned Entries Staying at Top During Search **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 ```svelte
showRecent = true} onblur={() => setTimeout(() => showRecent = false, 200)} /> {#if showRecent && recentSearches.length > 0 && inputValue.length === 0}
Recent searches
{#each recentSearches as search} {/each}
{/if}
``` ### Recent Searches Store ```typescript // src/lib/stores/recentSearches.ts import { persisted } from 'svelte-persisted-store'; const MAX_RECENT = 5; export const recentSearches = persisted('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 ```typescript // 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 ```typescript // 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 `` + 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 `` 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) - [Svelte 5 $effect Documentation](https://svelte.dev/docs/svelte/$effect) - cleanup patterns - [Drizzle ORM Operators](https://orm.drizzle.team/docs/operators) - like/ilike usage - [Drizzle ORM Select](https://orm.drizzle.team/docs/select) - WHERE clause patterns - [MDN `` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/mark) - semantic highlighting ### Secondary (MEDIUM confidence) - [Minimalist Django - Svelte 5 Debounced Input](https://minimalistdjango.com/snippets/2025-04-04-debounced-input-svelte/) - debounce pattern - [bitsofco.de - Highlighting search matches](https://bitsofco.de/a-one-line-solution-to-highlighting-search-matches/) - regex replace pattern - [SvelteKit State Management Docs](https://svelte.dev/docs/kit/state-management) - URL params guidance - [svelte-focus-key](https://metonym.github.io/svelte-focus-key/) - "/" key focus solution ### Tertiary (LOW confidence - verified against official sources) - [Svelte Issue #15474](https://github.com/sveltejs/svelte/issues/15474) - svelte:window keydown bug - [SvelteKit Discussion #9700](https://github.com/sveltejs/kit/discussions/9700) - client vs server filtering ## 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)