diff --git a/.gitignore b/.gitignore index 8b1e31e..15f6f54 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ out/ .DS_Store *.tsbuildinfo supabase/.temp +e2e/.results/ +e2e/.report/ +playwright-report/ diff --git a/app/_components/ArticleSearch.tsx b/app/_components/ArticleSearch.tsx new file mode 100644 index 0000000..fa3fc50 --- /dev/null +++ b/app/_components/ArticleSearch.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useState, useMemo } from "react" +import { Search } from "lucide-react" +import type { Post } from "@/lib/types" +import { PostCard } from "@/components/PostCard" + +interface ArticleSearchProps { + posts: Post[] +} + +function normalize(str: string): string { + return str + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() +} + +export function ArticleSearch({ posts }: ArticleSearchProps) { + const [query, setQuery] = useState("") + + const filtered = useMemo(() => { + if (query.trim() === "") return posts + const q = normalize(query.trim()) + return posts.filter( + (post) => normalize(post.title).includes(q) || normalize(post.excerpt).includes(q) + ) + }, [posts, query]) + + return ( +
+
+

Últimos artículos

+
+ +
+
+ + {filtered.length === 0 && query.trim() !== "" ? ( +

No se encontraron artículos

+ ) : ( +
+ {filtered.map((post) => ( + + ))} +
+ )} +
+ ) +} diff --git a/app/_components/SearchablePostList.tsx b/app/_components/SearchablePostList.tsx new file mode 100644 index 0000000..f1a4911 --- /dev/null +++ b/app/_components/SearchablePostList.tsx @@ -0,0 +1,59 @@ +"use client" + +import { useMemo, useState } from "react" +import type { Post } from "@/lib/types" +import { PostCard } from "@/components/PostCard" + +interface SearchablePostListProps { + posts: Post[] +} + +const DIACRITICS_REGEX = /[̀-ͯ]/g + +function normalize(str: string): string { + return str.normalize("NFD").replace(DIACRITICS_REGEX, "").toLowerCase() +} + +export function SearchablePostList({ posts }: SearchablePostListProps) { + const [query, setQuery] = useState("") + + const filtered = useMemo(() => { + const q = normalize(query.trim()) + if (!q) return posts + return posts.filter( + (post) => + normalize(post.title).includes(q) || + normalize(post.excerpt).includes(q) + ) + }, [posts, query]) + + return ( +
+
+ setQuery(e.target.value)} + className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {filtered.length === 0 ? ( +

+ No se encontraron artículos +

+ ) : ( +
+ {filtered.map((post) => ( + + ))} +
+ )} +
+ ) +} diff --git a/app/components/AuthNav.tsx b/app/components/AuthNav.tsx index 7a07efc..f0688b5 100644 --- a/app/components/AuthNav.tsx +++ b/app/components/AuthNav.tsx @@ -11,7 +11,7 @@ export function AuthNav() { async function handleLogout() { const supabase = createClient() - await supabase.auth.signOut() + await supabase?.auth.signOut() router.push("/") router.refresh() } diff --git a/app/components/PostCard.tsx b/app/components/PostCard.tsx index 4d7ea15..1ac72bd 100644 --- a/app/components/PostCard.tsx +++ b/app/components/PostCard.tsx @@ -47,7 +47,7 @@ export function PostCard({ post, className, action }: PostCardProps) {

- + {new Date(post.publishedAt).toLocaleDateString("es-ES", { year: "numeric", month: "long", diff --git a/app/hooks/use-user.ts b/app/hooks/use-user.ts index 42235e7..6afb575 100644 --- a/app/hooks/use-user.ts +++ b/app/hooks/use-user.ts @@ -16,6 +16,11 @@ export function useUser(): UseUserResult { useEffect(() => { const supabase = createClient() + if (!supabase) { + setIsLoading(false) + return + } + supabase.auth.getUser().then(({ data: { user: supabaseUser } }) => { if (supabaseUser) { setUser({ diff --git a/app/lib/supabase/client.ts b/app/lib/supabase/client.ts index cb5f10d..6eed0d7 100644 --- a/app/lib/supabase/client.ts +++ b/app/lib/supabase/client.ts @@ -1,8 +1,8 @@ import { createBrowserClient } from "@supabase/ssr" export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY! - ) + const url = process.env.NEXT_PUBLIC_SUPABASE_URL + const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY + if (!url || !key) return null + return createBrowserClient(url, key) } diff --git a/app/page.tsx b/app/page.tsx index 9b8cd0f..4689ffb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,8 @@ -import Link from "next/link" import { getPosts } from "@/lib/api" -import { PostCard } from "@/components/PostCard" +import { ArticleSearch } from "@/_components/ArticleSearch" export default async function HomePage() { const posts = await getPosts() - const featured = posts.slice(0, 3) return (
@@ -17,19 +15,7 @@ export default async function HomePage() {

-
-
-

Últimos artículos

- - Ver todos → - -
-
- {featured.map((post) => ( - - ))} -
-
+
) } diff --git a/e2e/.report/data/2a784762ff1e69c9953accdefd61d0bb07e17116.webm b/e2e/.report/data/2a784762ff1e69c9953accdefd61d0bb07e17116.webm new file mode 100644 index 0000000..9190705 Binary files /dev/null and b/e2e/.report/data/2a784762ff1e69c9953accdefd61d0bb07e17116.webm differ diff --git a/e2e/.report/data/568d7c9f3284eb982276228f043baedcf2510977.webm b/e2e/.report/data/568d7c9f3284eb982276228f043baedcf2510977.webm new file mode 100644 index 0000000..77eb06b Binary files /dev/null and b/e2e/.report/data/568d7c9f3284eb982276228f043baedcf2510977.webm differ diff --git a/e2e/.report/data/baa068ae1f41d0863370f8743945ca0e007a2c93.webm b/e2e/.report/data/baa068ae1f41d0863370f8743945ca0e007a2c93.webm new file mode 100644 index 0000000..0eb3440 Binary files /dev/null and b/e2e/.report/data/baa068ae1f41d0863370f8743945ca0e007a2c93.webm differ diff --git a/e2e/.report/data/f8a69bd1b67164d8285c53ab8666533689839727.webm b/e2e/.report/data/f8a69bd1b67164d8285c53ab8666533689839727.webm new file mode 100644 index 0000000..2984a1f Binary files /dev/null and b/e2e/.report/data/f8a69bd1b67164d8285c53ab8666533689839727.webm differ diff --git a/e2e/.report/index.html b/e2e/.report/index.html new file mode 100644 index 0000000..e3b3019 --- /dev/null +++ b/e2e/.report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/e2e/.results/.last-run.json b/e2e/.results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/e2e/.results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git "a/e2e/.results/article-search-B\303\272squeda-de-32e93-t\303\255culos-al-limpiar-el-input-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-32e93-t\303\255culos-al-limpiar-el-input-chromium/video.webm" new file mode 100644 index 0000000..2984a1f Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-32e93-t\303\255culos-al-limpiar-el-input-chromium/video.webm" differ diff --git "a/e2e/.results/article-search-B\303\272squeda-de-8f284-je-cuando-no-hay-resultados-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-8f284-je-cuando-no-hay-resultados-chromium/video.webm" new file mode 100644 index 0000000..9190705 Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-8f284-je-cuando-no-hay-resultados-chromium/video.webm" differ diff --git "a/e2e/.results/article-search-B\303\272squeda-de-cf1fc-ulos-por-t\303\255tulo-al-escribir-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-cf1fc-ulos-por-t\303\255tulo-al-escribir-chromium/video.webm" new file mode 100644 index 0000000..0eb3440 Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-cf1fc-ulos-por-t\303\255tulo-al-escribir-chromium/video.webm" differ diff --git "a/e2e/.results/article-search-B\303\272squeda-de-da2b8-nsitive-y-normaliza-acentos-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-da2b8-nsitive-y-normaliza-acentos-chromium/video.webm" new file mode 100644 index 0000000..77eb06b Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-da2b8-nsitive-y-normaliza-acentos-chromium/video.webm" differ diff --git a/e2e/article-search.spec.ts b/e2e/article-search.spec.ts new file mode 100644 index 0000000..b6a019a --- /dev/null +++ b/e2e/article-search.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from "@playwright/test" + +test.describe("Búsqueda de artículos en tiempo real", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector('[aria-label="Buscar artículos"]', { state: "visible" }) + }) + + test("muestra el campo de búsqueda en la página principal", async ({ page }) => { + const input = page.getByRole("searchbox", { name: "Buscar artículos" }) + await expect(input).toBeVisible() + await expect(input).toHaveAttribute("placeholder", "Buscar artículos…") + }) + + test("golden path — filtra artículos al escribir en el campo de búsqueda", async ({ page }) => { + const input = page.getByRole("searchbox", { name: "Buscar artículos" }) + + const initialCards = page.locator("article") + const initialCount = await initialCards.count() + expect(initialCount).toBeGreaterThan(0) + + await input.fill("react") + await expect(input).toHaveValue("react") + + const filteredCards = page.locator("article") + await expect(filteredCards.first()).toBeVisible() + const filteredCount = await filteredCards.count() + expect(filteredCount).toBeGreaterThan(0) + expect(filteredCount).toBeLessThanOrEqual(initialCount) + }) + + test("restaura todos los artículos al borrar el texto", async ({ page }) => { + const input = page.getByRole("searchbox", { name: "Buscar artículos" }) + + const initialCards = page.locator("article") + const initialCount = await initialCards.count() + + await input.fill("react") + await expect(input).toHaveValue("react") + + await input.clear() + await expect(input).toHaveValue("") + + const restoredCards = page.locator("article") + await expect(restoredCards.first()).toBeVisible() + await expect(restoredCards).toHaveCount(initialCount) + }) + + test("muestra mensaje cuando no hay resultados", async ({ page }) => { + const input = page.getByRole("searchbox", { name: "Buscar artículos" }) + + await input.fill("xxxxxxxxxx") + await expect(input).toHaveValue("xxxxxxxxxx") + + await expect(page.getByText("No se encontraron artículos")).toBeVisible() + await expect(page.locator("article")).toHaveCount(0) + }) + + test("el filtrado es case-insensitive y sin distinción de acentos", async ({ page }) => { + const input = page.getByRole("searchbox", { name: "Buscar artículos" }) + + await input.fill("REACT") + await expect(input).toHaveValue("REACT") + const cards = page.locator("article") + await expect(cards.first()).toBeVisible() + + await input.fill("supabase") + await expect(input).toHaveValue("supabase") + await expect(cards.first()).toBeVisible() + }) +}) diff --git a/playwright.config.ts b/playwright.config.ts index a33c9da..cf61467 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,5 +18,13 @@ export default defineConfig({ command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, + env: { + NEXT_PUBLIC_SUPABASE_URL: + process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co', + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + 'placeholder-anon-key', + }, }, }) diff --git a/specs/article-search/plan.md b/specs/article-search/plan.md new file mode 100644 index 0000000..0e9470a --- /dev/null +++ b/specs/article-search/plan.md @@ -0,0 +1,51 @@ +# Technical Plan — Búsqueda de Artículos en Tiempo Real + +> Derivado de `spec.md` confirmado. Feature slug: `article-search`. + +--- + +## 1. Stack final + +- **Frontend:** Next.js 15 App Router + React 19 + TypeScript strict — ya en el proyecto. +- **Filtrado:** cliente-side con `useState` + Array.filter — sin nueva API route ni React Query. +- **Normalización:** función `normalize(str)` con `str.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()`. +- **UI:** Tailwind CSS v4 + primitivas de `app/components/ui/` si aplica. + +--- + +## 2. Componentes + +### `app/_components/ArticleSearch.tsx` (nuevo, Client Component) + +Responsabilidades: +1. Recibe `posts: Post[]` como prop. +2. Mantiene estado `query: string` con `useState("")`. +3. Filtra los posts comparando `normalize(query)` contra `normalize(post.title)` y `normalize(post.excerpt)`. +4. Renderiza el `` con `aria-label="Buscar artículos"` y placeholder "Buscar artículos…". +5. Renderiza los `` filtrados. +6. Muestra "No se encontraron artículos" si el array filtrado está vacío y `query !== ""`. + +### `app/page.tsx` (modificado) + +- Sigue siendo async Server Component (obtiene posts en servidor). +- Pasa los posts al componente ``. + +--- + +## 3. Orden de construcción + +1. Crear `app/_components/ArticleSearch.tsx`. +2. Modificar `app/page.tsx` para usar ``. +3. Añadir test E2E en `e2e/article-search.spec.ts`. + +--- + +## 4. Definition of done + +- [ ] El input aparece en `/` con `aria-label="Buscar artículos"`. +- [ ] Escribir en el input filtra la lista en tiempo real. +- [ ] El filtrado ignora mayúsculas y acentos. +- [ ] Mensaje "No se encontraron artículos" cuando no hay coincidencias. +- [ ] Al borrar, vuelven todos los artículos. +- [ ] Test E2E (golden path) en verde. +- [ ] `npm run lint` y `npm test` en verde. diff --git a/specs/article-search/spec.md b/specs/article-search/spec.md new file mode 100644 index 0000000..065e80c --- /dev/null +++ b/specs/article-search/spec.md @@ -0,0 +1,80 @@ +# Spec — Búsqueda de Artículos en Tiempo Real + +> Dominicode Spec-First template (Bezael Pérez). +> Feature slug: `article-search` + +--- + +## Visión + +Permitir a cualquier visitante (autenticado o no) filtrar los artículos de la página +principal mientras escribe, sin recargar la página. Reduce el tiempo necesario para +encontrar un artículo concreto cuando la lista crece. + +--- + +## Usuarios + +**Visitante (anónimo o autenticado):** quiere encontrar un artículo por título o +descripción sin tener que leer toda la lista. + +--- + +## Funcionalidades + +**Must:** +- Campo `` con placeholder "Buscar artículos…" y `aria-label="Buscar artículos"` visible sobre la lista de artículos. +- Filtrado instantáneo (sin petición de red) mientras el usuario escribe. +- Coincidencia en título **y** excerpt del artículo. +- Filtrado case-insensitive y sin distinción de acentos (ej: "react" encuentra "React"). +- Mensaje "No se encontraron artículos" cuando no hay coincidencias. +- Al borrar el texto, se restaura la lista completa. + +**Should:** +- El input está limpio al entrar a la página (sin texto previo). + +**Could:** +- Debounce en el filtrado (no necesario dado que el filtrado es cliente-side y los posts son pocos). + +--- + +## Flujos + +**Happy path:** +1. El usuario visita `/`. +2. Ve el campo de búsqueda encima de la lista de artículos. +3. Escribe "react" en el campo. +4. La lista se actualiza instantáneamente mostrando solo artículos cuyo título o excerpt contiene "react" (insensible a mayúsculas y acentos). +5. El usuario borra el texto — vuelven a aparecer todos los artículos. + +**Sin resultados:** +1. El usuario escribe un término sin coincidencias (ej: "xxxxxxxxxx"). +2. La lista queda vacía y se muestra el mensaje "No se encontraron artículos". + +--- + +## Arquitectura + +- **`app/page.tsx`** — actualmente Server Component. Se convierte en un Client Component + o se extrae la lógica de búsqueda a un componente cliente hijo que recibe `posts` como prop. +- **`app/_components/ArticleSearch.tsx`** — nuevo Client Component (`"use client"`). Contiene + el ``, el estado `query` con `useState`, la lógica de filtrado (normalización de texto) + y la lista filtrada de ``. +- No se necesita API route ni React Query: el filtrado opera sobre los posts ya cargados. +- Sin dependencias nuevas. + +**Archivos afectados:** +| Archivo | Cambio | +|---------|--------| +| `app/page.tsx` | Pasa `posts` al nuevo componente `ArticleSearch` | +| `app/_components/ArticleSearch.tsx` | Nuevo — input + lógica de filtrado + lista | + +--- + +## NFRs + +- **Rendimiento:** filtrado síncrono en cliente sobre array pequeño (< 100 posts); sin latencia perceptible. +- **Accesibilidad:** `aria-label="Buscar artículos"` en el input para localización semántica por Playwright y lectores de pantalla. +- **Seguridad:** sin nuevas superficies de ataque — solo lógica cliente sobre datos estáticos. +- **Idioma:** placeholder y mensaje de estado vacío en español. +- **Dependencias:** cero librerías nuevas. diff --git a/specs/article-search/tasks.md b/specs/article-search/tasks.md new file mode 100644 index 0000000..7a48456 --- /dev/null +++ b/specs/article-search/tasks.md @@ -0,0 +1,35 @@ +# Tasks — Búsqueda de Artículos en Tiempo Real + +> Orden TDD: tests primero, luego implementación mínima que los pase. + +--- + +- [ ] **T1 — Crear componente `ArticleSearch`** + - Crea `app/_components/ArticleSearch.tsx` con `"use client"`. + - Acepta prop `posts: Post[]`. + - Renderiza ``. + - Renderiza todos los `` cuando `query` está vacío. + - **Criterio:** el input existe en el DOM con el aria-label correcto. + +- [ ] **T2 — Lógica de filtrado case-insensitive sin acentos** + - Implementa función `normalize(str)` que elimina diacríticos y convierte a minúsculas. + - Filtra posts donde `normalize(post.title)` o `normalize(post.excerpt)` contiene `normalize(query)`. + - **Criterio:** "react" filtra artículos con "React" en el título; "supabase" filtra "Supabase". + +- [ ] **T3 — Mensaje de estado vacío** + - Cuando el array filtrado está vacío y `query !== ""`, muestra el texto "No se encontraron artículos". + - Cuando `query === ""`, nunca muestra ese mensaje. + - **Criterio:** texto visible en pantalla cuando no hay resultados. + +- [ ] **T4 — Modificar `app/page.tsx`** + - Importar y usar `` en lugar de la sección de artículos actual. + - Mantener la sección de hero (h1 + párrafo). + - **Criterio:** la página `/` pasa los posts al componente de búsqueda. + +- [ ] **T5 — Test E2E golden path** + - Crea `e2e/article-search.spec.ts`. + - Navega a `/`, verifica que el input existe. + - Escribe "react" → verifica que aparece al menos un resultado. + - Borra el texto → verifica que vuelven todos los artículos. + - Escribe "xxxxxxxxxx" → verifica el mensaje "No se encontraron artículos". + - **Criterio:** `npm run test:e2e` en verde. diff --git a/specs/real-time-article-search/plan.md b/specs/real-time-article-search/plan.md new file mode 100644 index 0000000..84e100c --- /dev/null +++ b/specs/real-time-article-search/plan.md @@ -0,0 +1,63 @@ +# Technical Plan — Búsqueda de artículos en tiempo real + +> Derivado de `spec.md` confirmado. Feature slug: `real-time-article-search`. + +--- + +## 1. Stack final + +- **Frontend:** Next.js 15 App Router + React 19 + TypeScript strict — ya en el proyecto. +- **Estado:** `useState` de React para el valor del input; `useMemo` para la lista filtrada. +- **Iconos:** `lucide-react` — ya instalada; se usa el icono `Search`. +- **Test runner (unit):** Vitest 2.1.1 — ya disponible. Comando: `npm test`. +- **Test runner (E2E):** Playwright 1.60.0 — ya disponible. Comando: `npm run test:e2e`. + +### Stacks descartados + +- **React Query:** innecesario — los datos ya están en memoria (no hay fetch del lado del cliente). +- **URL search params:** descartado — el spec no requiere persistencia de búsqueda en URL. +- **Debounce:** descartado — la lista es estática y pequeña; no hay ganancia de rendimiento. + +--- + +## 2. Pasos de implementación + +### Paso 1 — Crear `app/_components/ArticleSearch.tsx` + +Componente cliente que recibe `Post[]` como prop: +- `useState("")` para el valor del input. +- `useMemo` para calcular `filteredPosts` filtrando por `title` y `excerpt` usando normalización + de diacríticos. +- Renderiza el input con `aria-label="Buscar artículos"` y el icono `Search` de lucide-react. +- Renderiza el grid de `PostCard` con `filteredPosts`, o el mensaje "No se encontraron artículos" + si `filteredPosts.length === 0` y hay texto en el input. + +### Paso 2 — Modificar `app/page.tsx` + +- Cambiar el componente para que pase `posts` completo (no solo `featured`) a ``. +- Mantener `page.tsx` como Server Component. + +### Paso 3 — Añadir test E2E `e2e/article-search.spec.ts` + +- Test 1 (golden path): navegar a `/`, escribir en el input, verificar que la lista se filtra. +- Test 2 (sin resultados): escribir texto sin coincidencias, verificar mensaje. +- Test 3 (limpiar): filtrar y luego borrar, verificar que vuelven todos los artículos. + +--- + +## 3. Normalización de acentos + +```ts +function normalize(str: string): string { + return str + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() +} +``` + +--- + +## 4. Riesgos + +- Ninguno significativo: el feature es puramente client-side sobre datos estáticos. diff --git a/specs/real-time-article-search/spec.md b/specs/real-time-article-search/spec.md new file mode 100644 index 0000000..a75330b --- /dev/null +++ b/specs/real-time-article-search/spec.md @@ -0,0 +1,97 @@ +# Spec — Búsqueda de artículos en tiempo real + +> Dominicode Spec-First template (Bezael Pérez). +> Feature slug: `real-time-article-search` + +--- + +## Visión + +Los usuarios que visitan la página principal (`/`) pueden encontrar artículos relevantes sin +necesidad de navegar por toda la lista. Un campo de búsqueda filtra los artículos visibles +instantáneamente mientras el usuario escribe, reduciendo la fricción para descubrir contenido. + +--- + +## Usuarios + +**Visitante anónimo:** busca artículos sobre un tema concreto desde la página principal sin +necesidad de autenticarse. + +**Usuario autenticado:** igual que el visitante anónimo; la búsqueda no depende de la sesión. + +--- + +## Funcionalidades + +**Must have** +- Un `` con `placeholder="Buscar artículos…"` y `aria-label="Buscar artículos"` aparece + encima de la lista de artículos en la página principal. +- Al escribir, la lista se filtra instantáneamente (sin recarga de página) mostrando solo los + artículos cuyo título o excerpt contenga el texto introducido. +- La comparación es case-insensitive y normaliza acentos (ej: "react" encuentra "React", "codigo" + encuentra "código"). +- Cuando el filtro no produce resultados, se muestra el mensaje "No se encontraron artículos". +- Al borrar el texto del input, vuelven a mostrarse todos los artículos. + +**Should have** +- El input tiene un icono de lupa (Search) a la izquierda para indicar su propósito visualmente. +- El estado de búsqueda no se persiste en la URL (filtrado puramente en memoria). + +**Could have** +- Debounce del input para entornos con listas muy largas (no necesario con datos estáticos). + +--- + +## Flujos + +**Happy path — encontrar un artículo:** +1. El usuario visita `/`. +2. Ve el campo de búsqueda sobre la lista de artículos. +3. Escribe "react" en el campo. +4. La lista se actualiza instantáneamente mostrando solo los artículos que contienen "react" + en título o excerpt. +5. El usuario hace clic en un artículo y navega al detalle. + +**Flujo alternativo — sin resultados:** +1. El usuario escribe un texto que no coincide con ningún artículo (ej: "xyz123"). +2. La lista muestra el mensaje "No se encontraron artículos" en lugar de cards. + +**Flujo alternativo — limpiar búsqueda:** +1. El usuario ha filtrado la lista. +2. Borra el texto del input. +3. Todos los artículos vuelven a ser visibles. + +--- + +## Arquitectura + +**Archivos afectados / creados:** + +``` +app/ +├── page.tsx # MODIFICADO: pasa posts como prop al componente cliente +└── _components/ + └── ArticleSearch.tsx # NUEVO: componente cliente con input + lista filtrada +``` + +**Patrón:** +- `app/page.tsx` permanece Server Component; obtiene los posts con `getPosts()` y los pasa como + prop a ``. +- `ArticleSearch` es un Client Component (`"use client"`); gestiona el estado del input con + `useState` y calcula los posts filtrados con `useMemo`. No necesita React Query porque los datos + ya están en memoria. +- La normalización de acentos usa `String.prototype.normalize('NFD')` con regex para eliminar + diacríticos. + +**Dependencias:** ninguna nueva; usa `lucide-react` (ya instalada) para el icono de lupa. + +--- + +## NFRs + +- **Rendimiento:** el filtrado se ejecuta en `useMemo` para evitar recálculos innecesarios. +- **Accesibilidad:** el input tiene `aria-label="Buscar artículos"` para lectores de pantalla y + localización semántica en tests Playwright. +- **Seguridad:** el filtrado es puramente client-side sobre datos estáticos; no hay entrada de + usuario enviada al servidor. diff --git a/specs/real-time-article-search/tasks.md b/specs/real-time-article-search/tasks.md new file mode 100644 index 0000000..7ef620b --- /dev/null +++ b/specs/real-time-article-search/tasks.md @@ -0,0 +1,47 @@ +# Tasks — Búsqueda de artículos en tiempo real + +> Derivado de `spec.md` + `plan.md` confirmados. +> Ejecutar en orden estricto. Un task a la vez. + +--- + +## Convenciones + +- `[ ]` pendiente · `[x]` hecho +- ⚙️ Setup · 🔴 Red (test falla) · 🟢 Green (implementación mínima) · 🔵 Refactor · 🔗 Integración + +--- + +## Phase 0 — Verificación del runner + +- [x] ⚙️ **Runner verificado:** Vitest 2.1.1 y Playwright 1.60.0 detectados en `package.json`. + +--- + +## Phase 1 — Componente `ArticleSearch` + +- [ ] 🟢 **Crear `app/_components/ArticleSearch.tsx`** + - Recibe `posts: Post[]` como prop. + - Estado: `useState("")` para el valor del input. + - Filtrado: `useMemo` que normaliza diacríticos y busca en `title` + `excerpt`. + - Renderiza: input con `aria-label="Buscar artículos"`, grid de `PostCard`, o mensaje vacío. + - Criterio: el componente renderiza correctamente en TypeScript strict sin errores. + +--- + +## Phase 2 — Integración en `page.tsx` + +- [ ] 🔗 **Modificar `app/page.tsx`** + - Pasar `posts` completo a ``. + - Mantener `page.tsx` como Server Component. + - Criterio: la página principal muestra el input y los artículos filtrados. + +--- + +## Phase 3 — Tests E2E + +- [ ] 🔴 **Crear `e2e/article-search.spec.ts`** + - Test golden path: navegar a `/`, escribir en el input, verificar lista filtrada. + - Test sin resultados: texto sin coincidencias → mensaje "No se encontraron artículos". + - Test limpiar: filtrar y borrar → todos los artículos visibles. + - Criterio: `npm run test:e2e` pasa en verde. diff --git a/vitest.config.ts b/vitest.config.ts index c6dc4cf..904734e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ test: { environment: "jsdom", passWithNoTests: true, + exclude: ["**/node_modules/**", "**/e2e/**"], }, resolve: { alias: {