Commits

The day production showed no mercy: dashboard, editor, and statistics

Dashboard migration to React, architecture documentation, and three errors

The dashboard, screen by screen

Today I wrapped up the dashboard migration (the control panel with the day's metrics) to React (the library used to build the interface), following the decision I had noted in the morning: screen by screen, without a global rewrite and without touching the public site. The panel had already been facing it since the previous day (From Summary to editor: the panel takes shape).

First, the article list: it had over two hundred lines of imperative JavaScript (code that says step by step how to move each piece) that selected nodes by ID and rebuilt rows by hand. Now it's a single React island (a chunk of page that manages itself) with the same styles as always. Then the account forms, then the summary —about 450 more lines of script moving KPIs, charts, and drafts— and finally the editor. In the editor, Milkdown (1.1 MB) now goes into a dynamic import(): the page downloads a 7 KB chunk and the library only loads when the editor mounts.

Before deploying, I ran the five islands through Vercel's performance rules. Almost everything already complied; the real fix was one: the date formatters were being created on every render and now live at module level.

The documentation that was missing

I wrote docs/architecture.md: the three containers (each one, an independent level for one part of the application) and their networks, the Alembic flow with real commands, backup recipes with pg_dump, and the pgvector index policy. Every piece of data cross-checked against sources. I didn't create the HNSW index: with the current volume it doesn't justify it, and the document captures the trigger for when it's time.

Production: three chained errors

The deployment ended with tests green and all routes responding 200. And then production arrived: three errors chained, like links in a chain; only the last one was new.

First, 502 at /api: nginx (the server that distributes requests) resolves proxy_pass only once when loading the configuration and caches the IP. When the API container was recreated, its IP changed and the proxy still pointed to the old one. A nginx -s reload fixes it instantly; the definitive solution is to use a variable in the location, which is resolved on every request.

Second, the editor wasn't mounting: the build wasn't emitting a CSS file that the manifest did reference (known bug of rolldown-vite with dynamic CSS imports) and Vite's preloader, when that file failed, discarded the entire import(). That's why the editor themes now go as a static import and only the Milkdown JavaScript remains deferred.

Third, the statistics returned 500: the server's router filters outgoing traffic to the Cloudflare range where analytics.escandell.cat resolved. I pinned the domain to a reachable IP with extra_hosts in the compose and the full flow works again. Known ceiling: if Cloudflare reassigns the IP, the pin will need to be updated.

The lesson of the day: a 502 in production isn't always from the code you just deployed. Here there were three chained layers and only one was new.

When the agent oversteps

My coding agent, looking for the cause of the statistics issue, invented two outputs without consulting and one pointed to a container from another project on the server. I corrected it and we've left the rule clear: if the cause is environmental (from the environment, not the code), stop, explain, and ask before improvising.

Phase 8: a real design system

Tailwind v4 integrated only in the admin — the public site doesn't get a single line. Color tokens in a single block, four primitives (the basic pieces: button, input, panel, and status badge) and a Styles page inside Settings to see it all live. First learning of the system: the old CSS resets all margins, so in new components typographic spacing is always declared explicitly.

Phase 9, the first consumer

The Articles page already uses the primitives: chips (the small tags) and buttons migrated. Silent improvement: delete now shows in destructive red; before it was a button identical to the others. Filters and search are waiting for a second consumer.

Other journal entries