Part 1 · 27 August 2026
Rebuilding a dashboard nobody asked me to replace
A school runs its whole operation from a Google Apps Script system that works. I read the handover pack looking for the case to replace it, found none, and started anyway — for different reasons.
series · Rebuilding a school-ops dashboard
A small language school I work with runs its whole operation through a system its developer built inside Google Apps Script: scheduling, attendance, teacher pay, invoices, all of it living behind a Google Sheet. When he handed me the documentation pack, I read it expecting to find the case for porting it to a “real” stack. I’m writing this to record what I found instead, the project that came out of it, and the reasoning behind each choice, because the reasoning is the part I’ll forget first.
The question that reshaped the project
The handover pack was better than most production systems I’ve seen. Twenty-eight thousand lines, thirty-eight data tabs, a schema document generated from the code so it can’t drift, and a test suite with 453 assertions that runs the shipped production code offline with plain node. The pay calculations reconciled against signed invoices for 67 of 68 teacher-months. Exact, to the yen.
The developer asked me one question before I sank time in: what would a new system be better at? He meant it as a real question: he wanted to know what we’d be buying, because a port brings its own pain: hosting, deploys, backups, secrets, someone to call when it breaks at 8am.
I sat with that question and the honest answer was: nothing, for the school. The dataset is tiny. There’s no performance problem. The one thing that’s broken (most staff can’t log in) has a documented fix inside Apps Script that costs a fraction of a rewrite. I told him so.
The project survived anyway, because I changed what it’s for. I want to learn to build and operate production-grade systems: real authentication, real databases, real deployments, the parts that portfolio projects fake. A todo app teaches none of that. This codebase, though, comes with real money logic, a bug history where each incident note marks something that reached actual teachers, and a set of written invariants like “the ledger is append-only” and “pay follows whoever filed the attendance, not who was scheduled”. Rebuilding a slice of it against those constraints is the best practice material I could ask for.
So the project is a reimplementation for my own education. The live system stays where it is. Nothing I build will ever hold real data; a seed script generates invented students and teachers. Saying this out loud at the start turned out to be the most useful planning decision I made, because it deleted whole categories of scope before they existed: no data migration, no privacy exposure, no integrations with the accounting and shop systems in version one, no cutover weekend.
What I chose to build
The full system is too big and mostly typing. I picked the slice with teeth: the money path.
teacher files attendance
|
v
computeMonth_ <- the pay calculation, integer yen only
|
v
invoices + warnings
|
v
append-only ledger <- corrections are new rows, never edits
Every rule in that pipe is real. Consumption tax is floor(0.10 × (work + materials)). Withholding is floor(0.1021 × work). There are no floats anywhere; money is whole yen in integer columns, and I’ve adopted the original’s rule that a decimal type on the money path fails code review. The append-only ledger becomes a database trigger in my version, so the rule the original enforces by discipline gets enforced by a lock on the door.
Choosing the stack
I compared three shapes, and writing down why I rejected two of them matters more to me than the winner.
A Next.js monolith would have been fastest, one language and one repo. I turned it down because its server actions hide the API boundary inside the framework, and designing an explicit API boundary is one of the skills I’m here to practise.
A Go API appeals to the part of me that reads infrastructure job listings. I turned it down because I’d be learning a new language, a new domain, and a new architecture at the same time, and the pay logic is intricate enough that I want to fight one thing at a time.
What I chose: a NestJS API with a separate React client. NestJS organizes code the way Spring Boot does (modules, dependency injection, guards), so the patterns transfer to the enterprise stacks I’m aiming at. The client is typed against an API contract generated from the server code, which makes the boundary between them a real, inspectable thing.
The test I applied to each piece: does it have an obvious grown-up equivalent I could swap in later without redesigning?
| My choice | What it becomes at enterprise scale |
|---|---|
| Postgres in Docker | RDS / Cloud SQL, same SQL, same migrations |
| Docker Compose | Kubernetes, same container images |
| Google sign-in via OIDC | Entra ID / Okta, change the issuer URL |
| Caddy reverse proxy | An ingress controller or load balancer |
| Migrations as versioned SQL files | Unchanged; this is already the grown-up way |
Auth deserves its own line. I chose Better Auth, a self-hosted library, over services like Clerk partly for the £0 price and partly because vendor lock-in was the thing I’d already decided to avoid. There was a bonus I didn’t engineer: Google-based sign-in is the documented fix for the real system’s login problem, so my practice project rehearses the solution to the one real defect in the original.
Everything runs on a mini-PC on my home network, in four containers, for nothing:
the internet
|
v
Cloudflare Tunnel (free public HTTPS, no ports opened on my router)
|
v
+--------------------------------------------------+
| mini-PC, one docker-compose file |
| |
| Caddy :8080 --- /api/* ---> NestJS API |
| (only door) --- rest -----> React app |
| | |
| Postgres 17 |
+--------------------------------------------------+
Caddy stays even though the tunnel provides HTTPS, because it’s the single entry point that keeps the app working on the LAN when the tunnel is down, and because “one front door that routes traffic” is the shape every ingress setup takes at work. The same compose file runs on my laptop; only the environment file differs.
The idea I’m most attached to: the differential oracle
The original ships something rare: its test suite loads the actual production .gs files into a Node sandbox and runs them. Which means I can run the old system’s pay calculation on my machine, next to my new one, and compare.
+--> legacy code (.gs, run in a Node vm) --+
thousands of | | compare:
invented + +-> one yen of
months, every | | difference
edge case +--> my new TypeScript money core ---------+ fails the build
Feed both implementations the same synthetic months (trial lessons, rate overrides, retainer edges, the works) and fail the build on any divergence of a single yen. I learned this is how real migrations are de-risked, running old and new in parallel and diffing the outputs, and I get to practise it without a migration.
I also wrote down fences before starting, because I could feel this idea becoming a swamp: it covers three functions only, it compares money and never wording, and when I choose to fix an old bug rather than reproduce it, that goes in a numbered register with a written reason. If the harness eats more than its time-box, I drop it for ordinary tests. Future me: hold the line.
Building in milestones, skeleton first
M0 walking skeleton <- done, and it fought back
M1 money core + oracle <- next
M2 the money path over HTTP
M3 authorisation done properly (row/column security in the DB)
M4 the client screens
M5 backups, monitoring, a restore drill
The tempting order was to start with the interesting pay logic. I went the other way: M0 was one trivial authenticated request travelling the entire pipe, browser to proxy to API to database and back, deployed, with CI. The reasoning I was given and now believe: deep domain code with nothing deployed is how you end up with beautiful logic and no system, and the operations half is the half I most need to practise. M0 is finished and verified with a real Google sign-in, which took two days and two stacked bugs longer than expected; that story got its own write-up.
One design choice from M0 I want on record. The original system lost months of calendar invites to a subtle fact: eleven of its nineteen logins are identity-only Google accounts with no mailbox, and code kept treating the login email as somewhere you could send things. My schema splits identity three ways from day one: a people table that is only a join key and a display name, a user_accounts table that owns login emails, and a contacts table (coming in M2) that owns addresses you can send to. The mistake that caused the incident is now a foreign key that doesn’t exist.
How I’m building it, and what that’s teaching me
I direct AI coding agents through this build: one plans, others implement task by task, and each task gets reviewed by a separate agent before I accept it. I read the reviews, adjudicate disagreements, and make the calls neither side can. The review loop has earned its keep in ways I didn’t predict: it caught generated secrets pasted into a work log, a type-check that only passed because of leftover build files, and test mocks that would have disabled our auth tests with nobody noticing. Each of those would have been my problem months later.
The part I keep relearning is that the process teaches me more than the code does. Writing non-goals deleted more work than any library choice saved. The one question from the original developer (“what would it be better at?”) was worth more than the whole handover pack, and the honest answer made the project better instead of killing it. And the skills I set out to practise (API boundaries, database security, backups with an actual restore drill) live in milestones M2 through M5, which puts the learning on the schedule.
M1 is next: the pay calculation itself, with the old system watching over my shoulder, one yen at a time.