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
| Scenario | Warning | Critical |
|---|---|---|
Received order (from placedAt) | 1 min | 3 min |
Preparing order (from preparingAt) | 15 min | 20 min |
Marketplace stale (driver, from createdAt) | 3 min | 7 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 State | Condition | Toast Message |
|---|---|---|
| Received, first alert | Age ≥ 3 min | "Order #N waiting too long!" |
| Received, repeating | Age ≥ 3 min | "Order #N still waiting!" |
| Preparing, first alert | Duration ≥ 20 min | "Order #N taking too long to prepare!" |
| Preparing, repeating | Duration ≥ 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
| Urgency | Border | Shadow | Animation |
|---|---|---|---|
| Normal | — | — | — |
| Warning | border-amber-300 | shadow-amber-200/50 | — |
| Critical | border-red-400 | shadow-red-200/50 | animate-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:
- Count increase: Plays notification sound when marketplace order count increases.
- 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 Level | Threshold | Badge Text | Dot Color | Border | Time Text |
|---|---|---|---|---|---|
| Warning | 3 min | "Waiting" | Amber pulsing | Amber left | Amber |
| Critical | 7 min | "Urgent" | Red pulsing | Red left | Red |
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:
| State | Icon | Message |
|---|---|---|
| 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
| State | Color | Message |
|---|---|---|
| Denied | Red | "Location access denied. Enable it in your browser settings to go online." |
| Unsupported | Yellow | "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).
| Sound | Pattern | Volume | Duration | Used In |
|---|---|---|---|---|
| New Order | 880Hz → 1047Hz → 880Hz (ascending) | 0.2 | 300ms | Both apps |
| Critical Alert | 880Hz → 660Hz → 880Hz (descending-ascending) | 0.25 | 360ms | orders_app |
| Urgency Transition | 660Hz → 880Hz → 660Hz (ascending-descending) | 0.2 | 300ms | orders_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 toREADYorders globally, ordered bycreatedAt.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
| Setting | Value | Location |
|---|---|---|
| Received order warning | 1 min | orders_app/src/lib/orderAge.ts:3 |
| Received order critical | 3 min | orders_app/src/lib/orderAge.ts:4 |
| Preparing order warning | 15 min | orders_app/src/lib/orderAge.ts:6 |
| Preparing order critical | 20 min | orders_app/src/lib/orderAge.ts:7 |
| Marketplace stale warning (driver) | 3 min | driver_app/.../OrderCard.tsx:118 |
| Marketplace stale critical (driver) | 7 min | driver_app/.../OrderCard.tsx:119 |
| Critical alert repeat interval | 30 sec | orders_app/.../useCriticalOrderAlerts.ts:34 |
| Driver continuous reminder interval | 10 sec | driver_app/.../useNewOrderAlert.ts:5 |
| Urgency check polling | 10 sec | orders_app/.../useOrderUrgency.ts:32 |
| Stale level check polling (driver) | 30 sec | driver_app/.../OrderCard.tsx:217 |
| Tab flash animation | 1.5s infinite | globals.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.
| Concern | Where |
|---|---|
| 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 wiring | Expo prebuild, from expo.android.googleServicesFile — there is no hand-written Gradle |
| Tray icon, tint, default channel | app.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
deniedand stays denied for the life of the install. - FCM v1 sending needs the Cloud Messaging API Admin role on the service
account, which
firebase-admindoes 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)
| Concern | Where |
|---|---|
| SSR-safe native detection | orders_app/src/lib/capacitor.ts |
| Permission + listeners + token | orders_app/src/lib/push.ts |
| Register/unregister the token | orders_app/src/hooks/usePushRegistration.ts |
| Token mutations | orders_app/src/rtk/api/firestoreApi.ts (registerDeviceToken / unregisterDeviceToken) |
| Token field | businesses/{businessId}.fcmTokens (packages/types/src/business.ts) |
| Permission card | orders_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":
| Concern | Where |
|---|---|
| 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)
| Concern | Where |
|---|---|
| Sender | customer_app/src/lib/server/orderPushNotification.ts |
| Trigger | customer_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 adatapayload (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, andfirstOrder("1"/"0"). Nonotificationblock — 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
alertwithsound: defaultandinterruption-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_apiWorker and its/v1/device-tokensroute 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