2026-03-17|6 min read|--devlog--xulangedu--saas--firebase--nextjs--indie-maker

The Day I Turned a Spreadsheet Into a Real Software Product

I spent today turning XuLang Edu from "a webapp that works" into "a software product someone could actually pay for."

Eight tasks. One day. A lot of coffee.


## Where we were this morning

XuLang Edu is a management system I'm building for my friend Dũng, who runs a tutoring center in Lạng Sơn. He currently manages everything in Excel — 26 sheets, ~314 students, 11 teachers, monthly attendance, fee tracking. All manual. All fragile.

I've been building the app for a few weeks. By this morning, the MVP was feature-complete. But "feature-complete" doesn't mean "ready for a real user." There were hardcoded dropdown values. Settings scattered across the sidebar. No user roles. No access control.

Today I fixed all of that.


## Schema aligned with real Excel data

The most important thing I did today wasn't writing code. It was reading a spreadsheet.

Dũng's Excel files have a very specific structure. Fee per session belongs to the class, not the student. Students have an (ONL) tag in their name if they're learning online. A cell with MP means the student is exempt from payment that session. Teachers are identified by short nicknames like "Tùng" or "Dũng" — not full names.

None of this matched the schema I had built.

So I redesigned it. Added gradeGroup and feePerSession to the Class model. Created an Enrollment collection to hold per-student-per-class metadata: isOnline, isFree, feeOverride, balance. Expanded the Subject type to cover all the subjects Dũng actually teaches.

This is the kind of work that looks invisible but breaks everything if you skip it. An import tool built on the wrong schema just imports garbage.

Previous devlog: Building XuLangEdu: Student Profiles and Dynamic Test Types


## Dynamic catalogs — no more hardcoded dropdowns

Before today, "Subjects" and "Grade Groups" were hardcoded TypeScript union types. If Dũng wanted to add a new subject — say, Informatics — I'd have to push a code change.

That's not a product. That's a prototype.

I created three Firestore collections: subjects, gradeGroups, and feeTemplates. Each has full CRUD from the Settings UI. The class creation form now loads these dynamically. There's an inline "+" button to add a new subject without leaving the form.

Small thing. Big difference in feel.


## Settings consolidation

The sidebar had 12 items. Two of them — Facilities and Rooms — were things Dũng would set up once and never touch again. They didn't belong next to Attendance and Salary.

I reorganized Settings into four tabs: General, Facilities & Rooms, Catalogs, and Permissions. The sidebar dropped to 10 items. The things Dũng needs daily are front and center. The things he sets up and forgets are one click away.


## Role-based access control

This was the biggest task of the day.

XuLang Edu will have at least two types of users: Dũng (the owner, full access) and advisors (staff who handle enrollments and attendance, but shouldn't see revenue reports or touch system settings).

I implemented RBAC with three roles: admin, advisor, teacher. Permissions are defined in code, but the UI is dynamic — admins can create accounts, assign roles, and deactivate users from a Settings panel. No Firebase Console needed.

The sidebar filters based on role. Advisors don't see Dashboard, Reports, or Settings. Delete buttons are hidden for anyone without the right permission. A usePermission() hook handles all of this:

const { can } = usePermission()
{can('delete:student') && <DeleteButton />}

Firestore security rules were updated to match. The users collection is locked down — only admins can read all profiles, and only admins can write.


## Deployed. Then immediately found a bug.

After all of this, I ran npm run build. Passed. Pushed to GitHub, Vercel picked it up, app went live.

Then I noticed the text was black on the landing page. On localhost it was white.

The bug: ThemeProvider defaulted to 'light' and checked localStorage for the user's preference. On localhost, I'd already toggled dark mode. On Vercel — a fresh visit — localStorage was empty, so it fell back to light.

One line fix. 'light''dark'. These are the bugs that are obvious in retrospect and invisible until they hit production.


## The import pipeline we designed (but haven't built yet)

The most interesting conversation today wasn't about code. It was about data.

Dũng has been using Excel for months. Real data — students, attendance records, fees. I want to import all of it without destroying what's in the app, and without breaking anything when he sends next month's file.

The solution: a staging area.

When Dũng uploads an Excel file, it gets parsed and stored temporarily. Nothing touches production yet. Then we open a Review UI — four tabs for Teachers, Classes, Students, and Warnings — where Dũng and I go through the data together.

The key insight: Excel doesn't have everything the app needs. It has teacher nicknames, not full names. It has class session counts, not room assignments. The Review UI is where we fill in those gaps. Only after we confirm does data get written to production.

And when Dũng sends next month's file, the import is scoped to that month only. Existing data stays safe.

This is next on the list.

Related: Sunday Shipping: How I Built Features Without Being at My Desk


## What "building for a real user" actually means

Everything I did today was invisible from the outside. No flashy new features. No redesigned UI.

But without today's work, the app would break the moment Dũng tried to actually use it. The schema wouldn't match his data. The settings would be buried. His staff would have full admin access to revenue reports they have no business seeing.

Making software work is easy. Making it work for a specific person, with specific data, in a specific context — that's the hard part.

We're getting there.


XuLang Edu is a management system for a small tutoring center in Lạng Sơn, Vietnam. Follow the full series: