Where Is the 25th of Every Month on My Calendar? — Routine Ghosts and Materialization
Routines only create tasks on the day they fire, so the calendar showed no future. Here's what I learned drawing upcoming occurrences as translucent ghosts — and turning them real on tap.
Where Is the 25th of Every Month on My Calendar? — Routine Ghosts and Materialization
I registered a monthly settlement routine that runs on the 25th, opened the calendar — and it was silent. Which was correct behavior: in fecit, a routine creates its task document only when it fires. No document, nothing to draw. But from the user’s seat it’s absurd. I clearly decided to do something on the 25th, and the 25th is empty.
Ghosts — Drawing What Doesn’t Exist Yet
I quickly dropped the idea of having the server pre-create documents. It would entangle the firing logic (template duplication, priority inheritance), and storing “things that haven’t happened” as real data felt wrong. Instead, the client computes upcoming occurrence dates from the routine definition (period, day rules, expiry) and draws them on the calendar as translucent ghosts.
At first I drew them exactly like real tasks, and reverted almost immediately. When you delete a routine, all its future ghosts vanish at once — and if they look real, that moment reads as “my documents just evaporated.” Translucency changes the reading. They’re a forecast drawn by the routine, so when the routine goes, the forecast lifting feels natural. Same behavior, different visual grammar, different meaning.
Materialization — Open It and It Becomes Real
Just seeing the future isn’t enough; you might want to fill in preparation for next month’s presentation today. So tapping a ghost creates the real task on the spot, through the exact same generation path the firing cron uses — an early-opened task is indistinguishable from a fired one.
The hard part is bookkeeping. If you materialize an occurrence early and the cron fires anyway, you get duplicates. The answer was a single door: the routine document keeps a list of consumed occurrence dates, and both the cron and manual materialization pass through the same atomic gate right before creating. Whoever wins the gate creates; the loser silently steps back. Delete the created task? The date stays consumed — it means “not that day,” and no zombie regeneration.
Three Traps
Mongo’s null array. Add a field as Optional[list] = None in a Pydantic model and new documents store null — and $addToSet refuses to operate on null. Fourth time this exact cause has bitten this project. This time an idempotent one-liner normalizes null to an empty array right before the gate.
Native modals fighting navigation. Tapping a ghost opens an action sheet; when materialization finishes, we navigate to the detail. Navigating while the sheet was still up produced a bounce-back afterimage. The fix was a callback the sheet component already had — onClosed, “right after the modal actually unmounts” — which turned out to be built for precisely this.
The cost of building everything upfront. Initially I generated a full year of ghosts at once. One daily routine became 365 virtual objects, and the calendar stuttered. Now ghost generation takes the same range the calendar uses to fetch tasks (current position ±12 weeks) and builds only that window, following along as you scroll. The number of live ghosts stays constant no matter how many routines you have or how far you scroll.
What I Learned
If there are two creation paths, there must be one door. When automatic (cron) and manual (materialize) each do their own checking, someday both will pass. Share one atomic gate and the race simply cannot exist.
And a preview should look like a preview. A preview indistinguishable from the real thing seems convenient, until edge situations — deletion, edits — shatter the user’s mental model. One notch of translucency held that boundary.
fecit’s calendar now shows a little of the future, ahead of time.