expenses_manager/ANALISIS_code.md
JKuijperM 4eb120927b Anade CLAUDE.md y el informe de analisis del proyecto
CLAUDE.md documenta la estructura de directorios (que tiene tres niveles
llamados expenses_manager y despista), los comandos habituales, las
variables de entorno y las decisiones de auth, para no tener que deducirlo
de settings.py cada vez.

ANALISIS_code.md es un informe de solo lectura sobre UX, deuda tecnica y
mantenibilidad, ordenado por impacto. Sirve de lista de trabajo pendiente;
varios de sus puntos ya estan resueltos en los commits anteriores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:22:08 +02:00

21 KiB

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 <meta name="viewport"> 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 <label> (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 <label> 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 <label> 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 divs 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 <ul>/<li>, mientras categories/list.html usa <table> 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 <h1> para el título, pero categories/confirm_delete.html, categories/form.html y fuel/confirm_delete.html usan <h2> sin ningún <h1> en la página; dashboard.html no tiene <h1> y empieza directamente en <h2>. 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.

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.