Part 1 · 27 August 2026
Why I rebuilt a dashboard that already worked
A school already had a working operations system, so replacing it made no business sense. I rebuilt a limited, synthetic version instead to practise the parts of full-stack development I still wanted to learn.
series · Rebuilding a school-ops dashboard
A small language school I work with runs its operation through a system built in Google Apps Script: scheduling, attendance, teacher pay, and invoices, all behind a Google Sheet. When the developer handed me the documentation, I expected to find a strong reason to move it to a more conventional web stack. I did not. This entry explains why I still built a smaller practice version and how I kept the learning goal separate from the school’s real needs.
The question that reshaped the project
The handover pack was more thorough than I expected: twenty-eight thousand lines,
thirty-eight data tabs, a schema document generated from the code, and a test
suite with 453 assertions that runs the 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 continued because I changed its purpose. I wanted hands-on practice with authentication, databases, deployments, backups, and other parts of a web application that simple portfolio projects often skip. This codebase also came with meaningful business rules, such as “the ledger is append-only” and “pay follows whoever filed the attendance, not who was scheduled”. Rebuilding a small slice against those constraints gave me a realistic learning exercise without putting the school at risk.
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 far too large for a practice project. I chose one challenging slice: the path from attendance to teacher pay.
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 question I applied to each piece was: could it be replaced by a managed or larger-scale equivalent later without redesigning the whole application?
| My choice | A possible larger-scale equivalent |
|---|---|
| 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 | The same approach, run through deployment tooling |
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 use AI coding agents as tools in this build. I set the requirements and architecture, break the work into tasks, inspect the changes, review the evidence, and decide what is accepted. Separate review passes have caught issues I might otherwise have missed: generated secrets pasted into a work log, a type-check that passed only because of leftover build files, and test mocks that weakened the authentication tests. The process is useful, but the responsibility for what ships remains mine.
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.