Skip to content
Development

A dashboard that stopped telling time

By Victor Da Luz
sveltetauritestingdev-loggreenhouse

Greenhouse’s whole pitch is a maturity timer - capture an idea, let it sit, and the app tells you when it’s ready. So it was a little embarrassing to find that the countdown never actually counted down.

The problem

ItemCard.svelte shows a chip like “Ripens in 2h” for a germinating idea. That chip is computed inside a Svelte 5 $derived block, and the computation reads Date.now(). The trap: a $derived only re-runs when one of its own dependencies changes. Date.now() isn’t a dependency Svelte can see - it’s a side-effecting read, not reactive state - so the block ran exactly once, at first render, and then never again. “Ripens in 2h” would sit there for the rest of the session, silently wrong, whether the real remaining time was ninety minutes or negative ninety minutes.

Same story one level up. Dashboard.svelte fetches the whole day’s state once on mount and only re-fetches after the user does something - vault an idea, capture a new one. Leave the app open across midnight and “Captured today” and the streak both go stale, and any idea that ripened overnight just never shows up in “Ripe today” until you take some unrelated action that happens to trigger a refetch.

Worse: because the chip’s clock could silently drift past an idea’s maturity without the app ever re-fetching to notice, there was a reachable state where the UI would render “Ripens in ready” - a grammatically broken sentence nobody wrote on purpose, just two independently-reasonable pieces of copy colliding in a case nobody tested for.

Matching the right midnight

The fix needed a way to detect “the day changed” from the frontend. The obvious instinct is new Date().getDate() - did the calendar day roll over. That instinct is wrong here, and reading the Rust engine before writing any frontend code is what caught it: the backend’s own “today” is computed as now - now.rem_euclid(86_400) - UTC epoch bucketing, not local midnight. A user west of UTC crossing local midnight has not crossed the backend’s day boundary yet, and vice versa. Writing the frontend’s day-check against local time would have “worked” in my timezone during testing and quietly misfired for a chunk of the app’s eventual real users.

So the frontend’s day-index check is just Math.floor(now / 86400), compared tick to tick - the same bucketing formula, ported directly, not reinvented. When it changes, silently re-pull the day. No new concept, just refusing to assume “midnight” means the same thing in two different places.

The fix

A now state in Dashboard.svelte, ticked every 60 seconds by a single $effect, threaded down into every ItemCard. The same tick recomputes the UTC day index and triggers a silent refetch when it changes. A window focus/visibility listener does the same on refocus, so switching back to the app after a while doesn’t need to wait for the next tick.

The “Ripens in ready” sentence became a dedicated “Ripe” label instead - a small thing, but it only became reachable once the clock could actually tick past maturity between real refetches. Fixing the frozen clock is what turned a theoretical edge case into one a test could actually exercise.

Testing a clock

Unit-testing “does this actually tick” needed fake timers for the first time in this project’s test suite, and the obvious tool - vi.waitFor - turned out to be the wrong one for a jump this size. It’s fake-timer-aware, but it advances the fake clock by the same amount as its own real polling interval, one real-time-bound step at a time. Fine for nudging a fake clock past a 200ms setTimeout. Reaching a 60-second interval that way would cost about sixty real seconds of actual test runtime. The right tool was vi.advanceTimersByTimeAsync called directly, with fake timers enabled before the component mounts (a component’s $effect captures whatever setInterval reference is live when it runs - flip to fake timers after mount and the real one just keeps ticking, invisible to any later advanceTimersByTime call).

Even with that sorted, the harder-to-fake part was the window focus listener. A unit test can dispatch a synthetic focus event on a jsdom window easily enough, but that only proves the app’s own code reacts correctly to a focus event - it says nothing about whether the real desktop window, running in Tauri’s actual WKWebView, generates one when a user really clicks back into the app. That gap needed the WebDriver harness against the real built app: seed a new item directly into the vault’s SQLite file - bypassing the app entirely, so it has no idea the item exists - then dispatch a real blur and focus against the real window and confirm the seeded item shows up with no other action taken. It did, first try.

Reflection

None of this is complicated logic. A ticking clock and a day-boundary check are the kind of thing you’d expect to get right without much thought. What actually took the time was noticing the two ways it’s easy to get wrong without noticing: trusting a reactive framework to re-run something it has no way to know needs re-running, and trusting your own timezone as the default instead of checking what the other side of the system actually computes. Both bugs would have shipped clean under a build that compiles and a test suite that’s green, because neither one throws - they just quietly tell you the wrong time.

Related reading

Development

The cancel button that didn't cancel

Escape looked like it worked in every manual click-through. Writing the regression test forced the real event order - and the stray blur that saved what should have been thrown away.

Read