The Chip That Asked for a Revision — A Day on Project Realtime
It started as adding a star to the project chip on list items. By evening, projects and milestones had revision counters, and the cache that rewrote every task on each change had become a render-time join. The discoveries, in order.
The Chip That Asked for a Revision — A Day on Project Realtime
Task rows in fecit carry a small chip that names the project they belong to. Gray background, a three-letter abbreviation. This morning’s request was one line.
“Shall we put the project icon on the project chip in list items?”
The app already had a glyph for “project”: the five-pointed star used by the workspace switcher and the project list. Drop it in front of the chip at 10px and we’re done. We were not done.
Same 10px, different size
The moment the star went in, the chip looked padded. The pentagram SVG fills only 60% of its 24-unit box. The four-pointed star we use for tasks fills 83%, plus stroke. At the same width={10} one renders at 6px and the other at 9px.
The proper fix is to redraw the SVG to fill its box. But the pentagram is used in 45 places across 32 files. Either everything grows by 1.4x, or 45 sizes get hand-scaled by 0.72. Neither was worth it for one star. Inside the chip only, we pulled the star inward by exactly the SVG’s built-in whitespace. The same decision came back an hour later with the milestone flag: it nearly fills its box, so at 10px it looked bigger than the star. The math said 7.7px; we used 8.
A second chip meant the server had to join
“Shall we continue on to showing the milestone too?”
A flag and the milestone title next to the project chip. The catch: the task payload carried only the milestone id, not its title. Fetching every project’s milestones from the client is an unbounded fetch in the “me” scope, a pattern we had already ruled out. That left the server embedding a milestone summary in each task response, the same read-time join we already did for the project abbreviation.
That part went to plan. Then we started the local server, opened the app, and the chip was not there. Detail responses and list responses are assembled on different paths, and the six batch-merge sites on the list path had not received the new join. Once they did, the chips appeared.
Change the project, the chip stays
“Milestones have their own color, separate from the project. Does the chip change when the milestone changes?”
It had to. It turned out there were two layers of no.
First, the mobile list hooks have a guard: if a task’s revision hasn’t changed, reuse the old object. It’s a necessary guard; replacing every row with a new object on every refetch would flood re-renders. But a project abbreviation or a milestone title never touches the task document. The revision doesn’t move. Refetch all you like, the guard hands back the old object and the chip is frozen forever. A path built in March for “reflect project changes immediately” had quietly died when this guard arrived in July.
Second, and more fundamental: “Does our Project even have a revision?” It did not. No revision, no revised_at. Tasks carry three layers (document revision, per-field revisions, an updated-at timestamp); projects had none. So task sync couldn’t follow project changes, the project list merge could only fend off late responses with an ordering guard, and concurrent edits from two devices resolved as last-write-wins. All one root.
Three layers of revision, then CAS
Projects and milestones got the same three layers as tasks. All eighteen writes that touch a project document now $inc the revision and stamp the time. Title, abbreviation, description, and the seven writing fields accept the field revision the client last saw; if it doesn’t match, the server answers 409.
The client side is the task grammar transplanted as is. On a 409 the field enters a “conflict” state: the text you typed stays, autosave stops for that field, and a single-line bar appears beneath it. Overwrite saves again with the latest revision; Take theirs shelves your text and shows the server’s. We first shipped it without the shelf, then “let’s put the shelf on projects too” brought the re-entry behavior along: come back later and the bar is still there. Milestones save as a bundle of fields, so their ticket takes a map of revisions, and on mobile the save-on-leave was split per field so a 409 lands on exactly the field that conflicted.
“Rewriting the whole cache is wasteful, isn’t it?”
With that in place, the shape of the thing was visible. Every task document carried a copy of its project and milestone info, and when the original changed, every task row in that project got rewritten. Change the color of a project with 500 tasks: 500 SQLite writes. We had a rule about keeping load off a small server. The client was making the same mistake.
We normalized. A task row now looks only at projectId and milestoneId; the project comes from the project atom and the milestone from a new lookup table, joined at render time. The server’s embedded copy remains only as a fallback. When an original changes, one cell in the table changes, and every row looking at that cell follows. Independent of task count.
Realtime fell into the same shape. The milestone routes, it turned out, had never emitted a change event at all. They now emit the same project_updated as project edits, and mobile received the hook desktop already had: on the event, refetch just that one project. The cost of one change is one single-document GET and a Map update.
Fixing the list left the detail behind
“I opened the milestone from a task, changed its color, came back, and the task still shows the old color.”
The list chip was joining; the task detail was not. It fetched the project’s milestone list once on entry and searched it. Same disease. The detail’s milestone row now overlays the lookup table’s latest values, and the list it fetched feeds the table, so both screens see the same thing. Four mobile detail screens and one on desktop.
Fallbacks still need a fallback
The project atom holds only active projects. Tasks of a completed project have no project in the lookup and render from the server’s embedded copy. Before we can ever drop that embed on the server, this gap has to close. So: any id missing from the lookup triggers a single-document fetch. Concurrent requests for the same id collapse into one, a 404 is remembered for five minutes, and each id is fetched at most once per session. A list with 100 tasks from a completed project costs one GET for that project.
Actually removing the embed, and caching the member list that change notifications read, are next month’s work. As long as older app builds are out there, the embed is their only source.
And the star became a galaxy, on the second try
In the evening we came back to the morning’s star. “What if the project icon were a galaxy?” A task is one star; a project is a collection of them. But scattering several stars was already the community icon’s language, so it had to be a single silhouette.
We didn’t draw it by hand. A logarithmic spiral came out of a few lines of math: two arms winding out of a core, thick near the center and tapering outward. It took three passes, all of them about a tiny hole between the core and the arms; what worked was leaving the outer shape alone and extending each arm inward at full width so the seam sits inside the core. We changed 31 sites and committed.
Then we looked at it for real. What read as a galaxy at 96px on the comparison page was a smudge at 8px in the chip. We reverted. Two causes: the new glyph fills its box, so we had scaled every size by 0.7 to keep it looking as small as the pentagram; and the arms tapered to a thread. We also tried an edge-on galaxy, which at icon size reads as a flying saucer.
The second attempt went the other way. Sizes stayed at their original numbers so the galaxy fills the box; the arms wind just one turn but keep a thick tail; only the Me tab’s list row got a slightly smaller one. This time it was a galaxy on the device too.
What we learned
Without a change marker there is no realtime. You can broadcast events all day; if the receiver can’t tell whether what arrived is newer than what it holds, it can’t decide whether to overwrite. Projects having no revision meant neither server nor client knew when a project had changed, and most of today’s symptoms traced back to that.
Denormalizing moves write cost onto the read side. Embedding joined values in responses makes screen code easy, but every change to the original means finding and rewriting the copies. Server hands out ids and revisions, client assembles: that turned out right for both a small server and an on-device cache.
A guard should say what it guards. “Reuse if the revision matches” was a correct guard, but every value added later that doesn’t bump the revision froze silently behind it. Today we narrowed what that guard inspects and moved the rest to joins.
It was supposed to be one star. That star is a galaxy now.