← Back to projects

StaffClock

Live · clock.mindvisionstudio.com

Internal restaurant web app — fast clock-in, estimated salaries, and automatic card-tip distribution (front/kitchen pro-rata to hours, exact to the cent). Pivoted into a multi-tenant SaaS: super-admin → establishments → admins, strict isolation, PIN-based tablet Consultation account, employee view, full audit log. Cloudflare edge (Workers + D1 + R2 + Hono), React 19, France GDPR. Deployed to production.

FR EN
~25K TS Lines
834 Tests
15 DB Tables
7 D1 Migrations
12 API Routes
169 Commits

Why I built it

A restaurant was tracking hours on a slow Android app — the round time picker, impossible to use quickly during a rush. Card tips were split by hand, salaries estimated by gut feel. I built StaffClock for that exact need: big-button clock-in, automatic front-of-house tip distribution to the cent, estimated salaries, formal closures. Once the tool was solid for one restaurant, I pivoted it into a real multi-tenant SaaS — super-admin → establishments → admins by invitation — so any establishment can have its own, isolated from the rest. All on Cloudflare edge (Workers + D1 + R2), ~$5/mo, deployed to production at clock.mindvisionstudio.com.

Structural technical decisions

01
Multi-tenancy retrofit with strict isolation + adversarial audit

The single-restaurant → multi-tenant SaaS pivot added an establishment_id column on every table and a TenantContext carried by the session, never the URL. The per-establishment slug (`/<slug>/…`) is purely cosmetic: login is global and the server never trusts it to decide the tenant. Super-admin with impersonation ("Enter" an establishment → "Leave" banner). Isolation was validated by an adversarial audit of 8 agents hunting for a cross-establishment leak: zero leaks found.

02
Tip distribution exact to the cent

Front-of-house distribution is pro-rata to hours worked, but the sum of the shares must land exactly on the entered total — no rounding that creates or destroys a cent. Implemented via the largest-remainder method: compute integer shares in cents, then distribute leftover cents to the largest remainders. Day / Week / Month views, and no amount visible in read-only consultation.

03
Per-establishment feature toggles, all server-gated

Each establishment has a JSON `settings` column (toggles A–G + identity) driven from a super-admin Settings card. Iron rule: no option is ever decided client-side — a toggle hidden in the UI is also refused by the server. Cash tips are opt-in and private (separate table, visible to employee + super-admin only, never a normal admin), with a dual admin/employee role and an "employee view" toggle.

04
Hardened crypto under the Cloudflare Workers CPU cap

Non-obvious launch-day production bug: `/setup` returned a false 409 on an empty database. Cause: the password hash ran at 210k PBKDF2 iterations while Cloudflare Workers caps `deriveBits` at 100k — the call threw, masked by a catch. Brought down to 100k. The trap: it passed every Node test locally and only broke in production on the edge. Lesson reused on the other Workers projects.

The non-trivial challenge

Guaranteeing isolation between establishments when the super-admin can impersonate and the URL carries a slug

Three mechanisms could leak data between restaurants: (1) the slug in the URL (`/<slug>/…`) — solved by making it non-authoritative, the tenant always comes from the server session; (2) super-admin impersonation ("Enter" an establishment) — a post-deploy bug meant "Enter" didn't refresh auth before navigating, so the router bounced back to /superadmin; fixed with refresh-before-navigate; (3) every DB query — each repository filters on establishment_id from the TenantContext, never a client parameter. All verified by an adversarial audit of 8 parallel agents, each hunting for a leak path: none found.

Lesson learned

A well-designed single-client tool pivots into a multi-tenant SaaS without a rewrite — as long as you NEVER trust the client to decide the tenant. The slug is cosmetic, the tenant comes from the session, and every repository filters on establishment_id hard. Second lesson, reused everywhere: crypto that passes locally can break on the edge — Cloudflare Workers caps PBKDF2 at 100k iterations, and a throw masked by a catch becomes a phantom bug that only shows in prod. Test on the target, not just in Node.

Features

⏱️
Fast clock-in

Big start/stop buttons, long-duration confirmation — replaces the slow, fiddly round Android time picker that was painful mid-service

💶
Automatic card tips

Daily entry, front-of-house distribution pro-rata to hours, exact to the cent (largest-remainder method), week/month recap

🧾
Closures & CSV export

Formal draft → validated → paid → locked cycle, week/month view, CSV export for payroll

👥
People & GDPR

Employee records, email invitation, GDPR anonymization of former employees (right to be forgotten)

🔐
Isolated multi-tenant

Global super-admin → establishments → admins (email invite), per-establishment slug non-authoritative server-side, establishment_id column + session TenantContext

📱
PIN tablet Consultation

Shared tablet account with 2 PINs: Server (view tips) and Manager (edit), no amounts shown in read-only consultation

🙈
Private cash tips

Opt-in, visible only to the employee and super-admin — never to a normal admin. Dual admin/employee role with an "employee view" toggle

🛡️
Full audit log

Journal of every action with the identity (email/name) of the account that made the change, employee-side error reporting

Tech Stack

Runtime
Cloudflare Workers (edge)nodejs_compat
Backend
Hono 4TypeScript strictZod 4RBAC sessions
Data
Cloudflare D1 (SQLite)Drizzle ORM15 tables7 migrations
Media / Email
R2 (photos)Resend (invites + demo)
Frontend
React 19React Router 7Tailwind CSS 4Vite 8
Tests / CI
Vitest 4 (834 tests)@testing-library/reactjsdom