- 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
303 lines
8.0 KiB
TypeScript
303 lines
8.0 KiB
TypeScript
"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<SearchState>({
|
|
query: "",
|
|
results: [],
|
|
loading: false,
|
|
error: null,
|
|
filters: {},
|
|
});
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(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<SearchHistoryItem[]>([]);
|
|
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<SearchHistoryItem, "timestamp">) => {
|
|
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,
|
|
};
|
|
}
|