Feature Flags
PIVOT does not use a third-party feature-flag service. There's no LaunchDarkly, GrowthBook, Statsig, or Split.io, and Firebase Remote Config has not been adopted. Instead:
Flags are fields on the company. They live in Firebase Realtime Database under
CompanySettings/{companyId}/...andCompanies/{companyId}/.... They're per-company, not per-user. They are flipped via the OwnerDashboard admin UI (or directly in RTDB), and clients react in real-time via RTDB listeners.
This is a deliberate choice — it lets us roll a feature out to one restaurant chain at a time. The trade-offs (no per-user, no dev override, no cohort experiments) are documented under "Limitations" below.
OwnerDashboard
The first question new developers ask is "is OwnerDashboard a feature flag?" No. OwnerDashboard is the admin UI for toggling all the flags below. It is itself gated by a role, not a flag.
- Route:
/stats→src/routes/OwnerDashboard/index.tsx - Bypass in
PrivateRoute:src/utils/routes.tsx:98-103—/statsskips the normal owner/manager/employee redirects. - Auth check on mount:
src/routes/OwnerDashboard/index.tsx:215-232— callsfirebase.auth().onAuthStateChanged, fetches the ID-token result, and only allows users whose token hasclaims.admin === true. Everyone else is redirected to/403. - Admin list: the dashboard reads
Usersfiltered byisAdmin === trueto render the admin list in the UI (OwnerDashboard/index.tsx:235onward). - Mobile: OwnerDashboard does not exist in
PIVOT-Mobile. The mobile app reads flag values from the same RTDB paths but has no admin UI.
How to enable OwnerDashboard for local testing
Set the auth custom claim admin = true on your Firebase user. That's it — /stats access is gated entirely on the custom claim. The Users/{uid}/isAdmin field is unrelated to access (it only controls whether you appear in the admin list the dashboard renders).
Custom claims can only be set with the Firebase Admin SDK — write a one-off script or add yourself via an existing admin's setup script. From a service account:
# in functions/ with a service-account JSON
node -e "
const admin = require('firebase-admin');
admin.initializeApp({ credential: admin.credential.applicationDefault() });
admin.auth().setCustomUserClaims('<YOUR_UID>', { admin: true })
.then(() => console.log('done'));
"
After running this, sign out and back in so your client picks up the new ID token, then navigate to /stats.
Flag Inventory
All flags found in the codebase, by location.
A flag (in this doc's sense) is a boolean that can be flipped at runtime — typically from OwnerDashboard — to enable/disable a feature for one company. Numeric thresholds, timezone overrides, and per-integration "is this integration connected?" markers are settings, not flags, and aren't listed here even though they live on the same RTDB nodes (see src/types/company.ts for the full type).
CompanySettings/{companyId}/... — toggleable from OwnerDashboard
Defined in src/types/company.ts:115-142 (the CompanySettings type).
| Flag | Purpose |
|---|---|
isPayrollTabsEnabled | Tips/cuts tabs in PayrollNew. Gradual rollout flag — read in PayrollNew/index.tsx and PayrollNew/modals/ExportModal/index.tsx. |
isCloverOAuth2Enabled | Use the OAuth2 Clover integration instead of API-key flow for this company. |
hasAccessToLaborCost | Gates the Labor Cost screens. |
attendanceBreaksEnabled | Toggle attendance break tracking. |
weatherApiEnabled | Weather widget on the dashboard. |
hasCaterer | Company has caterer sales (affects sales rollups). |
enableShiftSplitting | Allow long shifts to be split during schedule generation. |
conflictsDetectionEnabled | Detect conflicts in attendance data. |
hideFinancialInfoFromManagers | Hide financial info from managers. |
hideTipsFromManagers | Hide tips management from managers. |
Companies/{companyId}/...
Defined across src/types/company.ts: CompanyData (lines 57-68) holds isGeolocationEnabled; Company (lines 68-105) holds the rest.
| Flag | Purpose |
|---|---|
hasAccessToAttendance | Master switch for the attendance feature. Toggleable from OwnerDashboard. |
isGeolocationEnabled | Enable geolocation on clock-in. Lives on CompanyData (lines 57-68). Toggleable from OwnerDashboard (index.tsx:1023-1036); gated behind hasAccessToAttendance. |
isSuspended | Suspend the company (read-only / locked). Set from OwnerDashboard. The Stripe webhook only clears it (sets null) on active/trialing — it does not set it on cancellation (the suspend-on-cancel line is commented out at handle-subscription-change.ts:34). Can change at any time, so don't cache locally. |
How a Flag Is Used
Flags are read via AppContext (which is backed by RTDB listeners), so they update reactively in any component that subscribes. Example from PayrollNew:
// src/routes/PayrollNew/modals/ExportModal/index.tsx:116-117
const { maxHoursPerWeek, isPayrollTabsEnabled = false } =
useAppContext()?.companySettings || {}
isPayrollTabsEnabled is then used to render different UI (isPayrollTabsEnabled ? ['hours', 'tips', 'cuts'] : ['hours']). When an admin flips the flag in OwnerDashboard, every connected client re-renders within RTDB's sync latency — no client cache invalidation needed.
OwnerDashboard writes flags directly to RTDB, e.g.:
// src/routes/OwnerDashboard/index.tsx
firebase
.database()
.ref(`CompanySettings/${selectedCompanyId}/isPayrollTabsEnabled`)
.set(value)
Adding a New Flag
-
Pick the right node.
CompanySettings/for soft toggles (defaults off, gradual rollout).Companies/for "this company has X" facts (set during onboarding, rarely flipped). -
Extend the TypeScript type in
src/types/company.ts:export type CompanySettings = {
// ...
myNewFeatureEnabled?: boolean // why / when it activates
} -
Read the flag through the existing company hook, never via
useSelectordirectly in a component (seeCLAUDE.md→ "ABSOLUTE INTERDICTIONS"). If a hook for the slice you need doesn't exist, add one undersrc/hooks/. -
Default to
false/off — destructure with a default so feature-disabled is the safe path:const { myNewFeatureEnabled = false } = settings -
Add a toggle row in OwnerDashboard if the flag should be flippable per-company at runtime. Pattern: Switch →
firebase.database().ref(...).set(value). -
Mirror in mobile if the feature shows up there. Mobile reads the same RTDB paths via
@react-native-firebase/database— no separate config needed, just propagate the type and the conditional render. -
RTDB security rules (
database.rules.json) —CompanySettings/{companyId}/...is already gated by company membership. If you put the flag elsewhere, double-check the rules allow the right reads/writes.
Limitations
This is intentionally a small, opinionated system. Things it deliberately doesn't do:
- No per-user flags — every employee at a given company sees the same flag values. If you need per-user gating, do it via
Rolesor write something new. - No dev-only override — there's no localStorage flag, no
?ff_*query param, no env-var bypass. To test a flag locally, flip it in the dev RTDB. - No cohort / experiment infrastructure — no A/B groups, no targeting rules. Each flag is a single boolean (or value) per company.
- No audit log of who flipped what — RTDB writes from OwnerDashboard go straight to the leaf path. If you need history, add an
Audit/mirror.
If you ever need any of these, Firebase Remote Config would be the lowest-friction add (same Firebase project, same auth surface). Discuss before adopting — that's a system-wide decision.
Related
- Architecture overview — RTDB structure and where
CompanySettingsfits CLAUDE.md— coding rules (nouseSelectorin components, etc.)