Un calendario de verdad
Convertir el calendario de RedCheck de un adorno en una herramienta para planificar: historial de días pasados, prioridades, rutinas recurrentes con rachas y eventos de calendario.
RedCheck had a calendar with Day, Week and Month views. If I’m honest, two of the three were decorations. Week view drew an empty dashed box for each day. Day view drew an hourly grid with a “now” line, and then listed your tasks on top of it, ignoring the hours completely.
This post is about turning that calendar, and the routines behind it, into something you’d actually plan your week with. It’s also the first time in this series where most of the work started in the backend.

Making the views real
Week view now shows the actual tasks for each day, coloured by subject. Day view places each task in its real hour across the full 24 hours, with an “all day” strip for deadlines without a time, and scrolls to the current hour when it opens.
On desktop you can drag any task onto another day or hour to reschedule it. I used native HTML5 drag and drop, which is mouse-only by design. On touch devices a tap still opens the task editor, so the behaviour is never ambiguous.
Building the Day view exposed a lot of layout bugs that had been hidden because nothing was actually aligned to the grid before: an hour column and a task column scrolling independently and only lining up by accident, flex rows shrinking below their height, and a background that stopped painting at noon because of how flexbox stretches items. None of them were interesting individually. Together they were a good reminder that “it looks right” and “it’s built right” are different things.
Past days were empty, and the dashboard was slow
Two backend problems surfaced quickly.
The dashboard made one request per subject. It fetched the list of subjects, then the tasks of each one. With ten subjects, that’s eleven HTTP requests to render one screen, the classic N+1 problem, just across the network instead of inside the database. A new endpoint returns subjects with their tasks in one response, using exactly two queries (subjects, then all of the user’s relevant tasks) grouped in memory.
The calendar couldn’t show history. The dashboard only loads pending tasks and those completed today, so a past day in the calendar showed nothing but a heatmap count. A new top-level endpoint returns every task, pending or completed, whose deadline falls within a date range. I made it top-level rather than nested under a subject for the same N+1 reason. The calendar fetches one range per view, and only when that range contains a past day.
Priorities, and a migration strategy I had to own up to
Tasks now have a priority: low, medium or high. Two decisions are worth explaining.
Medium is invisible. Every task defaults to medium, and medium shows no badge at all. Only low and high do. If every task carries a label, nobody reads labels.
Priority has its own colour palette, separate from subject colours, because they’re two different dimensions and the calendar already uses colour for subjects.
The less glamorous part: RedCheck has no migration tool like Flyway or
Liquibase. Schema changes are applied by Hibernate’s ddl-auto=update.
Adding a non-null column to a table with existing rows only works if the
database itself has a default, so the column declares one at the database
level, not just in Java. It works, and I’ve now documented it, but I
know it’s a shortcut. The first schema change that update can’t express
safely (a rename, a split column) is the day I introduce a proper
migration tool.
Routines that behave like routines
Recurring tasks used to support four presets: daily, weekly, every two weeks and monthly. Real routines don’t look like that.
Specific weekdays. “Monday and Thursday” is stored as a restricted
cron expression and evaluated with Spring’s own CronExpression, with no
new dependency. The validation only accepts that exact shape, so every
stored value can always be turned back into checkboxes in the edit form.
Accepting arbitrary cron would have been more flexible and impossible to
edit through a UI.
A specific day of the month, or the last day. This one is handled by hand, not with cron. Spring’s cron implementation has no “last day of month” operator, and a plain “day 31” rule silently skips every month that doesn’t have one. For a routine like “pay rent on the 31st”, the user means “the last day”, not “seven times a year”.
A time of day and an end date. Generated tasks now get a real deadline time, and a routine with an end date generates its final occurrence on that day and then deactivates itself.
A live preview in the form shows the next few dates a routine will fire, computed client-side so it updates as you type. It deliberately replicates one quirk of the backend scheduler (a new routine’s first occurrence is always on the next scheduler run) so the preview never promises something the server won’t do.
Streaks and completion rate per routine. These are computed from the routine’s full history on every read, not stored as counters. A stored counter drifts the moment someone deletes or un-completes a past occurrence. A routine’s history is small enough that recomputing it is cheap, and it can never be wrong.
One robustness fix came with this: the scheduler used to process all routines in one loop, so a single unparseable frequency would abort generation for every user. Each routine is now processed in isolation.

A deploy that returned the wrong thing, successfully
The new “all my routines” view worked locally and broke in production. The cause: nginx only proxied a known list of
API paths to the backend. The new /recurring-tasks path wasn’t on it,
so it fell through to the single-page app’s catch-all and returned
index.html with a 200 OK. No error anywhere; the response was just
HTML instead of JSON. curl and the Content-Type header found it in a
minute, once I thought to look.
Events are not tasks
The latest addition is calendar events: meetings, birthdays, trips. It was tempting to model them as tasks with a flag. I didn’t, because they are genuinely different things. A task has a deadline and gets completed. An event has a start and an end and simply happens. Mixing them would have polluted every statistic and every “pending” count in the app.
So events are their own domain: user-defined, colour-coded categories, one-off and recurring events (with a new yearly frequency for birthdays), all-day and timed. In Day view, timed events are blocks that span their duration, and you can press and drag on the timeline to create one. In Month view, multi-day events render as continuous banners across the days they cover.
Those banners caused my favourite bug of the month. Banners and day cells share one CSS grid. Banners had explicit grid positions; day cells relied on auto-placement. Per the CSS Grid specification, auto-placed items skip cells already claimed by explicitly placed ones, so any day with a banner silently pushed every following day in that row into the wrong weekday. Day cells now get explicit coordinates too. While I was there, all day-matching logic was rewritten to compare whole-day integers instead of timestamps, which removed a whole class of boundary bugs.
The demo accounts were updated with sample events, with dates relative to the current day so they stay current through the hourly reset. That exposed one more issue: two overlapping runs of the reset procedure could each insert their own copy of the data. It’s now wrapped in a non-blocking named lock; a second concurrent run simply does nothing.

What’s next
Google Calendar sync is designed (a read-only overlay of your events, plus opt-in export of RedCheck tasks to a dedicated calendar) and waiting on Google’s OAuth verification. I told the story of that verification process in part 8.
What I took away
- N+1 problems also happen over HTTP. Count your requests, not just your queries.
- Restrict flexible formats to what your UI can edit back.
- Compute derived statistics on read until you have a reason not to.
- Model what things are, not what’s convenient: events aren’t tasks.
- If you’re not using a migration tool, write down why, and when you will.
Plan your week with the demo account at redcheckapp.com.
RedCheck tenía un calendario con vistas de Día, Semana y Mes. Siendo sincero, dos de las tres eran decorativas. La vista de Semana dibujaba una caja vacía con borde discontinuo por cada día. La de Día dibujaba una cuadrícula por horas con una línea de “ahora”, y luego listaba tus tareas por encima, ignorando las horas por completo.
Este post trata de convertir ese calendario, y las rutinas que hay detrás, en algo con lo que de verdad planificarías tu semana. También es la primera vez en esta serie en la que la mayor parte del trabajo empezó en el backend.

Vistas que muestran algo
La vista de Semana muestra ahora las tareas reales de cada día, coloreadas por asignatura. La de Día coloca cada tarea en su hora real a lo largo de las 24 horas, con una franja de “todo el día” para las fechas límite sin hora, y al abrirse se desplaza hasta la hora actual.
En escritorio puedes arrastrar cualquier tarea a otro día u otra hora para reprogramarla. Usé el drag and drop nativo de HTML5, que por diseño solo funciona con ratón. En dispositivos táctiles, un toque sigue abriendo el editor de la tarea, así que el comportamiento nunca es ambiguo.
Construir la vista de Día sacó a la luz un montón de bugs de maquetación que estaban ocultos porque antes nada estaba alineado de verdad con la cuadrícula: una columna de horas y otra de tareas que hacían scroll por separado y solo coincidían por casualidad, filas flex que se encogían por debajo de su altura y un fondo que dejaba de pintarse a mediodía por cómo estira flexbox los elementos. Ninguno era interesante por separado. Juntos fueron un buen recordatorio de que “se ve bien” y “está bien hecho” son cosas distintas.
Los días pasados estaban vacíos y el dashboard era lento
Enseguida aparecieron dos problemas de backend.
El dashboard hacía una petición por asignatura. Pedía la lista de asignaturas y luego las tareas de cada una. Con diez asignaturas, son once peticiones HTTP para pintar una pantalla: el clásico problema N+1, solo que a través de la red en lugar de dentro de la base de datos. Un endpoint nuevo devuelve las asignaturas con sus tareas en una sola respuesta, usando exactamente dos consultas (asignaturas y, después, todas las tareas relevantes del usuario) agrupadas en memoria.
El calendario no podía mostrar historial. El dashboard solo carga las tareas pendientes y las completadas hoy, así que un día pasado en el calendario no mostraba más que un recuento del mapa de calor. Un nuevo endpoint de primer nivel devuelve todas las tareas, pendientes o completadas, cuya fecha límite cae dentro de un rango. Lo hice de primer nivel en lugar de anidarlo bajo una asignatura por el mismo motivo del N+1. El calendario pide un rango por vista, y solo cuando ese rango incluye algún día pasado.
Prioridades, y una estrategia de migraciones que tuve que asumir
Las tareas tienen ahora prioridad: baja, media o alta. Hay dos decisiones que merece la pena explicar.
La media es invisible. Todas las tareas tienen prioridad media por defecto, y la media no muestra ninguna etiqueta. Solo la baja y la alta. Si todas las tareas llevan una etiqueta, nadie lee las etiquetas.
La prioridad tiene su propia paleta de colores, separada de la de las asignaturas, porque son dos dimensiones distintas y el calendario ya usa el color para las asignaturas.
La parte menos vistosa: RedCheck no tiene una herramienta de migraciones
como Flyway o Liquibase. Los cambios de esquema los aplica Hibernate con
ddl-auto=update. Añadir una columna no nula a una tabla con filas
existentes solo funciona si la propia base de datos tiene un valor por
defecto, así que la columna lo declara a nivel de base de datos, no solo
en Java. Funciona, y ahora está documentado, pero sé que es un atajo. El
primer cambio de esquema que update no pueda expresar de forma segura
(un renombrado, una columna que se divide) será el día en que introduzca
una herramienta de migraciones de verdad.
Rutinas que se comportan como rutinas
Las tareas recurrentes admitían cuatro opciones fijas: diaria, semanal, cada dos semanas y mensual. Las rutinas reales no son así.
Días concretos de la semana. “Lunes y jueves” se guarda como una
expresión cron restringida y se evalúa con el propio CronExpression de
Spring, sin dependencias nuevas. La validación solo acepta esa forma
exacta, así que cualquier valor guardado se puede convertir siempre de
vuelta en casillas en el formulario de edición. Aceptar cualquier cron
habría sido más flexible e imposible de editar desde una interfaz.
Un día concreto del mes, o el último. Esto se resuelve a mano, no con cron. La implementación de cron de Spring no tiene operador de “último día del mes”, y una regla simple de “día 31” se salta en silencio todos los meses que no lo tienen. Para una rutina como “pagar el alquiler el día 31”, el usuario quiere decir “el último día”, no “siete veces al año”.
Una hora del día y una fecha de fin. Las tareas generadas tienen ahora una hora límite real, y una rutina con fecha de fin genera su última repetición ese día y después se desactiva sola.
Una vista previa en directo en el formulario muestra las próximas fechas en las que saltará una rutina, calculadas en el cliente para que se actualicen mientras escribes. Replica a propósito una peculiaridad del planificador del backend (la primera repetición de una rutina nueva es siempre en la siguiente ejecución del planificador), para que la vista previa nunca prometa algo que el servidor no vaya a hacer.
Rachas y tasa de cumplimiento por rutina. Se calculan a partir del historial completo de la rutina en cada lectura, no se guardan como contadores. Un contador guardado se desincroniza en cuanto alguien borra o desmarca una repetición pasada. El historial de una rutina es lo bastante pequeño como para que recalcularlo sea barato, y así nunca puede estar mal.
Con esto llegó también una mejora de robustez: el planificador procesaba todas las rutinas en un único bucle, así que una sola frecuencia imposible de interpretar abortaba la generación para todos los usuarios. Ahora cada rutina se procesa de forma aislada.

Un despliegue que devolvía lo que no era, con éxito
La nueva vista de “todas mis rutinas” funcionaba en local y fallaba en
producción. La causa: nginx solo redirigía al backend una lista conocida
de rutas de la API. La nueva ruta /recurring-tasks no estaba en ella,
así que caía en la ruta comodín de la single-page app y devolvía
index.html con un 200 OK. Ningún error en ninguna parte; la respuesta
simplemente era HTML en lugar de JSON. curl y la cabecera
Content-Type lo encontraron en un minuto, en cuanto se me ocurrió
mirar.
Los eventos no son tareas
Lo último que he añadido son los eventos de calendario: reuniones, cumpleaños, viajes. Era tentador modelarlos como tareas con un flag. No lo hice, porque son cosas realmente distintas. Una tarea tiene una fecha límite y se completa. Un evento tiene un inicio y un fin y simplemente ocurre. Mezclarlos habría contaminado cada estadística y cada recuento de “pendientes” de la app.
Así que los eventos son un dominio propio: categorías definidas por el usuario y con colores, eventos puntuales y recurrentes (con una nueva frecuencia anual para los cumpleaños), de todo el día y con hora. En la vista de Día, los eventos con hora son bloques que ocupan su duración, y puedes mantener pulsado y arrastrar sobre la línea de tiempo para crear uno. En la vista de Mes, los eventos de varios días se dibujan como bandas continuas a lo largo de los días que abarcan.
Esas bandas provocaron mi bug favorito del mes. Bandas y celdas de día comparten una misma cuadrícula CSS. Las bandas tenían posiciones explícitas; las celdas dependían de la colocación automática. Según la especificación de CSS Grid, los elementos colocados automáticamente se saltan las celdas que ya ocupa uno colocado explícitamente, así que cualquier día con una banda empujaba en silencio todos los días siguientes de esa fila al día de la semana equivocado. Ahora las celdas también tienen coordenadas explícitas. Ya puesto, reescribí toda la lógica de comparación de días para usar enteros de día completo en lugar de timestamps, lo que eliminó toda una familia de bugs en los límites entre días.
Las cuentas demo se actualizaron con eventos de ejemplo, con fechas relativas al día actual para que sigan vigentes tras el reinicio de cada hora. Eso destapó un problema más: dos ejecuciones solapadas del procedimiento de reinicio podían insertar cada una su propia copia de los datos. Ahora va envuelto en un bloqueo con nombre que no espera; una segunda ejecución simultánea simplemente no hace nada.

Lo siguiente
La sincronización con Google Calendar está diseñada (una capa de solo lectura con tus eventos, más una exportación opcional de las tareas de RedCheck a un calendario dedicado) y esperando a la verificación OAuth de Google, un proceso cuya historia conté en la parte 8.
Con qué me quedo
- Los problemas N+1 también ocurren por HTTP. Cuenta tus peticiones, no solo tus consultas.
- Restringe los formatos flexibles a lo que tu interfaz pueda volver a editar.
- Calcula las estadísticas derivadas al leer hasta que tengas un motivo para no hacerlo.
- Modela lo que las cosas son, no lo que resulta cómodo: los eventos no son tareas.
- Si no usas una herramienta de migraciones, escribe por qué, y cuándo la vas a usar.
Planifica tu semana con la cuenta demo en redcheckapp.com.
- Backend
- Frontend