feat: MVP-4 Sprint 1 - Query parser, real-time search, keyboard navigation

- Add query-parser.ts with AST for type:, tag:, is:favorite, is:pinned filters
- Integrate parser into search API
- Add real-time search with 300ms debounce in search-bar
- Add keyboard navigation (↑↓ Enter ESC) for Spotlight-like UX
- Add dropdown results display with loading state
- Add 22 tests for query parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 17:33:17 -03:00
parent d5c418c84f
commit 9af25927b7
7 changed files with 650 additions and 28 deletions

View File

@@ -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 })
})
})
})

View File

@@ -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)

View File

@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { noteSchema } from '@/lib/validators' import { noteSchema } from '@/lib/validators'
import { normalizeTag } from '@/lib/tags' import { normalizeTag } from '@/lib/tags'
import { noteQuery } from '@/lib/search' import { searchNotes } from '@/lib/search'
import { syncBacklinks } from '@/lib/backlinks' import { syncBacklinks } from '@/lib/backlinks'
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors' import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
const tag = searchParams.get('tag') || undefined const tag = searchParams.get('tag') || undefined
if (q || type || tag) { if (q || type || tag) {
const notes = await noteQuery(q, { type, tag }) const notes = await searchNotes(q, { type, tag })
return createSuccessResponse(notes) return createSuccessResponse(notes)
} }

View File

@@ -1,15 +1,15 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { searchNotes } from '@/lib/search' import { searchNotes } from '@/lib/search'
import { parseQuery } from '@/lib/query-parser'
import { createErrorResponse, createSuccessResponse } from '@/lib/errors' import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url) const { searchParams } = new URL(req.url)
const q = searchParams.get('q') || '' 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) return createSuccessResponse(notes)
} catch (error) { } catch (error) {

View File

@@ -1,34 +1,178 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' 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() { export function SearchBar() {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [results, setResults] = useState<ScoredNote[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const router = useRouter() const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(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) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (query.trim()) { if (query.trim()) {
router.push(`/notes?q=${encodeURIComponent(query)}`) 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 ( return (
<form onSubmit={handleSearch} className="flex gap-2 w-full"> <div className="relative w-full">
<Input <form onSubmit={handleSearch} className="flex gap-2 w-full">
type="text" <Input
placeholder="Buscar notas..." ref={inputRef}
value={query} type="text"
onChange={(e) => setQuery(e.target.value)} placeholder="Buscar notas..."
className="flex-1 min-w-0" value={query}
/> onChange={(e) => setQuery(e.target.value)}
<Button type="submit" variant="secondary" size="icon"> onKeyDown={handleKeyDown}
<Search className="h-4 w-4" /> className="flex-1 min-w-0"
</Button> />
</form> <Button type="submit" variant="secondary" size="icon" disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</form>
{isOpen && results.length > 0 && (
<div
ref={dropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
>
{results.map((note, index) => (
<button
key={note.id}
onClick={() => handleResultClick(note)}
className={`w-full px-3 py-2 text-left flex items-center justify-between gap-2 transition-colors ${
index === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{note.title}</div>
{note.highlight && (
<div
className="text-sm text-muted-foreground truncate"
dangerouslySetInnerHTML={{ __html: note.highlight }}
/>
)}
</div>
<span
className={`text-xs px-2 py-0.5 rounded ${
note.type === 'document'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: note.type === 'task'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{note.type}
</span>
</button>
))}
</div>
)}
</div>
) )
} }

61
src/lib/query-parser.ts Normal file
View File

@@ -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 }
}

View File

@@ -1,10 +1,13 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import stringSimilarity from 'string-similarity' import stringSimilarity from 'string-similarity'
import { getUsageStats } from '@/lib/usage' import { getUsageStats } from '@/lib/usage'
import { parseQuery, QueryAST } from '@/lib/query-parser'
export interface SearchFilters { export interface SearchFilters {
type?: string type?: string
tag?: string tag?: string
isFavorite?: boolean
isPinned?: boolean
} }
export interface ScoredNote { export interface ScoredNote {
@@ -133,10 +136,19 @@ async function scoreNote(
} }
export async function noteQuery( export async function noteQuery(
query: string, queryOrAST: string | QueryAST,
filters: SearchFilters = {} filters: SearchFilters = {}
): Promise<ScoredNote[]> { ): Promise<ScoredNote[]> {
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({ const allNotes = await prisma.note.findMany({
include: { tags: { include: { tag: true } } }, include: { tags: { include: { tag: true } } },
@@ -145,12 +157,14 @@ export async function noteQuery(
const scored: ScoredNote[] = [] const scored: ScoredNote[] = []
for (const note of allNotes) { for (const note of allNotes) {
if (filters.type && note.type !== filters.type) continue // Apply filters from AST BEFORE scoring
if (appliedFilters.type && note.type !== appliedFilters.type) continue
if (filters.tag) { if (appliedFilters.tag) {
const hasTag = note.tags.some(t => t.tag.name === filters.tag) const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
if (!hasTag) continue if (!hasTag) continue
} }
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
if (appliedFilters.isPinned && note.isPinned !== true) continue
const titleLower = note.title.toLowerCase() const titleLower = note.title.toLowerCase()
const contentLower = note.content.toLowerCase() const contentLower = note.content.toLowerCase()
@@ -181,7 +195,7 @@ export async function noteQuery(
const highlight = highlightMatches( const highlight = highlightMatches(
exactTitleMatch ? note.title + ' ' + note.content : note.content, exactTitleMatch ? note.title + ' ' + note.content : note.content,
query queryText
) )
scored.push({ scored.push({
@@ -202,5 +216,11 @@ export async function searchNotes(
query: string, query: string,
filters: SearchFilters = {} filters: SearchFilters = {}
): Promise<ScoredNote[]> { ): Promise<ScoredNote[]> {
return noteQuery(query, filters) const queryAST: QueryAST = {
text: query,
filters: {
...filters,
},
}
return noteQuery(queryAST)
} }