From 0092b318c22ee3863159c64770b7b91a57ae1da0 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Wed, 25 Feb 2026 15:28:47 -0600 Subject: [PATCH] feat(search): Enhance Mission Control search functionality - Add search result highlighting with match positions - Implement advanced filters (type, status, date range) - Add relevance scoring algorithm with recency/status boosts - Add search history with localStorage persistence - Create useSearch and useSearchHistory hooks - Add filter UI with popover component - Improve visual feedback and status icons Task: 56ae2be4-fcf1-403a-87fb-ea9de966f456 --- SEARCH_ENHANCEMENTS.md | 148 +++++++++ TASK_UPDATE_56ae2be4.md | 76 +++++ app/api/search/route.ts | 267 +++++++++++++--- components/layout/quick-search.tsx | 475 ++++++++++++++++++++++++++--- components/ui/popover.tsx | 89 ++++++ hooks/useSearch.ts | 302 ++++++++++++++++++ 6 files changed, 1273 insertions(+), 84 deletions(-) create mode 100644 SEARCH_ENHANCEMENTS.md create mode 100644 TASK_UPDATE_56ae2be4.md create mode 100644 components/ui/popover.tsx create mode 100644 hooks/useSearch.ts diff --git a/SEARCH_ENHANCEMENTS.md b/SEARCH_ENHANCEMENTS.md new file mode 100644 index 0000000..73713f7 --- /dev/null +++ b/SEARCH_ENHANCEMENTS.md @@ -0,0 +1,148 @@ +# Mission Control Search Enhancement + +## Summary + +Enhanced Mission Control's search functionality with the following improvements: + +## Changes Made + +### 1. Enhanced Search API (`/app/api/search/route.ts`) + +#### New Features: +- **Search Result Highlighting**: Added `SearchResultHighlight` interface with match positions for highlighting matching terms in titles and snippets +- **Advanced Filters**: Added support for filtering by: + - Type (task, project, document, sprint) + - Status (open, in-progress, done, etc.) + - Date ranges (from/to) +- **Relevance Scoring**: Implemented `calculateRelevanceScore` function that considers: + - Exact title matches (+100) + - Title starts with query (+80) + - Title contains query (+60) + - Content matches (+10 per match, max 40) + - Recency boost (+10 for items updated < 7 days, +5 for < 30 days) + - Active status boost (+5 for in-progress/open/active items) +- **Performance Metrics**: Added `executionTimeMs` to track search performance +- **Improved Snippets**: Added `createHighlightedSnippet` function for better context around matches + +#### Response Structure: +```typescript +interface SearchResponse { + results: SearchableResult[]; + total: number; + query: string; + filters?: SearchFilters; + executionTimeMs?: number; +} +``` + +### 2. Enhanced Quick Search Component (`/components/layout/quick-search.tsx`) + +#### New Features: +- **Search Filters UI**: Added Popover-based filter panel with: + - Type filters (Tasks, Projects, Documents, Sprints) + - Status filters (Open, In Progress, Done, etc.) + - Active filter count badge + - Clear all filters button +- **Search History**: Added localStorage-based search history with: + - Last 10 searches stored + - 30-day expiration + - History display when search is empty + - Individual history item removal + - Clear all history option + - Result count tracking +- **Highlighted Results**: Added `HighlightedText` component that shows matching terms with yellow background +- **Improved UI**: Better grouping, status icons, and visual hierarchy + +### 3. New Search Hook (`/hooks/useSearch.ts`) + +#### Features: +- **`useSearch` hook**: Comprehensive search state management with: + - Debounced search (configurable, default 150ms) + - Request cancellation support + - Error handling + - Filter management + - Callbacks for results and errors + +- **`useSearchHistory` hook**: Reusable history management with: + - Automatic localStorage sync + - Duplicate prevention + - Max item limit (10) + - 30-day automatic cleanup + +- **Utilities**: + - `createHighlightedText`: Helper for text highlighting + +## API Usage Examples + +### Basic Search +``` +GET /api/search?q=mission +``` + +### Search with Filters +``` +GET /api/search?q=mission&types=task,project&status=open,in-progress +``` + +### Search with Date Range +``` +GET /api/search?q=mission&dateFrom=2026-01-01&dateTo=2026-12-31 +``` + +## UI Components + +### QuickSearch +The QuickSearch component now provides: +- ⌘K keyboard shortcut +- Filter button with active count +- Search history (recent searches) +- Highlighted matching terms +- Status icons +- Color indicators for projects + +### Filter Popover +- Type filter buttons +- Status filter buttons +- Clear all option +- Visual active state + +## Technical Improvements + +1. **Performance**: + - Debounced API calls (150ms) + - Request cancellation + - Limited to 50 results + - Execution time tracking + +2. **User Experience**: + - Visual feedback during loading + - No results message with filter reset + - Keyboard navigation support + - Persistent search history + +3. **Code Quality**: + - TypeScript interfaces for all types + - Reusable hooks + - Clean separation of concerns + - Proper error handling + +## Files Modified + +1. `/app/api/search/route.ts` - Enhanced API with highlighting and filters +2. `/components/layout/quick-search.tsx` - Enhanced UI with filters and history +3. `/hooks/useSearch.ts` - New search hook +4. `/components/ui/popover.tsx` - New shadcn component (auto-installed) + +## Testing + +The build completes successfully with these changes. The search functionality is backwards compatible - existing search functionality continues to work while new features are opt-in. + +## Future Enhancements (Optional) + +Potential additional improvements: +1. Fuzzy search with Levenshtein distance +2. Search analytics/tracking +3. Saved searches +4. Advanced date range picker +5. Search result pagination +6. Full-text search with PostgreSQL diff --git a/TASK_UPDATE_56ae2be4.md b/TASK_UPDATE_56ae2be4.md new file mode 100644 index 0000000..28d60fd --- /dev/null +++ b/TASK_UPDATE_56ae2be4.md @@ -0,0 +1,76 @@ +# Task Update: Mission Control Search Enhancement + +**Task ID:** 56ae2be4-fcf1-403a-87fb-ea9de966f456 +**Status:** COMPLETED +**Completed:** 2026-02-25 + +## What Was Accomplished + +### 1. Enhanced Search API (`/app/api/search/route.ts`) +- ✅ Added search result highlighting with match positions +- ✅ Implemented advanced filters (type, status, date range) +- ✅ Added relevance scoring algorithm (exact match > starts with > contains) +- ✅ Added recency and status boosts to scoring +- ✅ Added execution time tracking +- ✅ Improved snippet generation with context + +### 2. Enhanced QuickSearch Component (`/components/layout/quick-search.tsx`) +- ✅ Added filter UI with Popover component +- ✅ Implemented search history (localStorage, 10 items, 30-day expiry) +- ✅ Added highlighted text component for matching terms +- ✅ Added filter count badge +- ✅ Added clear filters functionality +- ✅ Improved visual hierarchy and status icons + +### 3. New Search Hook (`/hooks/useSearch.ts`) +- ✅ Created `useSearch` hook with debouncing and cancellation +- ✅ Created `useSearchHistory` hook for persistent history +- ✅ Added utility functions for text highlighting +- ✅ Full TypeScript support + +### 4. Dependencies +- ✅ Installed `@radix-ui/react-popover` via shadcn + +## Build Status +✅ Build completes successfully + +## Files Modified +1. `/app/api/search/route.ts` - Enhanced API +2. `/components/layout/quick-search.tsx` - Enhanced UI +3. `/hooks/useSearch.ts` - New hook +4. `/components/ui/popover.tsx` - New component +5. `/SEARCH_ENHANCEMENTS.md` - Documentation + +## Key Features Delivered + +| Feature | Status | +|---------|--------| +| Search highlighting | ✅ | +| Type filters | ✅ | +| Status filters | ✅ | +| Date range filters | ✅ | +| Search history | ✅ | +| Relevance scoring | ✅ | +| Performance tracking | ✅ | +| Debounced search | ✅ | +| Request cancellation | ✅ | + +## API Examples + +``` +# Basic search +GET /api/search?q=mission + +# With filters +GET /api/search?q=mission&types=task,project&status=open + +# With date range +GET /api/search?q=mission&dateFrom=2026-01-01&dateTo=2026-12-31 +``` + +## Notes +- All changes are backwards compatible +- Search history is stored in localStorage +- Filters are optional and additive +- Maximum 50 results returned +- Default debounce is 150ms diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 207c0b3..7de852f 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -15,6 +15,12 @@ export const runtime = "nodejs"; export type SearchableType = "task" | "project" | "document" | "sprint"; +export interface SearchResultHighlight { + field: string; + text: string; + matches: Array<{ start: number; end: number }>; +} + export interface SearchableResult { id: string; type: SearchableType; @@ -24,12 +30,25 @@ export interface SearchableResult { icon: string; status?: string; // For visual badges color?: string; // For project/task colors + highlights?: SearchResultHighlight[]; // Highlighted matches + score: number; // Relevance score for sorting + updatedAt?: string; + createdAt?: string; } export interface SearchResponse { results: SearchableResult[]; total: number; query: string; + filters?: SearchFilters; + executionTimeMs?: number; +} + +export interface SearchFilters { + types?: SearchableType[]; + status?: string[]; + dateFrom?: string; + dateTo?: string; } // ============================================================================ @@ -44,6 +63,7 @@ interface SearchableEntityConfig { snippetField?: string; statusField?: string; colorField?: string; + dateFields?: string[]; icon: string; enabled: boolean; searchFields: string[]; @@ -60,6 +80,7 @@ const searchableEntities: SearchableEntityConfig[] = [ titleField: "title", snippetField: "description", statusField: "status", + dateFields: ["created_at", "updated_at", "due_date"], icon: "kanban", enabled: true, searchFields: ["title", "description"], @@ -75,6 +96,7 @@ const searchableEntities: SearchableEntityConfig[] = [ snippetField: "description", colorField: "color", statusField: "status", + dateFields: ["created_at", "updated_at"], icon: "folder-kanban", enabled: true, searchFields: ["name", "description"], @@ -89,6 +111,7 @@ const searchableEntities: SearchableEntityConfig[] = [ titleField: "name", snippetField: "goal", statusField: "status", + dateFields: ["start_date", "end_date", "created_at"], icon: "timer", enabled: true, searchFields: ["name", "goal"], @@ -102,39 +125,165 @@ const searchableEntities: SearchableEntityConfig[] = [ type: "document", titleField: "title", snippetField: "content", + dateFields: ["created_at", "updated_at"], icon: "file-text", enabled: true, - searchFields: ["title", "content"], + searchFields: ["title", "content", "tags"], getUrl: () => getMissionControlDocumentsUrl(), getSnippet: (item) => item.content ? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}` : undefined, }, - // Add new searchable entities here: - // { - // table: "meetings", - // type: "meeting", - // titleField: "title", - // icon: "calendar", - // searchFields: ["title", "notes"], - // getUrl: (item) => `/meetings/${item.id}`, - // } ]; +// ============================================================================ +// HIGHLIGHTING UTILITIES +// ============================================================================ + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findMatches(text: string, query: string): Array<{ start: number; end: number }> { + const matches: Array<{ start: number; end: number }> = []; + if (!text || !query) return matches; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let index = 0; + + while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) { + matches.push({ start: index, end: index + query.length }); + index += query.length; + } + + return matches; +} + +function createHighlightedSnippet( + text: string, + query: string, + maxLength: number = 150 +): { text: string; matches: Array<{ start: number; end: number }> } { + if (!text) return { text: "", matches: [] }; + + const matches = findMatches(text, query); + if (matches.length === 0) { + // No match in this field, return truncated text + return { + text: text.length > maxLength ? text.substring(0, maxLength) + "..." : text, + matches: [] + }; + } + + // Find the first match and center the snippet around it + const firstMatch = matches[0]; + const contextSize = Math.floor((maxLength - query.length) / 2); + let start = Math.max(0, firstMatch.start - contextSize); + let end = Math.min(text.length, firstMatch.end + contextSize); + + // Adjust if we're at the beginning or end + if (start === 0) { + end = Math.min(text.length, maxLength); + } else if (end === text.length) { + start = Math.max(0, text.length - maxLength); + } + + let snippet = text.substring(start, end); + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + + // Adjust match positions for the snippet + const adjustedMatches = matches + .filter(m => m.start >= start && m.end <= end) + .map(m => ({ + start: m.start - start + prefix.length, + end: m.end - start + prefix.length + })); + + return { + text: prefix + snippet + suffix, + matches: adjustedMatches + }; +} + +// ============================================================================ +// RELEVANCE SCORING +// ============================================================================ + +function calculateRelevanceScore( + item: any, + query: string, + entity: SearchableEntityConfig +): number { + const lowerQuery = query.toLowerCase(); + let score = 0; + + // Title match scoring + const title = item[entity.titleField]?.toLowerCase() || ""; + if (title === lowerQuery) { + score += 100; // Exact match + } else if (title.startsWith(lowerQuery)) { + score += 80; // Starts with query + } else if (title.includes(lowerQuery)) { + score += 60; // Contains query + } + + // Snippet/content match scoring + if (entity.snippetField) { + const snippet = item[entity.snippetField]?.toLowerCase() || ""; + const snippetMatches = (snippet.match(new RegExp(escapeRegex(lowerQuery), 'g')) || []).length; + score += Math.min(snippetMatches * 10, 40); // Up to 40 points for multiple matches + } + + // Recency boost (if updated_at exists) + if (item.updated_at) { + const daysSinceUpdate = (Date.now() - new Date(item.updated_at).getTime()) / (1000 * 60 * 60 * 24); + if (daysSinceUpdate < 7) score += 10; + else if (daysSinceUpdate < 30) score += 5; + } + + // Status boost for active items + if (entity.statusField) { + const status = item[entity.statusField]?.toLowerCase(); + if (status === "in-progress" || status === "open" || status === "active") { + score += 5; + } + } + + return score; +} + // ============================================================================ // API HANDLER // ============================================================================ export async function GET(request: Request) { + const startTime = Date.now(); + try { const { searchParams } = new URL(request.url); - const query = searchParams.get("q")?.trim().toLowerCase(); + const query = searchParams.get("q")?.trim(); + + // Parse filters + const filters: SearchFilters = {}; + const typesParam = searchParams.get("types"); + if (typesParam) { + filters.types = typesParam.split(",") as SearchableType[]; + } + const statusParam = searchParams.get("status"); + if (statusParam) { + filters.status = statusParam.split(","); + } + filters.dateFrom = searchParams.get("dateFrom") || undefined; + filters.dateTo = searchParams.get("dateTo") || undefined; if (!query || query.length < 2) { return NextResponse.json({ results: [], total: 0, - query: query || "" + query: query || "", + filters }); } @@ -155,6 +304,9 @@ export async function GET(request: Request) { // Search each enabled entity for (const entity of searchableEntities) { if (!entity.enabled) continue; + + // Skip if type filter is applied and this entity is not included + if (filters.types && !filters.types.includes(entity.type)) continue; try { // Build OR filter for search fields @@ -167,12 +319,33 @@ export async function GET(request: Request) { if (entity.snippetField) selectFields.push(entity.snippetField); if (entity.statusField) selectFields.push(entity.statusField); if (entity.colorField) selectFields.push(entity.colorField); + if (entity.dateFields) { + entity.dateFields.forEach(df => { + if (!selectFields.includes(df)) selectFields.push(df); + }); + } - const { data, error } = await supabase + let dbQuery = supabase .from(entity.table) .select(selectFields.join(", ")) - .or(orConditions) - .limit(10); + .or(orConditions); + + // Apply status filter if specified + if (filters.status && entity.statusField) { + dbQuery = dbQuery.in(entity.statusField, filters.status); + } + + // Apply date filters if specified + if (entity.dateFields) { + if (filters.dateFrom && entity.dateFields[0]) { + dbQuery = dbQuery.gte(entity.dateFields[0], filters.dateFrom); + } + if (filters.dateTo && entity.dateFields[0]) { + dbQuery = dbQuery.lte(entity.dateFields[0], filters.dateTo); + } + } + + const { data, error } = await dbQuery.limit(10); if (error) { console.error(`Search error in ${entity.table}:`, error); @@ -180,16 +353,43 @@ export async function GET(request: Request) { } if (data) { - const mappedResults: SearchableResult[] = data.map((item: any) => ({ - id: item.id, - type: entity.type, - title: item[entity.titleField] || "Untitled", - snippet: entity.getSnippet ? entity.getSnippet(item) : undefined, - url: entity.getUrl(item), - icon: entity.icon, - status: entity.statusField ? item[entity.statusField] : undefined, - color: entity.colorField ? item[entity.colorField] : undefined, - })); + const mappedResults: SearchableResult[] = data.map((item: any) => { + const score = calculateRelevanceScore(item, query, entity); + const titleSnippet = createHighlightedSnippet( + item[entity.titleField], + query, + 100 + ); + const contentSnippet = entity.snippetField + ? createHighlightedSnippet(item[entity.snippetField], query, 150) + : undefined; + + return { + id: item.id, + type: entity.type, + title: item[entity.titleField] || "Untitled", + snippet: entity.getSnippet ? entity.getSnippet(item) : undefined, + url: entity.getUrl(item), + icon: entity.icon, + status: entity.statusField ? item[entity.statusField] : undefined, + color: entity.colorField ? item[entity.colorField] : undefined, + highlights: [ + { + field: entity.titleField, + text: titleSnippet.text, + matches: titleSnippet.matches + }, + ...(contentSnippet ? [{ + field: entity.snippetField!, + text: contentSnippet.text, + matches: contentSnippet.matches + }] : []) + ], + score, + updatedAt: item.updated_at || item.updatedAt, + createdAt: item.created_at || item.createdAt, + }; + }); results.push(...mappedResults); } @@ -198,19 +398,8 @@ export async function GET(request: Request) { } } - // Sort by relevance: exact match > starts with > contains - results.sort((a, b) => { - const aTitle = a.title.toLowerCase(); - const bTitle = b.title.toLowerCase(); - const lowerQuery = query.toLowerCase(); - - if (aTitle === lowerQuery && bTitle !== lowerQuery) return -1; - if (bTitle === lowerQuery && aTitle !== lowerQuery) return 1; - if (aTitle.startsWith(lowerQuery) && !bTitle.startsWith(lowerQuery)) return -1; - if (bTitle.startsWith(lowerQuery) && !aTitle.startsWith(lowerQuery)) return 1; - - return aTitle.localeCompare(bTitle); - }); + // Sort by relevance score (highest first) + results.sort((a, b) => b.score - a.score); // Limit total results const limitedResults = results.slice(0, 50); @@ -219,6 +408,8 @@ export async function GET(request: Request) { results: limitedResults, total: limitedResults.length, query, + filters: Object.keys(filters).length > 0 ? filters : undefined, + executionTimeMs: Date.now() - startTime, }); } catch (error) { console.error("Search API error:", error); diff --git a/components/layout/quick-search.tsx b/components/layout/quick-search.tsx index be2ff20..112dff7 100644 --- a/components/layout/quick-search.tsx +++ b/components/layout/quick-search.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { CommandDialog, @@ -11,6 +11,13 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Search, LayoutDashboard, @@ -27,21 +34,47 @@ import { AlertCircle, Timer, Circle, + Filter, + History, + X, + ChevronDown, } from "lucide-react"; import { siteUrls } from "@/lib/config/sites"; +import { cn } from "@/lib/utils"; // Search result type from API - matches SearchableResult interface type SearchResultType = "task" | "project" | "document" | "sprint"; +interface SearchResultHighlight { + field: string; + text: string; + matches: Array<{ start: number; end: number }>; +} + interface SearchResult { id: string; type: SearchResultType; title: string; - snippet?: string; // Brief preview text (replaces subtitle/description) - url: string; // Deep link to full view + snippet?: string; + url: string; icon: string; - status?: string; // For visual badges - color?: string; // For project/task colors + status?: string; + color?: string; + highlights?: SearchResultHighlight[]; + score: number; + updatedAt?: string; +} + +interface SearchFilters { + types?: SearchResultType[]; + status?: string[]; +} + +interface SearchHistoryItem { + query: string; + filters?: SearchFilters; + timestamp: number; + resultCount?: number; } const navItems = [ @@ -77,6 +110,22 @@ const typeLabels: Record = { sprint: "Sprint", }; +// Available filters +const typeFilterOptions: { value: SearchResultType; label: string; icon: React.ElementType }[] = [ + { value: "task", label: "Tasks", icon: Kanban }, + { value: "project", label: "Projects", icon: FolderKanban }, + { value: "document", label: "Documents", icon: FileText }, + { value: "sprint", label: "Sprints", icon: Timer }, +]; + +const statusFilterOptions = [ + { value: "open", label: "Open" }, + { value: "in-progress", label: "In Progress" }, + { value: "done", label: "Done" }, + { value: "todo", label: "Todo" }, + { value: "completed", label: "Completed" }, +]; + function getStatusIcon(status?: string) { switch (status) { case "done": @@ -92,33 +141,152 @@ function getStatusIcon(status?: string) { } } +// Highlight text component +function HighlightedText({ + text, + matches, + className +}: { + text: string; + matches: Array<{ start: number; end: number }>; + className?: string; +}) { + if (!matches || matches.length === 0) { + return {text}; + } + + const parts: React.ReactNode[] = []; + let lastEnd = 0; + + matches.forEach((match, index) => { + // Add text before match + if (match.start > lastEnd) { + parts.push( + {text.substring(lastEnd, match.start)} + ); + } + // Add highlighted match + parts.push( + + {text.substring(match.start, match.end)} + + ); + lastEnd = match.end; + }); + + // Add remaining text + if (lastEnd < text.length) { + parts.push({text.substring(lastEnd)}); + } + + return {parts}; +} + +const SEARCH_HISTORY_KEY = "mission-control-search-history"; +const MAX_HISTORY_ITEMS = 10; + export function QuickSearch() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({}); + const [showFilters, setShowFilters] = useState(false); + const [searchHistory, setSearchHistory] = useState([]); + const [showHistory, setShowHistory] = useState(false); + const inputRef = useRef(null); const router = useRouter(); + // Load search history from localStorage + useEffect(() => { + try { + const saved = localStorage.getItem(SEARCH_HISTORY_KEY); + if (saved) { + const parsed = JSON.parse(saved); + // Filter out items older than 30 days + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + setSearchHistory(parsed.filter((item: SearchHistoryItem) => item.timestamp > thirtyDaysAgo)); + } + } catch (e) { + console.error("Failed to load search history:", e); + } + }, []); + + // Save search history to localStorage + const saveToHistory = useCallback((query: string, resultCount: number, filters?: SearchFilters) => { + if (!query.trim()) return; + + try { + const newItem: SearchHistoryItem = { + query: query.trim(), + filters: Object.keys(filters || {}).length > 0 ? filters : undefined, + timestamp: Date.now(), + resultCount, + }; + + setSearchHistory(prev => { + // Remove duplicate queries + const filtered = prev.filter(item => item.query.toLowerCase() !== query.toLowerCase()); + // Add new item at the beginning + const updated = [newItem, ...filtered].slice(0, MAX_HISTORY_ITEMS); + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); + return updated; + }); + } catch (e) { + console.error("Failed to save search history:", e); + } + }, []); + + // Clear search history + const clearHistory = useCallback(() => { + localStorage.removeItem(SEARCH_HISTORY_KEY); + setSearchHistory([]); + }, []); + + // Remove single history item + const removeHistoryItem = useCallback((index: number) => { + setSearchHistory(prev => { + const updated = prev.filter((_, i) => i !== index); + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + // Debounced search useEffect(() => { if (!open || query.length < 2) { setResults([]); setLoading(false); + setShowHistory(query.length === 0); return; } + setShowHistory(false); setLoading(true); setResults([]); let cancelled = false; + const timer = setTimeout(async () => { try { - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + const params = new URLSearchParams(); + params.set("q", query); + if (filters.types?.length) params.set("types", filters.types.join(",")); + if (filters.status?.length) params.set("status", filters.status.join(",")); + + const res = await fetch(`/api/search?${params.toString()}`); if (!res.ok) { throw new Error(`Search request failed with status ${res.status}`); } - const data = (await res.json()) as { results?: SearchResult[] }; + const data = (await res.json()) as { results?: SearchResult[]; total?: number }; if (!cancelled) { setResults(Array.isArray(data.results) ? data.results : []); + // Save to history if we got results + if (data.total && data.total > 0) { + saveToHistory(query, data.total, filters); + } } } catch (err) { console.error("Search error:", err); @@ -136,7 +304,7 @@ export function QuickSearch() { cancelled = true; clearTimeout(timer); }; - }, [query, open]); + }, [query, open, filters, saveToHistory]); // Keyboard shortcut useEffect(() => { @@ -153,6 +321,7 @@ export function QuickSearch() { const runCommand = useCallback((command: () => void) => { setOpen(false); setQuery(""); + setShowHistory(false); command(); }, []); @@ -166,6 +335,40 @@ export function QuickSearch() { }); }; + const handleHistorySelect = (item: SearchHistoryItem) => { + setQuery(item.query); + if (item.filters) { + setFilters(item.filters); + } + setShowHistory(false); + // Focus input after selection + inputRef.current?.focus(); + }; + + const toggleTypeFilter = (type: SearchResultType) => { + setFilters(prev => ({ + ...prev, + types: prev.types?.includes(type) + ? prev.types.filter(t => t !== type) + : [...(prev.types || []), type] + })); + }; + + const toggleStatusFilter = (status: string) => { + setFilters(prev => ({ + ...prev, + status: prev.status?.includes(status) + ? prev.status.filter(s => s !== status) + : [...(prev.status || []), status] + })); + }; + + const clearFilters = () => { + setFilters({}); + }; + + const activeFilterCount = (filters.types?.length || 0) + (filters.status?.length || 0); + // Group results by type const groupedResults = results.reduce((acc, result) => { if (!acc[result.type]) acc[result.type] = []; @@ -178,6 +381,22 @@ export function QuickSearch() { (type) => groupedResults[type]?.length > 0 ); + // Get title highlight for a result + const getTitleHighlight = (result: SearchResult) => { + const titleHighlight = result.highlights?.find(h => h.field === "title"); + if (titleHighlight && titleHighlight.matches.length > 0) { + return titleHighlight; + } + return { text: result.title, matches: [] }; + }; + + // Get snippet highlight for a result + const getSnippetHighlight = (result: SearchResult) => { + const snippetField = result.type === "sprint" ? "goal" : + result.type === "document" ? "content" : "description"; + return result.highlights?.find(h => h.field === snippetField); + }; + return ( <> {/* Search Button Trigger */} @@ -195,14 +414,165 @@ export function QuickSearch() { {/* Command Dialog */} - +
+ + + {/* Filter Button */} + + + + + +
+
+

Filters

+ {activeFilterCount > 0 && ( + + )} +
+ + {/* Type Filters */} +
+ +
+ {typeFilterOptions.map(option => { + const Icon = option.icon; + const isActive = filters.types?.includes(option.value); + return ( + + ); + })} +
+
+ + {/* Status Filters */} +
+ +
+ {statusFilterOptions.map(option => { + const isActive = filters.status?.includes(option.value); + return ( + + ); + })} +
+
+
+
+
+
+ + {/* Search History */} + {showHistory && searchHistory.length > 0 && !loading && ( + + {searchHistory.slice(0, 5).map((item, index) => ( + handleHistorySelect(item)} + className="group" + > + +
+ {item.query} + {item.filters && ( + + {item.filters.types?.map(t => typeLabels[t]).join(", ")} + {item.filters.status && ` • ${item.filters.status.join(", ")}`} + + )} +
+ {item.resultCount !== undefined && ( + + {item.resultCount} results + + )} + +
+ ))} + + + Clear search history + +
+ )} + - {loading ? "Searching..." : query.length < 2 ? "Type to search..." : "No results found."} + {loading ? ( +
+
+
+ ) : query.length < 2 ? ( + "Type to search..." + ) : ( +
+

No results found{activeFilterCount > 0 ? " with current filters" : ""}

+ {activeFilterCount > 0 && ( + + )} +
+ )} {/* Show search results when query exists */} @@ -214,40 +584,53 @@ export function QuickSearch() { return ( - {typeResults.slice(0, 5).map((result) => ( - handleResultSelect(result)} - > - {result.type === "task" ? ( - getStatusIcon(result.status) - ) : result.type === "project" && result.color ? ( -
- ) : ( - - )} - -
- {result.title} - {result.snippet && ( - - {result.snippet} + {typeResults.slice(0, 5).map((result) => { + const titleHighlight = getTitleHighlight(result); + const snippetHighlight = getSnippetHighlight(result); + + return ( + handleResultSelect(result)} + > + {result.type === "task" ? ( + getStatusIcon(result.status) + ) : result.type === "project" && result.color ? ( +
+ ) : ( + + )} + +
+ + + + {snippetHighlight && ( + + + + )} +
+ + {result.status && result.type !== "task" && ( + + {result.status} )} -
- - {result.status && result.type !== "task" && ( - - {result.status} - - )} -
- ))} + + ); + })} ); })} diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx new file mode 100644 index 0000000..9519cc4 --- /dev/null +++ b/components/ui/popover.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} diff --git a/hooks/useSearch.ts b/hooks/useSearch.ts new file mode 100644 index 0000000..930166e --- /dev/null +++ b/hooks/useSearch.ts @@ -0,0 +1,302 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; + +export type SearchResultType = "task" | "project" | "document" | "sprint"; + +export interface SearchResultHighlight { + field: string; + text: string; + matches: Array<{ start: number; end: number }>; +} + +export interface SearchFilters { + types?: SearchResultType[]; + status?: string[]; + dateFrom?: string; + dateTo?: string; +} + +export interface SearchResult { + id: string; + type: SearchResultType; + title: string; + snippet?: string; + url: string; + icon: string; + status?: string; + color?: string; + highlights?: SearchResultHighlight[]; + score: number; + updatedAt?: string; + createdAt?: string; +} + +export interface SearchState { + query: string; + results: SearchResult[]; + loading: boolean; + error: string | null; + filters: SearchFilters; + executionTimeMs?: number; +} + +export interface UseSearchOptions { + debounceMs?: number; + minQueryLength?: number; + onResults?: (results: SearchResult[]) => void; + onError?: (error: string) => void; +} + +export function useSearch(options: UseSearchOptions = {}) { + const { debounceMs = 150, minQueryLength = 2, onResults, onError } = options; + + const [state, setState] = useState({ + query: "", + results: [], + loading: false, + error: null, + filters: {}, + }); + + const abortControllerRef = useRef(null); + const debounceTimerRef = useRef(null); + + const setQuery = useCallback((query: string) => { + setState(prev => ({ ...prev, query })); + }, []); + + const setFilters = useCallback((filters: SearchFilters) => { + setState(prev => ({ ...prev, filters })); + }, []); + + const clearFilters = useCallback(() => { + setState(prev => ({ ...prev, filters: {} })); + }, []); + + const search = useCallback(async () => { + const { query, filters } = state; + + if (!query || query.length < minQueryLength) { + setState(prev => ({ ...prev, results: [], loading: false, error: null })); + return; + } + + // Cancel any pending request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + setState(prev => ({ ...prev, loading: true, error: null })); + + try { + const params = new URLSearchParams(); + params.set("q", query); + if (filters.types?.length) params.set("types", filters.types.join(",")); + if (filters.status?.length) params.set("status", filters.status.join(",")); + if (filters.dateFrom) params.set("dateFrom", filters.dateFrom); + if (filters.dateTo) params.set("dateTo", filters.dateTo); + + const response = await fetch(`/api/search?${params.toString()}`, { + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + throw new Error(`Search failed with status ${response.status}`); + } + + const data = await response.json(); + + const newState: SearchState = { + query, + results: data.results || [], + loading: false, + error: null, + filters, + executionTimeMs: data.executionTimeMs, + }; + + setState(newState); + onResults?.(data.results || []); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return; // Request was cancelled, ignore + } + + const errorMessage = err instanceof Error ? err.message : "Search failed"; + setState(prev => ({ ...prev, loading: false, error: errorMessage })); + onError?.(errorMessage); + } + }, [state.query, state.filters, minQueryLength, onResults, onError]); + + // Debounced search effect + useEffect(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + search(); + }, debounceMs); + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [state.query, state.filters, search, debounceMs]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + return { + ...state, + setQuery, + setFilters, + clearFilters, + refresh: search, + }; +} + +// Hook for search history management +const SEARCH_HISTORY_KEY = "mission-control-search-history"; +const MAX_HISTORY_ITEMS = 10; + +export interface SearchHistoryItem { + query: string; + filters?: SearchFilters; + timestamp: number; + resultCount?: number; +} + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + + // Load history from localStorage on mount + useEffect(() => { + try { + const saved = localStorage.getItem(SEARCH_HISTORY_KEY); + if (saved) { + const parsed = JSON.parse(saved); + // Filter out items older than 30 days + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + const filtered = parsed.filter((item: SearchHistoryItem) => item.timestamp > thirtyDaysAgo); + setHistory(filtered); + } + } catch (e) { + console.error("Failed to load search history:", e); + } + setIsLoaded(true); + }, []); + + // Save history to localStorage whenever it changes + useEffect(() => { + if (isLoaded) { + try { + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)); + } catch (e) { + console.error("Failed to save search history:", e); + } + } + }, [history, isLoaded]); + + const addToHistory = useCallback((item: Omit) => { + if (!item.query.trim()) return; + + setHistory(prev => { + // Remove duplicate queries + const filtered = prev.filter(h => h.query.toLowerCase() !== item.query.toLowerCase()); + // Add new item at the beginning + const newItem: SearchHistoryItem = { + ...item, + timestamp: Date.now(), + }; + return [newItem, ...filtered].slice(0, MAX_HISTORY_ITEMS); + }); + }, []); + + const removeFromHistory = useCallback((index: number) => { + setHistory(prev => prev.filter((_, i) => i !== index)); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + }, []); + + return { + history, + isLoaded, + addToHistory, + removeFromHistory, + clearHistory, + }; +} + +// Utility function to highlight search matches +export function createHighlightedText( + text: string, + query: string, + maxLength: number = 150 +): { text: string; matches: Array<{ start: number; end: number }> } { + if (!text || !query) { + return { + text: text?.length > maxLength ? text.substring(0, maxLength) + "..." : text || "", + matches: [], + }; + } + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const matches: Array<{ start: number; end: number }> = []; + let index = 0; + + while ((index = lowerText.indexOf(lowerQuery, index)) !== -1) { + matches.push({ start: index, end: index + query.length }); + index += query.length; + } + + if (matches.length === 0) { + return { + text: text.length > maxLength ? text.substring(0, maxLength) + "..." : text, + matches: [], + }; + } + + // Center snippet around first match + const firstMatch = matches[0]; + const contextSize = Math.floor((maxLength - query.length) / 2); + let start = Math.max(0, firstMatch.start - contextSize); + let end = Math.min(text.length, firstMatch.end + contextSize); + + if (start === 0) { + end = Math.min(text.length, maxLength); + } else if (end === text.length) { + start = Math.max(0, text.length - maxLength); + } + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + + const adjustedMatches = matches + .filter(m => m.start >= start && m.end <= end) + .map(m => ({ + start: m.start - start + prefix.length, + end: m.end - start + prefix.length, + })); + + return { + text: prefix + text.substring(start, end) + suffix, + matches: adjustedMatches, + }; +}