Skip to main content

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}/... and Companies/{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: /statssrc/routes/OwnerDashboard/index.tsx
  • Bypass in PrivateRoute: src/utils/routes.tsx:98-103/stats skips the normal owner/manager/employee redirects.
  • Auth check on mount: src/routes/OwnerDashboard/index.tsx:215-232 — calls firebase.auth().onAuthStateChanged, fetches the ID-token result, and only allows users whose token has claims.admin === true. Everyone else is redirected to /403.
  • Admin list: the dashboard reads Users filtered by isAdmin === true to render the admin list in the UI (OwnerDashboard/index.tsx:235 onward).
  • 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).

FlagPurpose
isPayrollTabsEnabledTips/cuts tabs in PayrollNew. Gradual rollout flag — read in PayrollNew/index.tsx and PayrollNew/modals/ExportModal/index.tsx.
isCloverOAuth2EnabledUse the OAuth2 Clover integration instead of API-key flow for this company.
hasAccessToLaborCostGates the Labor Cost screens.
attendanceBreaksEnabledToggle attendance break tracking.
weatherApiEnabledWeather widget on the dashboard.
hasCatererCompany has caterer sales (affects sales rollups).
enableShiftSplittingAllow long shifts to be split during schedule generation.
conflictsDetectionEnabledDetect conflicts in attendance data.
hideFinancialInfoFromManagersHide financial info from managers.
hideTipsFromManagersHide 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.

FlagPurpose
hasAccessToAttendanceMaster switch for the attendance feature. Toggleable from OwnerDashboard.
isGeolocationEnabledEnable geolocation on clock-in. Lives on CompanyData (lines 57-68). Toggleable from OwnerDashboard (index.tsx:1023-1036); gated behind hasAccessToAttendance.
isSuspendedSuspend 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

  1. Pick the right node. CompanySettings/ for soft toggles (defaults off, gradual rollout). Companies/ for "this company has X" facts (set during onboarding, rarely flipped).

  2. Extend the TypeScript type in src/types/company.ts:

    export type CompanySettings = {
    // ...
    myNewFeatureEnabled?: boolean // why / when it activates
    }
  3. Read the flag through the existing company hook, never via useSelector directly in a component (see CLAUDE.md → "ABSOLUTE INTERDICTIONS"). If a hook for the slice you need doesn't exist, add one under src/hooks/.

  4. Default to false/off — destructure with a default so feature-disabled is the safe path:

    const { myNewFeatureEnabled = false } = settings
  5. Add a toggle row in OwnerDashboard if the flag should be flippable per-company at runtime. Pattern: Switch → firebase.database().ref(...).set(value).

  6. 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.

  7. 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 Roles or 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.