# Análisis de mejoras — Expenses Manager
Informe de solo lectura (no se ha modificado ni commiteado nada). Cubre UX/visual, técnico y mantenibilidad, ordenado por impacto dentro de cada bloque. No incluye bugs funcionales.
> **Nota**: durante el análisis han aparecido dos hallazgos que sí son bugs funcionales (fuera del alcance de este informe, para tu otra lista):
> - `expenses/templates/expenses/income_confirm_delete.html:10` muestra `{{ expense.date }}` en vez de `{{ income.date }}` (esa variable no existe en el contexto de esa vista, así que la fecha sale vacía). Parece un resto de copiar `expense_confirm_delete.html`.
> - `urls.py:36` apunta a `registration/password_help.html`, plantilla que no existe en `templates/registration/` (solo están `password_change_form.html` y `password_change_done.html`). Esa URL rompería con un `TemplateDoesNotExist`.
---
## 1. Visual / UX
### 1.1 Diseño no responsive: falta viewport y media queries (Alto impacto / Esfuerzo medio)
**Qué**: no hay ninguna etiqueta ` ` en `base.html` ni `base_auth.html`, y `base.css` (539 líneas) no tiene ni un solo `@media`. Las tablas (`expense_list.html`, `dashboard.html` tablas de comparación, `fuel/list.html` con 7 columnas) se renderizan sin envoltorio de scroll horizontal.
**Dónde**: `expenses/templates/expenses/base.html`, `base_auth.html`, `expenses/static/expenses/css/base.css`.
**Por qué importa**: en móvil el layout se renderiza a tamaño escritorio y se ve reducido/roto; las tablas anchas (fuel, comparación del dashboard) van a desbordar o comprimir columnas ilegibles.
**Esfuerzo**: medio — añadir el meta viewport es trivial, pero meter breakpoints reales y envolver tablas en `overflow-x:auto` toca varias plantillas.
### 1.2 Plantillas de confirmación de borrado divergentes (Alto impacto / Esfuerzo medio)
**Qué**: las 7 páginas de confirmar-borrado (`expense_confirm_delete.html`, `tag_confirm_delete.html`, `account_confirm_delete.html`, `income_confirm_delete.html`, `categories/confirm_delete.html`, `fuel/confirm_delete.html`, `goals/confirm_delete.html`) son casi idénticas pero cada una tiene su propia mezcla de etiqueta de botón ("Eliminar" vs "Sí, eliminar"), clase CSS (`btn` vs `btn danger`, `btn secondary` vs `btn` a secas) y nivel de encabezado (`h1` vs `h2`).
**Dónde**: las 7 plantillas listadas arriba.
**Por qué importa**: la misma acción destructiva se presenta visualmente distinta según la sección; y como ya demuestra el bug de `income_confirm_delete.html`, copiar-pegar estas plantillas sin unificarlas es justo lo que genera ese tipo de errores.
**Esfuerzo**: medio — unificarlas en un partial (`{% include %}`) con variables (`object_label`, `cancel_url`) o migrar a `DeleteView` con una plantilla genérica.
### 1.3 Colores hardcodeados y sin variables CSS (Alto impacto / Esfuerzo medio)
**Qué**: `base.css` no define ninguna custom property (`--variable`); los mismos conceptos semánticos (verde éxito, rojo error/peligro) están definidos con 3-4 tonos de hex ligeramente distintos en sitios distintos (`#166534`/`#1e7e34` para verde; `#b91c1c`/`#991b1b`/`#b71c1c` para rojo). Además, `dashboard.html` usa hex literales inline (`#d9534f`/`#5cb85c`, líneas 167, 191, 239) que no coinciden con los que ya existen en `base.css` para el mismo significado ("gasto sube/baja").
**Dónde**: `expenses/static/expenses/css/base.css`, `expenses/templates/expenses/dashboard.html` (16 `style="..."` inline, concentrados en este archivo).
**Por qué importa**: es la brecha de coherencia visual más clara del proyecto — mismo significado, colores distintos según la página; y dificulta cualquier cambio de paleta futuro (hay que buscar y reemplazar en vez de cambiar una variable).
**Esfuerzo**: medio — introducir `:root { --color-success: ...; --color-danger: ... }` y sustituir tanto en `base.css` como en los inline styles de `dashboard.html`.
### 1.4 Menú desplegable "Configuraciones" inaccesible por teclado (Medio impacto / Esfuerzo bajo)
**Qué**: el dropdown de navegación (`base.html:24-32`) se abre solo con `:hover` en CSS (`base.css:382-384`), sin `role`, `aria-expanded` ni gestor de teclado/foco.
**Dónde**: `expenses/templates/expenses/base.html:24-32`, `base.css:382-384`.
**Por qué importa**: un usuario que navega solo con teclado no puede llegar nunca a Categorías/Etiquetas/Objetivos — no es solo "buena práctica de accesibilidad", es una ruta completamente bloqueada.
**Esfuerzo**: bajo — añadir `aria-expanded`, un pequeño script de toggle por click/Enter, y `tabindex` en el toggle.
### 1.5 Selects de filtro sin `` (Medio impacto / Esfuerzo bajo)
**Qué**: los selects de año/mes/cuenta en `expense_list.html` (líneas 14, 25, 36) y `dashboard.html` (líneas 56, 66, 74) no tienen `` asociado, mientras que el filtro de "Categoría" en la misma página sí lo tiene.
**Dónde**: `expenses/templates/expenses/expense_list.html`, `dashboard.html`.
**Por qué importa**: lectores de pantalla no anuncian qué controla cada select; inconsistente incluso dentro de la misma página.
**Esfuerzo**: bajo — añadir `` a cada select.
### 1.6 Barras de progreso sin semántica ARIA (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: las barras de progreso de objetivos (`goals/list.html:36-39`, `home.html:75-78`, `dashboard.html:379-386`) son `div`s anidados sin `role="progressbar"` ni `aria-valuenow/min/max`.
**Dónde**: las 3 plantillas citadas.
**Por qué importa**: la información de progreso es puramente visual, invisible para tecnología asistiva.
**Esfuerzo**: bajo.
### 1.7 Clases CSS referenciadas en plantillas que no existen en `base.css` (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: `.auth-container`, `.pagination`/`.step-links`, `.form-errors`/`.form-field`, `.advanced-content`/`.tags-filters`, `.btn-secondary` (con guion, distinto de `.btn.secondary` que sí existe) se usan en plantillas pero no tienen regla CSS correspondiente.
**Dónde**: `base_auth.html:10`, `expense_list.html:48,60,75,151-152`, `expense_form.html:25,36,43`, `categories/confirm_delete.html:13,19,26`.
**Por qué importa**: probablemente la paginación y los mensajes de error de formulario se están viendo sin ningún estilo — vale la pena revisar si es intencional o un olvido.
**Esfuerzo**: bajo.
### 1.8 Patrones de UI distintos para datos estructuralmente iguales (Bajo-medio impacto / Esfuerzo bajo-medio)
**Qué**: `tag_list.html` renderiza como `/`, mientras `categories/list.html` usa `` para el mismo tipo de contenido ("nombre + acciones"). Además, crear una categoría se hace con un formulario embebido en la página de listado, mientras que cuentas/etiquetas/ingresos usan una página "nueva" separada.
**Dónde**: `expenses/templates/expenses/tag_list.html`, `categories/list.html`.
**Por qué importa**: dos patrones de UX distintos para la misma tarea ("añadir un elemento") sin razón aparente.
**Esfuerzo**: bajo-medio.
### 1.9 Jerarquía de encabezados inconsistente (Bajo impacto / Esfuerzo bajo)
**Qué**: la mayoría de páginas usa `` para el título, pero `categories/confirm_delete.html`, `categories/form.html` y `fuel/confirm_delete.html` usan `` sin ningún `` en la página; `dashboard.html` no tiene `` y empieza directamente en ``.
**Dónde**: plantillas citadas.
**Por qué importa**: rompe la jerarquía semántica que usan lectores de pantalla para navegar por secciones.
**Esfuerzo**: bajo.
---
## 2. Técnico / Código
### 2.1 N+1 en el dashboard por cuenta (Alto impacto / Esfuerzo medio)
**Qué**: `kpi_balance = sum(account.current_balance() for account in accounts)` (línea 317) ejecuta 2 queries por cuenta; más abajo, el bucle que construye los gráficos anuales por cuenta (líneas 445-470) llama a `monthly_balance()` (4 queries) y `current_balance()` otra vez (2 queries) por cada cuenta. Con 5 cuentas son ~40 queries solo para calcular saldos.
**Dónde**: `expenses/views.py`, función `dashboard` (líneas 288-514).
**Por qué importa**: es la página más visitada probablemente, y el coste crece linealmente con el número de cuentas del usuario.
**Esfuerzo**: medio — requiere consolidar en queries anotadas agrupadas por cuenta o cachear los resultados dentro de la misma petición.
### 2.2 `Goal.progress()` recalculado 3-5 veces por objetivo sin memoizar (Alto impacto / Esfuerzo bajo)
**Qué**: `percentage()`, `progress_state()`, `bar_width()` e `is_exceeded()` llaman todos a `progress()` (que hace una query de agregación), sin ningún cacheo. Las plantillas (`goals/list.html`, `home.html`, `dashboard.html`) llaman a varios de estos métodos por objetivo en el mismo render, así que con 10 objetivos son del orden de 50 queries que podrían ser 10.
**Dónde**: `expenses/models.py`, clase `Goal` (líneas 360-409); consumido en `templates/goals/list.html`, `templates/expenses/home.html`, `templates/expenses/dashboard.html`.
**Por qué importa**: es la relación coste/beneficio más favorable de todo el informe — un cambio pequeño y de bajo riesgo con impacto de rendimiento alto.
**Esfuerzo**: bajo — memoizar `progress()` con `functools.cached_property` o un atributo cacheado en la instancia.
### 2.3 Duplicación sistemática en `views.py`: ownership lookup, create/edit, delete (Alto impacto / Esfuerzo alto)
**Qué**: el patrón `get_object_or_404(Model, pk=pk, owner=request.user)` se repite 15 veces en 14 vistas; el bloque `if POST: form.is_valid()... else: form = Form(...)` se repite en 13 pares crear/editar; las 7 vistas de borrado comparten la misma forma (fetch → si POST borra+mensaje+redirect → si no, renderiza confirmación), con solo 3 excepciones reales (`account_delete` hace soft-delete, `category_delete` captura `ProtectedError`, `fuel_delete`/`fuel_edit` usan `_redirect_back` y lógica de `next`).
**Dónde**: `expenses/views.py` (todas las vistas de tags/cuentas/ingresos/categorías/objetivos/fuel).
**Por qué importa**: mucho código para mantener sincronizado; cada vista nueva copia el mismo patrón a mano, con riesgo de que alguna se olvide de filtrar por `owner` (justo lo que CLAUDE.md pide evitar).
**Esfuerzo**: alto — migrar a `ListView`/`CreateView`/`UpdateView`/`DeleteView` con un mixin de scoping por owner reduciría el archivo en unas 300-400 líneas, pero implica tocar 20+ vistas, sus URLs y validar que las plantillas sigan recibiendo el mismo contexto.
### 2.4 Patrón `user` duplicado 5 veces en `forms.py`, con inconsistencia `pop` (Medio impacto / Esfuerzo bajo)
**Qué**: `ExpenseForm`, `IncomeForm`, `FuelEntryForm`, `CategoryForm` y `GoalForm` repiten el mismo `__init__` que extrae `user` de `kwargs` y filtra querysets, sin una clase base común. Además, `ExpenseForm` usa `kwargs.pop("user", None)` (con default) mientras las otras 4 usan `kwargs.pop("user")` (lanza `KeyError` si alguien olvida pasar `user=`).
**Dónde**: `expenses/forms.py` (líneas 7-19, 56-62, 76-86, 94-106, 141-150).
**Por qué importa**: 5 copias del mismo código, y una inconsistencia que puede provocar un error confuso (`KeyError` en vez de un mensaje claro) si se instancia un formulario sin pasar `user`.
**Esfuerzo**: bajo — una clase base `OwnerScopedForm` con un método hook (`scope_querysets(user)`) resuelve ambos problemas de una vez.
### 2.5 `select_related`/`prefetch_related` casi ausentes (Medio impacto / Esfuerzo bajo-medio)
**Qué**: solo se usan en 2 de ~37 vistas (`home` y `fuel_list`, y este último solo para `expense`, no evita el N+1 de `km_since_previous()` que sigue haciendo una query por cada repostaje al iterar en `fuel_list`, líneas 785-789).
**Dónde**: `expenses/views.py`.
**Por qué importa**: cualquier vista que muestre listas con relaciones (categoría, cuenta, tags) es candidata a N+1 silencioso.
**Esfuerzo**: bajo-medio — revisar listado por listado y añadir `select_related`/`prefetch_related` donde corresponda.
### 2.6 `category_list` mezcla listado y creación, inconsistente con el resto de modelos (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: es la única vista donde crear un objeto ocurre dentro de la misma función/URL que el listado (no existe `category_create`), mientras que cuentas/etiquetas/ingresos/objetivos tienen una vista y URL de creación separada.
**Dónde**: `expenses/views.py` (líneas 871-893), `expenses/urls.py`.
**Por qué importa**: inconsistencia estructural que puede confundir al añadir una nueva funcionalidad similar (¿sigo el patrón de categorías o el de todo lo demás?).
**Esfuerzo**: bajo — separar en `category_create` + URL propia, o documentar que es intencional.
### 2.7 Query de `Tag` duplicada en `expense_list` (Bajo impacto / Esfuerzo bajo)
**Qué**: `Tag.objects.filter(owner=request.user)` se ejecuta una vez para construir `tags_with_state` (línea 168) y otra vez para el contexto (línea 206), en vez de reutilizar la misma lista.
**Dónde**: `expenses/views.py`, función `expense_list`.
**Por qué importa**: query redundante, fácil de eliminar.
**Esfuerzo**: bajo.
### 2.8 Organización de carpetas de plantillas inconsistente (Bajo impacto / Esfuerzo bajo)
**Qué**: `categories/`, `fuel/` y `goals/` tienen su propia carpeta de plantillas, pero cuentas/etiquetas/ingresos/gastos están todos mezclados en `templates/expenses/` junto con `dashboard.html`/`home.html`.
**Dónde**: `expenses/templates/`.
**Por qué importa**: no hay un criterio claro; dificulta encontrar una plantilla si no se conoce ya la excepción histórica.
**Esfuerzo**: bajo — mover plantillas a carpetas por función, actualizando las rutas en las vistas.
### 2.9 Import muerto en `models.py` (Bajo impacto / Esfuerzo muy bajo)
**Qué**: `from django.db.models.fields import related` (línea 6) no se usa en ningún sitio del archivo.
**Dónde**: `expenses/models.py:6`.
**Por qué importa**: limpieza trivial, cero riesgo.
**Esfuerzo**: muy bajo.
---
## 3. Mantenibilidad
### 3.1 CLAUDE.md desactualizado respecto al código actual (Alto impacto / Esfuerzo bajo)
**Qué**: el documento afirma que `Goal.progress()` agrega "across all time, not just the current period" — ya no es cierto para objetivos de tipo `budget` con periodo mensual/anual (el modelo actual sí reinicia por periodo). También menciona un diccionario `MONTHS` en `views.py` que no existe (solo hay una lista inline `["Ene", "Feb", ...]` dentro de `dashboard`). Y describe `DATABASE_URL` como variable de entorno cuando `settings.py` en realidad lee `DB_ENGINE`/`DB_NAME`/`DB_USER`/etc. por separado.
**Dónde**: `expenses_manager/CLAUDE.md` (raíz del repo).
**Por qué importa**: es el primer documento que se lee (por ti dentro de 6 meses o por cualquier asistente/colaborador); si describe un comportamiento que ya cambió, genera confusión activa en vez de ayudar.
**Esfuerzo**: bajo — actualizar esos 3 puntos concretos.
### 3.2 README.md no corresponde al proyecto real (Alto impacto / Esfuerzo bajo-medio)
**Qué**: el README (91 líneas) instruye a ejecutar `python app.py` y visitar `localhost:5000` (estilo Flask), y documenta un `Dockerfile`/`docker build`/`docker run` que no existen en el repo. También menciona una variable `DATABASE_URL=sqlite:///expenses.db` que el proyecto no usa.
**Dónde**: `README.md` (raíz del repo).
**Por qué importa**: es la primera impresión para cualquier colaborador nuevo, y ahora mismo activamente engaña en vez de guiar — probablemente boilerplate nunca adaptado al proyecto real.
**Esfuerzo**: bajo-medio — reescribir con los pasos reales (`manage.py runserver`, variables de entorno correctas, sin Docker si no existe).
### 3.3 `views.py` con ~1036 líneas / 37 funciones cubriendo 8 áreas (Alto impacto / Esfuerzo medio-alto)
**Qué**: todas las vistas de gastos, dashboard, tags, cuentas, ingresos, fuel, categorías y objetivos viven en un único archivo, sin más separación que líneas en blanco.
**Dónde**: `expenses/views.py`.
**Por qué importa**: a partir de ~600 líneas o 15-20 vistas ya cuesta encontrar algo sin buscar por texto; a 1036 líneas es claramente el punto de dolor del proyecto. Los puntos de corte por funcionalidad ya son evidentes en el propio código.
**Esfuerzo**: medio-alto — dividir en un paquete `views/` (`dashboard.py`, `expenses.py`, `goals.py`, `fuel.py`, `accounts.py`, `income.py`, `tags.py`, `categories.py`, `_utils.py`), actualizando `urls.py` en consecuencia. Se puede hacer de forma incremental, un módulo a la vez, sin romper nada.
### 3.4 Función `dashboard` sobrecargada de responsabilidades (Alto impacto / Esfuerzo medio-alto)
**Qué**: 226 líneas que mezclan 5 responsabilidades (resolución de periodo, selección de cuenta/KPI, agregación por categoría/día/mes, modo comparación, gráficos anuales por cuenta), con un diccionario de contexto de 35 claves construido inline, dos variables de nombre parecido (`expenses` vs `expenses_filtered`) que conviven en todo el ámbito de la función, y un `except Exception` silencioso (líneas 447-455) que sustituye cualquier error por datos vacíos, ocultando posibles problemas reales de datos.
**Dónde**: `expenses/views.py`, función `dashboard` (líneas 288-514).
**Por qué importa**: es la función más difícil de seguir del proyecto; modificar cualquier KPI implica leer las 226 líneas para no chocar con otra parte.
**Esfuerzo**: medio-alto — extraer en funciones privadas nombradas (`_resolve_period`, `_build_comparison`, `_build_account_charts`) y agrupar el contexto en sub-diccionarios (`kpis`, `comparison`, `charts`).
### 3.5 `Goal.progress()`/`_period_start()` conceptualmente sobrecargado (Medio impacto / Esfuerzo bajo)
**Qué**: en 30 líneas se resuelven 3 comportamientos distintos según `kind` (ahorro = saldo de cuenta; pago = gasto acumulado desde `start_date` sin reinicio; presupuesto = gasto del periodo actual), con "caídas" silenciosas (si `kind` no es `budget`, `_period_start()` simplemente devuelve `self.start_date`, que puede ser `None`) y sin docstring que explique el reparto de responsabilidades.
**Dónde**: `expenses/models.py`, líneas 349-409.
**Por qué importa**: fácil de malinterpretar sin cruzar mentalmente `KIND_CHOICES`/`PERIOD_CHOICES` y la validación de `GoalForm.clean()`.
**Esfuerzo**: bajo — añadir un docstring explicando los 3 casos, o separar en métodos privados por `kind` (`_progress_saving()`, `_progress_payment()`, `_progress_budget()`).
### 3.6 `gunicorn` declarado en `requirements.txt` pero no instalado en el venv (Medio impacto / Esfuerzo bajo)
**Qué**: mismo patrón que se encontró antes con `whitenoise` (ya resuelto). `gunicorn==25.1.0` está en `requirements.txt` pero no existe en `venv/Lib/site-packages`, y no hay `Procfile` ni `Dockerfile` que muestre dónde se usaría realmente. `pytest-cov>=4.0` es además la única dependencia sin pin exacto en un archivo que fija versión exacta en todo lo demás.
**Dónde**: `requirements.txt` (raíz del proyecto Django).
**Por qué importa**: mismo tipo de deriva silenciosa que causó el fallo de `whitenoise`; vale la pena revisar todas las dependencias de una vez.
**Esfuerzo**: bajo — `pip install -r requirements.txt` y confirmar cuáles hacen falta realmente en dev vs producción.
### 3.7 Comentarios en dos idiomas sin criterio (Bajo-medio impacto / Esfuerzo bajo)
**Qué**: los comentarios de `models.py` están en español (`# Para pago y presupuesto`) y los de `views.py` en inglés (`# Time presets`, `# Comparison`), sin ninguna razón aparente para la diferencia entre archivos.
**Dónde**: `expenses/models.py`, `expenses/views.py`.
**Por qué importa**: obliga a cambiar de idioma mentalmente al leer ambos archivos seguidos; sería bueno fijar una convención (aunque sea "comentarios en español, identificadores en inglés", que es lo que ya se hace en el resto del proyecto).
**Esfuerzo**: bajo — no urge reescribir todo, pero conviene aplicar la convención elegida a partir de ahora.
### 3.8 No existe `.env.example` (Medio impacto / Esfuerzo bajo)
**Qué**: solo existe el `.env` real; un colaborador nuevo tiene que deducir las variables necesarias (`SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS`, `CSRF_TRUSTED_ORIGINS`, `DB_ENGINE`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`) leyendo `settings.py` directamente, ya que ni el README ni CLAUDE.md las listan completas.
**Dónde**: raíz del proyecto Django.
**Por qué importa**: fricción de onboarding evitable con un archivo de 10 líneas.
**Esfuerzo**: bajo.
### 3.9 Restos menores de limpieza (Bajo impacto / Esfuerzo muy bajo)
**Qué**: comentario de linter obsoleto `# sourcery skip: assign-if-exp, merge-else-if-into-elif` en `views.py:240`; mezcla de comillas simples/dobles en `GoalForm.Meta.fields` (`forms.py:125-134`); `verbose_name_plural = "categories"` en inglés dentro de un modelo con todo lo demás en español (afecta solo al admin de Django).
**Dónde**: `expenses/views.py:240`, `expenses/forms.py:125-134`, `expenses/models.py:31`.
**Por qué importa**: ruido cosmético, cero riesgo, fácil de arrastrar sin darse cuenta a nuevo código.
**Esfuerzo**: muy bajo.