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)
468 lines
17 KiB
Markdown
468 lines
17 KiB
Markdown
# 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:**
|
|
```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
|
|
<!-- 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:**
|
|
```svelte
|
|
<!-- 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:**
|
|
```typescript
|
|
// 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>');
|
|
}
|
|
```
|
|
|
|
```svelte
|
|
<!-- 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.
|
|
|
|
### 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
|
|
<!-- 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
|
|
```typescript
|
|
// 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
|
|
```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 `<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)
|
|
- [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 `<mark>` 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)
|