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 +} diff --git a/registry.db b/registry.db index 8b22c77e..08ec0806 100644 Binary files a/registry.db and b/registry.db differ