TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
Planilla mensual de hábitos con puntaje por tarea y por día.
Translation pending
ProductivityJavaScript · CSSGitHub @juanchimar25OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Webapp responsive para llevar un seguimiento mensual de tareas/hábitos en una planilla de doble entrada: las filas son las tareas y las columnas los días del mes.
Sin dependencias, sin build, sin backend: HTML + CSS + JavaScript puro. Se abre haciendo doble clic en index.html.
tareas-diarias/
├── index.html
├── styles.css
├── app.js ← la planilla y todo lo que la rodea
├── auth.js ← la sesión (Supabase Auth)
├── config.js ← ⚠ acá van tus credenciales
├── manifest.webmanifest ← se instala como app — ver «En el teléfono»
├── sw.js ← el service worker: sin conexión y el recordatorio
├── pwa.js ← lo registra
├── iconos/ ← PNG del launcher, generados por tools/
├── db/
│ ├── supabase-app-state.sql ← la tabla que la app usa hoy
│ ├── schema.sql ← el esquema por tablas (etapa futura)
│ └── MIGRACION.md
└── README.md
La app ya no se abre con doble clic.
auth.jses un módulo ES y el navegador los bloquea sobrefile://. Hay que servirla por http(s) — ver abajo.
1. Crear el proyecto en Supabase. En supabase.com, proyecto nuevo.
2. Crear la tabla de datos. Copiar db/supabase-app-state.sql
entero en el SQL Editor y ejecutarlo. Este paso es obligatorio: es la tabla donde la app
guarda tus tareas. El script termina con una consulta de comprobación que tiene que devolver
una fila; si devuelve cero, la política de Row Level Security no quedó creada y la base va a
rechazar todo, incluso al dueño de los datos.
2b. Habilitar la baja de cuenta. Copiar db/eliminar-cuenta.sql en el
SQL Editor y ejecutarlo. Crea eliminar_cuenta(), la función que usa el botón Eliminar cuenta
de Ajustes. Es opcional: sin ella la app funciona igual y el botón avisa que falta instalarla.
2c. Habilitar la sincronización incremental. Copiar
db/sincronizacion-incremental.sql en el SQL Editor y
ejecutarlo. Crea merge_app_state, que mezcla los parches del lado de la base. También es
opcional: sin ella la app sube el estado entero, como hacía antes.
db/schema.sql es otra cosa: el esquema normalizado por tablas de la
etapa 3 de db/MIGRACION.md, que la app todavía no usa. No hace falta
cargarlo para que funcione.
3. Pegar las credenciales. En Project Settings → API están la URL y la anon key.
Van en config.js:
window.SUPABASE_URL = 'https://xxxxxxxx.supabase.co';
window.SUPABASE_ANON_KEY = 'eyJhbGciOi...';
La anon key es pública por diseño y puede vivir en el navegador: lo que protege los datos es Row Level Security. La service role key nunca va acá.
Al cambiar de proyecto hay que actualizar también el connect-src de la CSP, en
vercel.json y _headers: si no, el navegador bloquea las llamadas a
la base nueva. npm run check lo verifica contra config.js y avisa si quedaron desalineados.
4. Servirla. En local, npx serve . --single y abrir la URL que imprime. Para usarla de
verdad, publicarla en Netlify, Vercel o GitHub Pages —los tres gratis— y registrar esa URL en
Authentication → URL Configuration de Supabase.
El --single importa: cada sección tiene su propia dirección (ver Rutas) y el servidor
tiene que devolver index.html en todas.
Si config.js sigue con los valores de ejemplo, la pantalla de login lo dice en lugar de fallar
en silencio.
Una notificación del sistema a la hora que elijas, todos los días: «Recordá completar tu día». Se configura desde el menú lateral, en 🔔 Recordatorio diario, y se apaga desde el mismo lugar.
Por qué esto necesita un servidor. Una web —aunque esté instalada como app— no puede despertarse sola a una hora fija. La API que servía para eso, Notification Triggers, fue un experimento de Chrome que se retiró y nunca se lanzó. Un
setTimeoutsolo corre mientras la página está viva, así que con la app cerrada no hay nada que dispare.Por eso el aviso lo manda un servidor: una Edge Function que
pg_crondispara cada cinco minutos y que le manda un Web Push a quien le toque. Es la única forma de que suene con la app cerrada.
La app es la única parte que no decide cuándo avisar: solo registra el dispositivo y guarda la hora.
Son cuatro pasos y hay que hacerlos a mano, en tu proyecto de Supabase.
1. Generar el par de claves VAPID. Identifican a este servidor ante los servicios de push:
npm run vapid
Imprime dos claves. La pública va en config.js —es pública por diseño, como la anon key— y la privada nunca al repositorio: va como secreto de la Edge Function. Se generan una sola vez: regenerarlas invalida todas las suscripciones activas y cada persona tendría que volver a activar el recordatorio.
2. Crear las tablas y el reloj. Copiar db/notificaciones.sql entero en el SQL Editor. Antes de ejecutarlo hay que reemplazar <PROYECTO> y <SERVICE_KEY> en la definición del cron. Termina con una consulta que tiene que devolver tres filas; si devuelve menos, algo no quedó creado — y como con app_state, sin RLS la base rechaza todo.
3. Publicar la Edge Function con sus secretos:
supabase functions deploy recordatorios
supabase secrets set VAPID_PRIVATE_KEY="…" # la privada del paso 1
supabase secrets set VAPID_PUBLIC_KEY="…" # la pública, la misma de config.js
supabase secrets set VAPID_SUBJECT="mailto:[email protected]"
4. Probar. Activar el recordatorio en la app con una hora a cinco minutos de ahora, y mirar los logs de la función. Si algo falla, el diálogo de la app dice qué falta antes de dejar activarlo: navegador sin soporte, clave VAPID sin configurar, sesión cerrada o permiso bloqueado.
| Pieza | Dónde |
|---|---|
| Genera el par VAPID | tools/generar-vapid.mjs — se autoverifica antes de imprimir |
| Tablas, RLS y el cron | db/notificaciones.sql |
| Quién recibe el aviso y cuándo | supabase/functions/recordatorios/index.ts |
| La firma y el cálculo de la hora | supabase/functions/recordatorios/vapid.mjs |
| Mostrar la notificación | push y notificationclick en sw.js |
| Permiso, suscripción y preferencia | Sección Recordatorio diario de app.js |
Son dos tablas porque son dos cosas distintas. push_subscriptions es por dispositivo —cada navegador donde actives el recordatorio deja su endpoint, y podés tener el teléfono y la computadora—; push_reminders es por usuario: a qué hora querés que te avisen. Guardar la hora por dispositivo obligaría a configurarla en cada uno.
El push va sin contenido. Mandar texto obliga a cifrar el cuerpo con la clave del navegador —ECDH, HKDF y AES-GCM—, y acá el texto es siempre el mismo. Sin payload, todo lo que hace falta es la firma VAPID, que son unas pocas líneas de crypto.subtle. El texto lo pone el service worker al recibirlo. Las claves de cifrado se guardan igual, para que mandar texto variable más adelante no obligue a que todos vuelvan a suscribirse.
La hora se guarda en local, con el desfase aparte. Es la que ves en tu reloj; el servidor la lleva a UTC con utc_offset. Ese desfase se reescribe en cada guardado, así que si viajás o entra el horario de verano, el aviso sigue llegando a la hora que elegiste.
La ventana del disparo va hacia adelante, no a los dos lados. El cron corre cada cinco minutos, así que una ventana simétrica mediría diez —el doble— y dos disparos seguidos caerían adentro: el recordatorio llegaría repetido. Con la ventana hacia adelante cae exactamente uno, sea cual sea el minuto elegido.
npm run checklo comprueba simulando los 288 disparos de un día sobre cuatro combinaciones de hora y zona, incluida la que cruza la medianoche.
La app lo propone una vez por aparato: una franja sobre la planilla que dice «¿Te avisamos para completar el día?» con dos botones. Activar pide el permiso, registra el dispositivo y lo deja andando a las 22 de un clic; Ahora no lo cierra y no vuelve a preguntar.
Es una franja y no un diálogo a propósito: interrumpir la pantalla al abrir para pedir un permiso que nadie pidió es la forma más rápida de que lo rechacen para siempre —un permiso denegado no se puede volver a pedir desde la web—.
Solo aparece cuando se puede aceptar: con soporte del navegador, con sesión, sin el permiso ya denegado y sin un recordatorio ya activo.
Que se haya ofrecido se guarda por aparato, junto al zoom y al tema, aunque parezca una decisión del usuario y no una preferencia de pantalla. Es a propósito: la suscripción al push también es por navegador, así que ofrecerlo de nuevo en el teléfono cuando se activó en la computadora es lo correcto — ahí todavía no está activo.
No se puede activar por todos desde la base. Hace falta una suscripción en push_subscriptions, y esa solo la crea el navegador de cada persona al aceptar el permiso. Un update ... set activo = true dejaría a la función buscándolos todos los días para no mandarle nada a nadie, y en la base figurarían como activos. La hora sí se puede mover para quien nunca configuró nada — ver el final de db/notificaciones.sql.
La app es una PWA instalable: se agrega al launcher como cualquier otra, abre a pantalla completa y arranca sin conexión.
Para instalarla en Android, abrila en Chrome y elegí Instalar aplicación en el menú de tres puntos. Si no aparece, es que falta alguna de las condiciones de abajo. En iPhone es Safari → Compartir → Agregar a inicio; iOS no lee el manifest, así que la pantalla completa y el ícono los piden los <meta name="apple-*"> de index.html.
Hacen falta tres cosas, y las tres ya están resueltas:
| Dónde | |
|---|---|
| HTTPS | Lo dan Vercel, Netlify y GitHub Pages. En localhost también cuenta, así que se puede probar en local |
| Manifest | manifest.webmanifest — nombre, íconos, display: standalone y los atajos a Diario y Análisis |
| Service worker | sw.js, registrado por pwa.js |
Las rutas del manifest son relativas, no absolutas: así se instala igual servida en la raíz del dominio que en un subdirectorio, que es el caso de GitHub Pages. npm run check falla si alguna se vuelve absoluta.
El service worker precachea el armazón: el HTML, el CSS, los tres scripts, el cliente de Supabase y los íconos. Con eso la app dibuja aunque no haya red.
Los datos no se cachean ahí. Ya viven en localStorage, que es local, y en Supabase. Las llamadas a la base ni se tocan: una respuesta vieja de app_state servida desde el caché sería peor que un error, porque la app la tomaría por buena y subiría encima. Sin red, hydrateStateForUser cae en la copia local y avisa que no está sincronizando — el mismo camino de siempre.
| Qué pide la app | Qué hace el service worker |
|---|---|
Una ruta (/diario, /analisis) | Red primero; sin red, el index.html cacheado |
| CSS, scripts, íconos | Del caché, y se refresca de fondo |
| Cualquier cosa que no sea GET, o de otro origen | Pasa derecho — ahí adentro está Supabase |
Como los recursos salen del caché, una versión nueva entra recién en la carga siguiente: la primera visita después de publicar todavía muestra la anterior. Es el precio de que abrir sea instantáneo.
Al publicar cambios hay que subir
VERSIONensw.js. Es lo que invalida el caché viejo; si se olvida, los navegadores que ya tienen la app instalada siguen sirviendo el armazón anterior. La listaARMAZONla verificanpm run check, que falla si nombra un archivo que no existe o siindex.htmlcarga unvendor/que el service worker no precachea.
Los PNG de iconos/ se generan con tools/generar-iconos.mjs —npm run iconos— a partir del mismo logo que el favicon. El PNG se codifica a mano con zlib, que viene en Node: la app no tiene dependencias y el .npmrc bloquea los scripts de instalación, así que arrastrar una librería de imágenes para tres archivos que casi nunca cambian no valía la pena. Los PNG se versionan; el script existe para rehacerlos si cambia el logo, no para correr en cada build — la app sigue sin tener build.
Hay tres: 192 y 512 con fondo transparente, y un maskable de 512. Ese último lo recorta el launcher a la forma que quiera —círculo, gota, redondeado—, así que lleva fondo opaco y el logo dentro de la zona segura, el 80% central. Sin él, Android encierra el ícono en un cuadrado blanco.
No hay widgets en la pantalla de inicio ni notificaciones programadas para recordarte una tarea. Eso pide una app nativa, que significaría reescribir la interfaz y —más caro— duplicar toda la lógica de cálculo: metas por período, períodos partidos, días transcurridos. Dos implementaciones que tienen que dar el mismo número, o el teléfono y la computadora se contradicen.
Tampoco está en Play Store. Se puede empaquetar como TWA con Bubblewrap —lo de acá es requisito de eso, así que no hay trabajo tirado—, pero adentro seguiría siendo esta misma app.
Cada sección tiene dirección propia, se puede compartir y responde a los botones de atrás y adelante del navegador:
| Sección | Dirección |
|---|---|
| Hábitos | /diario |
| Análisis | /analisis |
| Pendientes | /pendientes |
| Ajustes | /ajustes |
| Cómo usar Hábitos | /comousar |
| Cómo usar Análisis | /comousar-analisis |
| Cómo usar Pendientes | /comousar-pendientes |
Sigue siendo una sola página: app.js reescribe la URL con la History API y muestra la
sección correspondiente. Cualquier ruta desconocida cae en Diario.
La guía es una por sección, y cada paso es un dibujo y una frase. El ☰ de Hábitos, el de Análisis y el de Pendientes ofrecen cada uno Cómo usar su pantalla, y ninguna habla de las otras. Antes era un solo texto que las recorría todas: para entender Pendientes había que atravesar Hábitos y Análisis, y cada cosa nueva lo hacía más largo sin hacerlo más útil.
Adentro, cada paso es el dibujo de lo que hay que tocar y una frase de lo que hace — el +, el ✎, los cuatro cuadrados de color, el ☰ —. Antes eran párrafos que explicaban por qué cada cosa es como es: cierto todo, pero Cómo usar no se abre para leer, se abre con una duda concreta y con la app delante. La de Hábitos pasó de 3.941 caracteres a 477.
Los dibujos son los controles de verdad: el
+de la guía es el mismopathque dibuja el botón de la esquina, y los cuadrados llevan las mismas clases que la leyenda del pie. Un ícono parecido pero propio sería una cosa más que aprender antes de encontrar el botón, y se desincronizaría sin que nadie se entere — hay un caso que compara los dos.El recuadro de estado se recorre solo: vacío → cumplido → a medias → no cumplido → no requerido, diez segundos la vuelta, un segundo quieto y un segundo fundiendo en el siguiente. Es la única manera de mostrar lo que la frase tendría que explicar; con el recuadro quieto hay que decir «cada toque pasa al siguiente» y aun así imaginarse el resto. Los colores son los mismos
--*-bgque la lista, y hay un caso que lo comprueba: la guía no puede quedar enseñando un verde que ya no existe.Ese ciclo lleva un
animation-duration: 10s !importantque le gana al freno de «reducir movimiento» — la regla del final destyles.cssque le pone.01msa toda animación. Esa regla está para que nada se sacuda, pero a un ciclo infinito lo deja girando cien mil veces por segundo, que no es quieto: es un color embarrado, y el paso queda mostrando un solo estado de los cinco. Acá no se mueve nada —un recuadro cambia de color en su lugar— y ese cambio no es adorno sino el contenido del paso. También hay un caso, porque sin él la próxima vez que alguien toque la regla general el recuadro se vuelve a clavar en un color y nadie se entera.Otro caso pone un techo al largo: 1.200 caracteres por guía, 200 por frase y ningún subtítulo. No está para pulir la redacción, sino para que la guía no vuelva a convertirse en un manual sin que nadie lo note.
El porqué de cada decisión no se perdió: está acá, en el README, que es donde se lo busca.
Las tres caen en la misma pantalla —
ui.page === 'help'— y se distinguen porui.guia, que sale de la ruta. Por eso hay tres direcciones y no una con un parámetro en memoria: recargar o compartir el enlace abre la que se estaba mirando./comousara secas sigue valiendo y abre la de Hábitos, que es donde estaba.Las tres viven en el mismo documento y se muestra una. Traerlas por separado obligaría a pedir algo por red justo en la pantalla que explica que la app anda sin conexión.
El cajón es uno solo —el mismo nodo en las tres pantallas—, así que la entrada dice cuál abre: Cómo usar Hábitos, Cómo usar Análisis, Cómo usar Pendientes. Con el rótulo a secas, la misma entrada en tres lugares parecería llevar siempre al mismo lado. El
hrefse arma en cada dibujado por lo mismo: es un enlace de verdad, y abrirlo en otra pestaña tiene que llevar a lo que promete.Ajustes no tiene guía: no es una pantalla que se explique, es una lista de cosas que se tocan una vez. Lo que se decía ahí —cerrar sesión, eliminar la cuenta, instalar la app, bajar una copia— salió de la guía con el reparto: no era de ninguna de las tres secciones. Lo que sí era de Hábitos y estaba enterrado en esos pasos se quedó donde corresponde: el día que se cierra solo, y el recordatorio, que es una entrada de ese mismo ☰.
Eso obliga al servidor a devolver index.html ante cualquier ruta, porque los archivos
/diario y /analisis no existen en disco. Ya viene resuelto para los tres hostings del paso 4:
_redirectsvercel.json. El rewrite es /(.*) a secas y alcanza: los rewrites se evalúan
después de buscar el archivo en disco, así que styles.css, app.js y compañía se sirven
normalmente y solo las rutas que no existen caen en index.html.
No lleva comentarios: el esquema de Vercel rechaza cualquier propiedad que no reconozca,
incluida la clave "//" que suele usarse para eso, y el deploy falla entero.404.html, que no sabe reescribir y rebota a la raíz pasando la ruta como
parámetro; el router la traduce y limpia la URL.En un subdirectorio (usuario.github.io/repo/) también funciona: la app deduce su carpeta de la
URL de app.js, así que las rutas quedan /repo/diario y compañía.
La app sigue sin build ni dependencias en tiempo de ejecución: package.json existe solo
para las pruebas, y nada de node_modules llega al navegador.
npm ci # instala exactamente lo del package-lock.json
npm run check # sintaxis, nombres repetidos, codificación, CSP, PWA y firma del push
npm test # snapshots del DOM + casos de comportamiento
npm run casos # solo los casos; acepta filtros: npm run casos -- zoom plegado
npm run snapshot # regraba la referencia (solo si el cambio es intencional)
npm run medir # cronometra la app con distintos volúmenes de datos
npm ci y no npm install: instala exactamente lo que fija el package-lock.json, sin volver a
resolver rangos ni traer una versión que nadie eligió. Y el .npmrc del repositorio
bloquea los scripts de instalación de todos los paquetes, que es la vía por la que funcionan casi
todos los ataques de cadena de suministro de npm. Hoy ningún paquete del árbol los necesita, así
que la protección sale gratis.
Conviene tener presente el alcance real: nada de node_modules llega al navegador. La única
dependencia es jsdom, y es de desarrollo. Un paquete comprometido acá compromete la máquina que
instala, no a quien usa la app.
check termina buscando el carácter de reemplazo U+FFFD en todos los fuentes. Aparece cuando
un archivo se guardó con la codificación equivocada: el carácter original se perdió y queda esa
lápida en su lugar. Nunca es intencional, así que encontrarlo es siempre un error — y es un error
que el snapshot no puede ver, porque compara contra una referencia que grabó ya rota. Pasó
exactamente eso con un ícono de la guía, que nació mal y sobrevivió a los 18 escenarios.
npm test monta la app entera dentro de jsdom —el index.html real, el app.js real— con el
reloj congelado y datos sembrados, y compara el HTML que produce contra una referencia guardada.
Son 17 escenarios: las tres vistas, los cuatro modos de la columna Estatus, análisis, guía, sin
tareas, selección múltiple, los diálogos, y dos de datos corruptos que ejercen las guardas de
normalize().
Sirve para refactorizar: si el HTML sale igual, el comportamiento no cambió. No reemplaza probar en el navegador —no cubre CSS, gestos ni la sesión—, pero atrapa las regresiones de render.
La referencia vive versionada en test/__snapshots__/dom.json, así que npm test corre recién
clonado el repositorio. Cuando un cambio de markup es intencional, npm run snapshot la regraba y
el diff resultante entra en el commit: ahí se lee exactamente qué se movió en el HTML.
Los snapshots dicen qué cambió; los casos de test/casos/ dicen si sigue funcionando. Son
728 comprobaciones repartidas en veinte archivos, cada uno sobre una funcionalidad:
| Caso | Qué cubre |
|---|---|
plantillas | Fecha obligatoria, mes destino, duplicados |
guardar-plantillas | Guardar el mes como plantilla, borrarlas y el tope de cinco |
recordatorio | Cuándo se puede configurar el recordatorio, cuándo se ofrece solo, y qué se dice cuando no |
tarea-fecha | Alta y edición: valor por omisión, validación, no mudar de mes al editar |
tope-100 | Ninguna tarea aporta más que su meta, y qué entra en el techo de cada día |
periodos-parciales | A un período partido por el borde del mes o de la vista se le pide solo la parte visible, y a un día que no transcurrió no se le pide nada |
indicadores | El mínimo de días de uso, los dos rankings de días productivos, las dos medidas del «Análisis diario» y la forma de las otras dos tarjetas |
ir-a | Los dos calendarios: el nombre del mes y «Ir a…» |
zoom | Los tres niveles, alto de fila y ancho de columna uniformes, ancho para el nombre |
plegado | «Análisis» en el encabezado, el armazón de alto fijo y el plegado de controles |
boton-alta | Los tres accesos al alta: el desplegable, el + de la planilla y el estado vacío |
color-marca | Los rellenos con el color de la marca y su contraste AA en ambos temas |
vendor | La app arranca con el cliente local y su integrity |
red | Qué primitivas de red toca el cliente real: ningún WebSocket |
landing | Pantalla partida de la puerta de sesión, apilado en móvil y contraste sobre el degradado |
recuperacion | El ciclo completo de «olvidaste tu contraseña», con el cliente instrumentado |
migracion | Un estado guardado por la versión anterior abre sin perder nada |
sincronizacion | Cómo se sube: agrupado, de a uno, con reintento y sin las preferencias |
vista |
Corren en procesos separados a propósito: cada uno instala su propio jsdom con reloj,
almacenamiento y globals sustituidos, y compartir proceso haría que un caso viera el entorno del
anterior. El costo es arrancar Node veinte veces; a cambio cada archivo se ejecuta a mano —
node test/casos/verificar-zoom.mjs—, que es como se depuran.
sincronizacionmonta además un reloj de temporizadores propio, para no esperar de verdad los 800 ms de la agrupación ni los 5 s del primer reintento. El caso adelanta el reloj a mano y comprueba qué se escribió en cada tramo.
Cuando un caso falla, el runner imprime su salida completa al final, después del resumen: no hay que buscarla entre la de los que pasaron.
test/declaraciones.mjs falla si un archivo declara dos veces el mismo
nombre en el mismo ámbito. Para el lenguaje eso es legal —la segunda gana, en silencio—; en un
archivo de miles de líneas es demoledor.
No es un chequeo teórico: moverPendiente(delta), escrita para las flechas de reordenar, pisó a
moverPendiente(id, fecha), que movía una tarea de día en el calendario. Arrastrar dejó de
funcionar y node --check no dijo nada, porque no hay error de sintaxis. Lo agarraron las pruebas
de comportamiento; esta guarda lo agarra antes y dice exactamente qué línea pisa a cuál.
Es lo que un ámbito por archivo daría gratis, y está puesta justamente porque app.js todavía es
uno solo.
El cliente de Supabase se sirve desde este mismo dominio, en
vendor/, y no desde un CDN. La diferencia importa porque ese código sí
corre en el navegador de cada visitante, con acceso completo a su sesión: importarlo de un CDN
significaba versión no fijada (@2 es «cualquier 2.x»), sin verificación de integridad —los
import de módulos ES no admiten integrity— y a merced de que el CDN sirviera lo que
corresponde.
Ahora la versión es exacta, el <script> lleva su hash y la
Content-Security-Policy no necesita permitir ningún origen externo:
| Directiva | |
|---|---|
default-src 'none' | nada está permitido salvo lo que se habilita abajo |
script-src 'self' <3 hashes> | solo scripts propios; los inline, por hash y sin 'unsafe-inline' |
connect-src 'self' <proyecto>.supabase.co | la app solo puede hablar con su propia base |
frame-ancestors 'none' | no se puede embeber en un iframe ajeno |
Autorizar los inline por hash es lo estricto —un script inyectado no se ejecuta— pero acopla el
hash al código: editar una línea del script anti-parpadeo lo invalida, y el navegador lo bloquea
en producción, en silencio. Por eso npm run check recalcula los hashes desde index.html,
verifica el integrity de vendor/ contra el archivo, controla que connect-src corresponda a
config.js y que _headers (Netlify) declare la política idéntica a
vercel.json. Es lo que vuelve mantenible al hash.
GitHub Pages no admite cabeceras propias. Publicado ahí, el sitio queda sin CSP. Es una limitación del hosting; Vercel y Netlify sí la sirven.
La app pide iniciar sesión antes de mostrar nada. Se puede crear cuenta desde la misma pantalla; si el proyecto tiene la confirmación por mail activada, avisa que hay que revisar el correo. Los mensajes de error de Supabase se muestran traducidos.
Esa puerta ocupa la pantalla completa partida al medio: en la mitad derecha, un panel con los degradados violeta de la marca que cuenta qué hace la app y por qué llevar el registro sirve; en la izquierda, el formulario flotando centrado. En menos de 860px las mitades se apilan y el formulario queda arriba.
El formulario va primero en el orden del documento aunque en pantalla ancha ocupe la mitad izquierda. Es la acción principal, y así al apilarse queda arriba sin invertir nada con
order: el orden de lectura y el visual no se separan, y quien ya tiene cuenta entra sin scrollear.
El texto del panel va sobre un degradado, así que su contraste se mide contra cada parada: la más clara es la que manda. Blanco puro da 5,43:1 ahí y el texto de apoyo 4,63:1 — los dos pasan AA. Por debajo del 86% de blanco se cae, y por eso el pie no usa un velo más tenue: su jerarquía la dan el cuerpo más chico y una línea de separación.
Debajo del formulario hay un «¿Olvidaste tu contraseña?» que pide solo el email y manda un correo con un enlace. Al volver desde ese enlace la app pide directamente la contraseña nueva.
El mismo formulario tiene cuatro modos —entrar, crear cuenta, pedir el correo y elegir la contraseña nueva—, y en cada uno se muestran solo los campos que hacen falta.
La puerta sigue cerrada durante la recuperación. El enlace del correo deja una sesión válida, así que sin una guarda explícita la app se abriría y la pantalla para cambiar la contraseña no llegaría a verse nunca: quien entró por haberla olvidado quedaría adentro sin haberla cambiado. Recién se abre cuando
updateUserconfirma la nueva.
El aviso no dice si la cuenta existe. Responde «si hay una cuenta con ese email, te llega un correo», y no «no encontramos esa cuenta». Lo segundo convertiría la pantalla en un verificador de quién está registrado, disponible sin iniciar sesión.
Para que el enlace vuelva a la app, su dirección tiene que estar declarada en Supabase → Authentication → URL Configuration. Si no está, el correo redirige al Site URL del proyecto.
En Ajustes aparecen el email de la sesión, desde cuándo sos miembro y las dos salidas:
La baja la hace eliminar_cuenta() en Supabase —ver Puesta en marcha— y no el
navegador: la clave anónima no puede tocar auth.users, y darle ese permiso al cliente sería darle
el poder de borrar cualquier cuenta. La función es security definer con search_path fijo y
adentro solo puede borrar una fila, la de auth.uid(). Todo lo demás se va con ella por
on delete cascade: app_state, push_subscriptions y push_reminders.
Del lado del navegador se borra también la copia local —incluida la copia sin dueño, que es la que adopta el próximo que inicie sesión en ese mismo navegador— y se recarga la página: con el usuario borrado, el cliente tiene en mano un token de alguien que ya no existe.
Antes de borrar nada se pasa por dos puertas:
No es una confirmación repetida por las dudas: son dos preguntas distintas —«¿sabés qué se borra?» y «¿estás seguro?»—, y la segunda existe porque acá no hay papelera, ni deshacer, ni una copia en la base que alguien pueda restaurar. Todo lo demás que borra la app se puede recuperar; esto no.
El rojo del texto aparece solo en esa última puerta, y es el MISMO rojo que el botón «Eliminar cuenta» —dos rojos distintos en la misma acción se leen como dos avisos distintos—. Es el mismo criterio que el del botón rojo: avisa mientras sea raro, y puesto en cada confirmación no avisaría de nada.
Y su botón espera diez segundos, contándolos a la vista. Disponible en el mismo lugar donde el anterior acaba de estar, se puede tocar sin haber leído nada, arrastrado por el impulso del clic que abrió esta pantalla. Los segundos no son un castigo: son el tiempo de leer la única frase que hay. Diez es bastante más que un parpadeo y bastante menos que un trámite.
Mientras cuenta, el botón va de contorno rojo; cuando se abre, de relleno rojo con letras blancas. Es el mismo par que distingue «Cerrar sesión» de «Eliminar cuenta» en Ajustes, y acá separa el «todavía no» del «ahora sí» sin que haya que leer el número.
El botón no cambia de ancho en toda la cuenta. El rótulo y el número son dos huecos, y el del número mide siempre lo mismo — también cuando queda vacío al final—, con cifras de ancho parejo para que el rótulo no tiemble por dentro.
No es cosmética: con el diálogo en la capa superior, un botón que se achica con cada dígito deja al navegador pintando donde ya no está. Se veía vacío hasta que algo forzaba un recálculo — abrir el inspector, por ejemplo, que fue como apareció el síntoma—.
La clase del botón no cambia nunca: es
.btn-dangerde principio a fin, y lo único que distingue esperar de poder tocar es:disabled.Costó llegar ahí. Primero fue
.btn-dangerapagado a secas —.btn:disabledlo deja al 45%, y un rojo al 45% con letra blanca al 45% no es un botón apagado, es uno invisible—. Después fueron dos clases que se turnaban, y las dos tocaban el fondo, una poniéndolotransparent: entre una y otra hay una transición debackground, así que existía un momento con el fondo transparente y la letra ya blanca. Blanco sobre nada es un rectángulo vacío, y así se veía.Lo que lo cierra no es ganar una pelea de especificidad sino no tener la pelea: una sola clase, y las dos caras con colores visibles —relleno pálido con letra oscura contra relleno pleno con letra blanca—, así ninguna transición puede pasar por el vacío por más que se demore.
Si ya tenías un diario cargado de antes de que existiera el login, la primera cuenta que entre lo adopta en lugar de empezar vacía.
Se guardan en dos lados a la vez:
app_state: una fila por usuario con todo el estado en un jsonb. Es la
copia buena, la que sobrevive a cambiar de navegador, de equipo o de dominio.tareas-diarias/v1/<id de usuario>: copia local, atada al navegador
y al dominio. Dos cuentas en la misma máquina no comparten diario.Al iniciar sesión se lee Supabase primero. Si la cuenta no tiene nada allá, sube la copia local.
Si la base no contesta al abrir, la app no escribe. Se queda en modo local, avisa en pantalla y no sincroniza hasta que recargues. Es a propósito: subir el estado que quedó en memoria sin haber podido leer el remoto reemplazaría datos del servidor a ciegas.
Ese aviso importa. Antes, un fallo de Supabase solo dejaba un
console.errory la app seguía como si nada, guardando únicamente en el navegador. Al cambiar de dominio o de navegador, esos datos quedaban fuera de alcance sin que nada lo hubiera advertido.
En localStorage se escribe en el acto, en cada cambio. A la base no:
Hasta acá cada escritura llevaba el estado entero. Con 24 tareas eso son unos 40 KB, y crece con el historial: cerca de 1 MB a los tres años, por cada tanda de clics. El tamaño no dependía de lo que hubieras cambiado sino de cuánto llevabas acumulado, que es exactamente al revés de lo que conviene.
Ahora el cliente manda un parche: la diferencia entre el estado actual y lo último que la base confirmó tener. Marcar una celda son 34 bytes, y siguen siendo 34 bytes con diez años de historial encima.
El formato es JSON Merge Patch (RFC 7386): las claves que
cambian traen su valor nuevo, las que se borran traen null, y lo que no cambió no viaja. Del otro
lado lo mezcla merge_app_state, una función SQL que hay que instalar una vez
—db/sincronizacion-incremental.sql—.
// desmarcar una celda y renombrar una tarea, en la misma tanda
{ "status": { "t1|2026-08-05": null },
"months": { "2026-08": [ /* el mes entero: un arreglo se reemplaza, no se mezcla */ ] } }
La fila y el formato del payload no cambian. Sigue siendo el mismo
jsonbenapp_state, así que un cliente que todavía no se actualizó lo lee y lo escribe como siempre. Es lo que permite hacer este cambio sin coordinar todos los dispositivos a la vez.
Tres cosas que sostienen que el parche sea correcto:
merge_app_state no está, el cliente lo detecta en el
primer intento, vuelve al upsert de siempre y no insiste. Instalarla es un paso de puesta en
marcha, no un requisito.Un efecto lateral útil: como el parche se calcula contra lo que la base tiene tal cual, la primera
escritura contra una fila vieja la normaliza —borra con null las claves de versiones
anteriores— sin necesidad de una migración aparte.
Lo que esto no resuelve. Entre dos dispositivos sigue ganando el último que escribe, solo que ahora pisa lo que tocó y no el estado entero: dos aparatos editando meses distintos ya no se borran mutuamente. La mezcla fina por celda necesita una fila por marca, que es la etapa 3 de la migración.
Agrupar abre una ventana de hasta 800 ms con cambios guardados acá y todavía no en la base. Si la
pestaña muere justo ahí —un cierre forzado, quedarse sin batería—, esos cambios quedan anotados en
tareas-diarias/v1/<id>/pendiente, con la hora en que se hicieron. Al volver a entrar esa marca
se compara contra el updated_at de la fila remota: si lo local es más nuevo, gana lo local y
se sube. Sin esa comparación, la lectura remota pisaba en silencio justo lo último que se hizo.
Las dos marcas las escribe un cliente, así que se comparan como instantes y no como texto: el
updated_atde Postgres vuelve con offset (+00:00) y el nuestro conZ, y ordenadas como cadena esas dos formas mienten.
El tema, el aviso del recordatorio y cómo quedó mirándose cada pantalla describen cómo se ve
la app en este aparato, no qué hizo el usuario. Se guardan solo en localStorage —en
tareas-diarias/prefs, y el tema además en tareas-diarias/theme, que es la que lee el script
anti-parpadeo antes de que exista app.js— y quedan fuera del payload.
Lo que se recuerda de cada pantalla:
| Pantalla | Qué recuerda |
|---|---|
| Análisis | El gráfico que se estaba viendo, el período de Barras y el orden de las tareas |
| Pendientes | La vista (lista o calendario), cómo se agrupa y cómo se ordena la lista |
Volver a entrar abre donde uno dejó, en vez de pedir que se vuelva a elegir todo. Es por aparato y no por usuario, igual que el tema: la pantalla de la computadora y la del teléfono no son la misma, así que elegir Barras en una no cambia lo que abre la otra.
Cada campo se valida al leerlo contra los valores que admite. Lo que está en localStorage es
texto que alguien pudo editar a mano, y una versión anterior pudo guardar un gráfico que ya no
existe: lo que no se reconoce se ignora y ese campo abre en su valor de siempre, sin arrastrar a
los demás ni romper la pantalla. Es el mismo criterio con el que se sanean los datos al entrar.
Antes iban en el mismo jsonb que los datos, con dos consecuencias: elegir tema claro en la
computadora lo cambiaba también en el teléfono, y cada una de esas preferencias subía el diario
entero para guardarse.
La causa más probable de que la base rechace todo es que falte la política de RLS — ver la
consulta de comprobación al final de db/supabase-app-state.sql.
Sobre el esquema de la base. El repositorio trae dos archivos SQL y solo uno está en uso:
db/supabase-app-state.sql, el de la tablaapp_stateque describe esta sección.db/schema.sqles un diseño relacional —tareas, meses y estados en tablas propias— al que la app todavía no habla: es el destino de la Etapa 3 dedb/MIGRACION.md. Correrlo crea tablas vacías que nadie consulta.Lo que falta de esa etapa es cambiar el guardado de «volcar todo el estado» a operaciones puntuales. Hoy cada subida lleva el estado entero —agrupada, pero entera— y entre dos dispositivos gana el último que escribe, pisando el mes completo en vez de la celda.
Cada tarea tiene nombre y frecuencia.
El nombre se guarda con la primera letra en mayúscula, sin importar qué venga antes: hola queda como Hola y 134+ hola como 134+ Hola. La transformación se aplica al salir del campo y al guardar, y también a los nombres que vengan de un archivo importado.
La frecuencia define la meta, no una restricción. Cualquier día del mes se puede marcar, para cualquier tarea y las veces que haga falta. Si un día hacés algo que no estaba planificado, lo cargás igual y suma.
| Frecuencia | Meta en el mes |
|---|---|
| Diaria | Un punto por día |
| Lunes a viernes | Un punto por día hábil |
| Semanal | 4 en el mes, siempre |
| Quincenal | 2 en el mes, siempre |
| Mensual | Un punto en todo el mes: 1 |
| Personalizado | Según el submodo, ver abajo |
La semanal y la quincenal son una cuota del mes. El rótulo dice el ritmo que se busca —«Una vez por semana»—, pero lo que se mide es un total: 4 y 2. Cuándo las hiciste no entra en la cuenta. Hacer las cuatro el mismo lunes cumple el mes igual que hacer una por semana, y la cuota no cambia porque el mes toque cinco o seis semanas ni porque todavía no haya terminado.
Es a propósito, y tiene un precio que conviene conocer: a mitad de mes el porcentaje se lee como avance de la cuota. El día 8, una semanal hecha una vez va 1 de 4 — 25% —, aunque no hayas faltado a nada. Estas frecuencias no prometen un ritmo: prometen un total.
Lo único que achica una cuota es la vigencia. Si la tarea no estuvo disponible el mes entero, paga solo la parte que cubrió: una semanal creada el 28 estuvo cuatro días de treinta y uno, así que debe una y no cuatro. Se redondea hacia arriba y nunca da cero — un mes en el que la tarea vivió un solo día igual le pide esa vez. Sin esto, estrenar un hábito a fin de mes lo dejaba en rojo por existir tarde.
Un mes que todavía no empezó es otra cosa: ahí no pide nada, porque lo que falta no es vigencia sino tiempo.
«Una vez por semana» y «4 veces al mes» miden igual. Significan lo mismo, así que dan lo mismo: las dos son una cuota, solo que en la segunda el número lo elegís vos. Antes no era así —la de frecuencia pedía 4 fijo y la de meta mensual se prorrateaba por los días transcurridos—, y dos tareas idénticas mostraban números distintos en la misma lista de barras.
Personalizado tiene dos formas de medirse:
Días fijos de la semana — elegís los días (L M X J V S D) y la meta es un punto por cada uno que caiga en el mes.
Meta mensual, sin días fijos — cargás un número (12 veces al mes) y no indicás días. Ningún día queda como requerido: hacés la tarea cuando puedas y la marcás ahí.
Es una cuota, igual que la Semanal y la Quincenal, solo que el número lo elegís vos: pide ese total en el mes, sin importar cuándo lo hagas ni que el mes esté a medio correr.
Si la tarea no estuvo vigente el mes entero, la cuota se reparte sobre los días del mes que alcanzó a cubrir y se redondea hacia arriba a acciones enteras: una de 8 al mes que arranca el 25 cubre 7 de 31 días, así que pide 8 × 7/31 ≈ 1,8 → 2. Es lo que hace que empezar tarde reduzca lo que se espera en vez de amontonarlo — arrancando el día 31 pide una sola vez, y hacerla da 100%.
Por qué se redondea. Estas metas se cumplen con actos indivisibles: «socializar» se hace o no se hace, no existe hacer 0,26 de socializar. Sin redondear, la meta de un tramo corto quedaba en fracciones —
8/31 = 0,26— y una única marca contra esa meta daba 388%: aritmética correcta sobre una unidad que no existe. Por eso nunca da cero: un mes en el que la tarea vivió un solo día igual le pide esa vez.
Dos cosas con el mismo nombre en la misma lista son indistinguibles: no hay forma de saber cuál es cuál en la lista, en el gráfico ni en el archivo. Por eso el nombre es único donde conviven, y se puede repetir donde no:
| Único entre | Se puede repetir en | |
|---|---|---|
| Tarea de Hábitos | las del mes | otro mes |
| Categoría de Pendientes | las de primer nivel | — |
| Subcategoría | las de su misma madre | otra categoría |
| Tarea pendiente | las de su misma categoría | otra categoría |
La comparación es como la leería una persona —sin distinguir mayúsculas ni acentos, y sin los espacios de los bordes—: «Correr» y «correr» son el mismo hábito escrito de dos maneras. Al editar, el nombre propio no cuenta como repetido, así que guardar sin tocar nada sigue funcionando.
Las plantillas siguen la misma regla: al aplicar una, las tareas cuyo nombre ya está en el mes quedan afuera y el aviso las nombra —«2 ya estaban» obligaba a ir a buscar cuáles—. Vale también para una plantilla que repita un nombre adentro suyo: entra una sola vez.
Cada tarea tiene una fecha de inicio obligatoria. Antes de esa fecha la tarea no existe:
sin meta.Si ya habías marcado días que después quedan antes del inicio, dejan de mostrarse y de sumar, pero el dato no se borra: si corrés la fecha hacia atrás, vuelven a aparecer.
Al crear una tarea el campo llega precargado con hoy, que es cuando suele empezar un hábito que estás dando de alta. Y esa fecha decide a qué mes va la tarea, sin importar en qué mes estés parado: darla de alta desde un mes viejo la crea igual en el mes de su fecha, y la app te lleva ahí. Es la misma regla que en las plantillas.
Al editar gana la fecha que ya tenga la tarea, y cambiarla no la muda de mes: la tarea vive en la lista del mes donde está, y mudarla partiría su historial en dos. Correr un inicio unos días es algo que se hace seguido y no debería tener ese efecto.
Si una tarea no tiene fecha —por ser anterior a que el campo fuera obligatorio—, al editarla se propone el día 1 del mes visible y no hoy: proponer hoy recortaría el historial, porque guardar sin tocar nada apagaría los días ya cargados anteriores a la fecha y dejarían de contar para la meta. El día 1 es justamente lo que significaba no tener fecha, así que completarla no cambia nada.
La fecha de inicio no se escribe en la fila de la tarea para no alargarla; se ve al pasar el mouse sobre la frecuencia y al editar la tarea.
Como marcar de más está permitido, los puntos pueden superar la meta: una tarea semanal marcada cinco veces en el mes suma 5 contra una cuota de 4. Pero el cumplimiento topea en 100% — cumplir es cumplir, y de ahí no se sube. Los números crudos siguen a la vista en el tooltip de la celda («3 sobre una meta de 1»), así que hacer de más se ve igual sin inflar la medida.
El cumplimiento del día cuenta por día, no por tarea. Entra en la cuenta lo que ese día se pidió y lo que se hizo aunque no se pidiera, un punto de meta por cada uno: es exactamente lo que la lista muestra encendido. Una tarea de lunes a viernes hecha un domingo cuenta 1 de 1 ese domingo — hacer de más nunca puede empeorar el día.
Antes esta cuenta se hacía por tarea y con el tope de la meta de cada una, así que esa misma fila se veía cumplida y valía 0 de 0: la tarea no pedía nada el domingo, y su punto no tenía dónde entrar. El resultado era que el día decía un porcentaje en la lista y otro en el gráfico de barras —que siempre contó por día—. El 30 de agosto de 2026 llegaron a decir 35% y 41% sobre las mismas marcas. Ahora es una sola cuenta.
Marcar ✕ un día que no tocaba sigue sin restar: no se puede incumplir lo que no se pidió.
El cumplimiento del MES no lleva ese tope. Ahí se suman los puntos enteros: lo hecho de más en una tarea compensa lo que falta en otra, que es lo que se pidió expresamente. La pregunta que contesta ese número es cuánto se hizo en el mes contra cuánto se pedía, no si cada tarea por separado se cumplió — eso lo contesta la barra de cada una, que sí topea en 100%. El porcentaje del mes tampoco pasa de 100%: un cumplimiento del 130% no querría decir nada.
En reposo cada fila muestra solo el nombre, la frecuencia y ✎ para editarla. Los casilleros y los chevrones ▲ ▼ aparecen recién al pedirlos, desde Opciones — ver Posturas de la fila. El orden se guarda junto con los datos.
También se puede arrastrar una tarea tomándola del nombre para reordenarla. Una línea de acento marca dónde va a caer según de qué lado del medio de la fila esté el puntero. La fila solo se vuelve arrastrable mientras el puntero apoya sobre el nombre: si no, arrastrar desde una celda de estado movería la tarea sin querer. Los chevrones siguen ahí porque el arrastre no funciona en pantallas táctiles. Para borrar se usa el casillero (ver abajo).
El alta tiene tres accesos, que abren el mismo diálogo:
| Dónde | Cuándo se usa |
|---|---|
| + Agregar tarea | Primero del apartado Tareas del desplegable Opciones |
| El + de la esquina inferior izquierda | Al pie de la columna Tareas: es donde está la mano después de recorrer la lista |
| El botón de la pantalla de «sin tareas» | Cuando el mes está vacío y no hay planilla que dibujar |
El + de la planilla lleva el mismo relleno de marca que Opciones, y mide lo mismo que las flechas que mueven el período porque comparte su clase icon-btn — no porque el número esté copiado. Va sangrado a la vertical de los nombres de las tareas.
No agrega ninguna fila: ocupa la celda del pie que antes tenía el rótulo Cumplimiento diario. Así la planilla no crece y el botón queda igual de cerca de la última tarea.
Ese rótulo desaparece de la vista pero no del árbol de accesibilidad: sigue en el
aria-labelde la celda, que es lo que un lector de pantalla anuncia antes de leer los porcentajes de cada día. Sin él, esa fila pasaría a llamarse «Agregar tarea» — que es el botón, no la fila.
Todo lo que se puede hacer sobre el mes vive en un botón Opciones, agrupado por lo que toca:
| Apartado | Qué hay |
|---|---|
| Tareas | + Agregar tarea; las tres posturas —Eliminar tareas, Reordenar tareas y Resetear estados—; y al final Copiar tareas del mes anterior y Guardar como plantilla |
| Visualización | El zoom de la planilla. Solo en móvil: en escritorio la planilla entra y el apartado entero no existe |
Antes eran seis botones sueltos en una fila. Agruparlos muestra una distinción que la fila plana no podía: ahí Resetear estados y Copiar tareas del mes anterior se leían como vecinos del mismo rango, cuando uno borra marcas y el otro reemplaza la lista entera.
El orden dentro del apartado también dice algo. Arriba, el alta y las tres posturas: lo que se usa a diario y opera sobre las tareas que elegís. Abajo, separadas, las dos que trabajan sobre la lista completa del mes — una la reemplaza y la otra la guarda.
Los íconos van en una ranura de ancho fijo: los glifos no miden lo mismo —un emoji es bastante más ancho que una flecha—, así que sin ranura cada renglón arrancaba su texto en un lugar distinto. Con ella quedan centrados en una columna y los siete rótulos empiezan en la misma vertical. El
+del alta pasó a ser un ícono más, en vez de vivir dentro del texto.El menú se cierra al elegir, porque casi todo lo que hay adentro abre un diálogo. La excepción es el zoom: cicla tres niveles, así que se queda abierto para poder recorrerlos sin volver a desplegar.
Es el mismo mecanismo que el desplegable de vista —cierra con
Esc, con un clic afuera, y devuelve el foco al botón— y abrir uno cierra al otro.El relleno con el color de la marca pasó de + Agregar tarea a Opciones: es la entrada a todas, y dejar el color adentro del menú lo escondía. El alta sigue siendo lo primero del primer apartado, y además tiene el + de la esquina inferior izquierda y el botón de la pantalla de «sin tareas», así que dar de alta una tarea sigue estando a un clic — ver Tareas.
En reposo las filas van limpias. Tres entradas del apartado Tareas las ponen en una postura, que hace aparecer en cada una los controles de esa operación:
| Entrada | Qué aparece en cada fila |
|---|---|
| 🗑 Eliminar tareas | El casillero, más el de todas junto al encabezado |
| ↺ Resetear estados | El casillero, igual que arriba |
| ⇅ Reordenar tareas | Los chevrones ▲ ▼, uno al lado del otro |
Con una postura puesta aparecen ✓ y ✕ en la barra, entre el selector de vista y Opciones. El ✓ remata la acción —siempre pasando por un diálogo de confirmación que nombra lo afectado— y el ✕ sale sin aplicar nada. El ✓ queda apagado mientras no haya nada que confirmar, y su tooltip dice qué falta: «Tildá las tareas a eliminar».
Elegir una postura apaga las otras —compiten por el mismo lugar de la fila— y volver a elegir la que está puesta devuelve las filas a su estado limpio. Sin tareas en el mes las tres se apagan, porque no habría sobre qué operar.
Reordenar es el caso raro. Los chevrones aplican en el acto: hay que ver el movimiento para saber si quedó bien. Lo que lo vuelve cancelable es que al entrar se anota el orden de partida, así el ✕ puede devolverlo. Confirmar es quedarse con lo que ya está aplicado y soltar esa vuelta atrás.
La confirmación está en la barra y no en el menú. Antes eran dos entradas más del desplegable —Eliminar N tareas, Resetear N tareas—, que obligaban a volver a abrirlo para rematar lo que ya se había empezado, justo cuando la mirada estaba en las filas tildadas.
Cualquier cambio de postura suelta lo tildado. Eliminar y resetear comparten los casilleros, y arrastrar una selección de una a la otra es exactamente el descuido que termina borrando lo que se quería limpiar.
Antes estaban siempre. Cada renglón cargaba con un casillero y un par de chevrones que casi nunca se usan, apretados contra el nombre y a media opacidad para que estorbaran poco. Sacándolos de ahí, el nombre respira y los chevrones pueden ser grandes y horizontales cuando por fin hacen falta — que es justo cuando hay que apuntarles con el dedo.
Los controles no se esconden con CSS: no se dibujan. Uno invisible pero presente sigue estando en el recorrido del teclado y en el árbol de accesibilidad.
Con una postura de selección puesta, al tildar una o más casillas las filas quedan resaltadas y el ✓ de la barra se habilita. Qué hace depende de la postura:
| Postura | Qué hace el ✓ |
|---|---|
| Eliminar tareas | Saca esas tareas del mes visible junto con sus estados de ese mes |
| Resetear estados | Deja en «sin cargar» las celdas de esas filas dentro del rango visible, sin tocar las tareas |
El casillero junto al encabezado Tareas selecciona o deselecciona todas de una vez, y queda en estado intermedio si hay algunas tildadas pero no todas. Las dos piden confirmación y listan las tareas afectadas; hecha la acción, la postura se cierra sola.
El alcance de cada una es distinto, a propósito. Eliminar saca la tarea del mes, así que borra sus estados de todo el mes. Resetear opera sobre lo que estás viendo: en vista semanal borra solo esa semana, en diaria solo ese día, en mensual el mes entero. El diálogo nombra el rango antes de confirmar, y el tooltip del ✓ lo anticipa.
Ambas respetan el aislamiento entre meses. La selección se limpia al cambiar de mes y al completar cualquiera de las dos acciones.
Cada celda se cambia haciendo clic, que cicla entre los cuatro estados:
| Estado | Color | Puntos |
|---|---|---|
| Sin cargar | — | 0 |
| Cumplido ✓ | Verde | 1 |
| Parcial ~ | Amarillo | 0,5 |
| No cumplido ✕ | Rojo | 0 |
| No requerido – | Gris | sale de la meta |
La leyenda al pie de la planilla nombra los cuatro estados del ciclo, con los mismos rótulos que la guía y sin el puntaje al lado: eso lo explica Cómo usar, y repetirlo en cada renglón alargaba las entradas sin agregar nada que se consulte de un vistazo. Tampoco lista Sin cargar, que es la ausencia de estado y no uno de ellos — la celda vacía ya se lee sola.
En un teléfono las cuatro entradas van en dos columnas, así ocupan dos renglones y no cuatro. Las columnas se miden por su contenido y el par va centrado como bloque, de modo que los rótulos arrancan alineados entre sí en lugar de quedar cada uno centrado por su cuenta.
El último estado del ciclo saca ese día de la meta en lugar de contarlo como fallado. Es para los días en que la tarea legítimamente no correspondía: un feriado en el que no trabajás ni estudiás, una licencia, un viaje.
Es un estado que elegís, no la ausencia de uno. Esa distinción es la que mantiene sana la métrica: si bastara con dejar la celda vacía para no ser penalizado, el ✕ pasaría a ser un botón que baja el promedio y la forma de llegar al 100% sería no anotar los fallos.
Solo aparece en las frecuencias donde un día concreto carga la obligación: Diaria, Lunes a viernes y Personalizado por días fijos. En Semanal, Quincenal, Mensual y Meta mensual no se habilita, porque ahí un feriado no quita nada — la obligación es del período y se corre a otro día sin tocar el promedio.
Se ve con el mismo gris que los días que la frecuencia no pide, más un guion, que distingue la decisión propia del tinte automático.
Todas las celdas son clickeables, sin importar la frecuencia de la tarea. Los días que la frecuencia no pide llevan un tinte gris suave para distinguirlos de un vistazo: en una tarea Lunes a viernes son los fines de semana, en una Personalizado los días que no elegiste, y en las tareas Semanal, Quincenal y Mensual la fila entera —porque ningún día puntual es obligatorio, sirve cualquiera del período.
El tinte de fin de semana se mantiene en el encabezado de días y en la fila de cumplimiento del pie, como referencia de calendario.
El día de hoy se marca solo en el encabezado, con la píldora del número. Antes su columna llevaba además un filete vertical del color de la marca a los dos lados, que competía con los colores de estado —que son los que hay que leer— y recortaba el verde o el rojo de cada celda. Saber qué día es hoy es contexto; qué se cumplió y qué no es el dato.
Shift + clic retrocede en el ciclo (útil si te pasaste de estado).
Además del color, cada estado tiene un símbolo, así la planilla sigue siendo legible en impresión blanco y negro o para daltonismo.
Por fila (columna Estatus a la derecha): porcentaje alcanzado sobre la meta de esa tarea en el rango visible, con un símbolo que resume el nivel:
| Cumplimiento | Símbolo | Color |
|---|---|---|
| 75% o más | ✓ | Verde |
| Entre 50% y 74% | ~ | Amarillo |
| Menos de 50% | ✕ | Rojo |
El nivel se decide sobre el número redondeado, que es el que está a la vista. Comparando el crudo, un 74,6% se rotulaba 75% y se pintaba de amarillo: el umbral es 75 y el número decía 75, así que la pantalla se contradecía consigo misma y no había forma de entender por qué. El valor exacto se sigue usando donde es una escala y no un umbral — el ancho de las barras.
La meta se cuenta por período. Para casi todas las frecuencias el período es el día, y para las de cuota —Semanal y Quincenal— es el mes entero: se miden contra 4 y contra 2, sin prorratear. Ver Tareas.
Lo que no transcurrió no se exige. La meta cuenta los días del rango que ya vencieron; los que todavía no llegaron quedan afuera, porque en ellos no se incumplió nada. El 20 de agosto, una tarea diaria marcada los veinte días va 20 de 20 — 100%, no 20 de 31.
Antes el porcentaje medía el avance del mes, no el cumplimiento. La meta era la del rango entero, así que el 4 de agosto esa misma tarea sin faltar un día marcaba 4 de 31: 13%, pintado de rojo. La columna decía llamarse cumplimiento y estaba midiendo otra cosa.
Lo que se perdió con el cambio es poder leer cuánto del mes llevás hecho de un vistazo: para eso ahora hay que mirar los números crudos del tooltip —«20 sobre una meta de 20»— en vez del porcentaje. A cambio, un mes al día se ve al día en cualquier momento.
Lo hecho por adelantado sí suma. Es el mismo criterio de la fila Cumplimiento diario: lo hecho es lo hecho; lo que no se cuenta es un incumplimiento que todavía no ocurrió. Un día futuro marcado entra con meta propia —ver Cuotas recortadas por vigencia, donde la meta la fija lo que hiciste—, así que da y no , que insinuaría haberse pasado. Un ✕ adelantado, en cambio, no agrega nada: cero puntos y cero meta.
Hay dos maneras de que una tarea no deba el mes entero, y la app las trata distinto a propósito:
| Qué falta | Qué pide | |
|---|---|---|
| El mes todavía no empezó | tiempo | nada — la fila abre en —, no en rojo |
| La tarea no estuvo vigente todo el mes | disponibilidad | la parte que cubrió, redondeada hacia arriba |
| El mes está a medio correr | nada: la tarea existe y el mes ya arrancó | la cuota entera |
La tercera fila es la que sorprende, y es deliberada: una cuota es un total del mes, así que el día 8 una semanal ya debe sus 4. El número se lee como avance de la cuota, no como «vas al día».
El recorte por vigencia se reparte sobre los días del mes: una de 8 veces al mes que arranca el 25 cubre 7 de 31 días y pide 8 × 7/31 ≈ 1,8 → 2. Nunca da cero, porque un mes en el que la tarea vivió un solo día igual le pide esa vez.
Las diarias y las de días fijos no tienen cuota: su período es el día, así que cada día vencido pide uno y los que no llegaron no piden nada. Ahí sí vale lo que no transcurrió no se exige, al pie de la letra.
Antes las de cuota se partían en semanas y quincenas, y el borde del mes distorsionaba la cuenta: agosto de 2026 arranca sábado y toca seis bloques de semana, así que una tarea semanal cumplida todas las semanas marcaba 4 sobre 6 — 67%, en amarillo. Arreglarlo contando solo las semanas enteras dejó un problema peor escondido: como el denominador se armaba con lo transcurrido, una quincenal hecha una sola vez mostraba 100% hasta el último día del mes, porque la segunda quincena todavía no se exigía.
Cuando la parte visible ni llega a pedir una acción, no hay obligación que incumplir: ahí la meta la fija lo que hiciste. Sin marcar muestra — y no un 0%, y marcando te acredita — una semanal hecha el 1 de agosto va 1 sobre 1, no queda invisible.
Esa meta improvisada nunca pasa de lo que pide el período completo. Una tarea de una vez por semana no puede pedir dos en media semana por haberla hecho dos veces. Sin el tope, hacerla de más se cobraba a sí mismo: las dos marcas levantaban una meta de 2 y se gastaban en llenarla, así que una tarea hecha dos veces en la semana en curso medía igual que otra hecha una sola vez. El tope es el del período, no un 1 fijo: una de dos veces al mes puede improvisar hasta 2.
Por eso el guion de la columna Estatus tiene ahora dos motivos distintos, y el tooltip dice cuál:
| Guion | Cuándo |
|---|---|
| La frecuencia no pide ningún día del rango | Una Lunes a viernes mirada un sábado |
| El rango es solo una parte del período | Una Semanal en el tramo del 1–2 de agosto |
Empezar a mitad de período funciona igual: una tarea semanal que arranca el jueves 20 tiene 4 de los 7 días de esa semana, así que esa semana no le pide nada. Es la misma idea de siempre — empezar tarde reduce lo que se espera, no lo amontona.
Por columna (la fila del pie, cuya esquina izquierda ocupa el botón de alta): cumplimiento de ese día, con el mismo símbolo y los mismos umbrales que la columna Estatus — el símbolo arriba y el porcentaje debajo.
El día cuenta lo que pedía, más lo que hiciste sin que lo pidiera. Cada una suma uno al techo:
| Entra | Cuándo |
|---|---|
| Las que el día pide | Siempre, se hayan hecho o no |
| Las que no pedía | Solo si se hicieron —cumplida o parcial— |
Lo que queda afuera es lo que ni se pedía ni se hizo: una Semanal sin tocar ese día, una Lunes a viernes en sábado, una Personalizado fuera de sus días, o una marcada a mano como no requerido. Una ✕ en un día que no la pedía tampoco entra — no hacer algo que nadie pidió no es un incumplimiento.
Hacer de más suma, no resta. Una tarea semanal marcada un martes agrega 1 al techo y 1 a lo hecho: el día tuvo una cosa más para mostrar. Es la diferencia con la fila y el total del rango, donde
aportetopea cada tarea en su meta — ahí lo de más compensaría el incumplimiento de otra, y acá no hay nada que compensar.Los días que no transcurrieron muestran
—. Nada venció todavía, así que no hay techo: un 0% ahí acusaría de incumplido un día que no llegó. Lo que se haya marcado por adelantado sí se ve —lo hecho es lo hecho—; lo que no se muestra es un incumplimiento inventado. Es el mismo criterio con el que los promedios por día de la semana ya descartaban el futuro.Antes el techo eran todas las tareas iniciadas, pidiera el día o no. Con 18 tareas de las que el lunes pedía 11, hacer 8 daba 44% cuando la cuenta es 8 sobre 11: 73%. Si el día no pide nada y no se hizo nada, muestra
—, no 0%.
Antes mostraba la suma cruda de puntos —«1,5»—, que no decía contra cuánto se medía: 1,5 puede ser excelente con dos tareas y flojo con ocho. El porcentaje ya trae esa comparación hecha y el símbolo la resume de un vistazo.
Los dos se ven siempre. El modo que cicla el encabezado
Estatusgobierna esa columna, no el pie, así que estas piezas no llevan las clases que ese modo esconde.Van apilados y no uno al lado del otro porque la columna de un día mide 29px en móvil, y 23,8 en Compacto: es el mismo apilado del encabezado, donde el nombre del día va sobre el número. El cuerpo del porcentaje sigue al zoom — con cuerpo fijo, «100%» pide 24px y en se desbordaba por dos décimas.
Clic en el encabezado Estatus para ciclar entre cuatro formas de mostrarlo. El modo activo se lee debajo del rótulo de la columna:
| Modo | Qué muestra |
|---|---|
iconos | Solo los símbolos ✓ ~ ✕ |
% | Solo el porcentaje |
iconos + % | Ambos |
oculto | Nada, con el mismo gris de los días no requeridos |
Los tres primeros conservan el color del nivel, tanto en el texto como en el fondo de la celda. El modo elegido queda guardado.
El botón ☰ del encabezado abre un menú lateral con:
Abren un diálogo con listas predefinidas. Cada una muestra sus tareas antes de aplicarla, y Agregar las suma al mes visible.
| Plantilla | |
|---|---|
| Hábitos básicos | Seis para empezar, sin abrumarse (6 tareas) |
| Salud e higiene | El cuidado del cuerpo, de lo diario a lo esporádico (10) |
| Limpieza del hogar | Lo de todos los días y lo que se posterga (10) |
| Día completo | La rutina entera, de levantarse a acostarse (22) |
Cada entrada declara nombre y frecuencia, y para Personalizado agrega weekdays (días fijos, como L · X · V) o target (meta mensual, como 8 veces al mes).
Se agregan, no reemplazan: lo que ya tenías en el mes queda. Las tareas cuyo nombre ya exista se omiten (sin distinguir mayúsculas), así aplicar dos veces la misma plantilla no duplica nada. El aviso final dice cuántas entraron y cuántas se saltearon.
Arriba de la lista hay un campo de fecha de inicio, obligatorio, que hace dos cosas:
start de todas las tareas que agregue la plantilla, como si lo hubieras cargado a mano en cada una. Antes de esa fecha las celdas quedan apagadas y no cuentan para la meta, así arrancar a mitad de mes no deja los días previos como incumplidos.El campo abre en hoy, así que la plantilla apunta por omisión al mes actual — aunque estés mirando otro mes. El botón Agregar nombra el mes destino al pasarle el mouse. Si borrás la fecha, los botones se apagan: sin ella no hay mes al que agregar.
El campo no recuerda el valor anterior: vuelve a hoy en cada apertura, así ninguna plantilla sale disparada a un mes que ya nadie eligió.
Los nombres duplicados también se comparan contra el mes destino, no contra el visible.
Las tareas de una plantilla se crean con ids nuevos, así que no arrastran estados de ningún lado.
Además de las cuatro que trae la app, se pueden guardar las tareas de un mes como plantilla propia, desde Opciones → 🔖 Guardar como plantilla. Pide un nombre y guarda cada tarea con su frecuencia.
Lo que no se guarda importa tanto como lo que sí:
La descripción se arma sola («Guardada desde julio 2026»): pedirla sería un campo más para algo que la app ya sabe, y de dónde salió una plantilla es justo lo que se olvida.
Para borrar, cada ficha del diálogo de Plantillas tiene su ✕. Pide confirmación nombrando la plantilla.
Se pueden tener cinco plantillas en total, contando las de fábrica. Con los cinco lugares tomados, la entrada de Opciones queda apagada y el tooltip dice por qué — igual que cuando el mes no tiene tareas que guardar, que es la otra razón para no poder.
Las de fábrica también se borran, y eso es lo que le da sentido al límite: sin poder sacarlas, cuatro de los cinco lugares estarían tomados de entrada y guardar una propia sería casi siempre reemplazar la anterior. Borrás las que no usás y las reemplazás por las tuyas.
De una plantilla de fábrica borrada se guarda su ausencia —el id en
removedTemplates— y no la lista completa. Copiar las cuatro al payload las congelaría en la versión que tuvieran el día que alguien tocó cualquier cosa, y dejarían de mejorar con la app. Un id que ya no exista enTEMPLATESse descarta al leer: si mañana se renombra o se quita una, su id no puede quedar marcado como borrado para siempre.No hay vuelta atrás para las de fábrica. Borrar las cuatro es definitivo; se recuperan creándolas a mano. El diálogo lo avisa antes de confirmar.
Las plantillas propias son datos tuyos, no una preferencia de pantalla: van en el payload junto a las tareas y los estados, así que están en la computadora y en el teléfono, y entran en Exportar e Importar. Un estado guardado por la versión anterior abre igual — sin la clave, arranca con las cuatro de fábrica.
Lo que llega de afuera se sanea con las mismas reglas: una plantilla sin tareas no describe nada y se descarta entera, los nombres repetidos se descartan sin distinguir mayúsculas, las frecuencias inventadas caen en Diaria, y un archivo con veinte plantillas se recorta al tope en vez de dejar la app en un estado que su propia interfaz no permite crear.
Se cierra con el botón ✕, con Esc o haciendo clic fuera. El selector de mes sigue visible en la sección de Análisis, porque las tarjetas son mensuales; el selector de vista y la navegación por semana/día solo aparecen en el Diario.
En el encabezado, junto al cambio de tema, hay un botón que alterna entre las dos secciones sin abrir el menú: dice ◔ Análisis en la planilla y ▦ Diario en la sección de análisis. En móvil se queda solo con el icono, porque el ancho no da para el rótulo.
La sección tiene dos paneles plegables, Indicadores y Gráficos. Ambos recuerdan si los dejaste cerrados. Todo lo que muestran describe el mes completo: un promedio por día de la semana no significa nada dentro de una sola semana.
Son SVG dibujados a mano, sin librerías, para no romper la regla de cero dependencias.
| Gráfico | Qué responde |
|---|---|
| Reparto de estados (torta) | De lo que va del mes, cuánto marcaste y cómo. El centro indica qué proporción llegaste a registrar |
| Análisis diario (barras) | Qué días rendís mejor, con la medida que elijas — ver abajo. El mejor va en verde y el peor en rojo, con su valor rotulado; los demás quedan neutros. Respeta el mínimo de siete días |
| Cumplimiento por tarea (barras horizontales) | Qué tareas están flojas, ordenadas de mejor a peor |
Notas de lectura:
Los colores son los mismos estados del tablero, para que una celda verde y su porción del gráfico se lean como lo mismo. Se usan pasos un punto más oscuros en modo claro para que todos superen 3:1 de contraste contra la tarjeta, y pasos propios —no un volteo automático— en modo oscuro.
Verde y amarillo no se distinguen por tono bajo protanopia. Por eso en la torta cada color viaja con su símbolo (✓ ~ ✕) y su palabra en la leyenda, y en el de tareas cada barra lleva su porcentaje al lado: la identidad nunca depende del color solo.
Día por día dibuja una barra fina por cada día del mes, con el cumplimiento de ese día. El eje vertical va de 25 en 25 —0, 25, 50, 75, 100—, alineado a la izquierda. El 75 es además el umbral del verde, así que la guía dice de un vistazo qué días lo cruzaron. No hay nada más alrededor: las barras van pegadas, repartiéndose el ancho, y el mes entra entero sin desplazarse de costado.
Qué día es cada barra lo contesta un cartel que aparece al pasar por encima o al tocarla, con la fecha completa y el porcentaje. La zona que responde es la columna entera y no la barra: la de un día flojo mide tres píxeles, y pedirle al dedo que acierte ahí sería pedir puntería. Un día sin barra dice 0%: en este gráfico la columna vacía ya lo dice, y poner en palabras una distinción que el dibujo no hace obligaba a leer el cartel para entender una barra que no está. Donde sí se sostiene es en los recuadros del año, que muestran un guion.
Tuvo los números de los días abajo y la inicial de su día de la semana arriba. Costaban caro: los números pedían unos 14px por columna para ser legibles, y con 31 días eso obligaba a desplazar el mes de costado; las letras ocupaban una franja entera para decir algo que el gráfico no mide. Sin ellos las barras entran en lo que haya, y la fecha la da el cartel — que además es exacta, en vez de un número que hay que cruzar con el calendario.
La cuenta de cada día es la misma del renglón Cumplimiento diario de Hábitos, y los colores la misma paleta condicional de Barras. Un día sin nada pedido ni hecho no dibuja barra: eso no es un cero, es que no había nada que medir.
Año es una grilla de tres columnas por cuatro filas: doce recuadros, uno por mes. Tres por fila deja los trimestres alineados; con cuatro, cada fila junta meses de dos trimestres distintos y el corte no significa nada. Arriba de cada uno el mes abreviado en mayúsculas —ENE, FEB, —, chico y apagado; abajo el cumplimiento de ese mes, con el recuadro teñido según el nivel y con los umbrales de siempre. Un mes que no pidió nada muestra un guion, no un 0%.
En el orden en que aparecen:
| Indicador | Qué muestra |
|---|---|
| Días más productivos | El día de la semana que rinde mejor, medido de las dos maneras — ver abajo |
| Días menos productivos | Lo mismo, en el otro extremo |
| Necesitan tu atención | Cuántas tareas del mes están en rojo (cumplimiento menor al 45%), con sus nombres |
| Mes actual vs mes pasado | Diferencia en puntos porcentuales entre el cumplimiento del mes visible y el del mes anterior, en verde si subió y en rojo si bajó. Los dos se miden sobre sus días vencidos, así que un mes a medio andar se compara por cómo viene y no pierde de entrada contra uno cerrado |
«Productivo» significa dos cosas distintas, y las dos tarjetas muestran las dos porque suelen coronar días distintos:
| Medida | Qué promedia | A quién favorece |
|---|---|---|
| Cantidad | Puntos del día: cuántas tareas hiciste, sin mirar contra qué | Al lunes, que tiene más tareas asignadas |
| Completitud | Qué proporción de lo que ese día pedía se cumplió | Al domingo, que cumple el 100% de las pocas que le tocan |
La completitud se agrega sumando puntos y techos antes de dividir, igual que la comparación mensual: promediar los porcentajes de cada día le daría el mismo peso a un domingo que pedía una tarea que a un lunes que pedía veinte.
Cada medida ocupa su propia subtarjeta dentro de la tarjeta, con el criterio a la izquierda y el resultado a la derecha:
DÍAS MÁS PRODUCTIVOS
┌──────────────────────────────────────┐
│ · Por cantidad: Viernes · Sábado│
│ 2,9 tareas │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ · Porcentualmente: Domingo │
│ 79% │
└──────────────────────────────────────┘
El día es el dato, así que se lleva el cuerpo grande y el color del extremo —verde el mejor, rojo el peor—; el criterio y la cifra lo acompañan en gris. Los empates dentro de una medida se listan separados por ·.
Encerrar cada medida separa las dos lecturas de un vistazo: en una lista corrida se leían como cuatro renglones sueltos. Las subtarjetas usan --bg-sunken, el token de superficie hundida, que queda apenas más gris que el blanco de la tarjeta en tema claro y apenas más negro en oscuro — la jerarquía se sostiene en los dos sin definir colores nuevos.
Dos detalles de alineación que el diseño necesita:
--rank-criterio), así los días quedan alineados entre sí en lugar de arrancar cada uno donde termine su rótulo — Por cantidad y Porcentualmente no miden igual. Es un ancho fijo y no subgrid, que lo resolvería sin número pero pide Chrome 117 cuando la app declara soporte desde el 105; es la misma razón por la que .month-nav lleva su propia rejilla.Si el criterio y el resultado no entran en la misma línea —una tarjeta angosta en móvil, o un empate de varios días— el resultado baja solo y queda centrado a lo ancho, y los días siguen alineados entre sí.
Los dos renglones aparecen aunque las dos medidas coronen al mismo día: ahí cada uno lo dice bajo un criterio distinto y con su propio número, así que no hay repetición que ahorrar. Lo que sí desaparece es el renglón de una medida que no distingue nada —todos los días empatados—; si ninguna de las dos distingue, la tarjeta muestra — y «Todos los días parejos».
Las tarjetas y el gráfico de Promedio por día de la semana no muestran nada hasta que el mes tenga 7 días de uso; en su lugar va un aviso que dice cuántos van.
Siete es el mínimo con sentido: es lo que tarda cada día de la semana en aparecer una vez. Con menos, el ranking no compara promedios sino qué días cayeron dentro del tramo — un mes recién empezado coronaría al lunes por haber tenido un lunes.
Un día de uso es un día del mes visible que:
El comienzo de uso es la primera marca, no la fecha de inicio de las tareas. El
startse declara y no se observa: dar de alta hoy un hábito con inicio en enero no significa que haya siete meses de datos. Con la primera marca, un alta retroactiva no adelanta nada.
El aviso también aparece en un mes anterior a tu primer uso, y en una cuenta sin ninguna marca.
Las cuatro comparten tratamiento, así la fila se lee pareja:
margin-top: auto y lo que va debajo margin-bottom: auto, de modo que el espacio libre se divide en partes iguales a los dos lados sin separar el valor de su explicación..tile-box), hermana de las subtarjetas de productividad: mismo fondo, mismo borde y mismo radio. Se estira hasta llenar el alto que queda bajo el rótulo, así la tarjeta se lee con el mismo lenguaje que sus vecinas en vez de como texto suelto. Vale para los tres estados de la tarjeta, incluido el de «todavía no tiene meta».Los gráficos llevan el título y su bajada centrados con el mismo criterio.
Los totales siempre se calculan sobre el rango visible, así que cambian al pasar de vista semanal a mensual.
Se eligen en un botón que muestra la vista activa y despliega las otras. Las flechas ◀ ▶ lo abrazan y mueven la ventana de esa vista: un día, una semana o —en la mensual, donde no hay ventana que correr dentro del mes— un mes. Al llegar al borde saltan al mes contiguo.
Los punteros de la app son todos triángulos sólidos: las flechas del rango, las del calendario, el del plegado y los
▲ ▼de reordenar. Van a cuerpo reducido —.62remcontra el1remdel resto de los glifos— porque rellenos pesan mucho más que los‹ ›que reemplazaron, y a cuerpo pleno se comían el botón.
Son los tres únicos controles de esa zona: todo lo demás —ir a hoy, saltar a un día— vive en el desplegable. Qué ventana se está mirando no lo dice un rótulo aparte sino los números de las columnas, que ya están ahí.
Las semanas se alinean al lunes y todas las vistas se recortan a los límites del mes, para que ningún total mezcle datos de dos meses distintos.
Antes eran dos filas y siete controles: un carril con «Hoy», Diario, Semanal y Mensual, y debajo otra fila con
‹, el rótulo,›y el calendario. Todo eso decía dos cosas —en qué ventana estoy y cómo me muevo entre ventanas—, así que ahora van juntas: el rótulo pasó a ser el subtítulo de su propio control en vez de una fila aparte.El salto bajó al desplegable, al final y detrás de un separador. No es una vista sino una acción, y así se lee: las tres vistas son
menuitemradioy esa es unmenuitem.El desplegable es propio y no un
<select>: sus opciones no se pueden estilar y el nativo de Android se abre a pantalla completa. Cierra conEsc, con un clic afuera o al elegir, y devuelve el foco al botón. Mientras está abierto los atajos de teclado quedan en suspenso, para que las flechas no muevan el período por detrás del menú.
El nombre del mes es el botón: abre Seleccionar fecha, un calendario con dos escalas.
| Qué se ve | Qué hacen las flechas | Qué pasa al elegir |
|---|---|---|
| Los días del mes, alineados al lunes | Cambian de mes | Lleva a ese día y cierra |
| Los meses del año, con nombre completo | Cambian de año | Baja a los días de ese mes |
El rótulo del medio cambia de escala: de los días de un mes a los meses de un año, y al revés. Es la misma idea que en cualquier calendario, y evita un segundo control para lo mismo.
Abre en los días, que es donde está la respuesta. Antes se llamaba Seleccionar mes y era eso: doce meses y nada más. Elegir uno caía en el día 1 —una respuesta inventada, salvo en el mes de hoy— y para llegar a otro día había que salir del diálogo y moverse con el carrete. Un calendario que se llama «ir a una fecha» y no deja elegir una fecha es un calendario a medias.
Elegir un mes no navega: baja a sus días. Un mes todavía no es una fecha, y navegar ahí sería volver a contestar por el usuario. Cuesta un toque más que antes para un salto de mes, a cambio de que el salto a un día exacto pase de imposible a directo.
El día de hoy va relleno con el color de la marca y el que ya estás mirando queda contorneado; lo mismo con los meses. Navegar dentro del diálogo no mueve la pantalla: recién cambia al elegir un día, y cerrar con la ✕ la deja como estaba.
Tuvo un hermano. Había un segundo calendario, de días, que se abría desde «☰ → Ir a una fecha…», y los dos compartían diálogo, encabezado y grilla. Se fue cuando el carrete pasó a moverse de a siete días — y con esto el modo vuelve, pero adentro del mismo calendario en vez de detrás de otra puerta.
El paso a paso no se pierde: el carrete se desliza de a siete días y cruza de mes al llegar al borde, y están las teclas
←→.
A la derecha de la línea del mes hay un botón que pliega la fila que hay entre el mes y la planilla: el selector de vista con sus flechas, y Opciones.
Es un triángulo que gira: apunta hacia abajo con los controles a la vista y hacia la izquierda cuando están plegados — el giro dice hacia dónde se fueron. Recuerda cómo lo dejaste. Funciona en cualquier ancho, no solo en móvil. Solo aparece en la planilla: en la sección de análisis esas zonas ya están ocultas y no habría sobre qué actuar.
Sigue siendo SVG y no el glifo
▼: girando una sola forma, las dos posiciones son idénticas y la transición sale gratis.
La barra son dos filas, en todos los anchos:
Agosto 2026 ⌄ ◀ Semanal ▶ ✓ ✕ OpcionesEl mes va centrado de verdad: su fila es una rejilla de
1fr auto 1fr, así que el rótulo queda al medio sin importar cuánto ocupe el plegado de la derecha. Debajo, las dos entradas a todo lo demás quedan una en cada punta.Para poder compartir fila,
#month-barpasó a vivir dentro de.controls. La rejilla del mes es propia y nosubgrid: eso pide Chrome 117 y la app declara soporte desde el 105.Lo único que cambia con el ancho es dónde cae el ✓ ✕. En escritorio se apoya contra Opciones, que es donde termina la mirada al recorrer una fila larga. En móvil va centrado en el hueco que queda entre el selector de vista y Opciones, sin pegarse a ninguno: ahí ese hueco es chico, y apoyarlo contra un extremo lo hacía leer como parte de él. Por eso es hijo directo de la rejilla y no del grupo de vista: desde adentro del grupo no habría forma de moverlo.
El selector de vista mide siempre lo mismo de ancho. Los tres rótulos no miden igual —«Diario» 38,2px de texto contra 53,9 de «Semanal»—, así que sin un piso el botón cambiaba de tamaño al cambiar de vista y arrastraba a las flechas que lo abrazan. El piso queda apenas por encima del más ancho: 84px para 81,1 de contenido. Va en
emy no enpx, para que se mida contra la tipografía del propio botón y la reserva acompañe un cambio de cuerpo.Ninguno de los dos lleva indicador de desplegable. El recuadro ya dice que son controles, y sin él los anchos se ajustaron: el selector de vista bajó de 105 a 84px —los 7,5em anteriores estaban dimensionados con el chevron que llevaban antes, y dejaban 24px de aire adentro— y el del mes perdió su piso, que con 93px nunca llegaba a atar nada (el mes más corto, «mayo 2026», ya mide 112).
Todos los controles de la barra miden lo mismo de alto, por
--ctl-h. Antes convivían cuatro alturas en la misma fila —30,8px las flechas y el plegado, 32,3 Opciones, 36,4 el mes y 38,3 la vista— porque cada uno lo resolvía con su propio padding. En una fila esa diferencia se lee como jerarquía, y acá no la hay: son todos pares.Cada desplegable ancla del lado en que está su botón —y ahora en todos los anchos, porque la disposición es una sola—. El de vista queda contra el borde izquierdo y el de opciones contra el derecho, así que crecen hacia adentro y ninguno se sale de la pantalla. Esa regla va : el menú de opciones comparte la clase , cuya regla base lo centra, y acotarla por clase le pisaba el ancla y lo dejaba cortado.
El plegado va por una clase en el
bodyy no por el atributohidden, que ya lo usa el router para ocultar esas mismas zonas según la sección. Así las dos condiciones conviven sin pisarse.
En pantallas angostas aparece el apartado Visualización del desplegable Opciones, con un control que cambia la densidad de la planilla. Cicla tres niveles y recuerda el elegido.
No toca solo el ancho de las columnas: baja también el alto de las filas y el cuerpo del nombre de la tarea.
| Nivel | Días visibles | Alto de fila | Nombre | Caracteres del nombre |
|---|---|---|---|---|
| Cómodo | ~3 | 66,3px | 15,3px | ~24 |
| Normal | ~5 | 55,3px | 12,3px | ~24 |
| Compacto | ~7 | 47,4px | 10px | ~26 |
(vista mensual sobre un viewport de 390px, con la raíz en 14px; los caracteres son estimados)
Todas las filas miden lo mismo, entre el nombre en una línea o en dos. La celda de tareas usa height y no min-height: con un mínimo, un nombre largo empujaba su fila y la planilla quedaba escalonada. El alto se arma con las mismas piezas que ocupa la celda —dos líneas de nombre, una de frecuencia y el padding—, así que sigue al zoom sin números sueltos que se desincronicen. En pantallas de menos de 380px la frecuencia se oculta y el alto deja de reservarle lugar.
Y todas las columnas también, por la misma razón y con el mismo remedio del lado del ancho: la tabla es table-layout: fixed, así que los anchos los fija la primera fila y el contenido de las celdas no los mueve.
Con el
autoque traen las tablas por omisión, el ancho de cada columna lo decide lo que tiene adentro, y una celda con glifo (✓ ~ ✕ –) mide más que una vacía. Mientras la tabla entra en la pantalla no se nota —el espacio sobrante se reparte y empareja todo—, pero apenas desborda, que es lo normal en un teléfono, marcar unos días dejaba la planilla con columnas de cinco anchos distintos: 29, 29,77, 31, 32,5 y 34,28px donde tenía que haber un solo 29.En escritorio nunca se vio porque ahí la columna de día mide 38px en la vista mensual y el glifo entra sin empujar. En móvil son 29px, y en Compacto 23,8: el contenido siempre empuja. Es un bug que aparece al usar la app, no al abrirla, porque hace falta marcar celdas para que las columnas se desalineen.
Zoom out gana días sin comerse el nombre, y eso descansa en dos decisiones:
En Compacto la fila queda en 37,7px: por encima del mínimo de 24px que pide WCAG 2.5.8, pero por debajo de los 44px que recomienda el criterio AAA para objetivos táctiles.
El nombre completo sigue en el
titlede la celda, pero al tacto no hay hover que lo muestre: por eso el objetivo es que entre en la fila.
El punto de partida en móvil es un escalón más chico que antes, porque en un teléfono entraban tres días y poco más. Cómodo recupera el tamaño anterior, por si el texto de las tareas queda demasiado recortado.
En escritorio el botón no existe: ahí la planilla entra y no hay nada que resolver. Por eso el nivel se aplica dentro del breakpoint angosto y fuera de él el atributo no cambia nada.
Los niveles multiplican las medidas del breakpoint en vez de fijar las suyas. Si fijaran medidas propias le ganarían por especificidad al bloque de 380px, y el ajuste para pantallas muy angostas quedaría sin efecto en dos de los tres niveles.
Tareas pendientes: lo que hay que hacer una vez. No tiene frecuencia ni puntaje y no entra en ningún cálculo:
ni en el cumplimiento del día, ni en el del mes, ni en los gráficos. Es la otra mitad de lo que se
anota —lo que se repite va en Hábitos— y por eso vive en su propia sección y en su propia rama del
estado (state.pendientes), no entre las tareas.
| Campo | Qué guarda |
|---|---|
| Nombre | Qué hay que hacer (hasta 35 caracteres, como el de una tarea) |
| Categoría | Dónde va. Se elige en dos pasos: la principal y, si tiene, su subcategoría |
| Estado | sin-iniciar, en-proceso o finalizada |
| Calendarizar | Qué día hay que hacerlo, o ninguno |
| Comentarios | Lo que no entra en el nombre (hasta 200 caracteres), o nada |
El lápiz de cada fila es la única puerta a su edición —nombre, categoría, fecha, comentarios— y a sus tres acciones, las mismas que las de una tarea o una categoría: Eliminar, Reordenar —entre los de su misma categoría; ese orden se ve agrupando por categoría— y Archivar, que lo guarda en ☰ → Archivo junto a las categorías guardadas. El nombre es el rótulo de la fila y no abre nada; en el calendario los pendientes son etiquetas que se arrastran, y editarlos sale de la lista.
El estado se cambia tocando el botón del extremo derecho de la fila —donde la lista del día de Hábitos lleva el suyo—, que cicla los tres en orden y vuelve al principio: el mismo gesto que la celda de la planilla. El botón dice el nombre del estado en vez de cifrarlo en un color —un color obliga a aprender cuál es cuál antes de poder leer la lista— y el tooltip anticipa adónde lleva el toque siguiente. Los tres van del mismo ancho: con el ancho del texto, los nombres de al lado bailaban de lugar cada vez que se tocaba uno.
La fecha es opcional a propósito. No todo lo que hay que hacer tiene día, y obligar a inventarle uno llenaría el calendario de fechas falsas. En la lista se muestra como una etiqueta, y en rojo si ya pasó — salvo que esté finalizada, donde una fecha vieja es exactamente lo que se esperaba.
Los comentarios son para lo que no entra en un nombre de 35 caracteres: un teléfono al que llamar, qué papeles llevar, dónde quedó anotado el número de trámite. Topean en 200 porque son una nota y no un documento — y porque una nota que no cabe se termina escribiendo en otro lado, y entonces la tarea deja de decir todo lo que hay que saber.
No se despliegan en la fila. La fila es una línea, con el nombre ya recortado con puntos suspensivos: meter 200 caracteres ahí obligaría a que cada tarea ocupe un párrafo y la lista dejaría de leerse de un vistazo, que es para lo que sirve. Así que la fila solo avisa con un ⋯ tenue al lado del nombre, y el comentario se lee donde se escribe: en el ✎. Con mouse, el tooltip del ⋯ lo adelanta; en un teléfono no hay tooltip, y ahí el ⋯ dice justamente tocá el lápiz.
Es el mismo recuadro que el del nombre —relleno, borde, radio, tipografía— y mide el doble de
alto, que es lo que lo distingue de un campo de rótulo. El calc lo dice con esas palabras: la
altura de un campo de una línea —relleno arriba y abajo, los dos bordes, un interlineado—
multiplicada por dos. Escrito así sigue siendo el doble exacto si mañana cambia el cuerpo de la
letra o el relleno de los campos, cosa que un número suelto de píxeles no puede prometer.
El doble de la caja no es el doble de renglones: el relleno y los bordes no se duplican con el texto, así que adentro entran casi tres líneas.
Desde ahí crece con lo que se tipea, hasta un techo; pasado ese punto se desplaza por dentro, porque una nota larga empujaba el botón de guardar fuera de la pantalla. Sin agarradera en la esquina: se acomoda solo, y esa agarradera era justamente lo que lo distinguía del resto de los campos a simple vista.
Es el único campo de varios renglones de la app, así que es el único que podía quedarse sin estilo: el bloque que pinta los campos los lista por tipo —
input[type="text"],[type="date"]…— para no alcanzar a los radios ni a las casillas, y untextareano es ninguno de esos. Lleva regla propia con las mismas declaraciones, una por una — hay un caso que las compara. Ydisplay: block, porque untextarease apoya en la línea de base y deja abajo un hueco que ningúninputtiene.El saneador lo acepta solo si es texto. Con los otros campos alcanza
String(...)porque después se los valida —una fecha pasa porRE_DIA, un estado por su lista—, pero un comentario es texto libre y lo que salga deString()se guarda tal cual: un objeto en un archivo importado terminaba escrito como[object Object]adentro de la tarea.
El anidado llega a dos niveles: una categoría, y adentro subcategorías. Con una cadena libre habría que dibujar una sangría sin fondo, y un padre que apunte a un descendiente dejaría el árbol sin poder recorrerse. Por eso el desplegable Dentro de solo ofrece categorías de primer nivel, y una que ya tiene subcategorías no puede mudarse adentro de otra.
Toda tarea pendiente tiene categoría. La que viene puesta es Varios, con su subcategoría del mismo nombre: existen desde la primera vez, van últimas en la lista y no se pueden eliminar ni archivar, porque son lo que sostiene que nada quede huérfano. El nombre sí se cambia —lo que las identifica es su id, no el rótulo—, y siguen siendo las de por defecto después de renombrarlas.
Ahí cae lo que no se ordenó en otro lado: una tarea creada sin tocar la categoría, una que venía sin ninguna en un archivo importado, y las que quedan cuando se borra la categoría que las contenía.
El lápiz de su encabezado es la única puerta, como en las tarjetas de Hábitos, y adentro están las mismas tres acciones que las de una tarea:
Una subcategoría guardada con su madre no se desarchiva sola: primero vuelve la madre. Si no, quedaría suelta adentro de un bloque guardado.
Borrar una categoría no borra lo que tiene adentro. Los pendientes quedan sueltos y las subcategorías pasan a primer nivel. Perder trabajo anotado por reordenar carpetas sería un precio absurdo, y el aviso de confirmación lo dice antes de que pase.
Lo mismo hace cleanPendientes con lo que entra de afuera —de la base o de un archivo importado—:
una categoría cuyo padre no existe, o cuyo padre ya es subcategoría, queda de primer nivel en
vez de descartarse, y un pendiente que apunta a una categoría que no está queda suelto. Solo se
tira lo que no se puede leer: sin nombre, sin id o con el id repetido.
El ícono de la derecha de la fila abre las dos elecciones —cómo se agrupa y cómo se ordena—, que están descritas en Cómo se agrupa y cómo se ordena. En el calendario no aparece: ahí no hay nada que agrupar y ordena el almanaque.
El ícono no lleva rótulo, así que el nombre de lo que está puesto va en su title y en su
aria-label —«Vista: por estado · por fecha»—: es lo único que dice, sin abrirlo, qué se está
mirando.
Personalizado es el orden propio de cada nivel: las categorías entre sí, las subcategorías dentro de su madre y las tareas dentro de su contenedor. Es el que mueven las flechas de Reordenar, y mover algo a mano lo elige solo: con un orden automático puesto, la flecha guardaba el cambio y la pantalla no se movía, que se lee como que el botón está roto.
Nada de esto se persiste: es una postura de pantalla, como el orden de Análisis.
A la izquierda del par de vistas, y abre el mismo cajón que el de Hábitos con lo suyo adentro: acá no hay línea de mes donde ponerlo —solo aparece en el calendario—, así que vive en esa fila.
| Entrada | Qué hay adentro |
|---|---|
| Archivo | Lo guardado: las categorías —con sus subcategorías y sus pendientes— y los pendientes sueltos. Se tilda lo que vuelve y un botón lo devuelve a la lista. De las categorías solo se listan las de primer nivel: una subcategoría se guardó con su madre y vuelve con ella. |
| Papelera | Lo borrado en los últimos 7 días, lo último arriba. |
Las dos se apagan cuando no hay nada que mostrar, y el título dice cuánto hay.
La papelera guarda el cambio y no solo la cosa: de un pendiente, el lugar que ocupaba; de una categoría, además, qué subcategorías quedaron de primer nivel por su culpa y qué pendientes quedaron sueltos. Restaurarla deshace las tres cosas. Lo que ya no exista al restaurar no revive por esa puerta: un pendiente cuya categoría se borró después vuelve suelto en vez de perderse.
Son la misma lista mirada de tres maneras, y por eso el cambio son tres botones y no tres secciones distintas. Van arriba, centrados y del mismo ancho —son alternativas de la misma pregunta, y una más ancha parecería la principal—, con el ☰ de la sección a su izquierda. En el calendario aparece debajo la línea del mes.
Esa línea del mes es la misma de Hábitos: en el calendario el nodo se muda adentro de la sección, debajo del par de vistas, y vuelve a su lugar al salir. Un segundo botón de mes sería un segundo lugar donde arreglar lo mismo.
Son dos preguntas distintas y viven juntas en el menú del ícono, cada una en su propia tarjeta — una lista corrida de siete botones las hacía parecer una sola de la que se elige uno—:
| Vista — qué junta con qué | Ordenar — cómo se acomoda lo que quedó junto |
|---|---|
| Por categoría: el árbol, cada categoría con sus subcategorías y sus tareas adentro | Personalizado: el orden propio de cada nivel, el que mueven las flechas de Reordenar |
| Por estado: tres tarjetas —Sin iniciar, En proceso, Finalizada—, siempre las tres | Por fecha: lo que vence antes primero; lo que no tiene fecha, al final |
| Sin agrupar: todo seguido, en una sola lista | Alfabéticamente |
| Por cantidad de tareas: solo agrupando por categoría |
Las dos se confirman juntas, con el ✓. Tocar una opción la marca y nada más; lo que cambia la pantalla es el botón. Aplicando cada toque al instante, elegir vista y orden eran dos aperturas del mismo diálogo, y entre una y otra la lista se redibujaba con media elección puesta — con la vista nueva y el orden viejo, que a veces ni siquiera valía ahí—. Cerrar con la ✕ no aplica nada: el borrador se descarta solo, porque la próxima apertura lo vuelve a copiar de lo que está puesto.
El apagado sigue al borrador y no a lo puesto: al marcar por categoría, por cantidad de tareas se enciende en el acto, sin esperar al ✓. Si no, habría que confirmar una elección para poder hacer la otra, que es exactamente lo que el ✓ vino a evitar.
El diálogo no lleva título propio: los dos de adentro ya dicen de qué se trata, y uno arriba de ellos era un tercer encabezado para lo mismo. Su nombre vive en el
aria-label, que es donde lo busca un lector de pantalla.
Antes eran dos vistas y no una elección. Lista y Categorías eran botones hermanos del Calendario, y cada una ofrecía sus órdenes: una podía ir por fecha y la otra por cantidad de tareas, pero nunca al revés. La asimetría no era un descuido, era el síntoma: adentro de un mismo botón estaban mezcladas la agrupación y el orden. Separarlas destapó además lo que faltaba —ver todo seguido, sin ninguna división— y dejó los tres órdenes disponibles en las tres agrupaciones.
«Por cantidad de tareas» ordena grupos por su tamaño, así que pide grupos que alguien haya armado: solo se enciende agrupando por categoría. Las tres tarjetas de estado van siempre en el orden del avance —no son grupos, son un progreso— y sin agrupar no hay grupos. Queda apagada y no escondida: una opción que desaparece hace dudar de si estaba y uno se la perdió. Al salir de las categorías con ella puesta, el orden cae en alfabético; dejarla marcada como activa estando apagada sería peor.
Por fecha no mueve las categorías. Una categoría no tiene fecha, así que esa elección habla de las tareas de adentro y no tiene por qué reordenar lo de afuera: las tarjetas se quedan en su orden propio.
Agrupada, las tarjetas abren plegadas. Lo primero que se ve es el mapa —los tres estados, las categorías— con su cuenta al costado, y se abre lo que se quiere mirar. Con unas pocas tareas daba igual; con treinta, entrar a Pendientes era caer en el medio de un muro de filas y tener que plegar a mano para poder mirar algo. La cuenta es lo que hace que plegado no sea lo mismo que vacío. Sin agrupar no hay tarjetas, así que es la única de las tres que muestra algo apenas se entra.
Se anota cuáles se abrieron, no el estado de cada una: son las menos, y una categoría recién creada no necesita que nadie la marque para verse como las demás. Antes arrancaban abiertas y hacían falta dos conjuntos —uno para las que se habían cerrado y otro para las que se habían abierto—, porque las archivadas arrancaban al revés que el resto; ahora todo arranca igual y queda uno solo. Es una postura de pantalla: no se guarda ni viaja a la base.
Calendario — por fecha. La rejilla del mes con lo que cae cada día. Los que no tienen fecha no entran en la rejilla, así que se los cuenta al pie: si no, cambiar de vista parecería haberlos perdido.
Ahí el calendario también se edita: tocar un día lo abre —lo que cae ahí, y un botón para agregar algo más con esa fecha puesta—, y arrastrar un pendiente a otro día se la cambia.
Antes tocar el día abría el alta directo. Agregar es lo que más se hace, pero ver qué hay obligaba a leerlo en la celda —donde entran dos o tres etiquetas y el resto se corta— o a irse a la lista y buscar la fecha. Un toque más para crear, a cambio de que mirar un día sea posible.
La tarjeta arranca con lugar para varias tareas, esté el día cargado o no. Con el alto del contenido era una franja de dos renglones —el título y «Sin tareas aún»— y cambiaba de tamaño con cada tarea que se agregaba: se abría distinta cada vez, y en un día cargado el botón de agregar terminaba en otro lugar de la pantalla que en uno vacío. Es un mínimo, así que un día muy cargado crece hasta la mitad de la pantalla y de ahí la lista se desplaza por dentro.
Adentro van las mismas filas de la lista, así que los mismos toques: el estado cicla y el lápiz edita. Sin la etiqueta de la fecha, eso sí — la dice el título, y repetirla en cada fila sería decir lo mismo tantas veces como tareas haya. Un día sin nada lo dice: «Sin tareas aún», porque un diálogo con el título y nada abajo se lee como algo que no llegó a cargar. Y deslizando de costado se cambia de mes: a la izquierda el siguiente, a la derecha el anterior. El calendario ocupa la pantalla y el nombre del mes queda arriba del todo, así que pasar de mes era subir el pulgar, apuntar a una flecha y volver a bajar.
Los dos gestos son un dedo moviéndose y no se pisan porque arrancan en lugares distintos: el arrastre solo empieza sobre un chip y el deslizamiento solo fuera de uno. Pide 60 px de recorrido y ser vez y media más ancho que alto — sin lo primero cualquier temblor cambia de mes, sin lo segundo lo hace desplazar la pantalla con el dedo apenas torcido—. Va solo con el dedo o el lápiz: con mouse están las flechas, y arrastrar para seleccionar texto no tiene por qué cambiar de mes.
Es el mismo gesto que los del Diario y la misma función,
deslizarDeCostado. Lo único propio de cada uno es qué pasa y qué gesto tiene que quedar afuera:
Dónde Qué pasa Qué queda afuera El calendario de Pendientes Un
El calendario es mensual como la planilla y usa la misma línea de mes: dos selectores de mes en la misma app serían dos cosas que aprender para lo mismo.
El botón redondo de la esquina crea lo de la sección: una tarea en Hábitos y, en Pendientes, una tarea pendiente o una categoría — ahí pregunta cuál.
Preguntar volvió a tener sentido. Dejó de hacerlo cuando Categorías era una vista: parado ahí, la pantalla ya contestaba, y volver a preguntarlo era un paso de más. Ahora las categorías son una forma de agrupar y no un lugar donde se está, así que la pregunta no la contesta nadie.
Hay un segundo camino para las categorías, y es el que se usa más seguido: los dos selectores del diálogo de la tarea terminan en + Nueva categoría…. Es para el momento en que ya estás cargando algo y te das cuenta de que le falta dónde ir; sin eso habría que cancelar, crear la categoría y volver a escribir la tarea.
Elegirla sale al formulario de categorías de siempre —no a una copia reducida: una categoría se crea igual venga de donde venga, y un segundo formulario para lo mismo son dos lugares donde arreglar cada cosa—. Y al terminar vuelve al alta, con lo que ya se había escrito y la categoría recién hecha puesta.
El «Dentro de» viene decidido y apagado. El desplegable del que se salió ya contestó de qué nivel es la que hace falta: desde el de arriba, una de primer nivel; desde el de abajo, una que cuelga de la que ya estaba elegida. Elegir otra cosa ahí la mandaría a un lugar distinto del que se pidió. La opción que no corresponde queda apagada y no borrada, para que se lea cuál era la alternativa descartada en vez de parecer que la lista está incompleta.
Se cierra el alta en vez de abrir el otro encima. Apilar dos modales en un teléfono deja al de abajo tapado y sin salida visible; cerrando, la pantalla dice una sola cosa por vez y el regreso se nota. Cerrar el formulario sin guardar también vuelve, con lo escrito y con la categoría que estaba antes de tocar la opción — el desplegable había quedado marcando «+ Nueva categoría…», que no es una categoría.
Ese regreso cuelga de
cerrarDialogoy no del eventoclose, que sería lo natural: jsdom no disparaclose, así que colgarse de él dejaba el camino de vuelta sin ningún caso que lo cuidara.Escsí necesita el evento —cierra por su cuenta— y ahí queda como red, sin repetir el regreso: lo primero que hace es limpiar el viaje.La categoría no existe hasta que la tarea se confirma. Vuelve puesta en el desplegable y se ve como cualquier otra, pero vive en un borrador aparte hasta el ✓ del alta: guardarla al cerrar su propio formulario dejaba una categoría vacía cada vez que la tarea se cancelaba, imposible de distinguir de una hecha a propósito. Y solo nacen las que la tarea usa: si se creó una, se cambió de idea y se eligió otra, la primera no llega a existir — no se pidió una categoría, se pidió una tarea que fuera ahí.
| Tecla | Acción |
|---|---|
← → | Período anterior / siguiente (en Análisis, mes anterior / siguiente) |
T | Ir a hoy |
N | Nueva tarea (solo en el Diario) |
Esc | Cerrar el menú o el diálogo abierto |
Medido con tools/medir.mjs, que corre la app real dentro del arnés con
distintos volúmenes de datos. Son tiempos de jsdom, entre 3 y 10 veces más lento que un
navegador: sirven para comparar escenarios y para ver si un cambio empeoró algo, no como el número
que ve alguien usando la app.
| Escenario | Peso | Marcar una celda | Cambiar de mes | Abrir Análisis |
|---|---|---|---|---|
| Hoy (24 tareas, 4 meses) | 77 KB | 3 ms | 12 ms | 14 ms |
| A 3 años (24 tareas, 40 meses) | 770 KB | 13 ms | 24 ms | 86 ms |
| A 3 años con 100 tareas | 3,2 MB | 34 ms | 26 ms | 149 ms |
Lo que importa de esa tabla no son los valores sino qué crece con el historial:
JSON.stringify(state) para guardar en
localStorage — a 3 años son 13 de esos 13 ms, o sea casi todo.Cuándo volver al tema. Si marcar una celda pasa de unos 5 ms en el navegador —hoy está muy por debajo—, el paso siguiente es agrupar la escritura local como ya se agrupa la de red: un temporizador corto y un volcado inmediato al ocultar la pestaña. No está hecho porque hoy no se nota, y porque cambiar «lo local se escribe en el acto» tiene un costo real: es la garantía de que un cierre forzado no pierde el último clic.
Cada mes tiene su propia lista de tareas, pero no hay que volver a cargarlas todos los meses:
Ejemplo: creás Leer y Correr en julio. Agosto y septiembre las muestran solas. En agosto eliminás Correr: julio la conserva, y septiembre —que hereda de agosto— deja de mostrarla.
El botón ⧉ Copiar tareas del mes anterior reemplaza la lista del mes visible por una copia de la del mes anterior. Pide confirmación porque es destructivo: las tareas que no estén en el mes anterior desaparecen de este mes junto con los estados que hayas cargado en él.
Las tareas que sobreviven a la copia conservan sus estados de este mes, así que sirve para volver a alinear un mes que quedó desprolijo sin perder lo ya marcado. Los meses anteriores nunca se tocan. El botón queda deshabilitado si el mes anterior no tiene tareas.
Un día se cierra solo apenas termina. No hay botón que apretar: a partir de las 00:00, todos los días transcurridos quedan cerrados, incluido el que acaba de concluir.
Cerrado no quiere decir intocable. Un clic sobre una celda de un día viejo no marca: abre un pedido de confirmación —«Día ya transcurrido. ¿Querés modificarlo?», con la fecha completa al pie— y recién al aceptar se aplica el mismo clic que se había hecho. Cancelar deja todo como estaba. El botón de aceptar va con el color de la marca y no con el rojo del resto de las confirmaciones: acá no se destruye nada, y el rojo diría lo contrario.
El permiso que da esa confirmación es de ese día y de nadie más: destrabado el martes, el miércoles sigue preguntando. Y no se persiste: vale mientras dure la sesión y se pierde en la medianoche siguiente, junto con el día que en ese momento deja de ser hoy.
Por qué se reemplazó al bloqueo por mes. Antes había un 🔒 Bloquear período que dejaba el mes entero en solo lectura, y había que acordarse de apretarlo. Lo que protegía —no ensuciar sin querer un período ya cerrado— es exactamente lo que hace falta todos los días, no una vez por mes; y lo que costaba era que un mes bloqueado también apagaba el alta de tareas y las tres posturas de la fila, que no tenían nada que ver.
La vuelta de medianoche es real, no un efecto del recargado. Con la pestaña abierta toda la noche, la fecha del navegador avanza igual: un temporizador apunta a las 00:00 siguientes y, al llegar, relee el día, vacía los permisos y redibuja. Un equipo suspendido no dispara temporizadores a tiempo, así que la misma comprobación corre también al volver a la pestaña.
El estado completo va al localStorage, bajo la clave tareas-diarias/v1/<id de usuario>, y a Supabase — ver Dónde viven los datos. La forma es la misma de los dos lados salvo por las preferencias de pantalla, que solo existen en la copia local (ver Lo que no se sincroniza).
Las tareas se guardan en months, indexadas por YYYY-MM; solo aparecen los meses que fueron modificados. Los estados van aparte, en status, con clave idDeTarea|YYYY-MM-DD, y los paneles del análisis en panels. El cierre por día no se guarda: se deduce de la fecha, así que no hay nada que sincronizar ni que pueda quedar desfasado entre dispositivos. Un estado viejo que traiga el locked del bloqueo por mes se descarta al abrirlo. Si tenías datos del formato anterior (una única lista global), se migran solos al mes más antiguo con estados cargados, así los meses siguientes lo heredan y no se pierde nada.
.json con tareas y estados.Usá exportar/importar para hacer backup o para pasar los datos a otro dispositivo.
Si editás la frecuencia de una tarea y algunos días dejan de aplicar, esos estados quedan guardados pero no se cuentan. Si volvés a la frecuencia anterior, reaparecen. Eliminar la tarea borra sus estados de ese mes definitivamente (ver Independencia por mes).
La app ocupa la ventana y no desplaza: desplaza la planilla. Quedan quietos el encabezado, la barra de controles, el encabezado de días de la planilla y la leyenda del pie; las filas de tareas pasan por debajo. En un mes cargado eso deja siempre a la vista qué día es cada columna, que es justo lo que hace falta mientras se recorren las tareas.
Por qué el armazón y no
sticky..table-wrapya desplazaba en horizontal, y al fijar un eje el otro deja de servisible: el contenedor es un contenedor de scroll, así que unstickyde adentro se ancla a él y no a la ventana. Con la página como scroller, el encabezado de la planilla se empujaba hacia abajo hasta el medio de la tabla en vez de quedarse arriba.Dándole el scroll a
.table-wrapy el alto sobrante porflex, ese mismostickyse pega a su borde superior y funciona. Y las otras dos bandas dejan de necesitarsticky: el armazón les da su franja y nada pasa por debajo.La pieza que no se ve venir es
min-height: 0en la cadena.app-main→.board→.table-wrap. Sin eso los hijos flex se niegan a bajar de su alto natural, no hay nada que recortar y el desborde reaparece abajo de todo.El alto es
100dvhcon100vhde reserva: sindvh, la barra del navegador en el teléfono deja un trozo de app fuera de la pantalla.En papel el armazón se suelta. La hoja de impresión devuelve los altos a
autoy quita eloverflow, o se imprimiría solo lo que entraba en una ventana.El tablero encierra su apilado con
isolation: isolate. Sus capas internas —el encabezado de días, las columnas fijas— competían de igual a igual con las de la barra de controles; al empatar decidía el orden del documento, y como el tablero va después, el encabezado de la tabla tapaba los desplegables. Encerrarlo es más sólido que subirle el número a la barra, que solo mueve el empate de lugar.El encabezado de la planilla se pega un píxel por encima del borde del contenedor, no pegado a él. Ese contenedor no siempre cae en una posición entera —medido: 178,97px—, y ahí lo pegado y lo que desplaza por detrás se rasterizan en bordes de píxel distintos: por esa costura de subpíxel asomaban las filas. El fondo estirado la tapa, y el píxel de más queda recortado contra el borde, donde no se ve.
El encabezado va por debajo del velo del menú lateral en el orden de apilado. Si estuviera por encima, abrir el menú lo dejaría iluminado y clickeable sobre un velo que atenúa todo lo demás.
Navegadores modernos (Chrome/Edge 105+, Firefox 121+, Safari 15.4+). Usa <dialog>, :has() y color-mix().
| Los dos desplegables, y las posturas de la fila que abre «Opciones» |
dia-cerrado | El cierre automático de los días transcurridos y la vuelta de medianoche |
identificadores | Ningún id repetido en el marcado |
guias | Las tres guías: que cada ☰ ofrezca la suya, que se abra la que dice y que cada una hable solo de su pantalla |
El diagnóstico salió de medir en el navegador, que era el único lugar donde se veía: fondo: rgba(0,0,0,0) con letra: rgb(255,255,255). jsdom no resuelve var() ni pinta nada, así que
desde los casos esto era invisible — lo que sí pueden cuidar es que la regla del estado de espera
tenga relleno de verdad y no transparent, y eso es lo que comprueban.
La espera se arma antes de mostrar el diálogo: si no, el botón aparece un instante con el rótulo entero y encendido, y recién después se apaga — un parpadeo que invita justo al toque que la espera quiere evitar.
Las dos puertas son dos PASOS del mismo diálogo, no dos diálogos. Cerrarlo y volver a abrirlo
en el mismo tic lo saca de la capa superior y lo devuelve en el mismo cuadro, y el navegador se
queda pintando lo de antes hasta que algo lo obligue a recalcular — abrir el inspector, por
ejemplo—. Es lo único que este flujo hacía y ninguna otra confirmación de la app, y por eso era el
único que se veía mal. showModal() sobre un diálogo abierto además es un error, así que reusarlo
es también lo que la API pide.
close() no dispara su evento en el acto: lo encola. Encadenando dos confirmaciones, el orden
real es cerrar, armar la segunda, y recién entonces correr la tarea encolada — que llega tarde,
sobre el botón de la segunda. Había un oyente de close que reponía el rótulo por defecto «como
red de seguridad», y era exactamente lo que le pisaba el botón: aparecía «Eliminar» con la
cuenta atrás andando por dentro, hasta que un segundo después el reloj lo repintaba.
La red no hacía falta: confirmAction fija el rótulo y las clases en cada apertura, que es la
única forma de abrir el diálogo. Y jsdom no dispara close, así que ningún caso podía verlo:
el que lo cuida ahora despacha el evento a mano, que es la única manera de reproducir ese orden.
La cuenta se ve: un botón apagado y mudo se lee como roto, y el número dice que la espera
termina. Se frena al salirse por cualquiera de las puertas, y el caso la maneja reemplazando el
setInterval de la ventana — esperar diez segundos de verdad serían diez segundos de suite por
una comprobación.
Dentro de un período ya vencido, la meta sigue siendo la del período completo: una tarea de lunes, miércoles y viernes vista en una semana terminada apunta a 3, y hacerla dos veces da 67%.
Si la frecuencia no pide ningún día del rango —una Lunes a viernes en la vista diaria de un sábado— no hay nada que medir y muestra —. Un mes entero por venir tampoco exige nada, así que sus filas abren en — en lugar de en rojo.
Esquina inferior derecha: cumplimiento global del rango visible, con el mismo criterio.
DICEl porcentaje es lo obtenido sobre lo pedido, sumando día por día con la misma cuenta que los otros dos. Un mes es la suma de sus días: si este recuadro saliera de otro lado, el año y el mes podrían contradecirse sobre el mismo período.
Es el único que no mira un mes sino un año, así que la fila de arriba cambia con él: el rótulo del medio pasa a decir el año y aparece Año actual del otro lado, que es el gemelo de Hoy y se muestra solo estando parado en otro año. Deslizar la grilla pasa de año, y el rótulo del medio abre un selector que ofrece años y no meses: con este gráfico puesto un mes no es una respuesta.
Deslizar un gráfico de costado lo mueve un período, con el mismo gesto con el que el carrete pasa de día y el calendario de Pendientes pasa de mes. Qué período mueve depende de lo que el gráfico mire, no de cuál sea: los cuatro que miran un mes pasan de mes, y Año pasa de año. Un gesto que moviera siempre lo mismo dejaría a Año corriéndose de a un mes sin que se notara, porque dibuja el año entero.
El gesto responde en toda la sección, incluido el hueco que queda entre el gráfico y la barra del pie — que es donde cae naturalmente el pulgar. Y en Día por día, un toque sobre una columna abre su cartel mientras que un deslizamiento cambia de mes: se separan por el recorrido, no por la zona.
En Barras, el botón de la esquina superior derecha elige el tramo que se promedia. Muestra el período puesto en corto —1M, 3M, 6M, 1A— y abre un diálogo donde se explican enteros: Este mes, Últimos 3 meses, Últimos 6 meses, Último año. Va en la misma fila que el nombre del mes y el ☰, y tiene ancho fijo: sin eso, pasar de 1M a 1A encoge la caja y corre al mes y al ☰ de lugar, porque las columnas de los costados se reparten lo que sobra.
Fue un
<select>nativo y duró poco. La flecha se le saca conappearance: none, pero la lista de opciones la dibuja el sistema operativo y no acepta una línea de CSS: se abría con su azul y su tipografía, sin nada que ver con la app. Es el mismo motivo por el que los otros desplegables son propios. Ahora es un botón con su diálogo, con las mismas clases que el del orden — que es de donde sale que se vea igual que el resto, y no de una paleta aparte.
El tramo termina en el mes que estés mirando y retrocede desde ahí. El mes y el período son dos controles de la misma fila que se reparten la pregunta: el mes dice dónde termina y el período cuánto abarca. Parado en Octubre 2026 con 3M, lo dibujado es Desde Agosto 2026 a Octubre 2026; retrocediendo a junio, pasa a ser Desde Abril a Junio.
Por eso elegir un mes en el calendario corre el período en vez de apagarlo: ese calendario es justamente cómo se mueve la ventana.
Contó desde hoy, con el argumento de que «los últimos 3 meses» quiere decir lo mismo en cualquier pantalla. El costo era que el mes de al lado quedaba de adorno: con un período puesto no cambiaba nada de lo dibujado, y no había forma de mirar un trimestre que no fuera el último.
Es de Barras y de nadie más. Es el único que promedia, y un promedio mejora cuanto más largo es el tramo; Puntos dibuja un punto por día —365 en una fila no es una fila— y Porcentajes mide contra la meta del período, que a un año es otra pregunta. La elección se guarda como preferencia de pantalla.
«Barras» se dibuja en DOS tarjetas. Arriba, el gráfico y nada más. Abajo, lo que se lee: el tramo encabezando, y debajo las dos de los días. Juntas en un solo marco, el dibujo y los dos párrafos competían y nada decía dónde terminaba uno y empezaba lo otro.
El tramo va con todas las letras y sin rótulo que lo anteceda: Octubre 2026, Desde Agosto 2026 a Octubre 2026. Es lo único que dice qué hay dibujado, así que se escribe entero; el año va en los dos meses aunque coincidan, para no tener que deducir ninguno.
Las dos tarjetas de los días van apiladas y a lo ancho: la pregunta a la izquierda, en dos renglones fijos, y el día a la derecha, centrado en un ancho fijo para que Jueves y Lunes queden alineados entre las dos. Los renglones los corta el marcado y no el ancho de la pantalla — si no, cada tarjeta cortaba en un lugar distinto y el bloque se veía desparejo.
Dicen con palabras lo que el podio marca sobre la barra —la corona y la cruz—, y salen del mismo cálculo, así que no pueden contradecirse. Cuando varios días empatan se nombran todos, igual que se coronan todos.
El rojo del peor día es --chart-missed, el mismo de la cruz del podio, y lo toma de esa variable y no de una copia: el día que la cruz marca sobre la barra y el que nombra la tarjeta son el mismo, así que dos variables distintas se habrían separado en cuanto alguien tocara una.
El verde del mejor es --dia-mejor: --dot-done un paso más oscuro. Los --dot- son colores de relleno, calculados para una columna de 24px; como texto, el verde quedaba en 1,9:1 contra la tarjeta, muy por debajo del 3:1 que pide WCAG AA para un texto grande en negrita.
| Texto de la tarjeta | Contraste | |
|---|---|---|
| Claro, mejor | #3b9160 | 3,4:1 |
| Claro, peor | #dc2626 | 4,3:1 |
| Oscuro, mejor | #4ec07d | 7,8:1 |
| Oscuro, peor | #ef4444 | 4,8:1 |
En oscuro el verde va al revés —más claro, no más oscuro—, y no es una inconsistencia: sobre un fondo oscuro, oscurecer es desaparecer. Es el mismo criterio que ya siguen
--done-fgy--missed-fg, que en claro son oscuros y en oscuro son claros.
De todos modos el color no es lo que informa: la oración de al lado ya dice si son los mejores o los peores, así que la tarjeta se entiende sin distinguir el tono. Es lo que la deja funcionar bajo daltonismo, donde verde y rojo pueden no separarse.
Con el tramo parejo no se dibujan, y el podio tampoco corona a nadie. Pasa siempre que hay un solo día con datos, y ahí «tus mejores días fueron los jueves» junto a «tus peores días fueron los jueves» sería la pantalla contradiciéndose sobre el mismo día.
Un día puede quedar sin barra si ninguna tarea le pedía nada — ahí no hay nada que medir, y no es lo mismo que un cero.
En Cumplimiento por tarea la barra se topea en la meta. Si una tarea llega al 600%, escalar a ese máximo aplastaría a todas las demás y el rango que importa —45 a 100%— dejaría de leerse; el número al costado conserva el valor exacto y la barra que se pasa termina en escuadra contra el borde.
.view-options| Arrastrar un pendiente de un día a otro |
| El carrete del Diario | Siete días: la tira entera | — |
| El resto del Diario —la lista— | Un día, como las teclas ← → | El carrete, que lo contiene |
Lo que entra se desliza desde el lado hacia el que fue el dedo, y es UNA sola definición para las dos pantallas: la tira de días de Hábitos y la rejilla del calendario de Pendientes. Las dos ya comparten la función que detecta el gesto, y un movimiento distinto en cada una haría sentir la app hecha por dos manos.
Pasar de semana cambia los siete números de una vez, y sin eso no se veía de dónde salieron: parecía que la tira se había reescrito y no que se había movido. Son 300ms y un corrimiento del 35% —el ojo tiene que alcanzar a leer que son los mismos días corridos—, y el carrete recorta lo que se sale, que si no el corrimiento empuja el ancho de la pantalla. Moverse de a un día no anima nada: ahí la tira corre un lugar y se ve sola.
Empezó en 180ms y se sentía un parpadeo: el movimiento terminaba antes de que el ojo lo siguiera, así que avisaba de un cambio en vez de mostrarlo. La ventana en la que un movimiento se lee como movimiento va de dos décimas a medio segundo, y el caso la fija así en vez de clavar un número — abajo parpadea, arriba la tira se siente pesada y se interpone.
«Hoy» se asoma con la misma receta. Aparece y desaparece según dónde esté parado el carrete, y se materializaba al lado del mes: eso se lee como un parpadeo de la pantalla y no como algo que llegó. Ahora se enciende desde un poco más abajo, con el mismo tiempo y la misma curva que la tira —viene del mismo movimiento, así que se acompañan en vez de competir— y solo al aparecer: mientras ya está a la vista, cada dibujado lo volvería a animar por nada.
Quien pidió reducir el movimiento no lo ve: la regla del final de styles.css le deja .01ms y
acá eso es exactamente lo que corresponde —la tira aparece puesta, sin viaje—. Al revés que el
ciclo de colores de la guía, que es infinito y ahí la misma regla lo dejaría temblando.
Sobre la tira pasa de a siete porque es la tira lo que se está moviendo: correrla un solo día
obligaría a repetir el gesto siete veces para ver la semana que sigue. Es lo que hacían las
flechas ◀ ▶ que la abrazaban, y por eso se fueron: el gesto hace lo mismo con el pulgar donde
ya está, y el ancho que ocupaban volvió a las fichas — que son lo que se toca—. La tira pasó de
nueve columnas a siete.
El del tablero deja afuera al carrete, que está adentro suyo. Sin eso, un dedo sobre la tira disparaba los dos y el día se iba ocho — la clase de error que se ve como «a veces se pasa de más», y por eso hay un caso que lo mira.
El contenedor lleva touch-action: pan-y: sin eso el navegador se queda con el gesto horizontal
y el deslizamiento no llega nunca; con none la pantalla dejaría de desplazarse ahí adentro. Y
el clic que cierra el gesto se descarta, como el que cierra un arrastre — si no, deslizar abriría
además el alta, y encima con el día de la rejilla nueva, que no es el que se tocó. El arrastre va con eventos de puntero y no con
el arrastre nativo de HTML5, que en el teléfono no existe — y arranca recién después de moverse
unos pixeles, así que un toque sigue siendo un toque y abre el diálogo. Los chips llevan
touch-action: none para que arrastrar con el dedo no desplace además la pantalla; el resto del
calendario se sigue desplazando como siempre.
Arrastrar no es el único camino: el campo de fecha del diálogo hace lo mismo. Eso es lo que lo deja al alcance de quien no puede arrastrar.
El borrador vive fuera de state y no adentro con una marca: cualquier save() de por medio
—marcar una celda en otra pantalla, una sincronización— lo habría escrito. Lo que sí mira son los
nombres repetidos: dos categorías pedidas en la misma alta chocan igual que dos guardadas, y
descubrirlo recién al confirmar sería avisar tarde.
Análisis y Cómo usar desplazan por dentro, que es lo que les corresponde: son largas y no tienen una planilla que se quede con el alto.
Escritorio: planilla completa; la columna de tareas y la de totales quedan fijas al hacer scroll horizontal.
Mobile: la tabla scrollea en horizontal con la columna de tareas siempre visible; celdas de 46 px de alto para que sean cómodas al tacto, y un botón de zoom para elegir cuántos días entran (ver arriba).
Tema claro/oscuro automático según el sistema, con toggle manual que se recuerda.
Hoja de estilos de impresión: oculta controles y deja solo la planilla.