diff --git a/__tests__/query-parser.test.ts b/__tests__/query-parser.test.ts new file mode 100644 index 0000000..72e07ba --- /dev/null +++ b/__tests__/query-parser.test.ts @@ -0,0 +1,145 @@ +import { parseQuery, QueryAST } from '@/lib/query-parser' + +describe('query-parser', () => { + describe('basic text queries', () => { + it('returns text with no filters for simple text', () => { + const result = parseQuery('docker') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({}) + }) + + it('preserves multi-word text', () => { + const result = parseQuery('hello world') + expect(result.text).toBe('hello world') + expect(result.filters).toEqual({}) + }) + }) + + describe('type filter', () => { + it('extracts type filter from beginning of query', () => { + const result = parseQuery('type:command docker') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({ type: 'command' }) + }) + + it('handles query with only type filter', () => { + const result = parseQuery('type:snippet') + expect(result.text).toBe('') + expect(result.filters).toEqual({ type: 'snippet' }) + }) + + it('extracts type filter from end of query', () => { + const result = parseQuery('docker type:command') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({ type: 'command' }) + }) + }) + + describe('tag filter', () => { + it('extracts tag filter with text', () => { + const result = parseQuery('tag:api error') + expect(result.text).toBe('error') + expect(result.filters).toEqual({ tag: 'api' }) + }) + + it('handles query with only tag filter', () => { + const result = parseQuery('tag:backend') + expect(result.text).toBe('') + expect(result.filters).toEqual({ tag: 'backend' }) + }) + }) + + describe('combined filters', () => { + it('parses multiple filters together', () => { + const result = parseQuery('docker tag:backend type:command') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({ type: 'command', tag: 'backend' }) + }) + + it('handles type, tag, and isFavorite combined', () => { + const result = parseQuery('type:snippet tag:python is:favorite') + expect(result.text).toBe('') + expect(result.filters).toEqual({ type: 'snippet', tag: 'python', isFavorite: true }) + }) + }) + + describe('boolean filters', () => { + it('extracts is:favorite filter', () => { + const result = parseQuery('is:favorite docker') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({ isFavorite: true }) + }) + + it('handles is:pinned filter alone', () => { + const result = parseQuery('is:pinned') + expect(result.text).toBe('') + expect(result.filters).toEqual({ isPinned: true }) + }) + + it('handles both boolean filters with text', () => { + const result = parseQuery('is:favorite is:pinned docker') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({ isFavorite: true, isPinned: true }) + }) + }) + + describe('edge cases', () => { + it('handles empty string', () => { + const result = parseQuery('') + expect(result.text).toBe('') + expect(result.filters).toEqual({}) + }) + + it('handles whitespace only', () => { + const result = parseQuery(' ') + expect(result.text).toBe('') + expect(result.filters).toEqual({}) + }) + + it('ignores empty type value', () => { + const result = parseQuery('type:') + expect(result.text).toBe('') + expect(result.filters).toEqual({}) + }) + + it('ignores empty tag value', () => { + const result = parseQuery('tag:') + expect(result.text).toBe('') + expect(result.filters).toEqual({}) + }) + + it('last duplicate filter wins for type', () => { + const result = parseQuery('type:command type:snippet docker') + expect(result.text).toBe('docker') + expect(result.filters).toEqual({ type: 'snippet' }) + }) + + it('last duplicate filter wins for tag', () => { + const result = parseQuery('tag:python tag:javascript code') + expect(result.text).toBe('code') + expect(result.filters).toEqual({ tag: 'javascript' }) + }) + }) + + describe('case sensitivity', () => { + it('filter name is case insensitive', () => { + const result = parseQuery('TYPE:command') + expect(result.filters).toEqual({ type: 'command' }) + }) + + it('filter value is case sensitive', () => { + const result = parseQuery('type:Command') + expect(result.filters).toEqual({ type: 'Command' }) + }) + + it('is:favorite is case insensitive', () => { + const result = parseQuery('IS:FAVORITE docker') + expect(result.filters).toEqual({ isFavorite: true }) + }) + + it('is:pinned is case insensitive', () => { + const result = parseQuery('IS:PINNED') + expect(result.filters).toEqual({ isPinned: true }) + }) + }) +}) diff --git a/backlog/recall-mvp4-tickets.md b/backlog/recall-mvp4-tickets.md new file mode 100644 index 0000000..57d2425 --- /dev/null +++ b/backlog/recall-mvp4-tickets.md @@ -0,0 +1,252 @@ +# Recall — Tickets técnicos MVP-4 (Camino Producto) + +## 🎯 Objetivo +Convertir Recall en una herramienta confiable, rápida y diaria, enfocada en: +- búsqueda tipo Google personal +- navegación instantánea +- confianza (historial + backup) +- contexto activo + +--- + +# 🧩 EPIC 1 — Búsqueda avanzada (Google personal) + +## [P1] Ticket 01 — Parser de query avanzada + +**Objetivo** +Permitir búsquedas expresivas tipo: `docker tag:backend type:command` + +**Alcance** +- Crear `src/lib/query-parser.ts` +- Soportar: + - texto libre + - `type:` + - `tag:` + - `is:favorite`, `is:pinned` +- Devolver AST simple + +**Criterios** +- Queries válidas parsean correctamente +- Soporta combinación de filtros + texto +- Tests unitarios incluidos + +--- + +## [P1] Ticket 02 — Integrar query avanzada en search + +**Objetivo** +Aplicar parser en `/api/search` + +**Alcance** +- Filtrar por AST antes de scoring +- Mantener scoring existente + +**Criterios** +- `type:command docker` filtra correctamente +- `tag:api error` funciona +- No rompe búsqueda actual + +--- + +## [P1] Ticket 03 — Búsqueda en tiempo real + +**Objetivo** +Actualizar resultados mientras el usuario escribe + +**Alcance** +- Debounce en `search-bar.tsx` +- Fetch automático +- Estado loading ligero + +**Criterios** +- Resultados cambian en <300ms +- No bloquea UI + +--- + +## [P1] Ticket 04 — Navegación por teclado en búsqueda + +**Objetivo** +UX tipo Spotlight + +**Alcance** +- ↑ ↓ para moverse +- Enter para abrir +- ESC para cerrar + +**Criterios** +- Navegación sin mouse +- Estado seleccionado visible + +--- + +# 🧠 EPIC 2 — Contexto activo + +## [P1] Ticket 05 — Sidebar contextual inteligente + +**Objetivo** +Mostrar contexto dinámico mientras navegas + +**Alcance** +- Crear `note-context-sidebar.tsx` +- Mostrar: + - relacionadas + - co-uso + - backlinks + - recientes + +**Criterios** +- Siempre muestra contenido relevante +- No rompe layout responsive + +--- + +## [P2] Ticket 06 — Sugerencias dinámicas en lectura + +**Objetivo** +Recomendar contenido mientras lees + +**Alcance** +- Hook en `notes/[id]/page.tsx` +- Actualizar sugerencias según scroll/uso + +**Criterios** +- Sugerencias cambian según contexto +- No afecta performance + +--- + +# 🔐 EPIC 3 — Confianza total + +## [P1] Ticket 07 — Historial de versiones + +**Objetivo** +Permitir ver y revertir cambios + +**Alcance** +- Modelo `NoteVersion` +- Guardar snapshot en cada update +- Endpoint `/api/notes/[id]/versions` + +**Criterios** +- Se pueden listar versiones +- Se puede restaurar versión +- No rompe edición actual + +--- + +## [P1] Ticket 08 — UI historial de versiones + +**Objetivo** +Visualizar cambios + +**Alcance** +- Vista en `notes/[id]` +- Mostrar lista de versiones +- Botón restaurar + +**Criterios** +- UX clara +- Confirmación antes de revertir + +--- + +## [P2] Ticket 09 — Backup automático + +**Objetivo** +Evitar pérdida de datos + +**Alcance** +- Export JSON automático +- Guardar en local (descarga o storage) + +**Criterios** +- Backup se genera periódicamente +- No bloquea app + +--- + +# ⚡ EPIC 4 — Rendimiento y UX + +## [P1] Ticket 10 — Cache de resultados de búsqueda + +**Objetivo** +Reducir latencia + +**Alcance** +- Cache en cliente por query +- Invalidación simple + +**Criterios** +- Queries repetidas son instantáneas +- No datos stale críticos + +--- + +## [P2] Ticket 11 — Preload de notas frecuentes + +**Objetivo** +Abrir notas más rápido + +**Alcance** +- Prefetch en hover/listado +- Usar Next.js prefetch + +**Criterios** +- Navegación instantánea en notas frecuentes + +--- + +# 🧪 EPIC 5 — Calidad + +## [P1] Ticket 12 — Tests query avanzada + +- parser +- integración search + +--- + +## [P1] Ticket 13 — Tests historial versiones + +- creación +- restore +- edge cases + +--- + +## [P2] Ticket 14 — Tests navegación teclado + +- selección +- acciones + +--- + +# 🗺️ Orden sugerido + +## Sprint 1 +- Query parser +- Integración search +- Real-time search +- Tests base + +## Sprint 2 +- Navegación teclado +- Sidebar contextual +- Cache búsqueda + +## Sprint 3 +- Historial versiones (API + UI) + +## Sprint 4 +- Backup automático +- Preload notas +- Tests finales + +--- + +# ✅ Definition of Done + +- Feature usable sin bugs críticos +- Tests pasando +- No regresiones +- UX fluida (<300ms interacción) diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 5cb9f14..ee1e68a 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' import { noteSchema } from '@/lib/validators' import { normalizeTag } from '@/lib/tags' -import { noteQuery } from '@/lib/search' +import { searchNotes } from '@/lib/search' import { syncBacklinks } from '@/lib/backlinks' import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors' @@ -14,7 +14,7 @@ export async function GET(req: NextRequest) { const tag = searchParams.get('tag') || undefined if (q || type || tag) { - const notes = await noteQuery(q, { type, tag }) + const notes = await searchNotes(q, { type, tag }) return createSuccessResponse(notes) } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index f51acca..3099a85 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,15 +1,15 @@ import { NextRequest } from 'next/server' import { searchNotes } from '@/lib/search' +import { parseQuery } from '@/lib/query-parser' import { createErrorResponse, createSuccessResponse } from '@/lib/errors' export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url) const q = searchParams.get('q') || '' - const type = searchParams.get('type') || undefined - const tag = searchParams.get('tag') || undefined - const notes = await searchNotes(q, { type, tag }) + const queryAST = parseQuery(q) + const notes = await searchNotes(queryAST.text, queryAST.filters) return createSuccessResponse(notes) } catch (error) { diff --git a/src/components/search-bar.tsx b/src/components/search-bar.tsx index ae5f59e..1e534a3 100644 --- a/src/components/search-bar.tsx +++ b/src/components/search-bar.tsx @@ -1,34 +1,178 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' -import { Search } from 'lucide-react' +import { Search, Loader2 } from 'lucide-react' +import { ScoredNote } from '@/lib/search' export function SearchBar() { const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) const router = useRouter() + const inputRef = useRef(null) + const dropdownRef = useRef(null) + const debounceRef = useRef(null) + + // Debounced search + useEffect(() => { + if (!query.trim()) { + setResults([]) + setIsOpen(false) + return + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + debounceRef.current = setTimeout(async () => { + setIsLoading(true) + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`) + const json = await res.json() + if (json.success) { + setResults(json.data.slice(0, 8)) + setIsOpen(true) + setSelectedIndex(-1) + } + } catch (error) { + console.error('Search failed:', error) + } finally { + setIsLoading(false) + } + }, 300) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [query]) + + // Click outside to close + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) const handleSearch = (e: React.FormEvent) => { e.preventDefault() if (query.trim()) { router.push(`/notes?q=${encodeURIComponent(query)}`) + setIsOpen(false) } } + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || results.length === 0) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)) + break + case 'Enter': + e.preventDefault() + if (selectedIndex >= 0 && results[selectedIndex]) { + router.push(`/notes/${results[selectedIndex].id}`) + setIsOpen(false) + setQuery('') + } else { + handleSearch(e) + } + break + case 'Escape': + e.preventDefault() + setIsOpen(false) + setSelectedIndex(-1) + break + } + } + + const handleResultClick = (note: ScoredNote) => { + router.push(`/notes/${note.id}`) + setIsOpen(false) + setQuery('') + } + return ( -
- setQuery(e.target.value)} - className="flex-1 min-w-0" - /> - -
+
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 min-w-0" + /> + +
+ + {isOpen && results.length > 0 && ( +
+ {results.map((note, index) => ( + + ))} +
+ )} +
) -} \ No newline at end of file +} diff --git a/src/lib/query-parser.ts b/src/lib/query-parser.ts new file mode 100644 index 0000000..27f8401 --- /dev/null +++ b/src/lib/query-parser.ts @@ -0,0 +1,61 @@ +export interface QueryAST { + text: string + filters: { + type?: string + tag?: string + isFavorite?: boolean + isPinned?: boolean + } +} + +const FILTER_REGEX = /(tag|type):(\S*)/gi +const IS_FAVORITE_REGEX = /\bis:favorite\b/gi +const IS_PINNED_REGEX = /\bis:pinned\b/gi + +export function parseQuery(query: string): QueryAST { + if (!query || typeof query !== 'string') { + return { text: '', filters: {} } + } + + const filters: QueryAST['filters'] = {} + + // Extract type: filter + const typeMatches = query.matchAll(/(?:^|\s)(type):(\S*)/gi) + for (const match of typeMatches) { + const value = match[2] + if (value) { + filters.type = value + } + } + + // Extract tag: filter + const tagMatches = query.matchAll(/(?:^|\s)(tag):(\S*)/gi) + for (const match of tagMatches) { + const value = match[2] + if (value) { + filters.tag = value + } + } + + // Check for is:favorite (case insensitive filter name) + const isFavoriteMatches = query.match(/\bis:favorite\b/i) + if (isFavoriteMatches) { + filters.isFavorite = true + } + + // Check for is:pinned (case insensitive filter name) + const isPinnedMatches = query.match(/\bis:pinned\b/i) + if (isPinnedMatches) { + filters.isPinned = true + } + + // Remove all filter patterns from the query to get remaining text + let text = query + .replace(FILTER_REGEX, '') + .replace(IS_FAVORITE_REGEX, '') + .replace(IS_PINNED_REGEX, '') + .trim() + .replace(/\s+/g, ' ') + + return { text, filters } +} diff --git a/src/lib/search.ts b/src/lib/search.ts index ce063a4..bd08a18 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,10 +1,13 @@ import { prisma } from '@/lib/prisma' import stringSimilarity from 'string-similarity' import { getUsageStats } from '@/lib/usage' +import { parseQuery, QueryAST } from '@/lib/query-parser' export interface SearchFilters { type?: string tag?: string + isFavorite?: boolean + isPinned?: boolean } export interface ScoredNote { @@ -133,10 +136,19 @@ async function scoreNote( } export async function noteQuery( - query: string, + queryOrAST: string | QueryAST, filters: SearchFilters = {} ): Promise { - const queryLower = query.toLowerCase().trim() + // Support both old signature (query string + filters) and new (QueryAST) + let queryAST: QueryAST + if (typeof queryOrAST === 'string') { + queryAST = { text: queryOrAST, filters } + } else { + queryAST = queryOrAST + } + + const { text: queryText, filters: appliedFilters } = queryAST + const queryLower = queryText.toLowerCase().trim() const allNotes = await prisma.note.findMany({ include: { tags: { include: { tag: true } } }, @@ -145,12 +157,14 @@ export async function noteQuery( const scored: ScoredNote[] = [] for (const note of allNotes) { - if (filters.type && note.type !== filters.type) continue - - if (filters.tag) { - const hasTag = note.tags.some(t => t.tag.name === filters.tag) + // Apply filters from AST BEFORE scoring + if (appliedFilters.type && note.type !== appliedFilters.type) continue + if (appliedFilters.tag) { + const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag) if (!hasTag) continue } + if (appliedFilters.isFavorite && note.isFavorite !== true) continue + if (appliedFilters.isPinned && note.isPinned !== true) continue const titleLower = note.title.toLowerCase() const contentLower = note.content.toLowerCase() @@ -181,7 +195,7 @@ export async function noteQuery( const highlight = highlightMatches( exactTitleMatch ? note.title + ' ' + note.content : note.content, - query + queryText ) scored.push({ @@ -202,5 +216,11 @@ export async function searchNotes( query: string, filters: SearchFilters = {} ): Promise { - return noteQuery(query, filters) + const queryAST: QueryAST = { + text: query, + filters: { + ...filters, + }, + } + return noteQuery(queryAST) }