Plan de trabajo priorizado #29

Open
opened 2026-07-28 14:55:59 +00:00 by jkuijperm · 0 comments
Owner

Plan de trabajo priorizado — expenses_manager

Junta las dos fuentes: las mejoras de producto (recorrido vista por vista,
archivos 0110) y el análisis técnico/visual de Code (ANALISIS_code.md).
Organizado en tandas, no por áreas sueltas: cada tanda agrupa cosas que se tocan
juntas, para no abrir la misma vista dos veces.

El orden es una recomendación, no una obligación. La decisión de producto es del
propietario; lo técnico va anotado con el criterio de por qué sí o por qué no.


Tanda 0 — Arreglos rápidos y documentación (bajo esfuerzo, hacer ya) COMPLETADA

Cosas de bajo riesgo y alta relación coste/beneficio. Ninguna toca la lógica de
negocio de forma peligrosa.

  • Bug: income_confirm_delete.html mostraba {{ expense.date }} en vez de
    {{ income.date }} (fecha vacía en la confirmación). — ya corregido
  • Memoizar Goal.progress() con cached_property (Code 2.2). Hoy se
    recalcula 3-5 veces por objetivo en cada render de home/dashboard/objetivos.
    Cambio pequeño, riesgo casi nulo, quita muchas queries. El mejor coste/beneficio
    de todo el informe.
    (Hecho: progress pasó a cached_property; llamadas internas
    en percentage()/is_exceeded() ajustadas a self.progress sin paréntesis; tests
    actualizados y en verde. Sin migración.)
  • Actualizar CLAUDE.md (Code 3.1): además de los 3 puntos de Code, se puso al
    día todo lo cambiado estas sesiones (Goal con kind/periodos/cached_property,
    soft-delete de cuentas, CRUD de categorías y prevención de ciclos, fuel_delete,
    /finance-accounts/, settings unificado por env, convención de nombres de plantillas,
    workflow dev/main).
  • Reescribir README.md (Code 3.2): reescrito de cero, fiel al proyecto real
    (conda + SQLite en local, tabla de variables de entorno, Dockerfile documentado y
    docker-compose.yml de ejemplo sin secretos — el real vive fuera del repo en el NAS).
  • Crear .env.example (Code 3.8): añadido al repo con todas las variables como
    plantilla (placeholders inútiles, sin secretos).
  • Limpieza trivial (Code 2.9, 3.9): eliminado el import muerto de models.py,
    el comentario obsoleto de sourcery en views.py y unificadas las comillas en
    GoalForm.Meta.fields.

Tanda 1 — Responsive / usarlo en el móvil (alto valor personal) COMPLETADA

Motivo de prioridad: el objetivo declarado es "poder usarlo más desde el móvil", y
hoy no se puede porque no está adaptado. Esto es lo que desbloquea ese uso.

  • Meta viewport en base.html y base_auth.html (Code 1.1) — una línea,
    cambia radicalmente cómo se ve en móvil. (Hecho; de paso se corrigió el <!DOCTYPE>
    incompleto de base.html y se añadió lang="es".)
  • Envolver tablas anchas en overflow-x:auto (gastos, comparación del
    dashboard, fuel con 7 columnas) para que no desborden. (Hecho: las 8 tablas del
    proyecto envueltas en .table-wrap con white-space:nowrap, se ensanchan según
    contenido sin anchos fijos arbitrarios.)
  • Media queries básicas en base.css para los breakpoints principales.
    (Hecho: breakpoints 768px y 480px; grids —kpi/dashboard/goals/settings— a 1 columna
    en móvil.)

Extras que salieron al probar en móvil y se resolvieron en la misma tanda:

  • Gráficos de Chart.js responsive: el bug era CSS (una .chart-box de altura
    fija envolvía título + canvas y desbordaba). Corregido en dashboard.html y
    home.html (título fuera de la caja, canvas en contenedor de altura controlada).
  • Menú hamburguesa (primer JS del proyecto, expenses/static/expenses/js/nav.js):
    botón ☰ bajo 768px que despliega la nav; vanilla JS solo para el toggle, con
    aria-expanded. En escritorio no cambia nada.
  • Dropdown "Configuraciones" plegable por click (amplía nav.js): pasó de
    :hover a click en escritorio y móvil, con cierre al pulsar fuera. El toggle es un
    <button> real con aria-haspopup/aria-controls y foco por teclado — esto
    adelanta el punto 1.4 de la Tanda 3
    (dropdown accesible por teclado).
  • "Quitar filtros temporales" movido a su propia línea bajo los presets del
    dashboard (ya no se parte/solapa en móvil).

Tanda 2 — Coherencia visual (base para que todo se vea igual) COMPLETADA

Lo que hace que la app deje de verse "un poco fea/inconsistente" (queja explícita).

  • Variables CSS de color (Code 1.3): definir :root { --color-success… --color-danger… } y sustituir los 3-4 tonos distintos de verde/rojo repartidos
    por base.css y los style="..." inline de dashboard.html. Base para todo lo
    visual posterior. (Hecho: paleta Forest Teal aplicada con variables semánticas
    (--color-bg/--color-surface/--color-text/--color-primary...), pensadas para
    soportar dos modos. Hex dispersos e inline sustituidos por variables.)
  • Botones unificados (parte de 1.2/1.7): clases .btn/.btn-primary/
    .btn-secondary/.btn-danger coherentes; los "Volver"/"Cancelar" que eran enlaces
    morados pasan a botón real con misma altura y separación; aplicado en formularios y
    confirmaciones. (Resuelve lo que se detectó al probar la Tanda 1 en móvil.)
  • Modo oscuro (extra añadido en esta tanda): segundo juego de valores bajo
    [data-theme="dark"], sigue la preferencia del sistema por defecto, botón sol/luna
    en la topbar para forzar, elección recordada en localStorage, con script inline
    anti-flash en el <head>. Login incluido.
  • Botones de acción en fila (.form-actions): los pares Guardar/Crear + Volver
    (y Eliminar + Cancelar en las confirmaciones) quedan en horizontal, acción principal
    a la izquierda, con separación y wrap responsive. Un solo cambio en la parcial
    _confirm_delete.html cubrió las 7 confirmaciones.
  • Bugs de fuel corregidos al pasar (destapados al probar los botones): fuel_edit
    construía el form sin instance= y hacía expense.date = form.save(...) → editar
    reventaba; y el cancel_url de fuel/confirm_delete.html estaba fijo a fuel_list
    ignorando el next → cancelar desde gastos llevaba a repostajes. Ambos corregidos y
    cubiertos con tests de regresión nuevos (editar guarda bien, POST inválido no
    rompe, next se respeta y un next externo se ignora).
  • Unificar plantillas de confirmación de borrado (Code 1.2): las 7 son casi
    idénticas pero divergen en botón/clase/encabezado. Unificar en un {% include %}
    con variables. De paso previene bugs como el de income_confirm_delete. (Hecho con
    herencia + bloques (_confirm_delete.html): más robusto que include con variables
    planas porque las preguntas mezclan <strong> e importes interpolados. Los avisos
    especiales (cascada de categorías, gasto asociado de fuel) se conservan vía bloque
    delete_warnings. De paso se limpiaron typos ( sobrante en tag, ? faltante en
    cuenta, form.as_p muerto en categorías) y se normalizó <h2><h1> en las 7
    —esto adelanta el punto 1.9 de la Tanda 3.)
  • Clases CSS referenciadas que no existen (Code 1.7): .pagination,
    .form-errors, .form-field, etc. — revisar si la paginación y los errores de
    formulario se están viendo sin estilo. (Inventario real: la mayoría ya tenían
    estilo de trabajo posterior al análisis de Code; los huecos reales eran los enlaces
    de paginación (.step-links a/span, sin clase → sin formato) y .checkbox del
    dashboard. Estilados reutilizando el lenguaje de chips existente. De paso se
    aclararon en modo oscuro los filtros de periodo (subiendo --color-chip-bg/
    --color-chip-hover bajo [data-theme="dark"]), tono final afinado a mano.)
  • Jerarquía de encabezados (Code 1.9) y patrones UI iguales para datos
    iguales
    (Code 1.8: tag_list usa <ul>, categories usa <table>).
    (Parcialmente avanzado: las 7 confirmaciones de borrado se normalizaron a <h1> en
    esta tanda, y goals/list.html en la Tanda 3. Lo que queda: el dashboard.html
    no tiene ningún <h1> —empieza en <h2> y el resto son <h3>—, así que arreglarlo
    bien implica bajar todos los h3h2 y revisar que no se muevan tamaños en CSS. Y
    el 1.8 sigue intacto.)

Ajuste — gráficos de Chart.js en modo oscuro COMPLETADA

Detectado al usar el modo oscuro; JS, no CSS. Los colores cambian según el tema activo
y se actualizan en vivo al cambiar de tema.

  • Cuadrícula (grid) con tono adecuado por tema (variable CSS
    --chart-grid-color), leída desde el JS.
  • Líneas, barras y relleno con contraste por tema (--chart-fill-color).
  • Actualización en vivo al cambiar de tema (sin recargar): charts.js con
    getChartColors/applyCartesianColors/registerThemedChart/refreshThemedCharts
    • MutationObserver sobre data-theme.

Bugs resueltos por el camino (varios intentos):

  • t.startsWith is not a function NO era por colores inválidos (verificado en consola
    que las variables devolvían strings válidos). Causa real: mutar chart.options.scales
    DESPUÉS de crear un gráfico type:'line' choca con las opciones reactivas de Chart.js.
  • Solución en dos frentes: (1) aplicar colores de grid/ticks DENTRO de la definición de
    opciones del new Chart(...) (arregló el render inicial); (2) en el refresco en vivo,
    reasignar scale.grid/scale.ticks enteros con Object.assign en vez de mutar
    scale.grid.color directamente (arregló el cambio de tema en vivo). Comentado en
    español para que nadie lo "simplifique" y reintroduzca el bug.
  • refreshThemedCharts usa chart.update('none') para no re-animar en cada cambio.

Tanda 3 — Accesibilidad COMPLETADA

  • Dropdown "Configuraciones" accesible por teclado (Code 1.4): hoy es
    :hover puro, sin toggle por teclado → un usuario de teclado no puede llegar a
    Categorías/Etiquetas/Objetivos. Es una ruta bloqueada, no solo "buena práctica".
    (Ya resuelto en la Tanda 1: el toggle pasó a <button> con click + Enter/Espacio
    y aria-expanded.)
  • Labels en selects de filtro (Code 1.5): los 6 selects de filtro (3 en el
    dashboard, 3 en gastos) no tenían ningún label asociado. Resueltos con
    <label class="sr-only"> + utilidad .sr-only nueva en base.css. Se eligió label
    oculto en vez de visible porque los selects ya llevan una primera opción que hace de
    etiqueta a la vista ("Año", "Mes", "Todas las cuentas"), así que el layout de la fila
    de filtros —afinado en la Tanda 2— no se toca. El select de categoría de los filtros
    avanzados pasó de label implícito a explícito con for/id.
  • ARIA en barras de progreso (Code 1.6): role="progressbar" +
    aria-valuemin/aria-valuemax/aria-valuenow + aria-label + aria-valuetext en
    goals/list.html y en el widget de objetivos del dashboard.
    Detalle a no perder: aria-valuenow tiene que ir con |unlocalize. Sin él, la
    localización española escribe 45,5 con coma y deja de ser un número válido para el
    atributo. El aria-valuetext sí lleva coma a propósito: es el texto que el lector de
    pantalla pronuncia, y en español la coma es lo correcto.

Arreglados de paso, en el mismo pase:

  • Bug real: el widget de objetivos del dashboard pintaba la barra con
    goal.percentage en vez de goal.bar_width, así que un presupuesto por encima del
    100% se salía de la caja. goals/list.html ya usaba bar_width; el dashboard se
    había quedado atrás.
  • Bug de solapamiento en las barras de progreso (defecto preexistente de la
    Tanda 2, destapado al probar con un objetivo al 1017%): .progress-container es flex
    y .progress-label tenía min-width: 90px pero nada que le impidiera encogerse.
    Como .progress-bar llevaba width: 100%, reclamaba todo el ancho y obligaba a las
    etiquetas a bajar a su mínimo; el texto largo (1017,0€ / 100,0€) se desbordaba de su
    caja y la barra, con fondo sólido y pintada después, lo tapaba. Corregido con
    flex-shrink: 0 + white-space: nowrap en .progress-label y flex: 1 +
    min-width: 0 en .progress-bar. El width: 100% de .progress-bar se conserva a
    propósito
    : en el widget del dashboard la barra NO está dentro de un flex
    (.goal-card es un bloque normal), así que ahí manda width, mientras que en el
    listado manda flex-basis. Una sola regla cubre las dos disposiciones.
    Solo se veía con etiquetas de más de 90px, o sea importes largos o porcentajes de
    tres cifras — por eso llevaba tiempo sin detectarse.
  • colspan incorrecto en dos estados vacíos: listado de gastos (4 → 6) y gastos
    recientes del dashboard (7 → 5).
  • Paginación de gastos de <div> a <nav aria-label="Paginación de gastos">.
  • Grupo de filtro por etiquetas con role="group" + aria-label, y aria-hidden
    en el "Tags:" visible para que no se lea dos veces.
  • goals/list.html empezaba en <h2><h1> (resto del punto 1.9, ver Tanda 2).

Tanda 4 — Home COMPLETADA

Ver 01_home.md.

  • Saldo total + desglose por cuenta. Calculado con dos agregaciones agrupadas
    por cuenta (3 consultas fijas) en vez de llamar a current_balance() en bucle, que
    reproduciría el N+1 anotado para el dashboard (Code 2.1). Ojo: no se puede
    resolver con un solo annotate() de dos Sum sobre expenses e incomes — el
    join multiplica filas e infla los totales.
  • Banner de avisos: presupuestos excedidos y cuentas activas en negativo.
    Solo aparece si hay algo que señalar. Reutiliza las clases .message de Django
    (warning para presupuestos, error para cuentas) en vez de inventar clases.
  • Comparativa gasto mes actual vs anterior, con importe y porcentaje. Cuando
    el mes anterior es 0 no se calcula el porcentaje (dividir por cero, y "+100%" desde
    cero engaña más que informa). Lleva una nota fija de que el mes en curso está
    incompleto.
  • Últimos movimientos: gastos e ingresos mezclados y ordenados por fecha, 8
    como máximo. Sin enlace por fila a propósito: comprobar expense.fuel_data para
    elegir la URL de edición dispararía una consulta por fila.
  • Objetivos consultados en una sola query y filtrados en Python (los de
    show_on_home para el widget, los excedidos para el banner). Es deliberado:
    Goal.progress es una cached_property, así que compartir instancias evita
    recalcular el progreso de un objetivo que aparezca en ambos sitios. is_exceeded()
    cortocircuita si el tipo no es presupuesto, así que pago y ahorro no tocan progress.

Arreglados de paso:

  • ARIA en la barra de progreso del widget de objetivos del home — se quedó
    fuera de la Tanda 3, donde solo se tocaron goals/list.html y el dashboard. Mismo
    patrón, con aria-valuenow sobre bar_width (no percentage) para no salirse del
    aria-valuemax="100" declarado cuando un presupuesto se pasa.
  • <h3>Objetivos</h3><h2>: la página empieza en <h1> y el resto de
    secciones son <h2>.
  • Espaciado vertical del home: no existía ninguna regla de margen para
    section ni para .btn en todo el CSS. El home antiguo lo disimulaba con <br>
    sueltos. Resuelto con .home-section y .section-actions, sin tocar reglas
    globales para no descolocar el dashboard ni el listado de gastos.
  • La clave de contexto last_expenses desaparece, sustituida por movements.

Tanda 4b — Formularios y pantallas de contraseña COMPLETADA

Detectado al terminar la Tanda 3, no estaba recogido en ninguna tanda anterior: la
Tanda 2 unificó botones y la fila de acciones (.form-actions), pero nunca tocó los
campos en sí.

Diagnóstico de partida: no existía ni una sola regla para input, select o
textarea en base.css. Solo estaban .form-field (un margin-bottom) y
.form-errors (color). Por eso cada campo se veía de un tamaño distinto: sin CSS,
cada control usa su ancho intrínseco del navegador.

  • Estilos base para controles de formulario: ancho, altura, padding, borde,
    tipografía y :focus con las variables semánticas existentes, así que funcionan en
    claro y oscuro sin trabajo extra. Decisión clave: las reglas se acotan a
    .form-field
    en vez de escribirlas sobre input/select/textarea a pelo. Así
    los controles con tratamiento propio quedan fuera por construcción —las chips de
    tags, el .checkbox del dashboard y los selects de la fila de filtros— sin depender
    de una lista de excepciones que se olvida.
  • Estructura de campo unificada: parcial nueva
    expenses/templates/expenses/_form_fields.html (label + widget + help_text +
    errores), aplicada a todas las plantillas de formulario en sustitución de
    form.as_p y del bucle manual. El caso especial de las chips de tags vive dentro
    de la parcial para no repetirlo. Las casillas invierten el orden (control antes que
    etiqueta) porque a todo lo ancho se leen fatal.
  • Pantallas de contraseña, login y ayuda rehechas con la parcial y el mismo
    lenguaje visual. Typo corregido en password_help.html ("administrados").

Bug real destapado — plantillas sombreadas por el admin:

/accounts/password_change/ estaba renderizando la pantalla del admin de Django,
no la de la app. La causa no eran las URLs (urls.py estaba bien) sino el orden de
INSTALLED_APPS: django.contrib.admin publica sus propias
registration/password_change_form.html y password_change_done.html, y el cargador
de plantillas por aplicación recorre INSTALLED_APPS en orden quedándose con la
primera coincidencia. Al ir el admin antes que expenses, ganaba siempre.

Esto explicaba una contradicción que costó desenredar: las plantillas de la app
extendían "base.html" (ruta inexistente; la base real es "expenses/base.html"),
así que si se hubieran renderizado habrían lanzado TemplateDoesNotExist — pero
nunca se renderizaban, así que el error jamás salió. El login no estaba afectado
porque el admin publica el suyo en admin/login.html, no en registration/.

  • "expenses" movido por encima de "django.contrib.admin" en
    INSTALLED_APPS, con comentario explicando por qué, para que nadie lo ordene
    alfabéticamente y reintroduzca el bug.
  • Las dos plantillas de contraseña pasan a extender "expenses/base.html".
  • Inventario de sombreado verificado con get_template().origin.name antes y
    después: solo estaban afectadas esas dos. Ninguna plantilla de expenses/,
    goals/ o categories/ colisiona con django.contrib.*.

Efecto secundario aceptado: /admin/password_change/ ahora usa la plantilla de
la app (navegación de la app en vez del look del admin). Funciona —
AdminPasswordChangeForm hereda los mismos tres campos, así que la parcial los
renderiza bien— y se decide dejarlo así: es un flujo interno que se usa muy de vez en
cuando, y de paso las dos rutas de cambio de contraseña llevan ahora a la misma
pantalla, lo que es más coherente, no menos. Criterio para el futuro: si algún
día hay que sobrescribir más plantillas del admin y empiezan a chocar, entonces sí
toca mover las de la app a un directorio propio en TEMPLATES["DIRS"], que tiene
prioridad sobre las de aplicación sin depender del orden. Reordenar es la solución
barata mientras solo se sombreen las de registration/.

Bugs corregidos de paso:

  • goals/form.html ponía "Nuevo objetivo" siempre, también al editar.
  • categories/list.html tenía la columna de acciones en el <tbody> pero solo
    dos <th> en el <thead>. Mismo tipo de desajuste que los colspan de la Tanda 3.

Sin migración, sin cambios en vistas: CSS, plantillas y una línea de settings.py.


Tanda 5 — Listado de gastos (la pieza de filtrado)

Ver 02_listado_gastos.md. Es la tanda más grande de producto.

  • Filtrado instantáneo sin recargar (introduce el primer JS de entidad).
  • Total de lo filtrado, ordenar por columna, exportar CSV/Excel, rango de fechas.
  • Técnico asociado: al reescribir la vista para responder JSON/fragmento, resolver
    de paso la query duplicada de Tag (Code 2.7) y añadir select_related/
    prefetch_related (Code 2.5).
  • Ver 09_transversales.md: el rango de fechas y el mecanismo dinámico deben
    diseñarse pensando en reutilizarlos en el dashboard.
  • Ojo al repintar la tabla con JS: los id de los selects de filtro y los
    atributos ARIA añadidos en la Tanda 3 tienen que sobrevivir al fragmento nuevo.
    Es fácil perderlos al generar HTML desde JS.

Tanda 6 — Dashboard (producto + el N+1)

Ver 03_dashboard.md. Es la vista más usada para análisis y la que más se
beneficia de tocar producto y rendimiento a la vez.

  • Tarta por categorías, desglose por subcategorías al pinchar, presupuestos
    integrados, rango de fechas, ingresos vs gastos.
  • Técnico asociado (importante hacerlo en la misma pasada):
    • N+1 de saldos por cuenta (Code 2.1): ~40 queries con 5 cuentas.
    • Refactor de la función dashboard (Code 3.4): 226 líneas, 5
      responsabilidades, contexto de 35 claves. Extraer en _resolve_period,
      _build_comparison, _build_account_charts.
    • El desglose por subcategorías reutiliza el mecanismo dinámico de la Tanda 5.
    • Aprovechar para la jerarquía de encabezados de esta vista (punto 1.9
      pendiente de la Tanda 2), que hoy no tiene <h1>.
    • Limpiar el <script> muerto de clearPeriodPreset() (definido y nunca llamado) y
      el bloque de script comentado de los presets.
    • El N+1 de saldos ya está resuelto a medias: el helper _account_balances
      existe desde la Tanda 7 y solo hay que usarlo aquí.
    • Escala tipográfica de encabezados (surge al arreglar la jerarquía): base.css
      nunca ha definido tamaños de encabezado, así que al bajar los niveles todo pasa a
      los del navegador (32/24/18.7px), que en una vista densa de datos compiten con las
      propias cifras. Escala acordada: h1 1.625rem, h2 1.25rem, h3 1rem, peso 600,
      con márgenes fijos en rem (los del navegador van en em y escalan con la fuente,
      dejando huecos irregulares). Afecta a toda la app, no solo al dashboard: repasar
      el resto de vistas después. Es el mismo agujero que el espaciado del home y los
      controles de formulario — CSS que nunca se escribió.

Traspasos entre cuentas — los gastos totales están inflados

Problema real detectado al revisar el dashboard. Hoy un traspaso entre cuentas se
registra como un Expense en la cuenta origen y un Income en la destino. Los saldos
cuadran, pero la salida cuenta como gasto en el dashboard, en la comparativa del
home y en el progreso de los presupuestos, aunque el dinero no haya salido del
patrimonio. Caso típico: al cobrar la nómina se manda una cantidad a otra cuenta.

Asimetría del modelo que condiciona la solución: Income no tiene categoría (solo
nombre, importe, fecha y cuenta), así que "marco una categoría de traspaso y la
excluyo" solo cubre la mitad de la operación.

Decisión tomada: booleano is_transfer en Expense y en Income. Los marcados
quedan fuera de todas las agregaciones de análisis pero siguen contando para el saldo
de la cuenta. No cambia la forma de registrar: se siguen metiendo las dos patas, ahora
marcadas.

La alternativa era un modelo Transfer propio (origen, destino, importe, fecha), que
es lo correcto de diseño: un solo registro en vez de dos sueltos que nada relaciona, y
Expense/Income recuperando su significado de dinero que entra o sale de verdad.
Se descarta por ahora porque obliga a tocar current_balance(),
monthly_balance(), balance_until() y monthly_net(), que es el código con más
tests y del que cuelga medio dashboard. El booleano arregla el problema que molesta y
es reversible: el día que exista Transfer, los registros ya marcados son exactamente
los que hay que migrar.

Lo que el booleano NO da: nada garantiza que se marquen las dos patas ni que los
importes coincidan. Con un solo usuario y disciplina, sirve.

  • Campo is_transfer en Expense e Income (migración), con su casilla en
    los dos formularios.
  • Excluir los marcados de todas las agregaciones de análisis: KPIs y gráficos
    del dashboard, comparativa y KPIs del home, by_category, y Goal.progress para
    los tipos pago y presupuesto. Repasar el proyecto entero, no solo el dashboard.
  • Que sigan contando para el saldo: current_balance(), monthly_balance(),
    balance_until() y monthly_net() NO deben filtrarlos. Es justo lo contrario que
    el punto anterior, y confundirlo descuadraría todos los saldos.
  • Distintivo visual en el listado de gastos e ingresos, para reconocerlos de un
    vistazo.
  • Tests: que un traspaso no sume a los gastos del dashboard ni al progreso de un
    presupuesto, y que sí afecte al saldo de las dos cuentas.

Aviso sobre los datos históricos: los traspasos ya registrados que no llevan
categoría no se pueden identificar automáticamente — nada distingue un gasto real
de un traspaso sin marcar. Hay que repasarlos a mano o asumir que lo antiguo queda
sucio y que a partir de la migración está bien. Mirar cuántos son antes de decidir.

Conecta con la Tanda 9: meter dinero en una cuenta remunerada es un traspaso, no
un gasto. Tener esto resuelto antes le quita medio problema al módulo de inversiones.

Clasificación de categorías: comprometido vs flexible (Idea A)

Es la Idea A de 10_modulo_inversiones.md, que ese documento ya marcaba como
independiente del módulo de inversiones y hacedera en cualquier momento. Va aquí y
no en la Tanda 9 porque no comparte nada con el patrimonio: clasificar categorías
es análisis del dinero que sale, el módulo de inversiones es el dinero que
está guardado
. Además el sitio donde esto se ve es el dashboard, que ya se abre
en esta tanda.

Dos campos, no uno de tres estados. "Fijo esencial", "fijo prescindible" y
"variable" mezclan dos ejes distintos y dejan fuera la casilla de variable
esencial
(compra, gasolina, farmacia), que es probablemente la mayor parte del
gasto. Con dos campos se pueden responder dos preguntas que hoy no se pueden:

  • is_fixed (fijo / variable) — cuánto del dinero está comprometido de antemano.
  • is_essential (esencial / prescindible) — cuánto se podría recortar apretándose
    el cinturón.

Herencia con vía de escape. El campo existe en todas las categorías y admite
estar vacío, y vacío significa "lo que diga mi padre". Así se rellenan solo los
padres y los hijos heredan, pero un hijo que se salga de la norma (el seguro del
coche dentro de "Suscripciones", que es prescindible) se puede marcar aparte.
Limitar el campo solo a las categorías padre daría el mismo ahorro sin esa salida.

Coste a saber de antemano: agregar por un atributo heredado no se puede hacer
en SQL directamente
. Para el desglose del dashboard hay que resolver la herencia en
Python antes de sumar. Con pocas categorías no es problema — el árbol ya se monta en
memoria en category_list — pero es trabajo real, no un campo y ya.

Partirlo en dos fases:

  • Fase 1: los dos campos, su edición en el formulario de categorías y la
    columna en el listado. Lleva migración. Las categorías existentes quedan
    vacías a propósito: mejor rellenarlas a mano que inventar un valor por defecto del
    que luego no te fías.
  • Fase 2: el desglose comprometido/flexible en el dashboard, con la herencia
    resuelta en Python. Hacerla cuando ya haya categorías clasificadas y se vea si la
    información resulta útil.

Tanda 7 — Vistas de gestión COMPLETADA (parcialmente)

Ver 0407. Se acotó a propósito a lo que no necesita migración de esquema ni
operaciones destructivas.

  • Cuentas: saldo en el listado sin N+1. El saldo ya se mostraba, pero
    llamando a account.current_balance desde la plantilla: 2 consultas por cuenta.
    Extraído el helper _account_balances(user, active_only=False) en views.py, que
    hace 3 consultas fijas, y home() pasa a usarlo también (era el segundo sitio con
    la misma lógica). Saldo negativo en rojo reutilizando .amount-negative de la
    Tanda 4. Disponible para la Tanda 6, que tiene el mismo N+1 pendiente.
  • Categorías: árbol jerárquico + nº de gastos. Árbol montado en memoria desde
    una sola consulta (helper _category_tree), con profundidad capada a 4 y sangría
    por clases .cat-depth-1..4. Un padre fuera del conjunto se trata como raíz para
    que ninguna categoría desaparezca en silencio. La columna "Categoría padre" se
    retira: la jerarquía ya se ve por la sangría. El contador cuenta solo gastos
    directos
    , no los de descendientes (decisión fijada con un test).
  • Etiquetas: contador de uso + paso de <ul> a <table>. Esto cierra el
    punto 1.8 del análisis de Code
    (patrones UI distintos para datos iguales), que
    seguía abierto desde la Tanda 2.
  • Objetivos: filtrar por tipo, validando el ?kind= contra KIND_CHOICES
    (un valor inventado se ignora en vez de romper, mismo criterio que _get_int), con
    label .sr-only y estado vacío nuevo para cuando el filtro no devuelve nada.
  • Unificado el coloreado de la barra de progreso: dashboard.html pasa del
    {% if %} sobre percentage a {{ goal.progress_state }}, como ya hacían
    goals/list.html y home.html. Las clases .low/.medium/.high quedaron
    muertas y se retiraron de base.css.

Extras hechos después, en el mismo bloque de trabajo:

  • Orden alfabético insensible a mayúsculas en Tag, Category y Account:
    ordering = [Lower("name")]. Antes SQLite ordenaba por valor binario (AA, MK, ZZ, mk, ms) y, lo que es peor, el orden difería entre SQLite en local y PostgreSQL en
    el NAS
    ; ahora se calcula en la consulta y coincide. goal_list pasa también a
    .order_by(Lower("name")). Tres sitios NO se propagaban solos y hubo que
    tocarlos: los .order_by("name") explícitos de tag_list y account_list (se
    convirtieron a Lower, no se borraron, porque el order_by explícito protege el
    annotate) y el sorted(key=lambda c: c.name) de _category_tree, que ordenaba
    en Python por code point igual que SQLite.
  • Panel de ayuda en el formulario de objetivos: <details> nativo (sin JS,
    accesible por teclado de serie, mismo patrón que los filtros avanzados de gastos)
    explicando los tres tipos y para qué sirve cada campo.

Migraciones: 0011 (ordering de Tag) y 0012 (los tres AlterModelOptions
con Lower). Ninguna toca el esquema, pero hay que desplegarlas.

Queda fuera, para otro momento:

  • Cuentas: marcar principal (🟡) y reordenar (🔴). Ambas necesitan campo nuevo.
  • Etiquetas: fusionar (🔴). Operación destructiva, necesita transacción.
  • Objetivos: histórico de cumplidos (🟡). Docstrings de
    Goal.progress/_period_start (Code 3.5) al tocar el modelo.
  • Separar category_create del listado (Code 2.6).
  • Etiquetas de formulario en inglés: GoalForm muestra "Category",
    "Include subcategories", "Show on home"... porque los campos no tienen
    verbose_name. Es la versión visible para el usuario de la deuda de "comentarios
    en dos idiomas". Pase aparte, con migración.

Tanda 8 — Módulo de vehículos (módulo nuevo grande)

Ver 08_repostajes_vehiculos.md. Es prácticamente un módulo nuevo; abordar entero,
no a trozos sueltos. Orden interno sugerido en su propio archivo.


Tanda 9 — Módulo de inversiones / patrimonio (el más grande)

Ver 10_modulo_inversiones.md. La evolución de mayor alcance. Empezar por registro
manual de los cuatro tipos; precios automáticos como capa posterior (cripto primero).
La Idea A (clasificar gasto fijo/variable) NO va aquí: se ha movido a la Tanda 6,
con dos campos (is_fixed e is_essential) en vez del campo único de tres estados
que se planteó en la ideación. Ver el detalle allí.


Refactors grandes — decididos NO hacer por ahora

Anotados a conciencia como "no hacer salvo que duela de verdad":

  • Migrar a class-based views (Code 2.3): reduciría ~300-400 líneas, pero toca
    20+ vistas que hoy funcionan y están cubiertas por tests. Riesgo/beneficio no
    compensa en un proyecto personal. Dejar salvo reescritura por otro motivo.
  • Partir views.py en un paquete views/ (Code 3.3): más razonable que lo
    anterior (es mover funciones, bajo riesgo) pero no urge. Hacerlo el día que el
    archivo estorbe de verdad al trabajar, y de forma incremental.
  • Reorganizar carpetas de plantillas (Code 2.8): puramente cosmético. Solo si
    se hace a la vez que otra reestructuración.

Deuda ya conocida (de la fase anterior, no del análisis de Code)

  • Jenkins: build de la imagen Docker en CI + despliegue automático desde main.
    Preparado, pendiente de ejecutar cuando se quiera (ver TODO_revision.md).
  • gunicorn en requirements pero no en el venv (Code 3.6): mismo patrón que
    whitenoise. Revisar todas las dependencias dev vs producción de una vez.
  • Comentarios en dos idiomas (Code 3.7): fijar convención (español en
    comentarios, inglés en identificadores, que es lo mayoritario) y aplicarla a
    partir de ahora sin reescribir lo viejo.

Lecciones que conviene no perder

  • Los tests de pytest no ven el navegador. Un gráfico roto por JS y "75 passed"
    conviven sin problema (pasó con el t.startsWith de Chart.js). Todo lo visual y
    todo lo de JS se valida a mano en DevTools, en móvil y en claro/oscuro.
  • Los defectos de layout con flex solo asoman con datos extremos. El solapamiento
    de las barras de progreso llevaba desde la Tanda 2 y no se vio hasta tener un
    objetivo al 1017%. Merece la pena probar las vistas con valores largos y absurdos,
    no solo con datos realistas.
  • La misma información en dos vistas tiende a divergir. Ha pasado ya dos veces con
    las barras de objetivos (bar_width en un sitio y percentage en otro,
    progress_state en un sitio y un {% if %} en otro). Cuando un cálculo vive en el
    modelo, usarlo en todas partes en vez de replicarlo en plantilla.
  • Una plantilla que nadie abre se queda atrás. El widget de objetivos del home no
    llevaba los ARIA de la Tanda 3 simplemente porque en esa sesión no se abrió el
    archivo. Cuando un cambio afecta a "todas las barras de progreso" o "todos los
    formularios", conviene un grep del patrón antes de dar la tanda por cerrada.
  • El CSS sin reglas no es CSS neutro. Ni section ni .btn tenían margen, y el
    home antiguo lo disimulaba con <br> sueltos hasta que llegó contenido de verdad.
    El mismo patrón apareció en los formularios (Tanda 4b): sin reglas para
    input/select/textarea, cada control usa su ancho intrínseco del navegador.
  • Acotar el selector es mejor que mantener excepciones. En la Tanda 4b las reglas
    de los controles se colgaron de .form-field en vez de escribirlas sobre input o
    select directamente. Las chips, el checkbox del dashboard y los filtros quedan
    fuera porque no están dentro de ese contenedor, no porque alguien se acordara de
    excluirlos. Una lista de excepciones se queda desfasada; una regla acotada no.
  • Una pantalla que "se ve mal" puede no ser tuya. El cambio de contraseña llevaba
    desde el principio sirviéndose desde django.contrib.admin por el orden de
    INSTALLED_APPS. El síntoma era estético, la causa era de configuración, y solo
    salió al empeñarse en rediseñarla. Cuando una plantilla no responde a los cambios,
    confirmar con get_template("ruta").origin.name qué archivo está usando Django de
    verdad
    antes de seguir tocándola.
  • Contrastar los resúmenes de Code con el git diff. En un mismo informe afirmó
    que el proyecto no tenía repositorio git (había comprobado git status en la carpeta
    contenedora, no en la del repo) y que expense_form.html tenía etiquetas roto
    cuando estaba bien formado. Los cambios eran correctos; la descripción no. El diff
    tarda menos en leerse que en deshacer un cambio hecho sobre una premisa falsa.
  • Meta.ordering no llega a todas partes. Al ponerlo en Tag había tres sitios
    que lo pisaban: dos .order_by("name") explícitos en vistas y un sorted() en
    Python dentro de _category_tree, que ordenaba por code point igual que SQLite. Un
    cambio "en el modelo y ya" hay que verificarlo en pantalla, no darlo por propagado.
  • El orden por defecto dependía del motor. Con ordering = ["name"], SQLite en
    local y PostgreSQL en el NAS ordenaban distinto y nadie lo había notado. Cualquier
    cosa que delegue en la base de datos (orden, colación, comparaciones de texto) puede
    comportarse distinto en los dos entornos. Lower("name") lo resuelve para
    mayúsculas; los acentos siguen dependiendo de la colación.
  • Un punto del prompt no es un punto hecho. El contador de gastos por categoría se
    pidió en la Tanda 7, no se implementó, y se dio por cerrado sin comprobarlo en
    pantalla. Repasar la lista del prompt contra la vista real antes de cerrar una tanda.
# Plan de trabajo priorizado — expenses_manager Junta las dos fuentes: las mejoras de **producto** (recorrido vista por vista, archivos `01`–`10`) y el **análisis técnico/visual de Code** (`ANALISIS_code.md`). Organizado en tandas, no por áreas sueltas: cada tanda agrupa cosas que se tocan juntas, para no abrir la misma vista dos veces. El orden es una recomendación, no una obligación. La decisión de producto es del propietario; lo técnico va anotado con el criterio de por qué sí o por qué no. --- ## Tanda 0 — Arreglos rápidos y documentación (bajo esfuerzo, hacer ya) ✅ COMPLETADA Cosas de bajo riesgo y alta relación coste/beneficio. Ninguna toca la lógica de negocio de forma peligrosa. - [x] **Bug**: `income_confirm_delete.html` mostraba `{{ expense.date }}` en vez de `{{ income.date }}` (fecha vacía en la confirmación). — *ya corregido* - [x] **Memoizar `Goal.progress()`** con `cached_property` (Code 2.2). Hoy se recalcula 3-5 veces por objetivo en cada render de home/dashboard/objetivos. Cambio pequeño, riesgo casi nulo, quita muchas queries. **El mejor coste/beneficio de todo el informe.** *(Hecho: `progress` pasó a `cached_property`; llamadas internas en `percentage()`/`is_exceeded()` ajustadas a `self.progress` sin paréntesis; tests actualizados y en verde. Sin migración.)* - [x] **Actualizar `CLAUDE.md`** (Code 3.1): además de los 3 puntos de Code, se puso al día todo lo cambiado estas sesiones (Goal con `kind`/periodos/`cached_property`, soft-delete de cuentas, CRUD de categorías y prevención de ciclos, `fuel_delete`, `/finance-accounts/`, settings unificado por env, convención de nombres de plantillas, workflow dev/main). - [x] **Reescribir `README.md`** (Code 3.2): reescrito de cero, fiel al proyecto real (conda + SQLite en local, tabla de variables de entorno, Dockerfile documentado y `docker-compose.yml` de ejemplo sin secretos — el real vive fuera del repo en el NAS). - [x] **Crear `.env.example`** (Code 3.8): añadido al repo con todas las variables como plantilla (placeholders inútiles, sin secretos). - [x] **Limpieza trivial** (Code 2.9, 3.9): eliminado el import muerto de `models.py`, el comentario obsoleto de sourcery en `views.py` y unificadas las comillas en `GoalForm.Meta.fields`. --- ## Tanda 1 — Responsive / usarlo en el móvil (alto valor personal) ✅ COMPLETADA Motivo de prioridad: el objetivo declarado es "poder usarlo más desde el móvil", y hoy no se puede porque no está adaptado. Esto es lo que desbloquea ese uso. - [x] **Meta viewport** en `base.html` y `base_auth.html` (Code 1.1) — una línea, cambia radicalmente cómo se ve en móvil. *(Hecho; de paso se corrigió el `<!DOCTYPE>` incompleto de `base.html` y se añadió `lang="es"`.)* - [x] **Envolver tablas anchas** en `overflow-x:auto` (gastos, comparación del dashboard, fuel con 7 columnas) para que no desborden. *(Hecho: las 8 tablas del proyecto envueltas en `.table-wrap` con `white-space:nowrap`, se ensanchan según contenido sin anchos fijos arbitrarios.)* - [x] **Media queries** básicas en `base.css` para los breakpoints principales. *(Hecho: breakpoints 768px y 480px; grids —kpi/dashboard/goals/settings— a 1 columna en móvil.)* **Extras que salieron al probar en móvil y se resolvieron en la misma tanda:** - [x] **Gráficos de Chart.js responsive**: el bug era CSS (una `.chart-box` de altura fija envolvía título + canvas y desbordaba). Corregido en `dashboard.html` y `home.html` (título fuera de la caja, canvas en contenedor de altura controlada). - [x] **Menú hamburguesa** (primer JS del proyecto, `expenses/static/expenses/js/nav.js`): botón ☰ bajo 768px que despliega la nav; vanilla JS solo para el toggle, con `aria-expanded`. En escritorio no cambia nada. - [x] **Dropdown "Configuraciones" plegable por click** (amplía `nav.js`): pasó de `:hover` a click en escritorio y móvil, con cierre al pulsar fuera. El toggle es un `<button>` real con `aria-haspopup`/`aria-controls` y foco por teclado — **esto adelanta el punto 1.4 de la Tanda 3** (dropdown accesible por teclado). - [x] **"Quitar filtros temporales"** movido a su propia línea bajo los presets del dashboard (ya no se parte/solapa en móvil). --- ## Tanda 2 — Coherencia visual (base para que todo se vea igual) ✅ COMPLETADA Lo que hace que la app deje de verse "un poco fea/inconsistente" (queja explícita). - [x] **Variables CSS de color** (Code 1.3): definir `:root { --color-success… --color-danger… }` y sustituir los 3-4 tonos distintos de verde/rojo repartidos por `base.css` y los `style="..."` inline de `dashboard.html`. Base para todo lo visual posterior. *(Hecho: paleta **Forest Teal** aplicada con variables semánticas (`--color-bg`/`--color-surface`/`--color-text`/`--color-primary`...), pensadas para soportar dos modos. Hex dispersos e inline sustituidos por variables.)* - [x] **Botones unificados** (parte de 1.2/1.7): clases `.btn`/`.btn-primary`/ `.btn-secondary`/`.btn-danger` coherentes; los "Volver"/"Cancelar" que eran enlaces morados pasan a botón real con misma altura y separación; aplicado en formularios y confirmaciones. *(Resuelve lo que se detectó al probar la Tanda 1 en móvil.)* - [x] **Modo oscuro** (extra añadido en esta tanda): segundo juego de valores bajo `[data-theme="dark"]`, sigue la preferencia del sistema por defecto, botón sol/luna en la topbar para forzar, elección recordada en `localStorage`, con script inline anti-flash en el `<head>`. Login incluido. - [x] **Botones de acción en fila** (`.form-actions`): los pares Guardar/Crear + Volver (y Eliminar + Cancelar en las confirmaciones) quedan en horizontal, acción principal a la izquierda, con separación y wrap responsive. Un solo cambio en la parcial `_confirm_delete.html` cubrió las 7 confirmaciones. - [x] **Bugs de fuel corregidos al pasar** (destapados al probar los botones): `fuel_edit` construía el form sin `instance=` y hacía `expense.date = form.save(...)` → editar reventaba; y el `cancel_url` de `fuel/confirm_delete.html` estaba fijo a `fuel_list` ignorando el `next` → cancelar desde gastos llevaba a repostajes. Ambos corregidos y **cubiertos con tests de regresión nuevos** (editar guarda bien, POST inválido no rompe, `next` se respeta y un `next` externo se ignora). - [x] **Unificar plantillas de confirmación de borrado** (Code 1.2): las 7 son casi idénticas pero divergen en botón/clase/encabezado. Unificar en un `{% include %}` con variables. De paso previene bugs como el de `income_confirm_delete`. *(Hecho con herencia + bloques (`_confirm_delete.html`): más robusto que include con variables planas porque las preguntas mezclan `<strong>` e importes interpolados. Los avisos especiales (cascada de categorías, gasto asociado de fuel) se conservan vía bloque `delete_warnings`. De paso se limpiaron typos (`€` sobrante en tag, `?` faltante en cuenta, `form.as_p` muerto en categorías) y se normalizó `<h2>`→`<h1>` en las 7 —esto adelanta el punto 1.9 de la Tanda 3.)* - [x] **Clases CSS referenciadas que no existen** (Code 1.7): `.pagination`, `.form-errors`, `.form-field`, etc. — revisar si la paginación y los errores de formulario se están viendo sin estilo. *(Inventario real: la mayoría ya tenían estilo de trabajo posterior al análisis de Code; los huecos reales eran los enlaces de paginación (`.step-links a/span`, sin clase → sin formato) y `.checkbox` del dashboard. Estilados reutilizando el lenguaje de chips existente. De paso se aclararon en modo oscuro los filtros de periodo (subiendo `--color-chip-bg`/ `--color-chip-hover` bajo `[data-theme="dark"]`), tono final afinado a mano.)* - [ ] **Jerarquía de encabezados** (Code 1.9) y **patrones UI iguales para datos iguales** (Code 1.8: `tag_list` usa `<ul>`, `categories` usa `<table>`). *(Parcialmente avanzado: las 7 confirmaciones de borrado se normalizaron a `<h1>` en esta tanda, y `goals/list.html` en la Tanda 3. **Lo que queda**: el `dashboard.html` no tiene ningún `<h1>` —empieza en `<h2>` y el resto son `<h3>`—, así que arreglarlo bien implica bajar todos los `h3`→`h2` y revisar que no se muevan tamaños en CSS. Y el 1.8 sigue intacto.)* --- ## Ajuste — gráficos de Chart.js en modo oscuro ✅ COMPLETADA Detectado al usar el modo oscuro; JS, no CSS. Los colores cambian según el tema activo y se actualizan en vivo al cambiar de tema. - [x] **Cuadrícula (grid)** con tono adecuado por tema (variable CSS `--chart-grid-color`), leída desde el JS. - [x] **Líneas, barras y relleno** con contraste por tema (`--chart-fill-color`). - [x] **Actualización en vivo** al cambiar de tema (sin recargar): `charts.js` con `getChartColors`/`applyCartesianColors`/`registerThemedChart`/`refreshThemedCharts` + `MutationObserver` sobre `data-theme`. **Bugs resueltos por el camino (varios intentos):** - `t.startsWith is not a function` NO era por colores inválidos (verificado en consola que las variables devolvían strings válidos). Causa real: mutar `chart.options.scales` DESPUÉS de crear un gráfico `type:'line'` choca con las opciones reactivas de Chart.js. - Solución en dos frentes: (1) aplicar colores de grid/ticks DENTRO de la definición de opciones del `new Chart(...)` (arregló el render inicial); (2) en el refresco en vivo, reasignar `scale.grid`/`scale.ticks` enteros con `Object.assign` en vez de mutar `scale.grid.color` directamente (arregló el cambio de tema en vivo). Comentado en español para que nadie lo "simplifique" y reintroduzca el bug. - `refreshThemedCharts` usa `chart.update('none')` para no re-animar en cada cambio. --- ## Tanda 3 — Accesibilidad ✅ COMPLETADA - [x] **Dropdown "Configuraciones" accesible por teclado** (Code 1.4): hoy es `:hover` puro, sin toggle por teclado → un usuario de teclado no puede llegar a Categorías/Etiquetas/Objetivos. Es una ruta bloqueada, no solo "buena práctica". *(Ya resuelto en la Tanda 1: el toggle pasó a `<button>` con click + Enter/Espacio y `aria-expanded`.)* - [x] **Labels en selects de filtro** (Code 1.5): los 6 selects de filtro (3 en el dashboard, 3 en gastos) no tenían ningún label asociado. Resueltos con `<label class="sr-only">` + utilidad `.sr-only` nueva en `base.css`. Se eligió label oculto en vez de visible porque los selects ya llevan una primera opción que hace de etiqueta a la vista ("Año", "Mes", "Todas las cuentas"), así que el layout de la fila de filtros —afinado en la Tanda 2— no se toca. El select de categoría de los filtros avanzados pasó de label implícito a explícito con `for`/`id`. - [x] **ARIA en barras de progreso** (Code 1.6): `role="progressbar"` + `aria-valuemin`/`aria-valuemax`/`aria-valuenow` + `aria-label` + `aria-valuetext` en `goals/list.html` y en el widget de objetivos del dashboard. **Detalle a no perder:** `aria-valuenow` tiene que ir con `|unlocalize`. Sin él, la localización española escribe `45,5` con coma y deja de ser un número válido para el atributo. El `aria-valuetext` sí lleva coma a propósito: es el texto que el lector de pantalla pronuncia, y en español la coma es lo correcto. **Arreglados de paso, en el mismo pase:** - [x] **Bug real**: el widget de objetivos del dashboard pintaba la barra con `goal.percentage` en vez de `goal.bar_width`, así que un presupuesto por encima del 100% se salía de la caja. `goals/list.html` ya usaba `bar_width`; el dashboard se había quedado atrás. - [x] **Bug de solapamiento en las barras de progreso** (defecto preexistente de la Tanda 2, destapado al probar con un objetivo al 1017%): `.progress-container` es flex y `.progress-label` tenía `min-width: 90px` pero nada que le impidiera encogerse. Como `.progress-bar` llevaba `width: 100%`, reclamaba todo el ancho y obligaba a las etiquetas a bajar a su mínimo; el texto largo (`1017,0€ / 100,0€`) se desbordaba de su caja y la barra, con fondo sólido y pintada después, lo tapaba. Corregido con `flex-shrink: 0` + `white-space: nowrap` en `.progress-label` y `flex: 1` + `min-width: 0` en `.progress-bar`. **El `width: 100%` de `.progress-bar` se conserva a propósito**: en el widget del dashboard la barra NO está dentro de un flex (`.goal-card` es un bloque normal), así que ahí manda `width`, mientras que en el listado manda `flex-basis`. Una sola regla cubre las dos disposiciones. *Solo se veía con etiquetas de más de 90px, o sea importes largos o porcentajes de tres cifras — por eso llevaba tiempo sin detectarse.* - [x] `colspan` incorrecto en dos estados vacíos: listado de gastos (4 → 6) y gastos recientes del dashboard (7 → 5). - [x] Paginación de gastos de `<div>` a `<nav aria-label="Paginación de gastos">`. - [x] Grupo de filtro por etiquetas con `role="group"` + `aria-label`, y `aria-hidden` en el "Tags:" visible para que no se lea dos veces. - [x] `goals/list.html` empezaba en `<h2>` → `<h1>` (resto del punto 1.9, ver Tanda 2). --- ## Tanda 4 — Home ✅ COMPLETADA Ver `01_home.md`. - [x] **Saldo total + desglose por cuenta.** Calculado con dos agregaciones agrupadas por cuenta (3 consultas fijas) en vez de llamar a `current_balance()` en bucle, que reproduciría el N+1 anotado para el dashboard (Code 2.1). **Ojo**: no se puede resolver con un solo `annotate()` de dos `Sum` sobre `expenses` e `incomes` — el join multiplica filas e infla los totales. - [x] **Banner de avisos**: presupuestos excedidos y cuentas activas en negativo. Solo aparece si hay algo que señalar. Reutiliza las clases `.message` de Django (`warning` para presupuestos, `error` para cuentas) en vez de inventar clases. - [x] **Comparativa gasto mes actual vs anterior**, con importe y porcentaje. Cuando el mes anterior es 0 no se calcula el porcentaje (dividir por cero, y "+100%" desde cero engaña más que informa). Lleva una nota fija de que el mes en curso está incompleto. - [x] **Últimos movimientos**: gastos e ingresos mezclados y ordenados por fecha, 8 como máximo. Sin enlace por fila a propósito: comprobar `expense.fuel_data` para elegir la URL de edición dispararía una consulta por fila. - [x] **Objetivos consultados en una sola query** y filtrados en Python (los de `show_on_home` para el widget, los excedidos para el banner). Es deliberado: `Goal.progress` es una `cached_property`, así que compartir instancias evita recalcular el progreso de un objetivo que aparezca en ambos sitios. `is_exceeded()` cortocircuita si el tipo no es presupuesto, así que pago y ahorro no tocan `progress`. **Arreglados de paso:** - [x] **ARIA en la barra de progreso del widget de objetivos del home** — se quedó fuera de la Tanda 3, donde solo se tocaron `goals/list.html` y el dashboard. Mismo patrón, con `aria-valuenow` sobre `bar_width` (no `percentage`) para no salirse del `aria-valuemax="100"` declarado cuando un presupuesto se pasa. - [x] `<h3>Objetivos</h3>` → `<h2>`: la página empieza en `<h1>` y el resto de secciones son `<h2>`. - [x] **Espaciado vertical del home**: no existía ninguna regla de margen para `section` ni para `.btn` en todo el CSS. El home antiguo lo disimulaba con `<br>` sueltos. Resuelto con `.home-section` y `.section-actions`, sin tocar reglas globales para no descolocar el dashboard ni el listado de gastos. - [x] La clave de contexto `last_expenses` desaparece, sustituida por `movements`. --- ## Tanda 4b — Formularios y pantallas de contraseña ✅ COMPLETADA Detectado al terminar la Tanda 3, no estaba recogido en ninguna tanda anterior: la Tanda 2 unificó botones y la fila de acciones (`.form-actions`), pero nunca tocó los campos en sí. **Diagnóstico de partida:** no existía **ni una sola regla** para `input`, `select` o `textarea` en `base.css`. Solo estaban `.form-field` (un `margin-bottom`) y `.form-errors` (color). Por eso cada campo se veía de un tamaño distinto: sin CSS, cada control usa su ancho intrínseco del navegador. - [x] **Estilos base para controles de formulario**: ancho, altura, padding, borde, tipografía y `:focus` con las variables semánticas existentes, así que funcionan en claro y oscuro sin trabajo extra. **Decisión clave: las reglas se acotan a `.form-field`** en vez de escribirlas sobre `input`/`select`/`textarea` a pelo. Así los controles con tratamiento propio quedan fuera *por construcción* —las chips de tags, el `.checkbox` del dashboard y los selects de la fila de filtros— sin depender de una lista de excepciones que se olvida. - [x] **Estructura de campo unificada**: parcial nueva `expenses/templates/expenses/_form_fields.html` (label + widget + `help_text` + errores), aplicada a todas las plantillas de formulario en sustitución de `form.as_p` y del bucle manual. El caso especial de las chips de `tags` vive dentro de la parcial para no repetirlo. Las casillas invierten el orden (control antes que etiqueta) porque a todo lo ancho se leen fatal. - [x] **Pantallas de contraseña, login y ayuda** rehechas con la parcial y el mismo lenguaje visual. Typo corregido en `password_help.html` ("administrados"). **Bug real destapado — plantillas sombreadas por el admin:** `/accounts/password_change/` estaba renderizando la pantalla del **admin de Django**, no la de la app. La causa no eran las URLs (`urls.py` estaba bien) sino el orden de `INSTALLED_APPS`: `django.contrib.admin` publica sus propias `registration/password_change_form.html` y `password_change_done.html`, y el cargador de plantillas por aplicación recorre `INSTALLED_APPS` en orden quedándose con la primera coincidencia. Al ir el admin antes que `expenses`, ganaba siempre. Esto explicaba una contradicción que costó desenredar: las plantillas de la app extendían `"base.html"` (ruta inexistente; la base real es `"expenses/base.html"`), así que si se hubieran renderizado habrían lanzado `TemplateDoesNotExist` — pero nunca se renderizaban, así que el error jamás salió. El login no estaba afectado porque el admin publica el suyo en `admin/login.html`, no en `registration/`. - [x] `"expenses"` movido **por encima** de `"django.contrib.admin"` en `INSTALLED_APPS`, con comentario explicando por qué, para que nadie lo ordene alfabéticamente y reintroduzca el bug. - [x] Las dos plantillas de contraseña pasan a extender `"expenses/base.html"`. - [x] Inventario de sombreado verificado con `get_template().origin.name` antes y después: solo estaban afectadas esas dos. Ninguna plantilla de `expenses/`, `goals/` o `categories/` colisiona con `django.contrib.*`. **Efecto secundario aceptado:** `/admin/password_change/` ahora usa la plantilla de la app (navegación de la app en vez del look del admin). Funciona — `AdminPasswordChangeForm` hereda los mismos tres campos, así que la parcial los renderiza bien— y se decide dejarlo así: es un flujo interno que se usa muy de vez en cuando, y de paso las dos rutas de cambio de contraseña llevan ahora a la misma pantalla, lo que es más coherente, no menos. **Criterio para el futuro:** si algún día hay que sobrescribir más plantillas del admin y empiezan a chocar, entonces sí toca mover las de la app a un directorio propio en `TEMPLATES["DIRS"]`, que tiene prioridad sobre las de aplicación sin depender del orden. Reordenar es la solución barata mientras solo se sombreen las de `registration/`. **Bugs corregidos de paso:** - [x] `goals/form.html` ponía "Nuevo objetivo" siempre, también al editar. - [x] `categories/list.html` tenía la columna de acciones en el `<tbody>` pero solo dos `<th>` en el `<thead>`. Mismo tipo de desajuste que los `colspan` de la Tanda 3. Sin migración, sin cambios en vistas: CSS, plantillas y una línea de `settings.py`. --- ## Tanda 5 — Listado de gastos (la pieza de filtrado) Ver `02_listado_gastos.md`. **Es la tanda más grande de producto.** - [ ] Filtrado instantáneo sin recargar (introduce el primer JS de entidad). - [ ] Total de lo filtrado, ordenar por columna, exportar CSV/Excel, rango de fechas. - Técnico asociado: al reescribir la vista para responder JSON/fragmento, resolver de paso la **query duplicada de `Tag`** (Code 2.7) y añadir `select_related`/ `prefetch_related` (Code 2.5). - Ver `09_transversales.md`: el rango de fechas y el mecanismo dinámico deben diseñarse pensando en reutilizarlos en el dashboard. - **Ojo al repintar la tabla con JS**: los `id` de los selects de filtro y los atributos ARIA añadidos en la Tanda 3 tienen que sobrevivir al fragmento nuevo. Es fácil perderlos al generar HTML desde JS. --- ## Tanda 6 — Dashboard (producto + el N+1) Ver `03_dashboard.md`. Es la vista más usada para análisis y la que más se beneficia de tocar producto y rendimiento a la vez. - [ ] Tarta por categorías, desglose por subcategorías al pinchar, presupuestos integrados, rango de fechas, ingresos vs gastos. - Técnico asociado (importante hacerlo *en la misma pasada*): - **N+1 de saldos por cuenta** (Code 2.1): ~40 queries con 5 cuentas. - **Refactor de la función `dashboard`** (Code 3.4): 226 líneas, 5 responsabilidades, contexto de 35 claves. Extraer en `_resolve_period`, `_build_comparison`, `_build_account_charts`. - El desglose por subcategorías reutiliza el mecanismo dinámico de la Tanda 5. - Aprovechar para la **jerarquía de encabezados** de esta vista (punto 1.9 pendiente de la Tanda 2), que hoy no tiene `<h1>`. - Limpiar el `<script>` muerto de `clearPeriodPreset()` (definido y nunca llamado) y el bloque de script comentado de los presets. - **El N+1 de saldos ya está resuelto a medias**: el helper `_account_balances` existe desde la Tanda 7 y solo hay que usarlo aquí. - **Escala tipográfica de encabezados** (surge al arreglar la jerarquía): `base.css` nunca ha definido tamaños de encabezado, así que al bajar los niveles todo pasa a los del navegador (32/24/18.7px), que en una vista densa de datos compiten con las propias cifras. Escala acordada: `h1` 1.625rem, `h2` 1.25rem, `h3` 1rem, peso 600, con márgenes fijos en `rem` (los del navegador van en `em` y escalan con la fuente, dejando huecos irregulares). **Afecta a toda la app**, no solo al dashboard: repasar el resto de vistas después. Es el mismo agujero que el espaciado del home y los controles de formulario — CSS que nunca se escribió. ### Traspasos entre cuentas — los gastos totales están inflados **Problema real detectado al revisar el dashboard.** Hoy un traspaso entre cuentas se registra como un `Expense` en la cuenta origen y un `Income` en la destino. Los saldos cuadran, pero **la salida cuenta como gasto** en el dashboard, en la comparativa del home y en el progreso de los presupuestos, aunque el dinero no haya salido del patrimonio. Caso típico: al cobrar la nómina se manda una cantidad a otra cuenta. Asimetría del modelo que condiciona la solución: **`Income` no tiene categoría** (solo nombre, importe, fecha y cuenta), así que "marco una categoría de traspaso y la excluyo" solo cubre la mitad de la operación. **Decisión tomada: booleano `is_transfer` en `Expense` y en `Income`.** Los marcados quedan fuera de todas las agregaciones de análisis pero siguen contando para el saldo de la cuenta. No cambia la forma de registrar: se siguen metiendo las dos patas, ahora marcadas. La alternativa era un modelo `Transfer` propio (origen, destino, importe, fecha), que es lo correcto de diseño: un solo registro en vez de dos sueltos que nada relaciona, y `Expense`/`Income` recuperando su significado de dinero que entra o sale de verdad. **Se descarta por ahora** porque obliga a tocar `current_balance()`, `monthly_balance()`, `balance_until()` y `monthly_net()`, que es el código con más tests y del que cuelga medio dashboard. El booleano arregla el problema que molesta y es reversible: el día que exista `Transfer`, los registros ya marcados son exactamente los que hay que migrar. Lo que el booleano NO da: nada garantiza que se marquen las dos patas ni que los importes coincidan. Con un solo usuario y disciplina, sirve. - [ ] Campo `is_transfer` en `Expense` e `Income` (**migración**), con su casilla en los dos formularios. - [ ] Excluir los marcados de **todas** las agregaciones de análisis: KPIs y gráficos del dashboard, comparativa y KPIs del home, `by_category`, y `Goal.progress` para los tipos pago y presupuesto. Repasar el proyecto entero, no solo el dashboard. - [ ] **Que sigan contando para el saldo**: `current_balance()`, `monthly_balance()`, `balance_until()` y `monthly_net()` NO deben filtrarlos. Es justo lo contrario que el punto anterior, y confundirlo descuadraría todos los saldos. - [ ] Distintivo visual en el listado de gastos e ingresos, para reconocerlos de un vistazo. - [ ] Tests: que un traspaso no sume a los gastos del dashboard ni al progreso de un presupuesto, y que sí afecte al saldo de las dos cuentas. **Aviso sobre los datos históricos:** los traspasos ya registrados que no llevan categoría **no se pueden identificar automáticamente** — nada distingue un gasto real de un traspaso sin marcar. Hay que repasarlos a mano o asumir que lo antiguo queda sucio y que a partir de la migración está bien. Mirar cuántos son antes de decidir. **Conecta con la Tanda 9**: meter dinero en una cuenta remunerada es un traspaso, no un gasto. Tener esto resuelto antes le quita medio problema al módulo de inversiones. ### Clasificación de categorías: comprometido vs flexible (Idea A) Es la **Idea A** de `10_modulo_inversiones.md`, que ese documento ya marcaba como independiente del módulo de inversiones y hacedera en cualquier momento. Va aquí y no en la Tanda 9 porque no comparte nada con el patrimonio: clasificar categorías es **análisis del dinero que sale**, el módulo de inversiones es **el dinero que está guardado**. Además el sitio donde esto se ve es el dashboard, que ya se abre en esta tanda. **Dos campos, no uno de tres estados.** "Fijo esencial", "fijo prescindible" y "variable" mezclan dos ejes distintos y dejan fuera la casilla de **variable esencial** (compra, gasolina, farmacia), que es probablemente la mayor parte del gasto. Con dos campos se pueden responder dos preguntas que hoy no se pueden: - `is_fixed` (fijo / variable) — cuánto del dinero está comprometido de antemano. - `is_essential` (esencial / prescindible) — cuánto se podría recortar apretándose el cinturón. **Herencia con vía de escape.** El campo existe en todas las categorías y admite estar **vacío**, y vacío significa "lo que diga mi padre". Así se rellenan solo los padres y los hijos heredan, pero un hijo que se salga de la norma (el seguro del coche dentro de "Suscripciones", que es prescindible) se puede marcar aparte. Limitar el campo solo a las categorías padre daría el mismo ahorro sin esa salida. **Coste a saber de antemano:** agregar por un atributo heredado **no se puede hacer en SQL directamente**. Para el desglose del dashboard hay que resolver la herencia en Python antes de sumar. Con pocas categorías no es problema — el árbol ya se monta en memoria en `category_list` — pero es trabajo real, no un campo y ya. Partirlo en dos fases: - [ ] **Fase 1**: los dos campos, su edición en el formulario de categorías y la columna en el listado. Lleva **migración**. Las categorías existentes quedan vacías a propósito: mejor rellenarlas a mano que inventar un valor por defecto del que luego no te fías. - [ ] **Fase 2**: el desglose comprometido/flexible en el dashboard, con la herencia resuelta en Python. Hacerla cuando ya haya categorías clasificadas y se vea si la información resulta útil. --- ## Tanda 7 — Vistas de gestión ✅ COMPLETADA (parcialmente) Ver `04`–`07`. Se acotó a propósito a lo que no necesita migración de esquema ni operaciones destructivas. - [x] **Cuentas: saldo en el listado sin N+1.** El saldo ya se mostraba, pero llamando a `account.current_balance` desde la plantilla: 2 consultas por cuenta. Extraído el helper `_account_balances(user, active_only=False)` en `views.py`, que hace 3 consultas fijas, y `home()` pasa a usarlo también (era el segundo sitio con la misma lógica). Saldo negativo en rojo reutilizando `.amount-negative` de la Tanda 4. **Disponible para la Tanda 6**, que tiene el mismo N+1 pendiente. - [x] **Categorías: árbol jerárquico + nº de gastos.** Árbol montado en memoria desde una sola consulta (helper `_category_tree`), con profundidad capada a 4 y sangría por clases `.cat-depth-1..4`. Un padre fuera del conjunto se trata como raíz para que ninguna categoría desaparezca en silencio. La columna "Categoría padre" se retira: la jerarquía ya se ve por la sangría. El contador cuenta **solo gastos directos**, no los de descendientes (decisión fijada con un test). - [x] **Etiquetas: contador de uso + paso de `<ul>` a `<table>`.** Esto **cierra el punto 1.8 del análisis de Code** (patrones UI distintos para datos iguales), que seguía abierto desde la Tanda 2. - [x] **Objetivos: filtrar por tipo**, validando el `?kind=` contra `KIND_CHOICES` (un valor inventado se ignora en vez de romper, mismo criterio que `_get_int`), con label `.sr-only` y estado vacío nuevo para cuando el filtro no devuelve nada. - [x] **Unificado el coloreado de la barra de progreso**: `dashboard.html` pasa del `{% if %}` sobre `percentage` a `{{ goal.progress_state }}`, como ya hacían `goals/list.html` y `home.html`. Las clases `.low`/`.medium`/`.high` quedaron muertas y se retiraron de `base.css`. **Extras hechos después, en el mismo bloque de trabajo:** - [x] **Orden alfabético insensible a mayúsculas** en `Tag`, `Category` y `Account`: `ordering = [Lower("name")]`. Antes SQLite ordenaba por valor binario (`AA, MK, ZZ, mk, ms`) y, lo que es peor, **el orden difería entre SQLite en local y PostgreSQL en el NAS**; ahora se calcula en la consulta y coincide. `goal_list` pasa también a `.order_by(Lower("name"))`. **Tres sitios NO se propagaban solos** y hubo que tocarlos: los `.order_by("name")` explícitos de `tag_list` y `account_list` (se convirtieron a `Lower`, no se borraron, porque el `order_by` explícito protege el `annotate`) y el `sorted(key=lambda c: c.name)` de `_category_tree`, que ordenaba en Python por code point igual que SQLite. - [x] **Panel de ayuda en el formulario de objetivos**: `<details>` nativo (sin JS, accesible por teclado de serie, mismo patrón que los filtros avanzados de gastos) explicando los tres tipos y para qué sirve cada campo. **Migraciones**: `0011` (ordering de `Tag`) y `0012` (los tres `AlterModelOptions` con `Lower`). Ninguna toca el esquema, pero hay que desplegarlas. **Queda fuera, para otro momento:** - [ ] Cuentas: marcar principal (🟡) y reordenar (🔴). Ambas necesitan campo nuevo. - [ ] Etiquetas: fusionar (🔴). Operación destructiva, necesita transacción. - [ ] Objetivos: histórico de cumplidos (🟡). Docstrings de `Goal.progress`/`_period_start` (Code 3.5) al tocar el modelo. - [ ] Separar `category_create` del listado (Code 2.6). - [ ] **Etiquetas de formulario en inglés**: `GoalForm` muestra "Category", "Include subcategories", "Show on home"... porque los campos no tienen `verbose_name`. Es la versión visible para el usuario de la deuda de "comentarios en dos idiomas". Pase aparte, con migración. --- ## Tanda 8 — Módulo de vehículos (módulo nuevo grande) Ver `08_repostajes_vehiculos.md`. Es prácticamente un módulo nuevo; abordar entero, no a trozos sueltos. Orden interno sugerido en su propio archivo. --- ## Tanda 9 — Módulo de inversiones / patrimonio (el más grande) Ver `10_modulo_inversiones.md`. La evolución de mayor alcance. Empezar por registro manual de los cuatro tipos; precios automáticos como capa posterior (cripto primero). La Idea A (clasificar gasto fijo/variable) NO va aquí: se ha movido a la Tanda 6, con dos campos (`is_fixed` e `is_essential`) en vez del campo único de tres estados que se planteó en la ideación. Ver el detalle allí. --- ## Refactors grandes — decididos NO hacer por ahora Anotados a conciencia como "no hacer salvo que duela de verdad": - **Migrar a class-based views** (Code 2.3): reduciría ~300-400 líneas, pero toca 20+ vistas que hoy funcionan y están cubiertas por tests. Riesgo/beneficio no compensa en un proyecto personal. Dejar salvo reescritura por otro motivo. - **Partir `views.py` en un paquete `views/`** (Code 3.3): más razonable que lo anterior (es mover funciones, bajo riesgo) pero no urge. Hacerlo el día que el archivo estorbe de verdad al trabajar, y de forma incremental. - **Reorganizar carpetas de plantillas** (Code 2.8): puramente cosmético. Solo si se hace a la vez que otra reestructuración. --- ## Deuda ya conocida (de la fase anterior, no del análisis de Code) - **Jenkins**: build de la imagen Docker en CI + despliegue automático desde `main`. Preparado, pendiente de ejecutar cuando se quiera (ver `TODO_revision.md`). - **`gunicorn` en requirements pero no en el venv** (Code 3.6): mismo patrón que `whitenoise`. Revisar todas las dependencias dev vs producción de una vez. - **Comentarios en dos idiomas** (Code 3.7): fijar convención (español en comentarios, inglés en identificadores, que es lo mayoritario) y aplicarla a partir de ahora sin reescribir lo viejo. --- ## Lecciones que conviene no perder - **Los tests de pytest no ven el navegador.** Un gráfico roto por JS y "75 passed" conviven sin problema (pasó con el `t.startsWith` de Chart.js). Todo lo visual y todo lo de JS se valida a mano en DevTools, en móvil y en claro/oscuro. - **Los defectos de layout con flex solo asoman con datos extremos.** El solapamiento de las barras de progreso llevaba desde la Tanda 2 y no se vio hasta tener un objetivo al 1017%. Merece la pena probar las vistas con valores largos y absurdos, no solo con datos realistas. - **La misma información en dos vistas tiende a divergir.** Ha pasado ya dos veces con las barras de objetivos (`bar_width` en un sitio y `percentage` en otro, `progress_state` en un sitio y un `{% if %}` en otro). Cuando un cálculo vive en el modelo, usarlo en todas partes en vez de replicarlo en plantilla. - **Una plantilla que nadie abre se queda atrás.** El widget de objetivos del home no llevaba los ARIA de la Tanda 3 simplemente porque en esa sesión no se abrió el archivo. Cuando un cambio afecta a "todas las barras de progreso" o "todos los formularios", conviene un grep del patrón antes de dar la tanda por cerrada. - **El CSS sin reglas no es CSS neutro.** Ni `section` ni `.btn` tenían margen, y el home antiguo lo disimulaba con `<br>` sueltos hasta que llegó contenido de verdad. El mismo patrón apareció en los formularios (Tanda 4b): sin reglas para `input`/`select`/`textarea`, cada control usa su ancho intrínseco del navegador. - **Acotar el selector es mejor que mantener excepciones.** En la Tanda 4b las reglas de los controles se colgaron de `.form-field` en vez de escribirlas sobre `input` o `select` directamente. Las chips, el checkbox del dashboard y los filtros quedan fuera porque no están dentro de ese contenedor, no porque alguien se acordara de excluirlos. Una lista de excepciones se queda desfasada; una regla acotada no. - **Una pantalla que "se ve mal" puede no ser tuya.** El cambio de contraseña llevaba desde el principio sirviéndose desde `django.contrib.admin` por el orden de `INSTALLED_APPS`. El síntoma era estético, la causa era de configuración, y solo salió al empeñarse en rediseñarla. Cuando una plantilla no responde a los cambios, confirmar con `get_template("ruta").origin.name` **qué archivo está usando Django de verdad** antes de seguir tocándola. - **Contrastar los resúmenes de Code con el `git diff`.** En un mismo informe afirmó que el proyecto no tenía repositorio git (había comprobado `git status` en la carpeta contenedora, no en la del repo) y que `expense_form.html` tenía etiquetas roto cuando estaba bien formado. Los cambios eran correctos; la descripción no. El diff tarda menos en leerse que en deshacer un cambio hecho sobre una premisa falsa. - **`Meta.ordering` no llega a todas partes.** Al ponerlo en `Tag` había tres sitios que lo pisaban: dos `.order_by("name")` explícitos en vistas y un `sorted()` en Python dentro de `_category_tree`, que ordenaba por code point igual que SQLite. Un cambio "en el modelo y ya" hay que verificarlo en pantalla, no darlo por propagado. - **El orden por defecto dependía del motor.** Con `ordering = ["name"]`, SQLite en local y PostgreSQL en el NAS ordenaban distinto y nadie lo había notado. Cualquier cosa que delegue en la base de datos (orden, colación, comparaciones de texto) puede comportarse distinto en los dos entornos. `Lower("name")` lo resuelve para mayúsculas; los acentos siguen dependiendo de la colación. - **Un punto del prompt no es un punto hecho.** El contador de gastos por categoría se pidió en la Tanda 7, no se implementó, y se dio por cerrado sin comprobarlo en pantalla. Repasar la lista del prompt contra la vista real antes de cerrar una tanda.
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: jkuijperm/expenses_manager#29
No description provided.