↑↓ para moverte · Enter para abrir · Esc para cerrar

    Todas las entradas
    Artículo, · 7 min de lectura

    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.

    RedCheck’s month view: days coloured by completion, task chips on upcoming days, and calendar events such as a sprint review and a birthday shown as banners.
    Month view with tasks, the completion heatmap and events.

    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.

    The “My routines” view: active routines, best streak and average completion at the top, and each routine with its frequency and next occurrence.
    All routines across subjects, in one request.

    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.

    Day view for a single day: an all-day birthday event at the top and a timed birthday dinner placed at its real hour on the timeline.
    Day view: an all-day event and a timed one.

    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.

    La vista de mes de RedCheck: días coloreados según el cumplimiento, tareas en los próximos días y eventos como una sprint review o un cumpleaños en forma de banda.
    Vista de mes con tareas, el mapa de calor y eventos.

    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.

    La vista “Mis rutinas”: rutinas activas, mejor racha y cumplimiento medio arriba, y cada rutina con su frecuencia y próxima repetición.
    Todas las rutinas de todas las asignaturas, en una sola petición.

    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.

    La vista de día: un evento de cumpleaños de todo el día arriba y una cena de cumpleaños colocada a su hora real en la línea de tiempo.
    Vista de día: un evento de todo el día y otro con hora.

    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