Fresclean POS
Jovanus Hartono2026
On this page
Fresclean is the shoe cleaning shop I started in West Jakarta in 2020, and this is the software our stores work from. Cashiers take orders on iPads at the counter, workers pick up jobs on Android phones in the workshop, and customers follow their shoes on a public tracking page. Almost everything hard about it started at the counter.
This page is the short version: what it's built with, and a couple of lessons that cut across everything. The chapters follow a pair of shoes through the shop. It's dropped off, photographed, paid for, printed onto a receipt and collected, and now and then brought back. The last two chapters are about the staff clocking in and about the app itself.
The short version
It's a One repository that holds several projects sharing code, here the API and the web app. on Bun, the JavaScript runtime and package manager. A Hono API lives in packages/server and a React app in apps/web, on top of Postgres on Neon through Drizzle. Both halves deploy as one Vercel project in Singapore, right next to the database.
The web app wasn't always React. It started in Vue, and I rewrote it mostly because of forms. I couldn't find anything as good as react-hook-form outside React, and a lot of what I wanted next, like Base UI and Recharts, only exists for React too.
Having both halves in one repo means the web app can call the API through Hono's typed client. The client is built from the server's own route types, so calling an endpoint reads like calling a function, and TypeScript knows what goes in and what comes back:
const res = await rpc.api.auth.login.$post({
json: { username, password },
});
The server package also exports its Zod schemas and a handful of business rules as plain functions, like "can this line be handed over?". The pickup dialog imports the same function the server runs, so the two can't disagree:
export function isHandedOverByPickup(line: ItemStatusLine): boolean {
return (
line.status === "ready_for_pickup" ||
(line.status === "refunded" && line.pickup_event_id == null)
);
}
When I rename a field, TypeScript complains on my laptop long before a cashier could notice.
The API runs in a container that Shuts down completely when nobody is using it, and starts again on the next request., so after a quiet spell the first request waits for it to boot. Compiling it to a Bun bytecode binary cut start-up inside the container from 194 ms to 87 ms. It's one line in the Dockerfile:
bun build --compile --bytecode packages/server/src/index.ts --outfile /app/server
--compile bundles the API and Bun itself into a single executable, and --bytecode parses the JavaScript once, during the build, instead of on every cold start.
The column that broke order pages
Drizzle can change a database in two ways. drizzle-kit push compares the schema file with the live database and applies the difference on the spot. A A saved SQL file that makes one change to the database, kept in the repo and run in order. is a SQL file you generate, read and commit, and drizzle-kit migrate runs the ones that haven't run yet.
I used to push schema changes straight to the database, until one column never made it to production. Nothing even used it yet, but the order detail query selected every column the schema file knew about, so every order detail page in production started returning a The status code a server sends back when something broke on its side, not in the request.. The push that would have fixed it also planned to drop a table that still had data in it.
So every change I keep now goes through a migration I read before it runs, and production only ever gets a read-only check of how it differs from the schema:
bun run generate --name=<short_name> # writes the SQL for a schema change
bun run migrate:prod # applies the migrations that haven't run yet
bun run drift:prod # read-only: how production differs from the schema
Reality disagrees
Before a big change I write down what I'm deciding and why. I've written 22 of those notes for the POS, and two of them turn on nearly the same sentence. One says "Reality disagrees." The other says "Shop practice disagrees." Both are about a model that made sense to me until the counter showed me how the shop really works: one about a pair with more than one treatment, and one about who takes the photos, and when.
Chapters
- 1One pair, three jobsWhat's the status of a pair of shoes booked for three treatments, and who gets to change it?
- 2Photos that settle argumentsTwo photo rules of very different strengths, and the three tries it took to get the photos right.
- 3Boring moneyWhole rupiah, vouchers you can't double-spend, and a repair that has no price until someone looks inside.
- 4Printing from a browserTalking to a thermal printer over Web Bluetooth, 48 characters at a time.
- 5Pickup codes and the tracking pageSix digits from Postgres, a receipt that became the claim ticket, and the one page customers see.
- 6When a pair comes backWhen a customer brings a pair back, the POS adds a free line instead of rewriting what happened.
- 7Clocking in from bedRefusing clock-ins away from the store, without locking out a phone that isn't sure where it is.
- 8Making a website feel like an appTen things that gave the POS away as a website, and how each one got fixed.