diff --git a/.planning/phases/05-search/05-RESEARCH.md b/.planning/phases/05-search/05-RESEARCH.md new file mode 100644 index 0000000..ef91bea --- /dev/null +++ b/.planning/phases/05-search/05-RESEARCH.md @@ -0,0 +1,467 @@ +# 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)