Saltar al contenido
Development

Un dashboard que dejó de decir la hora

Por Victor Da Luz
sveltetauritestingdev-loggreenhouse

Toda la propuesta de Greenhouse es un temporizador de maduración: capturar una idea, dejarla reposar, y que la app avise cuando está lista. Así que fue un poco vergonzoso descubrir que la cuenta regresiva nunca contaba regresivamente en realidad.

El problema

ItemCard.svelte muestra una etiqueta como “Ripens in 2h” para una idea en germinación. Esa etiqueta se calcula dentro de un bloque $derived de Svelte 5, y el cálculo lee Date.now(). La trampa: un $derived solo se vuelve a ejecutar cuando cambia una de sus propias dependencias. Date.now() no es una dependencia que Svelte pueda ver, es una lectura con efecto secundario, no estado reactivo, así que el bloque se ejecutó exactamente una vez, en el primer render, y nunca más. “Ripens in 2h” se quedaba ahí el resto de la sesión, silenciosamente equivocado, sin importar si el tiempo restante real eran noventa minutos o menos noventa minutos.

Misma historia un nivel más arriba. Dashboard.svelte obtiene el estado de todo el día una vez al montarse, y solo lo vuelve a pedir después de que el usuario hace algo, guardar una idea, capturar una nueva. Si se deja la app abierta durante la medianoche, tanto “Captured today” como la racha quedan desactualizados, y cualquier idea que maduró durante la noche simplemente nunca aparece en “Ripe today” hasta que se realiza alguna acción sin relación que dispare un refetch.

Peor: como el reloj de la etiqueta podía pasar en silencio la maduración de una idea sin que la app volviera a pedir datos para notarlo, existía un estado alcanzable donde la interfaz mostraba “Ripens in ready”, una oración gramaticalmente rota que nadie escribió a propósito, solo dos textos independientemente razonables chocando en un caso que nadie había probado.

Haciendo coincidir la medianoche correcta

La solución necesitaba una forma de detectar “el día cambió” desde el frontend. El instinto obvio es new Date().getDate(): si el día del calendario dio la vuelta. Ese instinto está equivocado acá, y leer el motor en Rust antes de escribir cualquier código de frontend fue lo que lo detectó: el propio “hoy” del backend se calcula como now - now.rem_euclid(86_400), agrupamiento por época UTC, no medianoche local. Un usuario al oeste de UTC que cruza la medianoche local todavía no cruzó el límite de día del backend, y viceversa. Escribir la verificación de día del frontend contra la hora local habría “funcionado” en mi zona horaria durante las pruebas y habría fallado en silencio para una parte de los usuarios reales eventuales de la app.

Entonces la verificación de índice de día del frontend es simplemente Math.floor(now / 86400), comparada tick a tick, la misma fórmula de agrupamiento, trasladada directamente, no reinventada. Cuando cambia, se vuelve a pedir el día en silencio. Ningún concepto nuevo, solo negarse a asumir que “medianoche” significa lo mismo en dos lugares distintos.

La solución

Un estado now en Dashboard.svelte, que avanza cada 60 segundos mediante un único $effect, pasado hacia abajo a cada ItemCard. El mismo tick recalcula el índice de día UTC y dispara un refetch silencioso cuando cambia. Un listener de foco/visibilidad de window hace lo mismo al recuperar el foco, así que volver a la app después de un rato no necesita esperar al siguiente tick.

La oración “Ripens in ready” se convirtió en una etiqueta dedicada “Ripe” en su lugar, algo pequeño, pero solo se volvió alcanzable una vez que el reloj podía realmente avanzar más allá de la maduración entre refetches reales. Arreglar el reloj congelado fue lo que convirtió un caso límite teórico en uno que una prueba podía realmente ejercitar.

Probando un reloj

Probar con pruebas unitarias si “esto realmente avanza” necesitó timers falsos por primera vez en la suite de pruebas de este proyecto, y la herramienta obvia, vi.waitFor, resultó ser la equivocada para un salto de este tamaño. Es consciente de los timers falsos, pero avanza el reloj falso en la misma cantidad que su propio intervalo de sondeo real, un paso ligado a tiempo real a la vez. Está bien para empujar un reloj falso más allá de un setTimeout de 200ms. Llegar a un intervalo de 60 segundos de esa forma costaría unos sesenta segundos reales de tiempo de ejecución de prueba. La herramienta correcta fue vi.advanceTimersByTimeAsync llamada directamente, con los timers falsos habilitados antes de que el componente se monte (el $effect de un componente captura la referencia de setInterval que esté activa cuando corre, cambiar a timers falsos después del montaje deja que el real siga avanzando, invisible para cualquier llamada posterior a advanceTimersByTime).

Incluso con eso resuelto, la parte más difícil de simular fue el listener de foco de window. Una prueba unitaria puede despachar un evento focus sintético en una ventana jsdom con bastante facilidad, pero eso solo demuestra que el código propio de la app reacciona correctamente a un evento de foco, no dice nada sobre si la ventana de escritorio real, corriendo en el WKWebView real de Tauri, genera uno cuando un usuario realmente vuelve a hacer clic en la app. Ese vacío necesitó el arnés de WebDriver contra la app real ya construida: sembrar un ítem nuevo directamente en el archivo SQLite del vault, saltándose la app por completo, así que no tiene idea de que el ítem existe, y después despachar un blur y un focus reales contra la ventana real y confirmar que el ítem sembrado aparece sin ninguna otra acción tomada. Y apareció, al primer intento.

Reflexión

Nada de esto es lógica complicada. Un reloj que avanza y una verificación de límite de día son el tipo de cosa que se esperaría resolver bien sin pensarlo mucho. Lo que realmente tomó tiempo fue notar las dos formas en que es fácil equivocarse sin darse cuenta: confiar en que un framework reactivo vuelva a ejecutar algo que no tiene forma de saber que necesita re-ejecutarse, y confiar en la propia zona horaria como valor por defecto en vez de revisar qué calcula realmente el otro lado del sistema. Ambos bugs habrían salido limpios bajo un build que compila y una suite de pruebas en verde, porque ninguno de los dos lanza una excepción, simplemente dicen la hora equivocada en silencio.

Lecturas relacionadas

Development

El botón de cancelar que no cancelaba

Escape parecía funcionar en cada prueba manual. Escribir la prueba de regresión obligó a ver el orden real de eventos, y el blur perdido que guardaba lo que debía descartarse.

Leer