← All work 05 / Side project · Mobile PWA · Field ops

A one-thumb
operating system for
a one-person business.

Jai Petlover — a mobile-first PWA built solo, on evenings and weekends, to replace paper, WhatsApp and memory for my wife's pet-care business. In production. The notebook is in a drawer.

Role Designer + builder
User My wife · 1 operator
Status In production · daily use
Stack Next.js · Supabase · TS · Vercel

A side project. Evenings and weekends. No budget, no team, no client. My wife runs Jai Petlover — boarding, daycare, home visits — out of our home. Before this product, the business ran on a paper notebook, WhatsApp threads and her memory.

That setup is the most generous design context I've ever had and the most ruthless. Generous: the user is sitting next to me. Ruthless: every bad call lands in her real working day, and she'll tell me by dinner.

It wasn't a CRM.
It was a tool that had to disappear.

Most admin tooling assumes someone at a desk with two monitors and time to learn the product. She uses her phone standing up, walking, in transit between houses, with a dog on a leash. A back button is a tax. A second screen doesn't exist. A loading state breaks the flow because the dog hasn't paused.

"What's happening today" is the question she asks twenty times a day. "What's happening this month" is the question her accountant asks once. The product had to be biased violently toward today, and toward gestures that work with one thumb.

A thumb-driven operating layer, not a CRM.

The reframe that decided every scope question — what to include, what to cut, and how much friction was fair to put between her hand and her business.

I worked alongside her for a week before designing anything. Four patterns shaped the product.

A side project ships only if you're honest
about what you won't build.

I had a list of "obviously valuable" features I decided against, on purpose.

Multi-operator would have added auth layers and conflict resolution for a problem she didn't have — she works alone. Push notifications would have made the product feel heavier, not lighter; her phone already buzzes from WhatsApp. PDF reports were replaced by CSV export with Excel-PT formatting (`;` separator, BOM) — same job, a quarter of the code. The tutor portal would have doubled the surface area of the product overnight, and tutors weren't asking for it.

Central decision

Every detail is a sheet —
never a page.

The default pattern in admin tooling is list → row → detail page → back button. I killed it. Every record — tutor, animal, booking, check-in, checkout — opens in a bottom sheet over the current context. There is no back button in the authenticated area, because there is nowhere to go back to.

The tradeoff I accepted: sheets stack. A tutor sheet can open an animal sheet which can open a booking sheet, and the close gesture has to mean the right thing at every depth. I traded a week of implementation complexity for navigational simplicity she never has to think about.

Boarding is the revenue driver — multi-night stays with photos, notes and billing all stacked on one tutor — and breaking that flow into separate pages would have broken the business.

Right: Luna's six-night boarding — dates, tutor link, taxi, valor, status pill, observações — opens over the dashboard, never replaces it.

Agenda · sliding week

An agenda that doesn't
start on Monday.

First version was a clean Mon–Sun strip, swipeable. It tested fine. It was also wrong.

When today is a Saturday, a Mon–Sun strip puts today at the end of the row — the most important day on the edge, the next six days invisible. She'd swipe to next week to see Sunday, then swipe back. A small tax, twenty times a day.

I rebuilt the strip as a sliding 7-day window anchored on today. Today is always leftmost. Swipe advances seven days; swipe back retreats. The "week" stops being a calendar concept and becomes a personal horizon.

Multi-animal booking

One input gesture,
two database rows.

A tutor with two dogs books boarding for both. Two design options were both wrong: two bookings doubled her input gestures; one booking with two animals broke the per-animal record where vaccines, notes and photos belong.

I built a third option. In the sheet, the tutor's animals appear as toggle chips, all selected by default. Submit creates one booking per animal under the hood. She sees one booking. The system sees per-animal records. The first animal charges the full daily rate; additionals charge €10/day automatically.

The €10 rule isn't a UX heuristic. It's a business rule that lived in her head. Encoding it in calcularValor() turned tribal knowledge into a default — she no longer has to remember to discount the second dog, and no longer occasionally forgets.

Photos are receipts —
not decoration.

When a tutor returns and claims a dog came back injured, the only defence is evidence. Every boarding starts with check-in photos taken at the door; every checkout shows those same photos again so she can compare and confirm before handing the animal back.

Two atomic sheets, not one wizard. The time between check-in and checkout can be a week — they're separate acts, not steps in a flow. The photos persist in checkin_fotos[] and reappear in the checkout sheet for direct visual comparison.

My custom picker was prettier.
She didn't trust it.

I designed a custom date picker. It was prettier. It also displayed dates in the iPhone's system locale, which on her phone meant "18 May 2026" in English — inside a Portuguese-language product.

I argued for keeping the custom picker and forcing PT-PT formatting. She pushed back: she trusts the native iOS picker. She uses it ten times a day in other apps. A custom one — even a better-looking one — was friction she didn't want.

I conceded, and kept the styling on the field, not the picker. The implementation is a transparent native <input type="date"> layered over a styled visual surface; the picker is the iOS wheel, the displayed value is reformatted to DD/MM/AAAA after selection. Invisible to her, correct for the locale.

The first deploy looked clean —
and broke silently.

Bookings created in the form weren't appearing in the agenda. No error, no toast, just nothing. PostgREST was rejecting writes against six missing columns, and the catch block was eating the failures.

She called me before I noticed it in logs. That stung — the kind of silent failure I'd lectured myself about for years, and I shipped it anyway.

The fix took an hour. The lesson took longer.

I added explicit error surfacing on every mutation and a pre-deploy schema diff (Supabase list_tables against lib/types.ts) so the next mismatch can't reach production.