diff --git a/dev/issues/completed/0017-frontend-hooks.md b/dev/issues/completed/0017-frontend-hooks.md new file mode 100644 index 00000000..3b0b89c7 --- /dev/null +++ b/dev/issues/completed/0017-frontend-hooks.md @@ -0,0 +1,410 @@ +# 0017 — Frontend Data Hooks + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0017 | +| **Estado** | pendiente | +| **Prioridad** | alta | +| **Tipo** | feature | + +## Dependencias + +Ninguna. + +--- + +## Objetivo + +Crear hooks React genericos para comunicacion con APIs HTTP (REST, SSE, WebSocket) que sirvan como base para cualquier frontend web. Actualmente solo existen hooks Wails (`use_wails_query`, `use_wails_mutation`, `use_wails_event`, `use_wails_stream`) que dependen de IPC Wails — no hay nada para apps web estandar que usan `fetch` contra un servidor HTTP. + +## Contexto + +- Los hooks Wails (`frontend/functions/ui/`) implementan un patron solido: cache, stale-while-revalidate, retry, optimistic updates. Pero estan atados a `window.runtime` de Wails. +- El registry tiene 70+ componentes Mantine en `frontend/functions/ui/` pero cero infraestructura de data-fetching para web. +- Cualquier app web nueva (dashboards, admin panels, SPAs) necesita reinventar el fetching desde cero o tirar de react-query/swr como dependencia externa. +- Con estos hooks, una app web solo importa `use_fetch`, `use_mutation`, etc. del registry y tiene todo resuelto. Misma filosofia que los hooks Wails pero sobre HTTP. +- El issue 0009 (HTTP Server) crea las primitivas del servidor. Este issue crea las primitivas del cliente que las consume. + +## Arquitectura + +``` +frontend/functions/core/ +├── api_client.ts — NEW: fetch wrapper configurable +├── api_client.md — NEW +├── use_fetch.tsx — NEW: hook GET con cache + SWR +├── use_fetch.md — NEW +├── use_mutation.tsx — NEW: hook POST/PUT/DELETE +├── use_mutation.md — NEW +├── use_infinite_scroll.tsx — NEW: hook paginacion infinita +├── use_infinite_scroll.md — NEW +├── use_form.tsx — NEW: hook formularios +├── use_form.md — NEW +├── use_debounced_search.tsx — NEW: hook busqueda con debounce +├── use_debounced_search.md — NEW +├── use_sse.tsx — NEW: hook Server-Sent Events +├── use_sse.md — NEW +├── use_websocket.tsx — NEW: hook WebSocket +├── use_websocket.md — NEW + +frontend/types/core/ +├── fetch_state.ts — NEW: tipo FetchState +├── fetch_state.md — NEW +├── mutation_state.ts — NEW: tipo MutationState +├── mutation_state.md — NEW +├── form_state.ts — NEW: tipo FormState +├── form_state.md — NEW +├── api_client_config.ts — NEW: tipo APIClientConfig +├── api_client_config.md — NEW +``` + +Todas las funciones van en `frontend/functions/core/` (dominio `core`) porque son TypeScript puro, framework-agnostic en el sentido de que no dependen de Wails, Electron, Tauri, ni ningun runtime especifico. Solo usan React + `fetch` nativo del browser. + +### Patron pure core / impure shell + +No hay funciones puras en este issue — todos los hooks manejan estado (React) y/o I/O (HTTP, SSE, WebSocket). `api_client` es una funcion impura (hace `fetch`), no un hook. + +## Diseno + +### Tipos + +```typescript +/** Estado de un fetch GET */ +interface FetchState { + data: T | null + error: Error | null + loading: boolean + /** Re-ejecutar la peticion */ + refetch: () => Promise + /** Si los datos son stale (cache viejo mostrando mientras refetch) */ + is_stale: boolean +} + +/** Estado de una mutacion POST/PUT/DELETE */ +interface MutationState { + /** Ejecutar la mutacion */ + mutate: (variables: TVariables) => void + /** Ejecutar la mutacion (async, retorna Promise) */ + mutate_async: (variables: TVariables) => Promise + data: TData | null + error: Error | null + loading: boolean + /** Resetear al estado inicial */ + reset: () => void +} + +/** Estado de un formulario */ +interface FormState> { + /** Valores actuales del form */ + values: T + /** Errores por campo */ + errors: Partial> + /** Campos que el usuario ha tocado */ + touched: Partial> + /** Si todos los campos pasan validacion */ + is_valid: boolean + /** Si el submit esta en progreso */ + is_submitting: boolean +} + +/** Configuracion del cliente HTTP */ +interface APIClientConfig { + /** URL base para todas las peticiones (ej: "http://localhost:8484/api") */ + base_url: string + /** Headers fijos para todas las peticiones */ + headers?: Record + /** Callback global de error */ + on_error?: (error: Error, url: string, init?: RequestInit) => void + /** Callback cuando el server responde 401 */ + on_unauthorized?: () => void +} +``` + +### Funciones + +| Funcion | Kind | Purity | Firma (simplificada) | +|---------|------|--------|---------------------| +| `api_client` | function | impure | `(config: APIClientConfig) => { get, post, put, del, patch }` | +| `use_fetch` | component | impure | `useFetch(url, opts?): FetchState` | +| `use_mutation` | component | impure | `useMutation(opts): MutationState` | +| `use_infinite_scroll` | component | impure | `useInfiniteScroll(url, opts?): { data, loading, has_more, load_more, reset }` | +| `use_form` | component | impure | `useForm(opts): { values, errors, touched, set_field, validate, submit, reset }` | +| `use_debounced_search` | component | impure | `useDebouncedSearch(url, opts?): { query, set_query, results, loading }` | +| `use_sse` | component | impure | `useSSE(url, opts?): { data, last_event, status, close }` | +| `use_websocket` | component | impure | `useWebSocket(url, opts?): { send, last_message, status, close }` | + +### Detalle de cada funcion + +**`api_client`** — Factory que retorna un objeto con metodos HTTP. No es un hook, es una funcion normal que crea un cliente reutilizable. Cada metodo hace `fetch` con los headers y base_url configurados, parsea JSON automaticamente, y llama a `on_error`/`on_unauthorized` segun el status code. + +```typescript +const api = apiClient({ base_url: 'http://localhost:8484/api' }) +const users = await api.get('/users') +await api.post('/users', { name: 'Lucas' }) +await api.del('/users/123') +``` + +**`use_fetch`** — Hook para GET requests. Hace fetch al montar (configurable con `enabled`). Cache en memoria con stale-while-revalidate: muestra datos viejos inmediatamente y refetchea en background. Soporta `refetch_interval`, `refetch_on_focus`, `stale_time`, `retry`. Recibe una URL string o una funcion que retorna `Promise` (para usar con `api_client`). + +**`use_mutation`** — Hook para operaciones de escritura. No ejecuta al montar — expone `mutate()` y `mutate_async()`. Soporta `on_mutate` para optimistic updates (retorna rollback context), `on_success`, `on_error`, `on_settled`. Opcionalmente invalida queries del cache al completar. + +**`use_infinite_scroll`** — Hook para paginacion infinita. Soporta cursor-based (`next_cursor` en response) y offset-based (`page` incrementando). Expone `load_more()`, `has_more`, `reset()`. Concatena paginas en un solo array plano. + +**`use_form`** — Hook para formularios. Recibe `initial_values` y opcionalmente `validate` (funcion que retorna errores por campo). Expone `set_field(name, value)`, `set_fields(partial)`, `validate()`, `submit(handler)`, `reset()`. El submit llama a `validate()` primero y solo ejecuta el handler si pasa. Marca campos como `touched` al hacer set. + +**`use_debounced_search`** — Hook que combina un input debounced con fetch. Recibe la URL base y el debounce delay (default 300ms). Mientras el usuario escribe, espera N ms sin actividad y luego hace GET con el query como parametro. Expone `query`, `set_query`, `results`, `loading`. + +**`use_sse`** — Hook para Server-Sent Events. Crea un `EventSource` al montar, parsea cada evento como JSON, auto-reconecta con backoff exponencial si se pierde la conexion. Expone `data` (array de eventos), `last_event`, `status` (connecting/open/closed), `close()`. + +**`use_websocket`** — Hook para WebSocket. Conecta al montar, auto-reconecta con backoff. Expone `send(data)`, `last_message`, `messages` (buffer), `status` (connecting/open/closing/closed), `close()`. Soporta tipado separado para envio (`TSend`) y recepcion (`TRecv`). + +## Tareas + +### Fase 1: Tipos y api_client + +- [ ] **1.1** Crear tipo `FetchState` en `frontend/types/core/fetch_state.ts` + `.md` +- [ ] **1.2** Crear tipo `MutationState` en `frontend/types/core/mutation_state.ts` + `.md` +- [ ] **1.3** Crear tipo `FormState` en `frontend/types/core/form_state.ts` + `.md` +- [ ] **1.4** Crear tipo `APIClientConfig` en `frontend/types/core/api_client_config.ts` + `.md` +- [ ] **1.5** Crear `api_client` en `frontend/functions/core/api_client.ts` + `.md` — fetch wrapper con base_url, headers, error handling, interceptors + +### Fase 2: Hooks de datos (core HTTP) + +- [ ] **2.1** `use_fetch` — GET con cache in-memory, stale-while-revalidate, retry, refetch por intervalo/foco +- [ ] **2.2** `use_mutation` — POST/PUT/DELETE con optimistic updates, invalidacion de queries +- [ ] **2.3** `use_debounced_search` — input con debounce + fetch automatico +- [ ] **2.4** `use_form` — state de formulario con validacion, touched, submit handler + +### Fase 3: Hooks de streaming y paginacion + +- [ ] **3.1** `use_infinite_scroll` — paginacion infinita (cursor y offset), concatenacion de paginas +- [ ] **3.2** `use_sse` — Server-Sent Events con auto-reconnect y parsing JSON +- [ ] **3.3** `use_websocket` — WebSocket con auto-reconnect, send tipado, buffer de mensajes + +### Fase 4: Integracion y verificacion + +- [ ] **4.1** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db +- [ ] **4.2** Verificar que los IDs siguen el patron `{name}_ts_core` +- [ ] **4.3** Verificar imports cruzados: los hooks importan los tipos de `frontend/types/core/` +- [ ] **4.4** Verificar que `api_client` se puede pasar como `fetch_fn` a `use_fetch` y `use_mutation` + +--- + +## Ejemplo de uso + +Pagina CRUD completa usando los hooks con el layout `crud_page_ts_ui` existente: + +```tsx +import { apiClient } from '@fn_library/core/api_client' +import { useFetch } from '@fn_library/core/use_fetch' +import { useMutation } from '@fn_library/core/use_mutation' +import { useForm } from '@fn_library/core/use_form' +import { useDebouncedSearch } from '@fn_library/core/use_debounced_search' +import { crudPage } from '@fn_library/ui/crud_page' +import { Stack, Modal, TextInput, Button, Group } from '@mantine/core' +import { useDisclosure } from '@mantine/hooks' + +// 1. Configurar cliente HTTP una vez +const api = apiClient({ + base_url: 'http://localhost:8484/api', + on_unauthorized: () => window.location.href = '/login', +}) + +interface User { + id: string + name: string + email: string + role: string +} + +function UsersPage() { + const [opened, { open, close }] = useDisclosure(false) + + // 2. Fetch lista de usuarios con cache + const { data: users, loading, refetch } = useFetch({ + url: '/users', + fetch_fn: api.get, + stale_time: 10_000, // Fresh por 10s + refetch_on_focus: true, // Refetch al volver a la tab + }) + + // 3. Busqueda con debounce + const { query, set_query, results: searchResults, loading: searching } = useDebouncedSearch({ + url: '/users/search', + fetch_fn: api.get, + delay: 300, + param_name: 'q', + }) + + // 4. Mutacion para crear usuario + const { mutate: createUser, loading: creating } = useMutation>({ + mutation_fn: (data) => api.post('/users', data), + on_success: () => { + refetch() + close() + form.reset() + }, + }) + + // 5. Mutacion para eliminar usuario + const { mutate: deleteUser } = useMutation({ + mutation_fn: (id) => api.del(`/users/${id}`), + on_success: () => refetch(), + }) + + // 6. Form con validacion + const form = useForm>({ + initial_values: { name: '', email: '', role: 'user' }, + validate: (values) => ({ + name: !values.name ? 'Nombre requerido' : undefined, + email: !values.email?.includes('@') ? 'Email invalido' : undefined, + }), + }) + + const displayedUsers = query ? searchResults : users + + return ( + + {/* Barra de busqueda */} + set_query(e.currentTarget.value)} + /> + + {/* Tabla CRUD usando crud_page del registry */} + {crudPage({ + title: 'Users', + subtitle: `${displayedUsers?.length ?? 0} usuarios`, + data: displayedUsers ?? [], + fields: [ + { key: 'name', label: 'Nombre', type: 'text', required: true }, + { key: 'email', label: 'Email', type: 'email', required: true }, + { key: 'role', label: 'Rol', type: 'select', options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ]}, + ], + columns: [ + { key: 'name', label: 'Nombre' }, + { key: 'email', label: 'Email' }, + { key: 'role', label: 'Rol' }, + ], + onAdd: open, + onDelete: (user) => deleteUser(user.id), + })} + + {/* Modal de creacion */} + + + form.set_field('name', e.currentTarget.value)} + /> + form.set_field('email', e.currentTarget.value)} + /> + + + + + + + + ) +} +``` + +### Ejemplo con SSE (logs en tiempo real) + +```tsx +import { useSSE } from '@fn_library/core/use_sse' +import { Paper, Code, ScrollArea, Badge, Group } from '@mantine/core' + +function LogViewer() { + const { data: logs, last_event, status, close } = useSSE({ + url: 'http://localhost:8484/api/logs/stream', + reconnect: true, + reconnect_interval: 3000, + }) + + return ( + + + {status} + + + {logs.join('\n')} + + + ) +} +``` + +### Ejemplo con WebSocket (chat) + +```tsx +import { useWebSocket } from '@fn_library/core/use_websocket' +import { TextInput, Paper, Stack, Text } from '@mantine/core' +import { useState } from 'react' + +interface ChatMessage { user: string; text: string; ts: number } + +function Chat() { + const [input, setInput] = useState('') + const { send, messages, status } = useWebSocket<{ text: string }, ChatMessage>({ + url: 'ws://localhost:8484/ws/chat', + reconnect: true, + }) + + return ( + + + {messages.map((msg, i) => ( + {msg.user}: {msg.text} + ))} + + setInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && input.trim()) { + send({ text: input }) + setInput('') + } + }} + /> + + ) +} +``` + +## Decisiones de diseno + +- **Zero dependencias externas:** No se usa react-query, swr, axios, ni ninguna libreria de fetching. Los hooks implementan un patron SWR-like minimalista con `fetch` nativo y `useState`/`useEffect`/`useRef`/`useCallback` de React. Esto mantiene el bundle pequeno y elimina dependencias que evolucionan independientemente. +- **Cache in-memory simple (Map):** Los hooks Wails ya tienen un cache propio (`wails_cache.ts`). Los nuevos hooks usan un `Map` simple compartido via un modulo singleton. No se necesita un cache manager complejo — el patron stale-while-revalidate con un Map cubre el 90% de los casos. +- **`api_client` es funcion, no hook:** El cliente HTTP es una factory pura (en el sentido de React) que retorna metodos. Se puede usar fuera de componentes: en event handlers, en funciones de utilidad, en tests. Los hooks lo consumen opcionalmente via `fetch_fn`. +- **Dominio `core`, no `ui`:** Los hooks Wails viven en `ui` porque dependen de `wails_provider` y el runtime Wails (infraestructura de UI especifica). Estos hooks solo dependen de React + fetch nativo, asi que van en `core` — son TypeScript puro reutilizable en cualquier proyecto React, no atados a un framework de UI. +- **`kind: component` para hooks:** Siguiendo el mismo patron que los hooks Wails en el registry. Un hook React es un componente funcional que tiene estado (`has_state: true`), emite callbacks (`emits`), y declara props. El `kind: component` con `framework: react` es el mapping correcto. +- **Tipado generico pero no over-engineered:** Cada hook acepta 1-2 genericos (`T` para data, `TVariables` para inputs). No se introducen abstractions como query keys jerarquicos, cache normalization, ni infinite query cursors tipados. Si se necesita TanStack Query, se instala — estos hooks cubren el 80% sin la complejidad. +- **`use_form` minimalista:** No reemplaza a Mantine `useForm` ni a formik/react-hook-form. Es un hook ligero para formularios simples: valores, errores, touched, validacion sincrona, submit. Para formularios complejos (arrays de campos, validacion async, schemas zod) se usa la solucion de Mantine directamente. + +## Riesgos + +- **Duplicacion con hooks Wails:** Los hooks de datos HTTP y los hooks Wails resuelven el mismo problema con diferente transporte. Mitigado manteniendo interfaces similares (`FetchState` vs `QueryState`, `MutationState` en ambos) para que migrar de uno a otro sea trivial. A futuro se podria abstraer el transporte detras de una interfaz comun, pero por ahora es premature abstraction. +- **Cache sin persistencia ni invalidacion cross-tab:** El cache es un `Map` en memoria. Si el usuario abre dos tabs, cada una tiene su cache independiente. Mitigado porque para el 90% de las apps del registry (tools internas, dashboards, admin panels) esto es aceptable. Si se necesita cache persistente o broadcast, se anade como iteracion futura. +- **Scope creep hacia framework de fetching:** Estos hooks deben mantenerse simples. Si el uso crece hasta necesitar normalized cache, query deduplication, garbage collection, o suspense — en ese punto se migra a TanStack Query. El valor de estos hooks es zero-dep y cubrir los casos comunes. +- **`use_form` puede solaparse con Mantine `useForm`:** Documentar claramente que `use_form` es para formularios simples sin dependencia de Mantine. Si la app ya usa Mantine (que es el caso comun en este registry), `@mantine/form` puede ser mejor opcion. El hook del registry es util cuando se quiere evitar la dependencia de Mantine en un proyecto core. diff --git a/frontend/functions/core/api_client.md b/frontend/functions/core/api_client.md new file mode 100644 index 00000000..2c489273 --- /dev/null +++ b/frontend/functions/core/api_client.md @@ -0,0 +1,56 @@ +--- +name: api_client +kind: function +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "apiClient(config: APIClientConfig): { get(path, params?): Promise; post(path, body?): Promise; put(path, body?): Promise; patch(path, body?): Promise; del(path): Promise }" +description: "Factory HTTP que retorna un cliente con get/post/put/patch/del. Usa fetch nativo con base_url+headers, parsea JSON automaticamente, llama on_error/on_unauthorized segun status code. Zero dependencias externas." +tags: [http, fetch, client, rest, api, factory, core] +uses_functions: [] +uses_types: [APIClientConfig_ts_core] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: config + desc: "Configuracion: base_url (URL base sin slash final), headers opcionales, callbacks on_error y on_unauthorized" +output: "Objeto con metodos get/post/put/patch/del que hacen fetch con la config aplicada" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/api_client.ts" +--- + +## Ejemplo + +```typescript +import { apiClient } from './api_client' + +const api = apiClient({ + base_url: 'http://localhost:8484/api', + headers: { 'X-Client': 'dashboard' }, + on_unauthorized: () => (window.location.href = '/login'), +}) + +const users = await api.get('/users') +const user = await api.post('/users', { name: 'Lucas', email: 'lucas@example.com' }) +await api.del('/users/123') +``` + +## Con parametros de query + +```typescript +const results = await api.get('/users', { search: 'lucas', role: 'admin' }) +// GET /users?search=lucas&role=admin +``` + +## Notas + +- `del` es un alias de DELETE para evitar colision con la keyword `delete` de JS +- Si la respuesta no es `application/json`, retorna el texto crudo como `T` +- `on_unauthorized` se llama ANTES de `on_error` en status 401 +- Los errores de red (sin conexion, CORS) tambien llaman a `on_error` +- Creado una vez a nivel de modulo/app, pasado a hooks via `fetch_fn` diff --git a/frontend/functions/core/api_client.ts b/frontend/functions/core/api_client.ts new file mode 100644 index 00000000..f0237c4c --- /dev/null +++ b/frontend/functions/core/api_client.ts @@ -0,0 +1,88 @@ +import type { APIClientConfig } from '../../types/core/api_client_config' + +/** Metodos que retorna el factory api_client. */ +export interface APIClient { + get: (path: string, params?: Record) => Promise + post: (path: string, body?: unknown) => Promise + put: (path: string, body?: unknown) => Promise + patch: (path: string, body?: unknown) => Promise + del: (path: string) => Promise +} + +/** + * Factory que crea un cliente HTTP configurado con base_url y headers fijos. + * Parsea JSON automaticamente. Llama a on_error/on_unauthorized segun el status. + */ +export function apiClient(config: APIClientConfig): APIClient { + const { base_url, headers: baseHeaders = {}, on_error, on_unauthorized } = config + + const buildUrl = (path: string, params?: Record): string => { + const url = `${base_url}${path}` + if (!params || Object.keys(params).length === 0) return url + const qs = new URLSearchParams(params).toString() + return `${url}?${qs}` + } + + const buildHeaders = (extra: Record = {}): HeadersInit => ({ + 'Accept': 'application/json', + ...baseHeaders, + ...extra, + }) + + const handleResponse = async (res: Response, url: string, init?: RequestInit): Promise => { + if (res.status === 401) { + on_unauthorized?.() + const err = new Error(`Unauthorized: ${url}`) + on_error?.(err, url, init) + throw err + } + + if (!res.ok) { + const text = await res.text().catch(() => '') + const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`) + on_error?.(err, url, init) + throw err + } + + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + return res.json() as Promise + } + return res.text() as unknown as T + } + + const request = async ( + method: string, + path: string, + body?: unknown, + params?: Record, + ): Promise => { + const url = buildUrl(path, params) + const hasBody = body !== undefined + const init: RequestInit = { + method, + headers: buildHeaders(hasBody ? { 'Content-Type': 'application/json' } : {}), + body: hasBody ? JSON.stringify(body) : undefined, + } + + try { + const res = await fetch(url, init) + return handleResponse(res, url, init) + } catch (err) { + if (err instanceof Error && err.message.startsWith('HTTP ')) throw err + if (err instanceof Error && err.message.startsWith('Unauthorized')) throw err + const error = err instanceof Error ? err : new Error(String(err)) + on_error?.(error, url, init) + throw error + } + } + + return { + get: (path: string, params?: Record) => + request('GET', path, undefined, params), + post: (path: string, body?: unknown) => request('POST', path, body), + put: (path: string, body?: unknown) => request('PUT', path, body), + patch: (path: string, body?: unknown) => request('PATCH', path, body), + del: (path: string) => request('DELETE', path), + } +} diff --git a/frontend/functions/core/http_cache.md b/frontend/functions/core/http_cache.md new file mode 100644 index 00000000..94a8eca0 --- /dev/null +++ b/frontend/functions/core/http_cache.md @@ -0,0 +1,41 @@ +--- +name: http_cache +kind: function +lang: ts +domain: core +version: "1.0.0" +purity: pure +signature: "cacheGet(key: string): T | null; cacheSet(key: string, data: T): void; cacheHas(key: string): boolean; cacheIsStale(key: string, staleTime: number): boolean; cacheDelete(key: string): void; cacheInvalidatePrefix(prefix: string): void; cacheClear(): void; cacheSize(): number" +description: "Cache in-memory singleton para use_fetch. Map con funciones de get/set/has/isStale/invalidate por prefijo. Compartido entre todas las instancias de use_fetch." +tags: [cache, http, fetch, swr, singleton, memory] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +params: + - name: key + desc: "Clave de cache (string serializado desde url + params)" + - name: staleTime + desc: "Tiempo en ms antes de considerar una entrada stale" +output: "Modulo con funciones de lectura/escritura/invalidacion del cache in-memory compartido" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/http_cache.ts" +--- + +## Ejemplo + +```typescript +import { cacheGet, cacheSet, cacheIsStale } from './http_cache' + +cacheSet('users:list', [{ id: '1', name: 'Lucas' }]) +const users = cacheGet('users:list') +const stale = cacheIsStale('users:list', 30_000) // > 30s? +``` + +## Notas + +Singleton de modulo — el `Map` vive mientras dure la sesion de la SPA. No se persiste en localStorage ni IndexedDB. Para invalidacion jerarquica, `cacheInvalidatePrefix('users')` elimina `users`, `users:list`, `users:123`, etc. diff --git a/frontend/functions/core/http_cache.ts b/frontend/functions/core/http_cache.ts new file mode 100644 index 00000000..6510124e --- /dev/null +++ b/frontend/functions/core/http_cache.ts @@ -0,0 +1,55 @@ +/** Cache in-memory compartido para use_fetch. Singleton de modulo. */ + +interface CacheEntry { + data: unknown + timestamp: number +} + +const cache = new Map() + +/** Obtener dato del cache. Retorna null si no existe. */ +export function cacheGet(key: string): T | null { + const entry = cache.get(key) + return entry !== undefined ? (entry.data as T) : null +} + +/** Guardar dato en el cache. */ +export function cacheSet(key: string, data: T): void { + cache.set(key, { data, timestamp: Date.now() }) +} + +/** Verificar si existe una entrada en el cache. */ +export function cacheHas(key: string): boolean { + return cache.has(key) +} + +/** Verificar si una entrada esta stale (supera staleTime en ms). */ +export function cacheIsStale(key: string, staleTime: number): boolean { + const entry = cache.get(key) + if (!entry) return true + return Date.now() - entry.timestamp > staleTime +} + +/** Eliminar una entrada del cache. */ +export function cacheDelete(key: string): void { + cache.delete(key) +} + +/** Eliminar todas las entradas cuya key empiece con prefix. */ +export function cacheInvalidatePrefix(prefix: string): void { + for (const key of cache.keys()) { + if (key === prefix || key.startsWith(prefix + ':')) { + cache.delete(key) + } + } +} + +/** Vaciar el cache completo. */ +export function cacheClear(): void { + cache.clear() +} + +/** Numero de entradas en cache. */ +export function cacheSize(): number { + return cache.size +} diff --git a/frontend/functions/core/use_debounced_search.md b/frontend/functions/core/use_debounced_search.md new file mode 100644 index 00000000..a5d6b42d --- /dev/null +++ b/frontend/functions/core/use_debounced_search.md @@ -0,0 +1,90 @@ +--- +name: use_debounced_search +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useDebouncedSearch(opts: UseDebouncedSearchOptions): UseDebouncedSearchResult" +description: "Hook que combina input con debounce y fetch automatico. Espera N ms de inactividad tras el ultimo cambio antes de fetchear. Expone query, set_query, results, loading, error y clear." +tags: [search, debounce, hook, input, fetch, autocomplete, core, component] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: fetch_fn + desc: "Funcion que recibe la query string y retorna Promise con los resultados" + - name: delay + desc: "Ms de inactividad antes de ejecutar el fetch (default 300)" + - name: initial_query + desc: "Query inicial (default string vacio)" + - name: fetch_on_empty + desc: "Si hace fetch cuando la query es string vacio (default true)" +output: "Objeto con query, set_query, results, loading, error y clear" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_debounced_search.tsx" +props: + - name: fetch_fn + type: "(query: string) => Promise" + required: true + description: "Funcion de fetch que recibe la query" + - name: delay + type: "number" + required: false + description: "Ms de debounce (default 300)" + - name: initial_query + type: "string" + required: false + description: "Query inicial (default '')" + - name: fetch_on_empty + type: "boolean" + required: false + description: "Fetch cuando query es vacio (default true)" +emits: [] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo + +```tsx +import { useDebouncedSearch } from '@/functions/core/use_debounced_search' +import { apiClient } from '@/functions/core/api_client' + +const api = apiClient({ base_url: 'http://localhost:8484/api' }) + +interface User { id: string; name: string; email: string } + +function UserSearch() { + const { query, set_query, results, loading } = useDebouncedSearch({ + fetch_fn: (q) => api.get('/users/search', { q }), + delay: 300, + fetch_on_empty: false, // No buscar cuando el input esta vacio + }) + + return ( +
+ set_query(e.currentTarget.value)} + placeholder="Buscar usuarios..." + /> + {loading && Buscando...} + {results?.map((u) =>
{u.name}
)} +
+ ) +} +``` + +## Notas + +- Cancela el timer anterior en cada keystroke — solo ejecuta el fetch tras N ms de silencio +- `clear()` cancela el timer pendiente y limpia estado +- `fetch_on_empty: false` es util para autocomplete (no buscar con input vacio) +- `fetch_on_empty: true` (default) es util para filtros (mostrar todos sin query) diff --git a/frontend/functions/core/use_debounced_search.tsx b/frontend/functions/core/use_debounced_search.tsx new file mode 100644 index 00000000..44fb4083 --- /dev/null +++ b/frontend/functions/core/use_debounced_search.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface UseDebouncedSearchOptions { + /** Funcion de fetch que recibe la query y retorna resultados */ + fetch_fn: (query: string) => Promise + /** Delay en ms antes de ejecutar la busqueda tras el ultimo cambio (default 300) */ + delay?: number + /** Query inicial (default "") */ + initial_query?: string + /** Si false no hace fetch cuando query es empty string (default true = fetch con "") */ + fetch_on_empty?: boolean +} + +export interface UseDebouncedSearchResult { + /** Valor actual del input de busqueda */ + query: string + /** Actualizar la query (dispara el debounce) */ + set_query: (q: string) => void + /** Resultados de la ultima busqueda exitosa */ + results: T | null + /** Si hay una busqueda en curso */ + loading: boolean + /** Error si la ultima busqueda fallo */ + error: Error | null + /** Limpiar query y resultados */ + clear: () => void +} + +/** + * Hook que combina un input con debounce y fetch automatico. + * Espera N ms de inactividad tras el ultimo cambio de query antes de hacer la peticion. + */ +export function useDebouncedSearch( + opts: UseDebouncedSearchOptions, +): UseDebouncedSearchResult { + const { fetch_fn, delay = 300, initial_query = '', fetch_on_empty = true } = opts + + const [query, setQuery] = useState(initial_query) + const [results, setResults] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const mountedRef = useRef(true) + const timerRef = useRef | null>(null) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + useEffect(() => { + if (!fetch_on_empty && query === '') { + setResults(null) + setLoading(false) + return + } + + setLoading(true) + setError(null) + + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + } + + timerRef.current = setTimeout(async () => { + try { + const data = await fetch_fn(query) + if (!mountedRef.current) return + setResults(data) + } catch (err) { + if (!mountedRef.current) return + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + if (mountedRef.current) setLoading(false) + } + }, delay) + + return () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + } + } + }, [query, delay, fetch_on_empty]) // eslint-disable-line react-hooks/exhaustive-deps + + const setQueryDebounced = useCallback((q: string) => { + setQuery(q) + }, []) + + const clear = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + } + setQuery('') + setResults(null) + setLoading(false) + setError(null) + }, []) + + return { query, set_query: setQueryDebounced, results, loading, error, clear } +} diff --git a/frontend/functions/core/use_fetch.md b/frontend/functions/core/use_fetch.md new file mode 100644 index 00000000..911d282b --- /dev/null +++ b/frontend/functions/core/use_fetch.md @@ -0,0 +1,112 @@ +--- +name: use_fetch +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useFetch(opts: UseFetchOptions): FetchState" +description: "Hook GET con cache in-memory stale-while-revalidate. Muestra datos cacheados inmediatamente y refetchea en background. Soporta refetch_interval, refetch_on_focus, stale_time y retry." +tags: [fetch, hook, cache, swr, get, http, core, component] +uses_functions: [http_cache_ts_core] +uses_types: [FetchState_ts_core] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: url + desc: "URL a fetchear (string). Actua como cache key." + - name: fetch_fn + desc: "Funcion de fetch alternativa que recibe la URL (ej: api.get de api_client). Si se omite usa fetch nativo." + - name: enabled + desc: "Si false no hace fetch automatico al montar (default true)" + - name: stale_time + desc: "Tiempo en ms antes de considerar datos stale y refetchear (default 30000)" + - name: refetch_interval + desc: "Intervalo en ms para refetch automatico. 0 desactiva (default 0)" + - name: refetch_on_focus + desc: "Si refetchea al recuperar foco de la ventana (default true)" + - name: retry + desc: "Numero de reintentos ante error (default 0)" + - name: retry_delay + desc: "Delay en ms entre reintentos (default 1000)" +output: "FetchState con data, error, loading, is_stale y refetch" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_fetch.tsx" +props: + - name: url + type: "string" + required: false + description: "URL a fetchear" + - name: fetch_fn + type: "(url: string) => Promise" + required: false + description: "Funcion de fetch alternativa (ej: api.get)" + - name: enabled + type: "boolean" + required: false + description: "Habilitar fetch automatico (default true)" + - name: stale_time + type: "number" + required: false + description: "Ms antes de considerar datos stale (default 30000)" + - name: refetch_interval + type: "number" + required: false + description: "Ms entre refetches automaticos (0 = off)" + - name: refetch_on_focus + type: "boolean" + required: false + description: "Refetch al recuperar foco (default true)" + - name: retry + type: "number" + required: false + description: "Reintentos ante error (default 0)" + - name: retry_delay + type: "number" + required: false + description: "Ms entre reintentos (default 1000)" +emits: [] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo + +```tsx +import { useFetch } from '@/functions/core/use_fetch' +import { apiClient } from '@/functions/core/api_client' + +const api = apiClient({ base_url: 'http://localhost:8484/api' }) + +function UsersList() { + const { data: users, loading, error, is_stale, refetch } = useFetch({ + url: '/users', + fetch_fn: api.get, + stale_time: 10_000, + refetch_on_focus: true, + }) + + if (loading && !users) return
Cargando...
+ if (error) return
Error: {error.message}
+ + return ( +
+ {is_stale && Actualizando...} + {users?.map((u) =>
{u.name}
)} + +
+ ) +} +``` + +## Notas + +- La URL actua como cache key — misma URL = mismos datos cacheados +- Stale-while-revalidate: si hay datos en cache y son stale, los muestra (`is_stale: true`) y refetchea en background +- Si no se pasa `fetch_fn`, usa `fetch` nativo con `Accept: application/json` +- Usar `enabled: false` para fetch condicional (ej: cuando falta un parametro) diff --git a/frontend/functions/core/use_fetch.tsx b/frontend/functions/core/use_fetch.tsx new file mode 100644 index 00000000..19ace721 --- /dev/null +++ b/frontend/functions/core/use_fetch.tsx @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { cacheGet, cacheHas, cacheIsStale, cacheSet } from './http_cache' +import type { FetchState } from '../../types/core/fetch_state' + +export interface UseFetchOptions { + /** URL a fetchear, o funcion que retorna una Promise con los datos */ + url?: string + /** Funcion de fetch alternativa (ej: api.get de api_client). Recibe la URL. */ + fetch_fn?: (url: string) => Promise + /** Si false, no hace fetch automatico al montar (default true) */ + enabled?: boolean + /** Tiempo en ms antes de considerar datos stale (default 30000) */ + stale_time?: number + /** Intervalo en ms para refetch automatico (0 = desactivado) */ + refetch_interval?: number + /** Si refetchea al recuperar foco de la ventana (default true) */ + refetch_on_focus?: boolean + /** Numero de reintentos ante error (default 0) */ + retry?: number + /** Delay en ms entre reintentos (default 1000) */ + retry_delay?: number +} + +/** + * Hook GET con cache in-memory stale-while-revalidate. + * Muestra datos cacheados inmediatamente mientras refetchea en background. + */ +export function useFetch(opts: UseFetchOptions): FetchState { + const { + url = '', + fetch_fn, + enabled = true, + stale_time = 30_000, + refetch_interval = 0, + refetch_on_focus = true, + retry = 0, + retry_delay = 1000, + } = opts + + const cacheKey = url + + type FetchInternalState = Omit, 'refetch'> + + const [state, setState] = useState(() => ({ + data: cacheHas(cacheKey) ? cacheGet(cacheKey) : null, + error: null, + loading: enabled && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time)), + is_stale: cacheHas(cacheKey) && cacheIsStale(cacheKey, stale_time), + })) + + const mountedRef = useRef(true) + const retryCountRef = useRef(0) + + const doFetch = useCallback(async (): Promise => { + if (!mountedRef.current) throw new Error('unmounted') + + setState((s: FetchInternalState) => ({ ...s, loading: true, error: null })) + + try { + const data = fetch_fn + ? await fetch_fn(url) + : await fetch(url, { headers: { Accept: 'application/json' } }).then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() as Promise + }) + + if (!mountedRef.current) throw new Error('unmounted') + + cacheSet(cacheKey, data) + retryCountRef.current = 0 + + setState({ data, loading: false, error: null, is_stale: false }) + return data + } catch (err) { + if (!mountedRef.current) throw err + + if (retryCountRef.current < retry) { + retryCountRef.current += 1 + await new Promise((resolve) => setTimeout(resolve, retry_delay)) + return doFetch() + } + + const error = err instanceof Error ? err : new Error(String(err)) + setState((s: FetchInternalState) => ({ ...s, loading: false, error })) + throw error + } + }, [url, fetch_fn, cacheKey, stale_time, retry, retry_delay]) + + // Fetch inicial + useEffect(() => { + mountedRef.current = true + + if (enabled && url && (!cacheHas(cacheKey) || cacheIsStale(cacheKey, stale_time))) { + doFetch().catch(() => {}) + } else if (enabled && url && cacheHas(cacheKey)) { + // Datos en cache no stale — actualizar estado si es necesario + setState((s: FetchInternalState) => ({ + ...s, + data: cacheGet(cacheKey), + loading: false, + is_stale: false, + })) + } + + return () => { + mountedRef.current = false + } + }, [enabled, url, stale_time]) // eslint-disable-line react-hooks/exhaustive-deps + + // Refetch por intervalo + useEffect(() => { + if (!refetch_interval || !enabled || !url) return + + const id = setInterval(() => { + doFetch().catch(() => {}) + }, refetch_interval) + + return () => clearInterval(id) + }, [refetch_interval, enabled, url, doFetch]) + + // Refetch al recuperar foco + useEffect(() => { + if (!refetch_on_focus || !enabled || !url) return + + const handler = () => { + if (cacheIsStale(cacheKey, stale_time)) { + doFetch().catch(() => {}) + } + } + + window.addEventListener('focus', handler) + return () => window.removeEventListener('focus', handler) + }, [refetch_on_focus, enabled, url, cacheKey, stale_time, doFetch]) + + return { ...state, refetch: doFetch } +} diff --git a/frontend/functions/core/use_form.md b/frontend/functions/core/use_form.md new file mode 100644 index 00000000..de6d3673 --- /dev/null +++ b/frontend/functions/core/use_form.md @@ -0,0 +1,94 @@ +--- +name: use_form +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useForm>(opts: UseFormOptions): UseFormResult" +description: "Hook de formulario con validacion sincrona, touched tracking y submit handler. Minimalista: set_field, set_fields, validate, submit, reset. Para formularios simples sin dependencia de Mantine." +tags: [form, hook, validation, touched, submit, core, component] +uses_functions: [] +uses_types: [FormState_ts_core] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: initial_values + desc: "Valores iniciales del formulario tipados con T" + - name: validate + desc: "Funcion de validacion sincrona: recibe values, retorna errores por campo (undefined = campo valido)" +output: "UseFormResult con values, errors, touched, is_valid, is_submitting, set_field, set_fields, validate, submit y reset" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_form.tsx" +props: + - name: initial_values + type: "T" + required: true + description: "Valores iniciales del formulario" + - name: validate + type: "(values: T) => Partial>" + required: false + description: "Funcion de validacion sincrona" +emits: [] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo + +```tsx +import { useForm } from '@/functions/core/use_form' + +interface LoginForm { + email: string + password: string +} + +function LoginPage() { + const form = useForm({ + initial_values: { email: '', password: '' }, + validate: (values) => ({ + email: !values.email.includes('@') ? 'Email invalido' : undefined, + password: values.password.length < 8 ? 'Minimo 8 caracteres' : undefined, + }), + }) + + return ( +
{ + e.preventDefault() + form.submit(async (values) => { + await loginApi(values.email, values.password) + }) + }}> + form.set_field('email', e.currentTarget.value)} + /> + {form.touched.email && form.errors.email && {form.errors.email}} + + form.set_field('password', e.currentTarget.value)} + /> + {form.touched.password && form.errors.password && {form.errors.password}} + + +
+ ) +} +``` + +## Notas + +- `touched` permite mostrar errores solo despues de que el usuario modifica un campo +- `submit(handler)` llama a `validate()` primero y solo ejecuta el handler si pasa +- `is_valid` es true si no hay errores (incluso antes de tocar campos) +- Para formularios complejos con validacion async, arrays de campos o schemas zod, usar `@mantine/form` diff --git a/frontend/functions/core/use_form.tsx b/frontend/functions/core/use_form.tsx new file mode 100644 index 00000000..366e65e1 --- /dev/null +++ b/frontend/functions/core/use_form.tsx @@ -0,0 +1,133 @@ +import { useCallback, useState } from 'react' +import type { FormState } from '../../types/core/form_state' + +export interface UseFormOptions> { + /** Valores iniciales del formulario */ + initial_values: T + /** Funcion de validacion sincrona. Retorna errores por campo (undefined = sin error). */ + validate?: (values: T) => Partial> +} + +/** Resultado del hook use_form, extiende FormState con metodos de control */ +export interface UseFormResult> extends FormState { + /** Actualizar un campo individual */ + set_field: (name: K, value: T[K]) => void + /** Actualizar multiples campos a la vez */ + set_fields: (partial: Partial) => void + /** Ejecutar validacion manualmente. Retorna true si pasa. */ + validate: () => boolean + /** Ejecutar validacion y llamar al handler solo si pasa */ + submit: (handler: (values: T) => Promise | void) => Promise + /** Resetear al estado inicial */ + reset: () => void +} + +/** + * Hook para formularios con validacion sincrona, touched tracking y submit handler. + * Minimalista: no reemplaza @mantine/form. Para formularios simples sin Mantine. + */ +export function useForm>( + opts: UseFormOptions, +): UseFormResult { + const { initial_values, validate: validateFn } = opts + + const [values, setValues] = useState(initial_values) + const [errors, setErrors] = useState>>({}) + const [touched, setTouched] = useState>>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + const runValidation = useCallback( + (vals: T): Partial> => { + if (!validateFn) return {} + const result = validateFn(vals) + const filtered: Partial> = {} + for (const key in result) { + const val = result[key as keyof T] + if (val !== undefined) { + filtered[key as keyof T] = val + } + } + return filtered + }, + [validateFn], + ) + + const setField = useCallback( + (name: K, value: T[K]) => { + setValues((prev: T) => { + const next = { ...prev, [name]: value } + if (validateFn) { + const newErrors = runValidation(next) + setErrors(newErrors) + } + return next + }) + setTouched((prev: Partial>) => ({ ...prev, [name]: true })) + }, + [validateFn, runValidation], + ) + + const setFields = useCallback( + (partial: Partial) => { + setValues((prev: T) => { + const next = { ...prev, ...partial } + if (validateFn) { + const newErrors = runValidation(next) + setErrors(newErrors) + } + return next + }) + const newTouched: Partial> = {} + for (const key in partial) { + newTouched[key as keyof T] = true + } + setTouched((prev: Partial>) => ({ ...prev, ...newTouched })) + }, + [validateFn, runValidation], + ) + + const validate = useCallback((): boolean => { + const newErrors = runValidation(values) + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + }, [values, runValidation]) + + const submit = useCallback( + async (handler: (values: T) => Promise | void): Promise => { + const newErrors = runValidation(values) + setErrors(newErrors) + + if (Object.keys(newErrors).length > 0) return + + setIsSubmitting(true) + try { + await handler(values) + } finally { + setIsSubmitting(false) + } + }, + [values, runValidation], + ) + + const reset = useCallback(() => { + setValues(initial_values) + setErrors({}) + setTouched({}) + setIsSubmitting(false) + }, [initial_values]) + + const isValid = Object.keys(errors).length === 0 + + return { + values, + errors, + touched, + is_valid: isValid, + is_submitting: isSubmitting, + set_field: setField, + set_fields: setFields, + validate, + submit, + reset, + } +} diff --git a/frontend/functions/core/use_infinite_scroll.md b/frontend/functions/core/use_infinite_scroll.md new file mode 100644 index 00000000..caa88e9e --- /dev/null +++ b/frontend/functions/core/use_infinite_scroll.md @@ -0,0 +1,104 @@ +--- +name: use_infinite_scroll +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useInfiniteScroll(opts: UseInfiniteScrollOptions): UseInfiniteScrollResult" +description: "Hook de paginacion infinita cursor-based y offset-based. Concatena paginas en array plano. Expone load_more, has_more y reset. Zero dependencias externas." +tags: [infinite, scroll, pagination, cursor, hook, http, core, component] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: fetch_fn + desc: "Funcion que recibe el cursor actual (string, number o null) y retorna Promise<{items, next_cursor}>" + - name: initial_cursor + desc: "Cursor inicial, null para empezar desde el principio (default null)" + - name: enabled + desc: "Si hace fetch automatico al montar (default true)" +output: "Objeto con data (array concatenado), loading, error, has_more, load_more() y reset()" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_infinite_scroll.tsx" +props: + - name: fetch_fn + type: "(cursor: string | number | null) => Promise>" + required: true + description: "Funcion de fetch que recibe cursor y retorna pagina con items y next_cursor" + - name: initial_cursor + desc: "Cursor inicial (default null)" + type: "string | number | null" + required: false + description: "Cursor/offset inicial" + - name: enabled + type: "boolean" + required: false + description: "Fetch automatico al montar (default true)" +emits: [] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo cursor-based + +```tsx +import { useInfiniteScroll } from '@/functions/core/use_infinite_scroll' +import { apiClient } from '@/functions/core/api_client' + +const api = apiClient({ base_url: 'http://localhost:8484/api' }) + +function EventsList() { + const { data, loading, has_more, load_more } = useInfiniteScroll({ + fetch_fn: async (cursor) => { + const res = await api.get<{ items: Event[]; next_cursor: string | null }>( + '/events', + cursor ? { after: String(cursor) } : undefined, + ) + return { items: res.items, next_cursor: res.next_cursor } + }, + }) + + return ( +
+ {data.map((e) =>
{e.name}
)} + {has_more && ( + + )} +
+ ) +} +``` + +## Ejemplo offset-based + +```tsx +const { data, load_more, has_more } = useInfiniteScroll({ + fetch_fn: async (cursor) => { + const page = typeof cursor === 'number' ? cursor : 0 + const items = await api.get('/products', { + offset: String(page * 20), + limit: '20', + }) + return { + items, + next_cursor: items.length === 20 ? page + 1 : null, + } + }, + initial_cursor: 0, +}) +``` + +## Notas + +- `InfiniteScrollPage.next_cursor = null` indica que no hay mas paginas +- `reset()` vuelve al cursor inicial y refetchea desde el principio +- `load_more` es no-op si `loading` o `!has_more` diff --git a/frontend/functions/core/use_infinite_scroll.tsx b/frontend/functions/core/use_infinite_scroll.tsx new file mode 100644 index 00000000..2a140690 --- /dev/null +++ b/frontend/functions/core/use_infinite_scroll.tsx @@ -0,0 +1,106 @@ +import { useCallback, useRef, useState } from 'react' + +export interface UseInfiniteScrollOptions { + /** Funcion de fetch que recibe el cursor/pagina actual y retorna la pagina de datos */ + fetch_fn: (cursor: string | number | null) => Promise> + /** Cursor inicial (null para empezar desde el principio) */ + initial_cursor?: string | number | null + /** Si el primer fetch es automatico al montar (default true) */ + enabled?: boolean +} + +/** Pagina retornada por fetch_fn */ +export interface InfiniteScrollPage { + /** Datos de esta pagina */ + items: T[] + /** Cursor para la siguiente pagina. null/undefined = no hay mas paginas. */ + next_cursor?: string | number | null +} + +/** Estado del hook de paginacion infinita */ +export interface UseInfiniteScrollResult { + /** Todos los items concatenados de todas las paginas */ + data: T[] + /** Si hay una peticion en curso */ + loading: boolean + /** Error si la ultima peticion fallo */ + error: Error | null + /** Si hay mas paginas disponibles */ + has_more: boolean + /** Cargar la siguiente pagina */ + load_more: () => void + /** Resetear al estado inicial y refetchear desde el principio */ + reset: () => void +} + +/** + * Hook para paginacion infinita cursor-based y offset-based. + * Concatena paginas en un solo array. Expone load_more, has_more y reset. + */ +export function useInfiniteScroll( + opts: UseInfiniteScrollOptions, +): UseInfiniteScrollResult { + const { fetch_fn, initial_cursor = null, enabled = true } = opts + + const [data, setData] = useState([]) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(null) + const [hasMore, setHasMore] = useState(true) + + const cursorRef = useRef(initial_cursor) + const loadingRef = useRef(false) + const mountedRef = useRef(true) + + const fetchPage = useCallback( + async (cursor: string | number | null): Promise => { + if (loadingRef.current) return + loadingRef.current = true + + setLoading(true) + setError(null) + + try { + const page = await fetch_fn(cursor) + + if (!mountedRef.current) return + + setData((prev: T[]) => (cursor === initial_cursor ? page.items : [...prev, ...page.items])) + + const nextCursor = page.next_cursor ?? null + cursorRef.current = nextCursor + setHasMore(nextCursor !== null && nextCursor !== undefined) + } catch (err) { + if (!mountedRef.current) return + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + if (mountedRef.current) setLoading(false) + loadingRef.current = false + } + }, + [fetch_fn, initial_cursor], + ) + + // Fetch inicial + const initializedRef = useRef(false) + if (!initializedRef.current && enabled) { + initializedRef.current = true + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchPage(initial_cursor) + } + + const loadMore = useCallback(() => { + if (!hasMore || loadingRef.current) return + fetchPage(cursorRef.current).catch(() => {}) + }, [fetchPage, hasMore]) + + const reset = useCallback(() => { + cursorRef.current = initial_cursor + loadingRef.current = false + setData([]) + setError(null) + setHasMore(true) + fetchPage(initial_cursor).catch(() => {}) + }, [fetchPage, initial_cursor]) + + return { data, loading, error, has_more: hasMore, load_more: loadMore, reset } +} diff --git a/frontend/functions/core/use_mutation.md b/frontend/functions/core/use_mutation.md new file mode 100644 index 00000000..630c4910 --- /dev/null +++ b/frontend/functions/core/use_mutation.md @@ -0,0 +1,120 @@ +--- +name: use_mutation +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useMutation(opts: UseMutationOptions): MutationState" +description: "Hook POST/PUT/DELETE con optimistic updates, invalidacion de cache, retry y callbacks completos. No ejecuta al montar — expone mutate y mutate_async." +tags: [mutation, hook, post, put, delete, http, optimistic, cache, core, component] +uses_functions: [http_cache_ts_core] +uses_types: [MutationState_ts_core] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: mutation_fn + desc: "Funcion que ejecuta la mutacion, recibe variables y retorna Promise" + - name: on_mutate + desc: "Callback antes de la mutacion para optimistic updates. Retorna contexto de rollback." + - name: on_success + desc: "Callback al exito con data, variables y context" + - name: on_error + desc: "Callback al error con error, variables y context para rollback" + - name: on_settled + desc: "Callback siempre (exito o error)" + - name: invalidate_cache + desc: "Prefijos de cache a invalidar en exito (ej: 'users' invalida users, users:list, etc.)" + - name: retry + desc: "Numero de reintentos ante error (default 0)" + - name: retry_delay + desc: "Delay en ms entre reintentos (default 1000)" +output: "MutationState con mutate, mutate_async, data, error, loading y reset" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_mutation.tsx" +props: + - name: mutation_fn + type: "(variables: TVariables) => Promise" + required: true + description: "Funcion que ejecuta la mutacion" + - name: on_mutate + type: "(variables: TVariables) => Promise | unknown" + required: false + description: "Optimistic update antes de la mutacion" + - name: on_success + type: "(data: TData, variables: TVariables, context: unknown) => void" + required: false + description: "Callback al exito" + - name: on_error + type: "(error: Error, variables: TVariables, context: unknown) => void" + required: false + description: "Callback al error (para rollback de optimistic updates)" + - name: invalidate_cache + type: "string[]" + required: false + description: "Prefijos de cache a invalidar en exito" + - name: retry + type: "number" + required: false + description: "Reintentos ante error (default 0)" + - name: retry_delay + type: "number" + required: false + description: "Ms entre reintentos (default 1000)" +emits: [on_success, on_error, on_settled] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo + +```tsx +import { useMutation } from '@/functions/core/use_mutation' +import { apiClient } from '@/functions/core/api_client' + +const api = apiClient({ base_url: 'http://localhost:8484/api' }) + +function DeleteButton({ userId }: { userId: string }) { + const { mutate: deleteUser, loading } = useMutation({ + mutation_fn: (id) => api.del(`/users/${id}`), + invalidate_cache: ['users'], + on_success: () => console.log('Eliminado'), + }) + + return ( + + ) +} +``` + +## Optimistic update + +```tsx +const { mutate } = useMutation>({ + mutation_fn: (data) => api.post('/users', data), + on_mutate: (newUser) => { + // Guardar snapshot para rollback + const prev = cacheGet('users') + cacheSet('users', [...(prev ?? []), { ...newUser, id: 'temp' }]) + return { prev } + }, + on_error: (_err, _vars, context) => { + // Rollback + const ctx = context as { prev: User[] } + cacheSet('users', ctx.prev) + }, +}) +``` + +## Notas + +- `mutate` es fire-and-forget (no lanza, silencia el error) +- `mutate_async` retorna Promise (puede rechazar — usar try/catch) +- `invalidate_cache` usa `cacheInvalidatePrefix` — invalida prefijos jerarquicos diff --git a/frontend/functions/core/use_mutation.tsx b/frontend/functions/core/use_mutation.tsx new file mode 100644 index 00000000..96fb48f3 --- /dev/null +++ b/frontend/functions/core/use_mutation.tsx @@ -0,0 +1,117 @@ +import { useCallback, useRef, useState } from 'react' +import { cacheInvalidatePrefix } from './http_cache' +import type { MutationState } from '../../types/core/mutation_state' + +export interface UseMutationOptions { + /** Funcion que ejecuta la mutacion (POST/PUT/DELETE) */ + mutation_fn: (variables: TVariables) => Promise + /** Callback antes de la mutacion — permite optimistic updates. Retorna contexto de rollback. */ + on_mutate?: (variables: TVariables) => Promise | unknown + /** Callback al exito */ + on_success?: (data: TData, variables: TVariables, context: unknown) => void + /** Callback al error */ + on_error?: (error: Error, variables: TVariables, context: unknown) => void + /** Callback siempre (exito o error) */ + on_settled?: (data: TData | undefined, error: Error | null, variables: TVariables, context: unknown) => void + /** Prefijos de cache a invalidar en exito (ej: ['users'] invalida users, users:list, etc.) */ + invalidate_cache?: string[] + /** Numero de reintentos ante error (default 0) */ + retry?: number + /** Delay en ms entre reintentos (default 1000) */ + retry_delay?: number +} + +/** + * Hook para operaciones de escritura POST/PUT/DELETE. + * No ejecuta al montar. Expone mutate() y mutate_async(). + * Soporta optimistic updates via on_mutate y invalidacion de cache. + */ +export function useMutation( + opts: UseMutationOptions, +): MutationState { + const { + mutation_fn, + on_mutate, + on_success, + on_error, + on_settled, + invalidate_cache = [], + retry = 0, + retry_delay = 1000, + } = opts + + type MutationInternalState = { + status: 'idle' | 'loading' | 'success' | 'error' + data: TData | null + error: Error | null + } + + const [state, setState] = useState({ status: 'idle', data: null, error: null }) + + const retryCountRef = useRef(0) + + const execute = useCallback( + async (variables: TVariables): Promise => { + setState((s: MutationInternalState) => ({ ...s, status: 'loading', error: null })) + + let context: unknown + + try { + if (on_mutate) { + context = await on_mutate(variables) + } + + const data = await mutation_fn(variables) + + retryCountRef.current = 0 + setState({ status: 'success', data, error: null }) + + // Invalidar cache en exito + for (const prefix of invalidate_cache) { + cacheInvalidatePrefix(prefix) + } + + on_success?.(data, variables, context) + on_settled?.(data, null, variables, context) + + return data + } catch (err) { + if (retryCountRef.current < retry) { + retryCountRef.current += 1 + await new Promise((resolve) => setTimeout(resolve, retry_delay)) + return execute(variables) + } + + const error = err instanceof Error ? err : new Error(String(err)) + setState((s: MutationInternalState) => ({ ...s, status: 'error', error })) + + on_error?.(error, variables, context) + on_settled?.(undefined, error, variables, context) + + throw error + } + }, + [mutation_fn, on_mutate, on_success, on_error, on_settled, invalidate_cache, retry, retry_delay], + ) + + const mutate = useCallback( + (variables: TVariables) => { + execute(variables).catch(() => {}) + }, + [execute], + ) + + const reset = useCallback(() => { + setState({ status: 'idle', data: null, error: null }) + retryCountRef.current = 0 + }, []) + + return { + mutate, + mutate_async: execute, + data: state.data, + error: state.error, + loading: state.status === 'loading', + reset, + } +} diff --git a/frontend/functions/core/use_sse.md b/frontend/functions/core/use_sse.md new file mode 100644 index 00000000..b5999d2f --- /dev/null +++ b/frontend/functions/core/use_sse.md @@ -0,0 +1,104 @@ +--- +name: use_sse +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useSSE(opts: UseSSEOptions): UseSSEResult" +description: "Hook para Server-Sent Events con auto-reconexion y backoff exponencial. Parsea eventos como JSON. Acumula todos los eventos en data[]. Expone last_event, status y close." +tags: [sse, server-sent-events, stream, hook, realtime, reconnect, core, component] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: url + desc: "URL del endpoint SSE (ej: http://localhost:8484/api/logs/stream)" + - name: enabled + desc: "Si conecta automaticamente al montar (default true)" + - name: reconnect + desc: "Si reconecta automaticamente con backoff exponencial (default true)" + - name: reconnect_interval + desc: "Delay inicial de reconexion en ms (default 1000). Se duplica hasta max_reconnect_interval." + - name: max_reconnect_interval + desc: "Delay maximo de reconexion en ms (default 30000)" + - name: event_name + desc: "Nombre del evento SSE a escuchar (default 'message')" + - name: on_event + desc: "Callback por cada evento recibido con el dato parseado" +output: "Objeto con data[], last_event, status (connecting/open/closed), error, close() y connect()" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_sse.tsx" +props: + - name: url + type: "string" + required: true + description: "URL del endpoint SSE" + - name: enabled + type: "boolean" + required: false + description: "Conectar al montar (default true)" + - name: reconnect + type: "boolean" + required: false + description: "Auto-reconectar (default true)" + - name: reconnect_interval + type: "number" + required: false + description: "Delay inicial reconexion en ms (default 1000)" + - name: max_reconnect_interval + type: "number" + required: false + description: "Delay maximo reconexion en ms (default 30000)" + - name: event_name + type: "string" + required: false + description: "Nombre evento SSE (default 'message')" +emits: [on_event, on_error] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo + +```tsx +import { useSSE } from '@/functions/core/use_sse' + +interface LogEntry { level: string; msg: string; ts: number } + +function LogViewer() { + const { data: logs, last_event, status, close } = useSSE({ + url: 'http://localhost:8484/api/logs/stream', + reconnect: true, + reconnect_interval: 2000, + }) + + return ( +
+ Status: {status} +
+ {logs.map((log, i) => ( +
+ [{log.level}] {log.msg} +
+ ))} +
+ +
+ ) +} +``` + +## Notas + +- El backoff exponencial duplica el delay hasta `max_reconnect_interval` +- Si el evento no es JSON valido, se guarda como string +- `close()` previene reconexion automatica +- `connect()` fuerza reconexion manual aunque haya sido cerrado manualmente +- `data` acumula todos los eventos de la sesion. Para limpiar, desmontar y remontar el componente. diff --git a/frontend/functions/core/use_sse.tsx b/frontend/functions/core/use_sse.tsx new file mode 100644 index 00000000..4055d78e --- /dev/null +++ b/frontend/functions/core/use_sse.tsx @@ -0,0 +1,163 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export type SSEStatus = 'connecting' | 'open' | 'closed' + +export interface UseSSEOptions { + /** URL del endpoint SSE */ + url: string + /** Si conecta automaticamente al montar (default true) */ + enabled?: boolean + /** Si reconecta automaticamente al perder la conexion (default true) */ + reconnect?: boolean + /** Delay inicial de reconexion en ms (default 1000). Se duplica con backoff hasta max_reconnect_interval. */ + reconnect_interval?: number + /** Delay maximo de reconexion en ms (default 30000) */ + max_reconnect_interval?: number + /** Nombre del evento a escuchar (default 'message'). Para multiples eventos usar on_event. */ + event_name?: string + /** Callback por evento recibido. Recibe el dato parseado. */ + on_event?: (data: T) => void + /** Callback al error */ + on_error?: (error: Event) => void +} + +export interface UseSSEResult { + /** Todos los eventos recibidos en esta sesion */ + data: T[] + /** Ultimo evento recibido */ + last_event: T | null + /** Estado de la conexion */ + status: SSEStatus + /** Error de conexion si ocurrio */ + error: Event | null + /** Cerrar la conexion manualmente */ + close: () => void + /** Reconectar manualmente */ + connect: () => void +} + +/** + * Hook para Server-Sent Events con auto-reconexion y backoff exponencial. + * Parsea cada evento como JSON. Acumula todos los eventos en data[]. + */ +export function useSSE(opts: UseSSEOptions): UseSSEResult { + const { + url, + enabled = true, + reconnect = true, + reconnect_interval = 1000, + max_reconnect_interval = 30_000, + event_name = 'message', + on_event, + on_error, + } = opts + + const [data, setData] = useState([]) + const [lastEvent, setLastEvent] = useState(null) + const [status, setStatus] = useState('closed') + const [error, setError] = useState(null) + + const esRef = useRef(null) + const reconnectTimerRef = useRef | null>(null) + const currentIntervalRef = useRef(reconnect_interval) + const mountedRef = useRef(true) + const closedManuallyRef = useRef(false) + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const connect = useCallback(() => { + if (!mountedRef.current) return + + // Cerrar conexion previa si existe + if (esRef.current) { + esRef.current.close() + esRef.current = null + } + + closedManuallyRef.current = false + currentIntervalRef.current = reconnect_interval + + setStatus('connecting') + setError(null) + + const es = new EventSource(url) + esRef.current = es + + es.addEventListener(event_name, (e: MessageEvent) => { + if (!mountedRef.current) return + + let parsed: T + try { + parsed = JSON.parse(e.data) as T + } catch { + parsed = e.data as unknown as T + } + + setData((prev: T[]) => [...prev, parsed]) + setLastEvent(parsed) + on_event?.(parsed) + // Reset backoff on successful message + currentIntervalRef.current = reconnect_interval + }) + + es.onopen = () => { + if (!mountedRef.current) return + setStatus('open') + currentIntervalRef.current = reconnect_interval + } + + es.onerror = (e) => { + if (!mountedRef.current) return + setError(e) + on_error?.(e) + setStatus('closed') + es.close() + esRef.current = null + + if (reconnect && !closedManuallyRef.current) { + const delay = Math.min(currentIntervalRef.current, max_reconnect_interval) + currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval) + + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current && !closedManuallyRef.current) { + connect() + } + }, delay) + } + } + }, [url, event_name, reconnect, reconnect_interval, max_reconnect_interval, on_event, on_error]) + + const close = useCallback(() => { + closedManuallyRef.current = true + clearReconnectTimer() + if (esRef.current) { + esRef.current.close() + esRef.current = null + } + setStatus('closed') + }, []) + + useEffect(() => { + mountedRef.current = true + + if (enabled) { + connect() + } + + return () => { + mountedRef.current = false + clearReconnectTimer() + if (esRef.current) { + esRef.current.close() + esRef.current = null + } + } + }, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps + + return { data, last_event: lastEvent, status, error, close, connect } +} diff --git a/frontend/functions/core/use_websocket.md b/frontend/functions/core/use_websocket.md new file mode 100644 index 00000000..5a72f0c3 --- /dev/null +++ b/frontend/functions/core/use_websocket.md @@ -0,0 +1,115 @@ +--- +name: use_websocket +kind: component +lang: ts +domain: core +version: "1.0.0" +purity: impure +signature: "useWebSocket(opts: UseWebSocketOptions): UseWebSocketResult" +description: "Hook WebSocket con auto-reconexion y backoff exponencial. Serializa envios como JSON, parsea recepcion como JSON. Buffer de mensajes con limite configurable. Mensajes en vuelo se bufferean hasta reconectar." +tags: [websocket, ws, hook, realtime, reconnect, stream, core, component] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [react] +params: + - name: url + desc: "URL del WebSocket (ej: ws://localhost:8484/ws/chat)" + - name: enabled + desc: "Si conecta automaticamente al montar (default true)" + - name: reconnect + desc: "Si reconecta automaticamente con backoff exponencial (default true)" + - name: reconnect_interval + desc: "Delay inicial de reconexion en ms (default 1000)" + - name: max_reconnect_interval + desc: "Delay maximo de reconexion en ms (default 30000)" + - name: max_messages + desc: "Numero maximo de mensajes a mantener en buffer (default 100)" + - name: on_message + desc: "Callback por cada mensaje recibido con el dato parseado" +output: "Objeto con send, last_message, messages[], status (connecting/open/closing/closed), close() y connect()" +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/core/use_websocket.tsx" +props: + - name: url + type: "string" + required: true + description: "URL del WebSocket" + - name: enabled + type: "boolean" + required: false + description: "Conectar al montar (default true)" + - name: reconnect + type: "boolean" + required: false + description: "Auto-reconectar (default true)" + - name: reconnect_interval + type: "number" + required: false + description: "Delay inicial reconexion en ms (default 1000)" + - name: max_reconnect_interval + type: "number" + required: false + description: "Delay maximo reconexion en ms (default 30000)" + - name: max_messages + type: "number" + required: false + description: "Tamano del buffer de mensajes (default 100)" +emits: [on_message, on_open, on_close, on_error] +has_state: true +framework: react +variant: [default] +--- + +## Ejemplo + +```tsx +import { useWebSocket } from '@/functions/core/use_websocket' + +interface ChatMessage { user: string; text: string; ts: number } +interface SendMessage { text: string } + +function Chat() { + const { send, messages, status } = useWebSocket({ + url: 'ws://localhost:8484/ws/chat', + reconnect: true, + max_messages: 50, + }) + + const [input, setInput] = useState('') + + return ( +
+
Status: {status}
+
+ {messages.map((msg, i) => ( +
{msg.user}: {msg.text}
+ ))} +
+ setInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && input.trim()) { + send({ text: input }) + setInput('') + } + }} + /> +
+ ) +} +``` + +## Notas + +- Genericos separados: `TSend` para envio, `TRecv` para recepcion (pueden diferir) +- Mensajes enviados antes de `open` se bufferean y envian al conectar +- El buffer `messages` se limita a `max_messages` (FIFO, elimina el mas viejo) +- El backoff exponencial duplica el delay hasta `max_reconnect_interval` +- `close()` previene reconexion automatica; `connect()` fuerza reconexion manual diff --git a/frontend/functions/core/use_websocket.tsx b/frontend/functions/core/use_websocket.tsx new file mode 100644 index 00000000..e19c5b53 --- /dev/null +++ b/frontend/functions/core/use_websocket.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export type WebSocketStatus = 'connecting' | 'open' | 'closing' | 'closed' + +export interface UseWebSocketOptions { + /** URL del WebSocket (ej: ws://localhost:8484/ws/chat) */ + url: string + /** Si conecta automaticamente al montar (default true) */ + enabled?: boolean + /** Si reconecta automaticamente al perder la conexion (default true) */ + reconnect?: boolean + /** Delay inicial de reconexion en ms (default 1000) */ + reconnect_interval?: number + /** Delay maximo de reconexion en ms (default 30000) */ + max_reconnect_interval?: number + /** Numero maximo de mensajes a mantener en el buffer (default 100) */ + max_messages?: number + /** Callback por cada mensaje recibido */ + on_message?: (data: TRecv) => void + /** Callback al conectar */ + on_open?: () => void + /** Callback al cerrar */ + on_close?: (event: CloseEvent) => void + /** Callback al error */ + on_error?: (error: Event) => void +} + +export interface UseWebSocketResult { + /** Enviar un mensaje (serializado como JSON) */ + send: (data: TSend) => void + /** Ultimo mensaje recibido */ + last_message: TRecv | null + /** Buffer de mensajes recibidos (limitado a max_messages) */ + messages: TRecv[] + /** Estado de la conexion */ + status: WebSocketStatus + /** Cerrar la conexion manualmente */ + close: () => void + /** Reconectar manualmente */ + connect: () => void +} + +/** + * Hook WebSocket con auto-reconexion y backoff exponencial. + * Serializa mensajes enviados como JSON. Parsea mensajes recibidos como JSON. + * Mantiene buffer de los ultimos N mensajes. + */ +export function useWebSocket( + opts: UseWebSocketOptions, +): UseWebSocketResult { + const { + url, + enabled = true, + reconnect = true, + reconnect_interval = 1000, + max_reconnect_interval = 30_000, + max_messages = 100, + on_message, + on_open, + on_close, + on_error, + } = opts + + const [messages, setMessages] = useState([]) + const [lastMessage, setLastMessage] = useState(null) + const [status, setStatus] = useState('closed') + + const wsRef = useRef(null) + const reconnectTimerRef = useRef | null>(null) + const currentIntervalRef = useRef(reconnect_interval) + const mountedRef = useRef(true) + const closedManuallyRef = useRef(false) + const pendingMessages = useRef([]) + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const connect = useCallback(() => { + if (!mountedRef.current) return + + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + + closedManuallyRef.current = false + currentIntervalRef.current = reconnect_interval + + setStatus('connecting') + + const ws = new WebSocket(url) + wsRef.current = ws + + ws.onopen = () => { + if (!mountedRef.current) return + setStatus('open') + currentIntervalRef.current = reconnect_interval + on_open?.() + + // Enviar mensajes pendientes + for (const msg of pendingMessages.current) { + ws.send(JSON.stringify(msg)) + } + pendingMessages.current = [] + } + + ws.onmessage = (e: MessageEvent) => { + if (!mountedRef.current) return + + let parsed: TRecv + try { + parsed = JSON.parse(e.data as string) as TRecv + } catch { + parsed = e.data as unknown as TRecv + } + + setLastMessage(parsed) + setMessages((prev: TRecv[]) => { + const next = [...prev, parsed] + return next.length > max_messages ? next.slice(next.length - max_messages) : next + }) + on_message?.(parsed) + } + + ws.onclose = (e) => { + if (!mountedRef.current) return + setStatus('closed') + wsRef.current = null + on_close?.(e) + + if (reconnect && !closedManuallyRef.current) { + const delay = Math.min(currentIntervalRef.current, max_reconnect_interval) + currentIntervalRef.current = Math.min(delay * 2, max_reconnect_interval) + + reconnectTimerRef.current = setTimeout(() => { + if (mountedRef.current && !closedManuallyRef.current) { + connect() + } + }, delay) + } + } + + ws.onerror = (e) => { + if (!mountedRef.current) return + on_error?.(e) + } + }, [url, reconnect, reconnect_interval, max_reconnect_interval, max_messages, on_message, on_open, on_close, on_error]) + + const close = useCallback(() => { + closedManuallyRef.current = true + clearReconnectTimer() + if (wsRef.current) { + setStatus('closing') + wsRef.current.close() + wsRef.current = null + } + setStatus('closed') + }, []) + + const send = useCallback((data: TSend) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(data)) + } else { + // Buffer el mensaje para enviarlo cuando conecte + pendingMessages.current.push(data) + } + }, []) + + useEffect(() => { + mountedRef.current = true + + if (enabled) { + connect() + } + + return () => { + mountedRef.current = false + clearReconnectTimer() + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + } + }, [enabled, url]) // eslint-disable-line react-hooks/exhaustive-deps + + return { + send, + last_message: lastMessage, + messages, + status, + close, + connect, + } +} diff --git a/frontend/types/core/api_client_config.md b/frontend/types/core/api_client_config.md new file mode 100644 index 00000000..0f346e1f --- /dev/null +++ b/frontend/types/core/api_client_config.md @@ -0,0 +1,43 @@ +--- +name: APIClientConfig +lang: ts +domain: core +version: "1.0.0" +algebraic: product +definition: | + interface APIClientConfig { + base_url: string + headers?: Record + on_error?: (error: Error, url: string, init?: RequestInit) => void + on_unauthorized?: () => void + } +description: "Configuracion del cliente HTTP. Define base_url, headers fijos, callback de error global y callback de 401 Unauthorized." +tags: [api, client, config, http, fetch, auth] +uses_types: [] +file_path: "frontend/types/core/api_client_config.ts" +--- + +## Campos + +- `base_url` — prefijo de URL para todos los requests, sin slash final (ej: `"http://localhost:8484/api"`) +- `headers` — headers HTTP fijos que se mergeaan en cada peticion (ej: `{ Authorization: 'Bearer ...' }`) +- `on_error` — callback global llamado cuando cualquier peticion falla, recibe el error, la URL y el RequestInit +- `on_unauthorized` — callback llamado cuando el servidor responde 401, tipicamente redirige al login + +## Uso + +```typescript +import type { APIClientConfig } from '@/types/core/api_client_config' +import { apiClient } from '@/functions/core/api_client' + +const config: APIClientConfig = { + base_url: 'http://localhost:8484/api', + headers: { 'X-Client': 'dashboard' }, + on_unauthorized: () => (window.location.href = '/login'), +} +const api = apiClient(config) +``` + +## Notas + +Pasado como primer argumento a `api_client_ts_core`. Los interceptors `on_error` y `on_unauthorized` son globales — aplican a todos los metodos (get, post, put, del, patch) del cliente creado. diff --git a/frontend/types/core/api_client_config.ts b/frontend/types/core/api_client_config.ts new file mode 100644 index 00000000..c8d244a2 --- /dev/null +++ b/frontend/types/core/api_client_config.ts @@ -0,0 +1,11 @@ +/** Configuracion del cliente HTTP creado por api_client. */ +export interface APIClientConfig { + /** URL base para todas las peticiones (ej: "http://localhost:8484/api"). Sin slash final. */ + base_url: string + /** Headers fijos que se incluyen en todas las peticiones */ + headers?: Record + /** Callback global de error — llamado en cualquier peticion fallida */ + on_error?: (error: Error, url: string, init?: RequestInit) => void + /** Callback llamado cuando el servidor responde 401 Unauthorized */ + on_unauthorized?: () => void +} diff --git a/frontend/types/core/fetch_state.md b/frontend/types/core/fetch_state.md new file mode 100644 index 00000000..051910a2 --- /dev/null +++ b/frontend/types/core/fetch_state.md @@ -0,0 +1,41 @@ +--- +name: FetchState +lang: ts +domain: core +version: "1.0.0" +algebraic: product +definition: | + interface FetchState { + data: T | null + error: Error | null + loading: boolean + refetch: () => Promise + is_stale: boolean + } +description: "Estado de un fetch GET con cache stale-while-revalidate. Incluye datos, error, loading, refetch y flag de staleness." +tags: [fetch, state, http, cache, swr, hook] +uses_types: [] +file_path: "frontend/types/core/fetch_state.ts" +--- + +## Campos + +- `data` — datos obtenidos, null hasta que llega la primera respuesta +- `error` — error de red o de parsing, null si no hubo error +- `loading` — true mientras hay una peticion en vuelo +- `refetch` — funcion para re-ejecutar la peticion manualmente +- `is_stale` — true cuando se muestran datos del cache mientras se refetchea en background + +## Uso + +```typescript +import type { FetchState } from '@/types/core/fetch_state' + +function useMyHook(): FetchState { + // ... +} +``` + +## Notas + +Retornado por `use_fetch_ts_core`. El patron stale-while-revalidate permite mostrar datos viejos inmediatamente mientras se actualizan en background, mejorando la UX percibida. diff --git a/frontend/types/core/fetch_state.ts b/frontend/types/core/fetch_state.ts new file mode 100644 index 00000000..2acd8c40 --- /dev/null +++ b/frontend/types/core/fetch_state.ts @@ -0,0 +1,13 @@ +/** Estado de un fetch GET con cache stale-while-revalidate. */ +export interface FetchState { + /** Datos obtenidos, null si todavia no se han cargado */ + data: T | null + /** Error si la peticion fallo */ + error: Error | null + /** Si la peticion esta en progreso */ + loading: boolean + /** Re-ejecutar la peticion manualmente */ + refetch: () => Promise + /** Si los datos son stale (cache viejo mostrando mientras refetch en background) */ + is_stale: boolean +} diff --git a/frontend/types/core/form_state.md b/frontend/types/core/form_state.md new file mode 100644 index 00000000..82de44c7 --- /dev/null +++ b/frontend/types/core/form_state.md @@ -0,0 +1,43 @@ +--- +name: FormState +lang: ts +domain: core +version: "1.0.0" +algebraic: product +definition: | + interface FormState> { + values: T + errors: Partial> + touched: Partial> + is_valid: boolean + is_submitting: boolean + } +description: "Estado de un formulario con validacion sincrona, tracking de touched y flag de submit en progreso." +tags: [form, state, validation, touched, hook] +uses_types: [] +file_path: "frontend/types/core/form_state.ts" +--- + +## Campos + +- `values` — valores actuales del formulario, tipados con el generico T +- `errors` — errores de validacion por campo (string con mensaje), undefined si el campo es valido +- `touched` — true por campo cuando el usuario lo ha modificado (util para mostrar errores solo al tocar) +- `is_valid` — true cuando no hay ningun error de validacion en ningun campo +- `is_submitting` — true mientras el handler de submit esta en ejecucion + +## Uso + +```typescript +import type { FormState } from '@/types/core/form_state' + +interface LoginForm { email: string; password: string } + +function LoginPage() { + const form: FormState = useForm({ initial_values: { email: '', password: '' } }) +} +``` + +## Notas + +Retornado por `use_form_ts_core`. El generico `T extends Record` permite tipado completo de los campos y sus errores. Para formularios complejos con validacion async o arrays de campos, usar `@mantine/form` directamente. diff --git a/frontend/types/core/form_state.ts b/frontend/types/core/form_state.ts new file mode 100644 index 00000000..f9c63328 --- /dev/null +++ b/frontend/types/core/form_state.ts @@ -0,0 +1,13 @@ +/** Estado de un formulario con validacion sincrona. */ +export interface FormState> { + /** Valores actuales del formulario */ + values: T + /** Errores por campo (undefined si el campo no tiene error) */ + errors: Partial> + /** Campos que el usuario ha modificado al menos una vez */ + touched: Partial> + /** Si todos los campos pasan la validacion */ + is_valid: boolean + /** Si el submit esta en progreso */ + is_submitting: boolean +} diff --git a/frontend/types/core/mutation_state.md b/frontend/types/core/mutation_state.md new file mode 100644 index 00000000..5e35940c --- /dev/null +++ b/frontend/types/core/mutation_state.md @@ -0,0 +1,43 @@ +--- +name: MutationState +lang: ts +domain: core +version: "1.0.0" +algebraic: product +definition: | + interface MutationState { + mutate: (variables: TVariables) => void + mutate_async: (variables: TVariables) => Promise + data: TData | null + error: Error | null + loading: boolean + reset: () => void + } +description: "Estado de una mutacion POST/PUT/DELETE. Expone mutate, mutate_async, data, error, loading y reset." +tags: [mutation, state, http, post, put, delete, hook] +uses_types: [] +file_path: "frontend/types/core/mutation_state.ts" +--- + +## Campos + +- `mutate` — ejecutar la mutacion de forma fire-and-forget (no lanza excepciones) +- `mutate_async` — ejecutar la mutacion y obtener una Promise (puede rechazar) +- `data` — resultado de la ultima mutacion exitosa, null si aun no hubo exito +- `error` — error de la ultima mutacion fallida, null si fue exitosa o no se ha ejecutado +- `loading` — true mientras la mutacion esta en vuelo +- `reset` — volver al estado inicial (idle, data null, error null) + +## Uso + +```typescript +import type { MutationState } from '@/types/core/mutation_state' + +function useCreateUser(): MutationState { + // ... +} +``` + +## Notas + +Retornado por `use_mutation_ts_core`. El patron `mutate` / `mutate_async` permite usar el hook tanto en event handlers (sin async/await) como en funciones que necesitan esperar el resultado. diff --git a/frontend/types/core/mutation_state.ts b/frontend/types/core/mutation_state.ts new file mode 100644 index 00000000..ec46136f --- /dev/null +++ b/frontend/types/core/mutation_state.ts @@ -0,0 +1,15 @@ +/** Estado de una mutacion POST/PUT/DELETE. */ +export interface MutationState { + /** Ejecutar la mutacion (fire-and-forget) */ + mutate: (variables: TVariables) => void + /** Ejecutar la mutacion y esperar el resultado */ + mutate_async: (variables: TVariables) => Promise + /** Datos del resultado de la ultima mutacion exitosa */ + data: TData | null + /** Error si la ultima mutacion fallo */ + error: Error | null + /** Si hay una mutacion en progreso */ + loading: boolean + /** Resetear al estado inicial (idle) */ + reset: () => void +}