Orders Alerting System

This document describes the order alerting mechanisms in orders_app (restaurant/manager side) and driver_app (driver side).

Overview

The web alerting system is client-side and browser-based. The Capacitor Android build of orders_app adds a native FCM push path that rings the device — WhatsApp/Telegram-style full-screen call with a looping ringtone and vibration — whether the app is open, backgrounded, or closed (see Push notifications (Capacitor orders app)). Otherwise, alerting uses a layered combination of:

  • Real-time Firestore data subscriptions driving reactive state
  • Web Audio API synthesized sound alerts
  • Sonner toast notifications
  • CSS animation-based visual alerts on UI elements
  • Browser tab title badges
  • In-app banner warnings

Urgency Thresholds

ScenarioWarningCritical
Received order (from placedAt)1 min3 min
Preparing order (from preparingAt)15 min20 min
Marketplace stale (driver, from createdAt)3 min7 min

Defined in orders_app/src/lib/orderAge.ts and driver_app/src/components/orders/OrderCard.tsx.


orders_app Alerting

Incoming Order Call Overlay (web)

Files: orders_app/src/hooks/useIncomingOrderCall.ts, orders_app/src/components/IncomingOrderOverlay.tsx, orders_app/src/lib/ringtone.ts, orders_app/src/lib/vibration.ts

Web-only: the browser build (no Capacitor shell) has no native call surface, so it keeps the full-screen overlay as its primary new-order alert. useIncomingOrderCall queues orders that enter RECEIVED after mount (the initial snapshot is baselined so already-present orders never ring), exposes a single activeOrder at a time, and auto-dismisses it when the order leaves RECEIVED (accepted or rejected). State is derived during render rather than in an effect, to satisfy the React compiler's set-state-in-effect lint rule.

The overlay rings through startRing() — a repeating playback of ready-go.wav on a shared AudioContext with a 5s cooldown between plays, plus a synthesized fallback tone — and buzzes through startVibration() (the Web Vibration API, falling back to Capacitor Haptics, which iOS needs). It shows the order number, first-order badge, customer, items, total, and note, with Accept & Prepare (→ PREPARING) and Decline (stops the ring/vibration and dismisses the overlay, leaving the order on the board as RECEIVED) actions. Audio must be unlocked by a user gesture (unlockAudio()), so the ring only starts after the manager has interacted with the page at least once.

layout.tsx gates the overlay with a hydration-safe isNativePlatform() read (via useSyncExternalStore), so Android never renders it.

Consumed at: orders_app/src/app/[locale]/(main)/layout.tsx

Critical Order Alerts (Sound + Toast)

File: orders_app/src/hooks/useCriticalOrderAlerts.ts

The most aggressive alerting mechanism. Monitors received and preparing orders for critical age status. When triggered, plays a descending-then-ascending three-tone warning sound and shows a Sonner toast, then repeats every 30 seconds until the order leaves the critical state.

Order StateConditionToast Message
Received, first alertAge ≥ 3 min"Order #N waiting too long!"
Received, repeatingAge ≥ 3 min"Order #N still waiting!"
Preparing, first alertDuration ≥ 20 min"Order #N taking too long to prepare!"
Preparing, repeatingDuration ≥ 20 min"Order #N still being prepared!"

Uses per-order timers (useRef<Map>) and first-alert tracking (useRef<Set>). Cleans up timers when orders leave critical state or are removed.

Consumed at: orders_app/src/app/[locale]/(main)/page.tsx:12

Per-Order Urgency Sound

File: orders_app/src/hooks/useOrderUrgency.ts

Tracks urgency per individual order card. Plays a sound (660Hz → 880Hz → 660Hz) when an order's urgency level transitions to warning or critical. Polls every 10 seconds.

Consumed at: orders_app/src/components/order-card/OrderCard.tsx:25

Browser Tab Title Badge

File: orders_app/src/hooks/useDocumentTitle.ts

Updates the browser tab title to show new order count as a passive visual alert:

  • Orders pending: "(N) New Orders - Orders"
  • No orders: "Orders"

Consumed at: orders_app/src/app/[locale]/(main)/page.tsx:11

Tab Flash Animation

Files: orders_app/src/app/OrdersTabs.tsx:140-153, orders_app/src/globals.css:182-221

When the RECEIVED tab has unviewed orders or the PREPARING tab has orders with urgency, the tab button pulses with animate-tab-flash (1.5s ease-in-out infinite). Light and dark variants are defined in CSS.

The hasPreparingUrgency flag is computed in orders_app/src/app/[locale]/(main)/layout.tsx:12-15 by checking if any preparing order has urgency ≠ "normal".

Order Card Visual Styling

File: orders_app/src/components/order-card/OrderCard.tsx:10-14

UrgencyBorderShadowAnimation
Normal———
Warningborder-amber-300shadow-amber-200/50—
Criticalborder-red-400shadow-red-200/50animate-pulse

Only applied when viewing the RECEIVED or PREPARING tabs.

Ready State Pulsing Indicator

File: orders_app/src/components/order-card/OrderFooter.tsx:97-105

When an order is in READY status (waiting for driver pickup), shows a pulsing green dot with "Ready" / "Waiting for driver" labels using animate-ping.


driver_app Alerting

New Order Sound + Continuous Reminder

File: driver_app/src/hooks/useNewOrderAlert.ts

Two behaviors:

  1. Count increase: Plays notification sound when marketplace order count increases.
  2. Continuous reminder: When the driver has no active orders but unclaimed marketplace orders exist, plays the sound immediately then repeats every 10 seconds.

Consumed at: driver_app/src/app/orders/active/page.tsx:41

Marketplace Tab Flash

File: driver_app/src/components/orders/BottomNav.tsx:23-25,46

When the driver has no active orders AND marketplace orders are available, the "Marketplace" tab flashes with animate-tab-flash. CSS defined in driver_app/src/app/globals.css:120-149.

Stale Order Visual Alerts

File: driver_app/src/components/orders/OrderCard.tsx:118-128,209-266,420-426

Stale LevelThresholdBadge TextDot ColorBorderTime Text
Warning3 min"Waiting"Amber pulsingAmber leftAmber
Critical7 min"Urgent"Red pulsingRed leftRed

Both include an AlarmClock icon. Polls every 30 seconds. Only applies to marketplace variant cards.

Stale Alerts on Recommended Orders

File: driver_app/src/components/orders/RecommendedOrders.tsx:24-34,56-69,88-137

Same stale-level logic as OrderCard (3-min warning, 7-min critical). Shows pulsing dots, colored borders, and AlarmClock icon. Polls every 30 seconds.

Finance Warning Banner

File: driver_app/src/components/FinanceWarningBanner.tsx

Persistent banner when the driver's cash balance approaches configured limits:

StateIconMessage
Warning (amber)AlertTriangle"Your cash balance ($X.XX) has reached the warning limit ($Y.YY)."
Blocked (red)Ban"Your cash balance ($X.XX) has reached the limit ($Y.YY). You cannot claim new orders."

Powered by driver_app/src/hooks/useDriverFinance.ts which reads finance.currentCash, finance.warningLimit, and finance.blockLimit from the Firestore driver document.

Mounted at: driver_app/src/app/orders/layout.tsx:15

Finance Block on Marketplace

File: driver_app/src/app/orders/marketplace/page.tsx:91-111

When isBlocked is true, the marketplace view is replaced with a blocking screen showing a red Ban icon and a message directing the driver to contact their manager. Also disables the Claim button on the order detail page.

Location Permission Warning

File: driver_app/src/components/LocationPermissionBanner.tsx

StateColorMessage
DeniedRed"Location access denied. Enable it in your browser settings to go online."
UnsupportedYellow"Geolocation is not supported by your browser"

Mounted at: driver_app/src/app/orders/layout.tsx:14

Native alert() for Errors

File: driver_app/src/app/orders/[orderId]/page.tsx

Uses browser-native alert() for action failures: claim, start delivery, start route, complete delivery, and cancel order. Also used in OrderCard.tsx:231 for generic action failures.


Sound Patterns

All sounds are synthesized via Web Audio API (AudioContext + OscillatorNode + GainNode).

SoundPatternVolumeDurationUsed In
New Order880Hz → 1047Hz → 880Hz (ascending)0.2300msBoth apps
Critical Alert880Hz → 660Hz → 880Hz (descending-ascending)0.25360msorders_app
Urgency Transition660Hz → 880Hz → 660Hz (ascending-descending)0.2300msorders_app

Data Pipeline

Alerting is driven by real-time Firestore onSnapshot listeners:

orders_app

File: orders_app/src/rtk/api/firestoreApi.ts:93-126

fetchActiveOrders subscribes to all orders for the business. Uses RTK Query onCacheEntryAdded + onSnapshot. React re-renders drive alert evaluations.

driver_app

File: driver_app/src/rtk/api/firestoreApi.ts

  • fetchMarketplaceOrders (lines 94-130): Subscribes to READY orders globally, ordered by createdAt.
  • fetchMyOrders (lines 133-177): Subscribes to orders assigned to this driver (excluding terminal statuses).
  • fetchUserData (lines 28-66): Subscribes to driver document for finance data.

Configuration Summary

SettingValueLocation
Received order warning1 minorders_app/src/lib/orderAge.ts:3
Received order critical3 minorders_app/src/lib/orderAge.ts:4
Preparing order warning15 minorders_app/src/lib/orderAge.ts:6
Preparing order critical20 minorders_app/src/lib/orderAge.ts:7
Marketplace stale warning (driver)3 mindriver_app/.../OrderCard.tsx:118
Marketplace stale critical (driver)7 mindriver_app/.../OrderCard.tsx:119
Critical alert repeat interval30 secorders_app/.../useCriticalOrderAlerts.ts:34
Driver continuous reminder interval10 secdriver_app/.../useNewOrderAlert.ts:5
Urgency check polling10 secorders_app/.../useOrderUrgency.ts:32
Stale level check polling (driver)30 secdriver_app/.../OrderCard.tsx:217
Tab flash animation1.5s infiniteglobals.css in both apps

Push notifications (mobile orders app)

Separately from the Capacitor shell described above, the Expo mobile orders app has its own push path via expo-notifications over raw FCM v1 tokens rather than the Expo push service — it is built with npx expo run:android only, so there is no EAS projectId for getExpoPushTokenAsync to attribute a token to. It registers the raw token with the orders_api Worker and the Worker posts to fcm.googleapis.com itself.

This app is not part of this monorepo — it lives in a standalone repo at ../orders_app_mobile (extracted from here at b44cbaa). It still targets the same Firebase project and is still authorised by firestore.rules under the BUSINESS_MANAGER claim, but it has its own copy of the rules-facing code.

ConcernWhere
Foreground handler, channel, token fetch../orders_app_mobile/src/lib/notifications.ts
Registration + tap routing../orders_app_mobile/src/features/push/usePushRegistration.ts
Firebase config (Android app registration)../orders_app_mobile/google-services.json + ../orders_app_mobile/.env
Gradle / native wiringExpo prebuild, from expo.android.googleServicesFile — there is no hand-written Gradle
Tray icon, tint, default channelapp.json → expo-notifications plugin

Two constraints that are easy to get wrong:

  • The channel has to exist before the permission request. On Android 13+ the OS shows no prompt until one does, so requesting first returns denied and stays denied for the life of the install.
  • FCM v1 sending needs the Cloud Messaging API Admin role on the service account, which firebase-admin does not imply.

Push notifications (Capacitor orders app)

The orders_app Capacitor Android shell registers for FCM and is rung by the customer app whenever an order is placed.

Registration (orders_app)

ConcernWhere
SSR-safe native detectionorders_app/src/lib/capacitor.ts
Permission + listeners + tokenorders_app/src/lib/push.ts
Register/unregister the tokenorders_app/src/hooks/usePushRegistration.ts
Token mutationsorders_app/src/rtk/api/firestoreApi.ts (registerDeviceToken / unregisterDeviceToken)
Token fieldbusinesses/{businessId}.fcmTokens (packages/types/src/business.ts)
Permission cardorders_app/src/app/settings/OrdersNotification.tsx

On launch the hook requests POST_NOTIFICATIONS, obtains the FCM token, and stores it on the restaurant document with arrayUnion. If the device later signs in to a different restaurant, the stale token is removed from the previous restaurant first (tracked in localStorage) so the old restaurant stops ringing this device. Tapping a notification routes to /[locale]/order/{orderId}.

The notification channel (incoming_calls, high importance, silent) is created natively on first ring by RingService, not from JS: the native ring supplies its own looping audio, and an app-created channel would be frozen with the wrong settings the first time Android sees it. (The previous JS-created incoming_orders channel is gone.)

Native ringing (orders_app Android)

The incoming order is delivered as a data-only message, so it always reaches the app's own FirebaseMessagingService rather than being rendered by the Firebase SDK. That native code owns the "call":

ConcernWhere
FCM receiver (always rings; foreground also opens the call surface)orders_app/android/app/src/main/java/com/zajil/orders/capacitor/ZajilMessagingService.java
Ringing foreground service (ringtone + vibration, full-screen notification, 60s timeout)orders_app/android/app/src/main/java/com/zajil/orders/capacitor/RingService.java
Lock-screen call surface (Open / Decline)orders_app/android/app/src/main/java/com/zajil/orders/capacitor/IncomingCallActivity.java + android/app/src/main/res/layout/activity_incoming_call.xml
Manifest wiring (service swap, FSI/FGS permissions)orders_app/android/app/src/main/AndroidManifest.xml

The Capacitor plugin's MessagingService is removed from the merged manifest (tools:node="remove") so the message is not delivered twice; onNewToken still forwards to PushNotificationsPlugin.onNewToken so the JS token listeners keep working. ZajilMessagingService always starts RingService, so the native call is the single alert surface whether the app is open, backgrounded, or killed. Because a full-screen intent is only auto-launched when the screen is locked or off, the receiver also opens IncomingCallActivity directly when the app is already visible (otherwise it would degrade to a heads-up notification). There is no in-app ready-go.wav chime on native any more; the web overlay is unchanged.

Opening the native call just launches the app on the orders board (IncomingCallActivity); it no longer navigates to the order page.

The lock-screen call surface mirrors the web overlay's design: slate-950 background, pulsing green call badge, "Incoming order" label, order number with the first-order badge, customer name, an items card showing up to 6 lines plus a "+N" row, the total (with the app's currency: $ / "ج.م"), and the order note. Its strings live in android/app/src/main/res/values*/strings.xml (English + Arabic, aligned with the web messages/*.json terms). Data comes from the FCM data payload above; older pushes without the new keys simply omit the empty rows.

On Android 14+, USE_FULL_SCREEN_INTENT is a normal permission granted by default for sideloaded installs (it is a calling/alarm use case, so it stays policy-safe for a future Play release), but the user can revoke it in system settings — in that case the alert degrades to a high-importance heads-up notification that opens the same call surface.

Sending (customer_app)

ConcernWhere
Sendercustomer_app/src/lib/server/orderPushNotification.ts
Triggercustomer_app/src/app/actions/placeOrder.ts (inside after(), after the order commits)

notifyNewOrderPush reads fcmTokens from the business document (captured in the same transaction as the order), then calls sendEachForMulticast with a data-only, high-priority payload:

  • Android: android: { priority: "high", ttl: 60000 } and a data payload (type: "incoming_order", orderId, orderNumber, customerName, restaurantName, title, body). It also carries a compact preview so the native call surface matches the web overlay: items (first 6 lines as JSON {n,s,q} — the 4 KB FCM cap), itemCount, total (final post-redemption total), note, and firstOrder ("1"/"0"). No notification block — that is what lets the app build a full-screen call in the background instead of leaving it to the Firebase SDK. Delivery to a killed app relies on the high priority.
  • iOS: (not shipped) an APNs alert with sound: default and interruption-level: time-sensitive.

Invalid/expired tokens reported by FCM are pruned from the business document.

Environments

Native builds pick their backend with CAP_ENV at cap sync time (npm run cap:sync:preview / cap:sync:production), and Android uses the matching product flavor (Firebase project + package id per environment). See orders_app/capacitor.config.ts and orders_app/android/app/build.gradle.


Limitations

The current system does not include:

  • Push in the browser/PWA — the native Capacitor Android shell only (iOS is not shipped yet)
  • iOS: the APNs payload is sent, but there is no iOS native call surface yet
  • Ringing after the user force-stops the app (Android disables FCM entirely), or when the OEM battery manager kills background delivery — autostart allow-listing is needed on aggressive OEM ROMs
  • A sender for the Expo app's push: the orders_api Worker and its /v1/device-tokens route do not exist yet, so those tokens are collected and dropped
  • Background service workers for alerts
  • Scheduled tasks or cron jobs
  • Database models for notifications or alerts
  • Notification SDKs beyond Firebase Cloud Messaging
  • Persistent notification storage
  • User-configurable alert preferences (sound on/off, volume, etc.)
  • Notification history or read/unread tracking