Smart School — One App, Two Desks
Schools on the Maktabsoft platform reached their students through an Android app with 180,000 installs, a 3.4 rating, and reviews that said slow. We rebuilt it from zero — new design, new codebase — as one React app that is a student's school day or a teacher's desk, depending on who signs in, on the web, as a PWA, and on Android.
01 — Problem
A rebuild, not a reskin
On paper the old app did the job: the weekly schedule, exams, grades, absences, homework, finances, messages. In practice it sat at 3.4 from 945 votes, and the reviews said it was slow and not worth installing unless the school made you.
The backend stayed — Maktabsoft's school API, used by every school on the platform. Everything in front of it was ours to replace: the design, the codebase, and the release path. Persian, right to left, on whatever phone a family owns.
02 — Roles
Two desks in one codebase
A student and a teacher come in through the same door, with a school code. After that the router decides. The role is checked in beforeLoad and the wrong one is redirected to its own home — never branched inside a component. Student URLs are frozen because existing users depend on them; teacher paths sit beside them and never collide.
Each section is a vertical slice that exports hooks and types through a public index, and shared UI takes behaviour as props, with no if (role) inside it. Server data lives in TanStack Query, shareable state in Zod-validated search params, the rest in Zustand — and nothing lives in two places, so refresh and back restore the same screen.

03 — Teacher
The desk the old app never had
The teacher's side is roll call by date and bell, grade entry by class, subject, and term, the points bank, discipline records, homework with a thread per student, virtual classes, surveys, and planning.
A grade sheet is one card per student: present, excused, or absent; a number; a word — Excellent, Good, Average, Needs effort — then group entry and a final submit. Dates are Jalali, and the calendar refuses a day that has not happened yet.

04 — Contract
When the documentation is wrong
The backend had three sources of truth, and they disagreed: auto-generated method pages, backend documents, and what production actually answers. The generated student docs marked fields as required that production does without. One documented method was dead. A documented member delete failed with the documented body.
So the contract became evidence. Every method that diverges is probed live, ranked — live capture over backend docs over generated docs — and recorded with its session in a contract-exceptions file. 99 methods are wrapped, one file each, and 69 mappers turn wire shapes into types the screens can trust. No component ever sees a raw response.
05 — Motion
Measure the phone, then decide what moves
The design is glass and gradients, and it has to run on a four-year-old Android. Device specs lie — iOS reports four cores on every iPhone, and deviceMemory punishes 4 GB phones that render fine — so the app measures instead.
Reduced motion or Save-Data selects minimal before the first frame. Everyone else starts at lite, and a 1.4-second frame-rate probe after idle promotes the phone to full at 55 fps, steps it back under 52, or drops it to minimal under 38. Three long tasks inside two seconds also step down. Components read seven permission flags, not the tier, so the look survives and only the motion changes.
06 — Release
Deploy once, and every channel updates
The Android app is a Capacitor shell that loads the deployed web app, so a web deploy reaches it with no store release. Cafe Bazaar and Myket submissions are kept for native changes — a plugin, a permission.
Every client checks the version every five minutes and whenever the app comes back to the foreground. A newer one reloads the app silently — no please-refresh prompt — and after the reload the changelog appears, written in Persian in the repository. A pending-version guard stops a stale cache from looping the reload.
07 — Shell
A thin native layer, on purpose
Everything a school day needs lives in TypeScript and ships with the web app. The Android shell only does what a WebView cannot, and every call to it is behind a platform check, so the same code runs in a browser tab, an installed PWA, and the Bazaar build.
The shell is four touch points, each with an edge case handled:
- Deep links — smartschool://open works from a cold start and from the background, a repeated URL is handled once, and the target goes through login with the school code and a safe redirect. No token ever rides in the link.
- Camera and microphone for virtual classes — the native permission is asked first, then probed. A phone that has no microphone is not reported as a refusal: nothing in the settings would fix it, so the class opens in listen-only mode.
- Resume — on Android the version check runs when the app returns from the background, and open tabs reload together over a BroadcastChannel.
- PDFs — on the native shell and Safari a PDF opens in the system browser rather than an embedded viewer; elsewhere it stays in an in-app sheet.
08 — Operations
A deploy path with no internet
Production builds on a server with no outbound internet. A push to Gitea fires a webhook, Portainer rebuilds the stack, and Docker installs from a private npm mirror with install scripts off. The team has no access to the Portainer UI, so every deploy fix ships through the repository.
Analytics are self-hosted Umami beside the app. Up to five accounts can be linked on one phone, so a family sharing a phone switches between accounts without signing out and back in. Deep links resolve to a safe internal path after login and never carry a token.
09 — Outcome
A school day in one app
Students get the week, exams, grades by month, attendance, the points bank, homework answered with text, files, or voice, virtual classes, surveys, planning, and finances. Teachers get the desk. Both get announcements that mark themselves read on the server, and both run on the same 60,000 lines of TypeScript behind 587 tests.
Version 1.6.6 is the current release. The old app's reviews were about waiting. This one measures the phone before it animates anything.
Stack
Next case
RcoinX — Real-time Spot Trading→