Case study — Shiftova architecture
Shiftova's architecture: how I built and run a scheduling app on my own
Shiftova is a staff scheduling and time-clock app for restaurants and other hourly teams. I designed, built and run all of it: the mobile app, the database, the server code, payments and the app-store releases. This is how it's put together, and the decisions I'd defend in an interview.
Short on time? Read the 1-minute version →
The short version, in plain English
- One app, three modes: managers build schedules and run payroll reports, employees see their shifts and clock in, and a shared tablet can work as a time clock.
- The phone app talks to a hosted database (Supabase, built on PostgreSQL). The security rules live in the database itself, so every request only ever returns what that person is allowed to see, whichever screen asks.
- Anything that needs a secret key or an outside service (payments, push notifications, email) runs in small server functions, started by the app, by changes in the database, or on a timer.
- The business rules (overtime law for 13 provinces and territories and 50 states and DC, holiday pay, payroll periods, the sales forecast) are plain functions covered by 476 automated tests.
32 / 32
database tables protected by row-level security: every request is filtered by who's asking
13
server functions for payments, push notifications, email, invites and the time-clock kiosk
476
automated tests on the business rules: overtime, holiday pay, payroll and forecasting
iOS + Android
from one TypeScript codebase, live on the App Store, Android in Google Play review
At a glance
- My role
- Sole engineer: product design, the mobile app, the database, server code, payments, releases and support.
- Stack
- React Native and Expo (TypeScript); Supabase (PostgreSQL, Auth, Edge Functions, scheduled jobs); Stripe; push notifications through Expo; Resend email.
- Size
- ~31,600 lines of app code across 34 screens and 87 logic modules; 70 database migrations; 13 server functions.
- Status
- Live and in daily use at a real restaurant, sold as a monthly subscription. Android is in Google Play review.
Skills used
What it does
One app serves three kinds of user. Which screens someone sees depends on their role at the company they're signed in to.
- Managers build and publish the weekly schedule, post open shifts, approve shift swaps and time off, watch labour cost against a sales forecast, and export payroll-ready hour reports (PDF and CSV) with overtime and holiday pay worked out for their province or state.
- Employees see their own schedule and the whole team's, clock in and out (with an optional location snapshot), trade or pick up shifts, set availability, request time off, chat with the team, and sync shifts to their phone calendar.
- A kiosk is a shared tablet at the store that works as a time clock: staff tap their name and enter a PIN.
The architecture at a glance
There's no traditional server that I run myself. The phone app talks directly to a hosted database platform, and the database decides what each request is allowed to do. Small server functions handle anything that needs a secret or an outside service.
Phone app (iOS & Android)
React Native + Expo, one TypeScript codebase. Manager, employee and kiosk modes. Overtime, payroll and forecast logic run here as tested functions.
Supabase (hosted PostgreSQL)
Sign-in and invites; 32 tables with row-level security on every one; 48 database functions and 41 triggers; 70 versioned migrations.
13 server functions + 5 scheduled jobs
Payments (checkout, billing portal, Stripe webhook, nightly reconcile), people (invites, account changes, kiosk), messaging (push, chat, incident emails), and scheduled reminders, alerts and data retention.
Third-party services
Stripe, Apple and Google push notifications (through Expo), Resend email.
Design decisions I'd point to
These are the choices that shaped the app most, each with the cost that came with it.
1.Security lives in the database, not the app
- The problem
- The app talks to the database directly. If a screen had a bug, it could ask for someone else's data, like a coworker's pay rate.
- What I did
- Every one of the 32 tables has row-level security: rules inside the database that filter every request by who's asking and which company they're signed in to. Where people legitimately need a little information about each other (names on the team schedule, say), a narrow database function returns just names instead of opening up the whole profile. When I added a team schedule view for employees, it needed no server change at all: the rules already allowed it, and pay rates stayed private.
- The trade-off
- Database rules are harder to debug than app code. I check changes against live data inside transactions that always roll back, so nothing is saved.
2.Many businesses in one system, kept apart
- The problem
- Each business must only ever see its own data, and some people work for more than one business.
- What I did
- Every piece of business data belongs to a company. A person can belong to several companies and switch between them, and the security rules all key off the company they're currently in, through one shared helper.
- The trade-off
- Every table, rule and query has to carry that company scope. Letting one person belong to several companies came later, and retrofitting it into every rule took real care.
3.Business rules are plain, tested functions
- The problem
- Overtime law differs by place (13 provinces and territories and 50 states and DC, with special daily and double-time rules), and holiday pay has its own eligibility tests. A mistake costs a customer real money.
- What I did
- The rules for overtime, holiday pay, payroll periods and the sales forecast are written as plain functions with no database or screen code in them. 476 automated tests run them in seconds, and the same functions drive the screens and the PDF reports, so the numbers always agree.
- The trade-off
- Some of that logic runs on the phone, so a fix needs an app release and a store review. There's no over-the-air update channel yet.
4.Events, not polling
- The problem
- People need to hear about new schedules, shift swaps and upcoming shifts promptly, without the app checking constantly.
- What I did
- When something changes in the database (a schedule is published, a shift is offered), a trigger calls a server function that sends the push notification. Scheduled jobs send shift reminders and overtime alerts every minute. Who gets notified is decided on the server, mostly inside the database itself, and every send's outcome is recorded so I can check afterwards what actually reached a phone. Checking that record against live data is how I found that deactivated employees could still be notified, and that a schedule update could ping people whose shifts hadn't changed. Both are fixed.
- The trade-off
- Logic is spread between triggers, server functions and the app, so I document why each piece lives where it does.
5.Payments are enforced by the database too
- The problem
- A lapsed subscription has to stop changes reliably, but a customer should never lose their records.
- What I did
- Stripe handles checkout and the billing portal. A webhook updates the company's subscription, and a nightly job re-checks every subscription with Stripe in case a webhook was missed. If a subscription lapses, a database check on 21 tables blocks changes while existing records stay readable.
- The trade-off
- Two sources of truth (Stripe and the database) have to be kept in sync, which is exactly why the nightly reconcile exists.
6.Correct before fast
- The problem
- The manager dashboard felt slow, and caching was the obvious fix. But a labour-cost number that's out of date is worse than a slow one. Separately, the database API quietly returns at most 1,000 rows per request, so growing history could have been cut off with no error.
- What I did
- I sped the dashboard up by cutting the number of round trips instead of caching, so every number is still fresh. Long lists are now read in pages with a fixed sort order, so nothing can be skipped or repeated.
- The trade-off
- It's not as fast as a cache would be. That was a deliberate choice.
7.A shared tablet is its own kind of user
- The problem
- A store tablet used as a time clock is signed in all day, in a public spot.
- What I did
- The tablet signs in as a special kiosk identity with its own narrow permissions. It can show the staff list and clock people in and out by PIN, but it can't read schedules, chat, pay rates or contact details. It's set up through a server function, not by sharing a manager's account.
- The trade-off
- Every new feature has to consider the kiosk explicitly, so it's never included by accident.
Shipping and running it
- Releases: iOS and Android builds with Expo's build service, uploaded to App Store Connect and Google Play. I handle store review myself, including a Google Play policy rejection over the app's name, which I handled by renaming the listing and resubmitting.
- Database changes: 70 versioned migrations in the repository, each with a comment explaining why, applied deliberately and checked against live data.
- Privacy and account care: a versioned privacy notice people accept in the app, a personal data export, and a deactivation flow that still lets a departing employee review their final hours.
- Marketing site: a separate Next.js site, deployed on Vercel.
How I keep it working
- Strict TypeScript and a clean lint run across the codebase, and 476 automated tests in 31 test files on the business rules.
- Database changes are tested against real data inside transactions that always roll back, so I can see exactly what a change would do before it matters.
- Before a change ships, I replay it against real history where I can. The sales-forecast change, for example, was replayed over a full year of real days and touched no day it wasn't meant to.
What I'd improve next
Being honest about the gaps matters as much as the design:
- No automated build pipeline yet. Tests, type checks and lint run on my machine before each commit, not automatically on every push.
- No over-the-air updates. Every fix waits for an app-store build and review.
- No end-to-end tests or error monitoring. The business rules are well covered, but full flows like clocking in are tested by hand, and crashes aren't reported automatically.
- One developer. Everything is documented in the code and the migrations, but there's no second reviewer.
Lessons learned
- 1. Put the rules where they can't be skipped. Security in the database protects data even from my own bugs in the app.
- 2. Keep the important logic boring. Plain functions with no database or screen code are easy to test, easy to reason about, and shared by every screen and report.
- 3. Correct beats clever. No caching where stale numbers would mislead, and paging where a silent limit could drop data.
- 4. Check the system against reality. Querying live data, safely, found real bugs that tests couldn't, like notifications reaching people they shouldn't.
- 5. The product is more than the code. Store reviews, billing, privacy and support are part of shipping software people pay for.
Hiring, or building something similar?
I'm a full-stack developer with a computer science degree and over a decade of building software and analytics for a restaurant franchise business, and I'm open to full-stack, backend and data roles as well as project work. For the data side of the same app, see the sales-forecasting case study.