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>
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:10muestra{{ 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 copiarexpense_confirm_delete.html.urls.py:36apunta aregistration/password_help.html, plantilla que no existe entemplates/registration/(solo estánpassword_change_form.htmlypassword_change_done.html). Esa URL rompería con unTemplateDoesNotExist.
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.
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.