/** * Contrato framework-agnostic para una vista asíncrona. * * Adapta los casos al dominio. La meta es representar sólo combinaciones * válidas y hacer exhaustivo el render. */ export type UiProblem = Readonly<{ code: string; message: string; retryable: boolean; cause?: 'network' | 'timeout' | 'server' | 'validation' | 'unknown'; }>; export type EmptyReason = 'first-use' | 'no-results' | 'completed'; export type BlockReason = 'unauthenticated' | 'forbidden' | 'rate-limited'; export type ViewState = | Readonly<{ kind: 'idle' }> | Readonly<{ kind: 'loading'; startedAt: number }> | Readonly<{ kind: 'ready'; data: T; freshness: 'fresh' | 'stale'; activity: 'idle' | 'refreshing'; updatedAt: number; }> | Readonly<{ kind: 'empty'; reason: EmptyReason }> | Readonly<{ kind: 'partial'; data: T; problem: UiProblem; updatedAt: number }> | Readonly<{ kind: 'offline'; cached?: T; cachedAt?: number; pendingChanges: number; }> | Readonly<{ kind: 'blocked'; reason: BlockReason; retryAt?: number }> | Readonly<{ kind: 'error'; problem: UiProblem }>; export const uiState = { idle: (): ViewState => ({ kind: 'idle' }), loading: (startedAt = Date.now()): ViewState => ({ kind: 'loading', startedAt, }), ready: (data: T, updatedAt = Date.now()): ViewState => ({ kind: 'ready', data, freshness: 'fresh', activity: 'idle', updatedAt, }), empty: (reason: EmptyReason): ViewState => ({ kind: 'empty', reason, }), partial: ( data: T, problem: UiProblem, updatedAt = Date.now(), ): ViewState => ({ kind: 'partial', data, problem, updatedAt, }), offline: ( options: { cached?: T; cachedAt?: number; pendingChanges?: number } = {}, ): ViewState => ({ kind: 'offline', cached: options.cached, cachedAt: options.cachedAt, pendingChanges: options.pendingChanges ?? 0, }), blocked: ( reason: BlockReason, retryAt?: number, ): ViewState => ({ kind: 'blocked', reason, retryAt, }), error: (problem: UiProblem): ViewState => ({ kind: 'error', problem, }), } as const; /** * Conserva los datos mientras una actualización ocurre. */ export function beginRefresh( state: ViewState, ): ViewState { if (state.kind !== 'ready') { return state; } return { ...state, activity: 'refreshing', }; } /** * Si un refresco falla, conserva los datos y marca su frescura. * La UI puede mostrar el problema en un mensaje independiente. */ export function markStale( state: ViewState, ): ViewState { if (state.kind !== 'ready') { return state; } return { ...state, freshness: 'stale', activity: 'idle', }; } export function hasUsableData( state: ViewState, ): state is Extract< ViewState, { kind: 'ready' | 'partial' } | { kind: 'offline'; cached: T } > { return ( state.kind === 'ready' || state.kind === 'partial' || (state.kind === 'offline' && state.cached !== undefined) ); } export function assertNever(value: never, label = 'UI state'): never { throw new Error(`${label} not handled: ${JSON.stringify(value)}`); } /** * Ejemplo de render exhaustivo sin acoplarse a un framework. */ export function stateLabel(state: ViewState): string { switch (state.kind) { case 'idle': return 'Listo para iniciar'; case 'loading': return 'Cargando'; case 'ready': return state.activity === 'refreshing' ? 'Actualizando' : state.freshness === 'stale' ? 'Datos desactualizados' : 'Datos actualizados'; case 'empty': return state.reason === 'no-results' ? 'Sin coincidencias' : state.reason === 'completed' ? 'Todo al día' : 'Aún no hay contenido'; case 'partial': return 'Resultado parcial'; case 'offline': return state.pendingChanges > 0 ? `${state.pendingChanges} cambios pendientes` : 'Sin conexión'; case 'blocked': return state.reason === 'unauthenticated' ? 'Sesión terminada' : state.reason === 'forbidden' ? 'Permiso insuficiente' : 'Límite alcanzado'; case 'error': return state.problem.message; default: return assertNever(state); } }