Pontificia Universidad Católica del Ecuador · 2026
Bolsillo
Tu dinero, ordenado sin esfuerzo.
RDA 3 — Codificación de Diseño UI de Figma
Marco del problema
52,5%
No registra los gastos cotidianos
68%
Rechaza la terminología financiera
41,8%
Abandona su método por resultar tedioso
El diseño tenía un objetivo medible: reducir el costo de registrar un movimiento.
Usuario objetivo
26 años · Quito · USD 800 mensuales
Método actual: registro manual en cuaderno.
«Sé que gasto más de lo que debería, pero al cierre del mes no puedo identificar en qué se destinó el dinero.»
Restricción de diseño derivada de la investigación
Si no comprende la interfaz en dos o tres minutos, abandona el sitio.
Método de validación
18/40
Portada
17/40
Panel de control
0
Hallazgos del análisis estático de código sobre el mismo proyecto
Un segundo detector sobre el DOM sí encontró 19 anti-patrones, pero ninguno de los defectos de uso. Son cosas distintas.
Heurística 6 · Reconocimiento antes que recuerdo
src/piezas/FilaMovimiento.tsx// Antes: la acción destructiva solo se distinguía al pasar el puntero "text-texto-3 hover:text-gasto" // Ahora: color y fondo propios en reposo, sin interacción previa "text-gasto bg-gasto-sutil"
Verificación
Una prueba automatizada comprueba la distinción sin simular puntero.
Consecuencia
En un teléfono, eliminar y editar eran visualmente idénticos hasta el momento de pulsar.
El 100 % de las sesiones móviles quedaba sin esa señal.
Heurística 5 · Prevención de errores
src/utils/moneda.ts// El backend responde "120.50"; el formato local usa "1.248,50". // Si hay coma, la coma es decimal y los puntos son miles. const normalizado = crudo.includes(',') ? crudo.replace(/\./g, '').replace(',', '.') : crudo // ← sin esta rama, "120.50" se convertía en 12050
Impacto
Un consumo de USD 10,00 quedaba registrado como USD 1.000,00.
Control implantado
Caso cubierto en la suite: aNumero('12.40') === 12.4
Del archivo de Figma al código
src/index.css@theme { /* Escala extraída de la fotografía de portada */ --color-lavanda-900: #553d78; --color-borde-fuerte: #cbd5e1; /* 3:1, WCAG 1.4.11 */ /* 20px y no 14: `grande` y `extra` valían lo mismo y el sistema prometía una distinción inexistente */ --radius-extra: 20px; }
Alcance
65 tokens de color, más tipografía, radios y curvas, declarados en un solo archivo.
Nomenclatura en español, igual que en el archivo de diseño.
Corregido en la auditoría
Faltaban 13 tokens semánticos. Sin ellos era posible escribir una clase inexistente sin que nada fallara.
Heurística 8 · Diseño estético y minimalista
Versión anterior
Cinco elementos de 500 px con blur(190px).
Estética genérica de aplicación de bienestar y una de las operaciones más costosas para la GPU.
Versión actual
Guilloché sobre retícula de libro contable, trazado en SVG con elipses concéntricas.
Es el grabado de los títulos valores: existe porque resulta difícil de falsificar.
src/layout/landing/Fondo.tsx// El degradado se detiene donde el texto secundario aún cumple AA. // Medido en el navegador: 5,05:1 en el punto más oscuro. 'linear-gradient(175deg, #f7f9fb 0%, … 80%, #c9d4e2 100%)'
Diseño responsivo · Los tres tipos de pantalla
1440 · escritorio
820 · tableta
390 · celular
En móvil se retira la fotografía, entran registros reales y aparece una navegación inferior inexistente en escritorio.
Diseño responsivo · Implementación
src/pantalla/useTipoPantalla.ts// Espejo de los breakpoints de Tailwind (md = 48rem, lg = 64rem). // La desincronización aquí no se paga como en una media query suelta: // esto no decide cómo se ve algo, decide QUÉ se renderiza. const CONSULTAS = { celular: '(max-width: 47.999rem)', tableta: '(min-width: 48rem) and (max-width: 63.999rem)', }
En el CSS compilado
Consultas de medios reales en 40, 48, 64 y 80 rem, más hover y prefers-reduced-motion.
Sin parpadeo inicial
La lectura ocurre en el primer render con useSyncExternalStore: nunca hay un fotograma con la versión equivocada.
Diseño responsivo · Decisión de arquitectura
src/app/App.tsxif (pantalla === 'celular') { return ( <Suspense fallback={null}> <AppCelular /> // pantallas propias, navegación inferior </Suspense> ) }
La tableta conserva la versión de escritorio, que a ese ancho ya resuelve. La bifurcación se aplica solo donde cambia la expectativa de uso.
Accesibilidad implementada
src/ui/Boton.tsx// focus-visible y no focus, para que el anillo // no quede fijado tras un clic con el ratón 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tinta-900'
src/movimiento/Revelar.tsxconst menosMovimiento = useReducedMotion() if (menosMovimiento) { // ya visible: la visibilidad nunca queda // condicionada a que una animación corra return <div>{children}</div> }
Además: 5,05:1 de contraste sobre el fondo, 3:1 en controles y áreas táctiles de 44 px en 14 componentes.
Trazabilidad con la investigación
| Definido en la investigación | Implementado | Justificación |
|---|---|---|
| Registro e inicio de sesión en páginas independientes | Pantalla única con panel deslizante | Heurística 3: corregir la elección no debe exigir abandonar la pantalla |
| Persistencia en el navegador | API propia sobre PostgreSQL | El almacenamiento local no ofrece garantías sobre información financiera |
| Alerta de saldo con umbral configurable | Clasificación automática calculada en el servidor | Requerir el umbral traslada al usuario la decisión que no está en condiciones de tomar |
Estado actual · Hallazgos no resueltos
Cada punto está localizado en un archivo concreto y medido. No es una lista de intenciones.
Conclusión
Gracias.
A0
Qué se midió, con qué escala y por qué la puntuación es baja.
Concepto · Anti-patrón
El código funciona: no es un error de sintaxis. Es una decisión tan repetida que ya está catalogada, con su consecuencia documentada. Por eso una herramienta puede detectarlos.
| Anti-patrón detectado | Dónde | Por qué está catalogado |
|---|---|---|
dark-glow | Boton.tsx:19 | Sombra de color saturado bajo un botón: simula profundidad que la interfaz no tiene |
line-length | SeccionQueEs.tsx:60 | 87 caracteres por línea. Por encima de ~75 el ojo pierde el renglón al volver |
undersized-ui-text | GraficoMesAMes ×10 | Texto de 10 px en las etiquetas del gráfico |
De los 19 detectados, uno resultó falso positivo verificado y otro quedó suprimido: la tipografía «repetida» proviene del UI Kit del proyecto.
Método · Las dos mediciones
| Detección de anti-patrones | Evaluación heurística | |
|---|---|---|
| Qué evalúa | La forma del resultado renderizado | Si la persona puede operar el sistema |
| Cómo | Reglas automáticas sobre el DOM y el código | Recorrido de tareas contra los 10 principios de Nielsen |
| Resultado | 0 en el código · 19 en el DOM | 18/40 en portada · 17/40 en panel |
| Qué no vio | El hover inexistente en táctil · el monto multiplicado por cien | Longitudes de línea, tamaños de fuente |
Ninguna regla mecánica puede detectar que una señal desaparece en un dispositivo sin puntero: eso exige recorrer la tarea.
Escala · Cómo se obtiene un 18 sobre 40
| # | Heurística | Puntos |
|---|---|---|
| 1 | Visibilidad del estado | 2 |
| 2 | Mundo real | 1 |
| 3 | Control y libertad | 3 |
| 4 | Consistencia | 3 |
| 5 | Prevención de errores | 1 |
| 6 | Reconocer antes que recordar | 1 |
| 7 | Flexibilidad | 2 |
| 8 | Estético y minimalista | 2 |
| 9 | Recuperación de errores | 2 |
| 10 | Ayuda y documentación | 1 |
La escala, por heurística
4 — cumple sin reservas
3 — cumple con un defecto menor
2 — cumple a medias
1 — incumple con impacto en la tarea
0 — no aplica o falla por completo
Los cuatro «1» y su causa
El logotipo ilegible, «Nosotros» apuntando a otra sección, una promesa que el registro incumplía y la explicación del producto 700 px más abajo.
Interpretación · Por qué la puntuación es baja
Qué se midió
La portada antes de corregirla, el 26 de agosto.
El criterio es exigente a propósito: un 2 significa «cumple a medias». Con una escala benévola, la evaluación no habría producido ninguna corrección.
Qué se hizo con el resultado
Las láminas 5 a 12 de esta exposición son, una por una, correcciones derivadas de esos hallazgos.
La portada se rehízo a dos columnas, los botones se diferenciaron y las acciones destructivas pasaron a distinguirse en reposo.
La misma evaluación registró tres fortalezas: el fondo como activo de marca, la disciplina de tokens y una rejilla que crece con el texto en vez de recortarlo.
Responsive · El mecanismo completo
src/pantalla/useTipoPantalla.ts// Espejo de los breakpoints de Tailwind (md = 48rem, lg = 64rem). const CONSULTAS = { celular: '(max-width: 47.999rem)', tableta: '(min-width: 48rem) and (max-width: 63.999rem)', } // Se llama en el PRIMER render: no hay un fotograma con la versión // equivocada antes de que un efecto lo corrija. function leer(): TipoPantalla { if (window.matchMedia(CONSULTAS.celular).matches) return 'celular' if (window.matchMedia(CONSULTAS.tableta).matches) return 'tableta' return 'escritorio' } // Reacciona al rotar el dispositivo y al redimensionar la ventana. export function useTipoPantalla(): TipoPantalla { return useSyncExternalStore(suscribir, leer, () => 'escritorio') }
Y en App.tsx, el valor decide: por debajo de 768 px se monta <AppCelular />, un árbol de componentes distinto.
Responsive · Por qué no basta con una media query
Qué hace cada mecanismo
CSS: el mismo elemento se ve distinto.
JavaScript: se monta otro elemento. En móvil, la barra de navegación inferior no existe en el DOM de escritorio; no está oculta.
Por eso los valores se declaran una sola vez
Si el CSS dijera 768 px y el JavaScript 800, entre esos dos anchos se aplicarían los estilos de móvil al componente de escritorio.
No se vería como un estilo extraño: se vería como el componente equivocado.
El coste de mantener el espejo es un comentario en cada archivo. El coste de no mantenerlo es un fallo que solo aparece en una franja de 32 píxeles.
Resultado · Estado de los hallazgos
| Hallazgo de la evaluación | Heur. | Estado hoy |
|---|---|---|
| La portada no mostraba el producto | 8 | Corregido — dos columnas con movimientos reales |
| La llamada a la acción se confundía con «Ingresar» | 7 | Corregido — variante propia y sombra en tinta |
| Faltaba escala tipográfica de despliegue | 4 | Corregido — tokens de 40 y 64 px |
| «Estás a un clic» prometía lo que el registro incumplía | 5 | Corregido — texto eliminado |
| Acciones destructivas indistinguibles sin puntero | 6 | Corregido — y con prueba automatizada |
| El logotipo se lee «olsillo» | 6 | Abierto |
| «Nosotros» conduce a «Cómo funciona» | 2 | Abierto |
Una reevaluación daría una puntuación más alta. No la presento como cifra porque no he vuelto a ejecutar el instrumento completo: lo que presento es el estado de cada hallazgo.
A1
Material de respaldo. No forma parte de la exposición.
Problema
try: servicio.crear_cuenta(...) except NombreRepetido: raise HTTPException(status_code=409, detail="nombre repetido") except NoEncontrado: raise HTTPException(status_code=404, detail="no existe") # ...y lo mismo en las otras 56 rutas, cada una con su redacción
El mismo error con distinto formato según el punto de entrada. El cliente no puede confiar en la respuesta.
Herencia
class DomainError(Exception): code = "INTERNAL_ERROR" status = 500 class NotFound(DomainError): code = "NOT_FOUND" status = 404
14 clases de error heredan de una. Cada una porta su propio código y su propio estado.
Polimorfismo
async def domain_handler(request, exc: DomainError): return JSONResponse( status_code=exc.status, # lo aporta la excepción content=_cuerpo(exc.code, str(exc)), )
El router no captura: lanza y delega. Incorporar un error nuevo no exige modificar ninguna ruta.
Encapsulamiento e inyección de dependencias
class AccountService: def __init__(self, repo: AccountRepository, movimientos: TransactionRepository): self._repo = repo # recibido, nunca construido aquí def create_account(self, user_id, datos): if self._repo.get_by_name(user_id, datos.name): raise AccountNameTaken("ya tienes una cuenta con ese nombre")
Sin una línea de SQL. Y como el repositorio se inyecta, la regla se verifica en pruebas sin base de datos.
Arquitectura y estado pendiente
| routers/ | Reciben HTTP, verifican permisos y delegan. No consultan la base |
| services/ | Reglas de negocio. Aquí se decide |
| repositories/ | Consultas |
Pendiente identificado
Falta pool_pre_ping=True en el motor: tras un corte de conexiones inactivas, el primer acceso devuelve 500 y el segundo funciona. Reproducido con psycopg.errors.AdminShutdown.
A2
Material de respaldo. No forma parte de la exposición.
Modelo
Con columna de saldo
Cada movimiento obliga a actualizarla. Ante un fallo parcial coexisten dos verdades sin criterio para decidir cuál es válida.
Con vista
account_balances calcula el saldo desde los movimientos vigentes en cada consulta. No hay estado que sincronizar.
Restricciones · La más relevante
CONSTRAINT ck_tx_transfer_shape CHECK ( type <> 'transfer' OR ( counter_account_id IS NOT NULL -- tiene destino AND category_id IS NULL -- no es un gasto AND account_id <> counter_account_id ) );
Sin esta restricción, la operación descuenta de una cuenta sin acreditar en ninguna.
Restricciones · Límite del CHECK
La regla «un egreso no puede clasificarse con una categoría de ingreso» exige consultar otra tabla, y eso un CHECK no puede hacerlo.
Solución: trigger
Antes de insertar, recupera el tipo de la categoría y lo contrasta con el tipo del movimiento. Si no coinciden, rechaza la operación.
Un segundo trigger limita el árbol de categorías a dos niveles e impone la herencia del tipo.
Restricciones · Unicidad con nulos
Un UNIQUE convencional sobre columnas que admiten nulos permite duplicados: dos categorías del sistema homónimas no colisionan porque sus identificadores de usuario son nulos.
CREATE UNIQUE INDEX uq_categories_root ON categories ( COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid), lower(name), kind ) WHERE parent_id IS NULL;
Modelo relacional
| Caso | Solución adoptada | Fundamento |
|---|---|---|
| Una transferencia afecta a dos cuentas | Dos claves foráneas a la misma tabla en la misma fila | Un registro representa un hecho. Con tabla intermedia serían dos filas a conciliar |
| Jerarquía de categorías | Auto-referencia mediante parent_id |
El árbol tiene dos niveles por trigger; un nivel de expansión cubre todos los casos |
No hay ninguna relación de muchos a muchos en el modelo.
Índices y tipos
CREATE INDEX ix_tx_user_date ON transactions (user_id, occurred_at DESC) WHERE deleted_at IS NULL;
Compuesto y parcial
Entrega las filas ya ordenadas y excluye del índice las eliminadas lógicamente.
Tipos
Importes en numeric(14,2), nunca coma flotante: el redondeo binario produce descuadres de centavos.
Búsqueda de texto
Con comodín inicial, un índice B-tree es inservible, y la acentuación impide la coincidencia literal.
| Buscando «maria» en «maría guerrero maria.guerrero@ejemplo.com» | Similitud |
|---|---|
similarity — contra la cadena completa | 0,222 · no supera |
word_similarity — contra la palabra más próxima | 1,000 · supera |
Umbral fijado en 0,6: admite erratas en palabras largas y descarta coincidencias espurias en las cortas.