"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, }; }