StaffClock
Live · staffclock.appMulti-vertical HR suite on the Cloudflare edge — born as a clock-in app for one restaurant (fast clock-in, estimated salaries, card-tip distribution exact to the cent), pivoted into a multi-tenant SaaS now covering 8 industries (restaurant, healthcare, bakery, retail, security, construction, home care + team mode). French labour-law engine (paid leave, overtime, compliance alerts), KPI dashboards, tip distribution in 3 modes, real-time push notifications (web push + in-app center), scheduled payroll email (Cron). Strict isolation by establishment_id + TenantContext, per-employee private scope, full audit log, France GDPR. Single Worker: React 19, Hono 4, D1 + Drizzle, R2. Deployed to production.
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 — then widened it into a multi-vertical HR suite: today 8 industries (restaurant, healthcare, bakery, retail, security, construction, home care + team mode), each a CCN preset over generic engines. On top of clock-in: a French labour-law engine (paid leave, overtime, compliance alerts), KPI dashboards, a Cron-scheduled payroll email. All on Cloudflare edge (Workers + D1 + R2), deployed to production at staffclock.app.
Structural technical decisions
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.
Three distribution modes per establishment: per-point pro-rata, each-for-self, or per-service. Whatever the mode, 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.
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.
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 my other Workers projects.
V7 makes the platform real-time with no native dependency: the VAPID crypto (ES256 + aes128gcm, RFC 8291) is implemented in pure Web Crypto to run inside a Worker. Fan-out is best-effort by construction — the notification service never throws, sends go out via `waitUntil` after the response, and a 404/410 deletes the stale subscription: a push crash never breaks the underlying tip or leave action. Alongside it, moving to a 2nd domain (staffclock.app) required origin-aware email links: the request origin is trusted only if it's on an allowlist, otherwise it falls back to the default domain — no forging a phishing link via a header.
The non-trivial challenge
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 — then a multi-vertical suite — without a rewrite, given two things. First, NEVER trust the client to decide the tenant: the slug is cosmetic, the tenant comes from the session, and every repository hard-codes its filter on establishment_id. Second, keep the engines generic and derive each industry only through a CCN preset — that's what let me go from one restaurant to 8 verticals without forking the code. Second technical 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
Restaurant, healthcare, bakery, retail, security, construction, home care + team mode — each a CCN preset over generic engines, not a code fork
Web push (VAPID / RFC 8291, crypto implemented in pure Web Crypto inside the Worker) + in-app notification center: employees and managers notified the moment a tip is entered or a leave request is waiting. Best-effort fan-out — a failed send never breaks the business action, stale subscriptions are cleaned up
Paid leave (acquisition + ouvrés/ouvrables decount), overtime (25/50 tiers, CCN-overridable), compliance alerts (daily rest, max duration)
Request → decision (approve / refuse), per-establishment designated validators with a safe fallback to admins/managers, unified "Leave" tab, and a signed daily/weekly hour-balance counter — a lone paid-leave day stays at balance, not −8h
Per-establishment dashboard (hours, payroll mass, tips, attendance) + super-admin charts with stacked cash, all from pure SQL aggregation (no derived tables)
Per-point pro-rata / each-for-self / per-service, sum exact to the cent via the largest-remainder method, day/week/month recap
Dedicated demo environment (its own subdomain + D1 + R2), deterministic 2-month seed, no cron or payroll — explore the suite without touching real data
Monthly Cron that generates and sends the payroll recap as a PDF (pdf-lib) via Resend
Global super-admin → establishments → admins (email invite), tenant carried by the session (establishment_id + TenantContext), never the URL slug, per-employee private scope in private verticals
Journal of every action with the identity (email/name) of the account, PBKDF2-HMAC-SHA-256 password hash at 100k iterations, employee-side error reporting