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)
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
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:
// 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
$derivedfor debounce:$derivedcannot be debounced directly; use$effectto update a debounced$state - Using
svelte:window onkeydownfor "/" focus: Known bug preventspreventDefault()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.
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
<!-- 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:keydownfor "/" focus: Has known bug, use native listener instead- Svelte 4 reactive statements (
$:) for derived values: Use$derivedin Svelte 5
Open Questions
Things that couldn't be fully resolved:
-
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
-
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 - cleanup patterns
- Drizzle ORM Operators - like/ilike usage
- Drizzle ORM Select - WHERE clause patterns
- MDN
<mark>element - semantic highlighting
Secondary (MEDIUM confidence)
- Minimalist Django - Svelte 5 Debounced Input - debounce pattern
- bitsofco.de - Highlighting search matches - regex replace pattern
- SvelteKit State Management Docs - URL params guidance
- svelte-focus-key - "/" key focus solution
Tertiary (LOW confidence - verified against official sources)
- Svelte Issue #15474 - svelte:window keydown bug
- SvelteKit Discussion #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)