Skip to content

Commit d7e6623

Browse files
committed
feat: implement active geofencing with entry/exit/dwell events
This commit introduces full, industry-standard active geofencing capabilities to FleetOps, transforming Zones and Service Areas from passive visual/pricing constructs into live geofence triggers. ## Database Migrations (4 new) - add_geofence_config_to_zones_table: trigger_on_entry, trigger_on_exit, dwell_threshold_minutes, speed_limit_kmh columns - add_geofence_config_to_service_areas_table: same config columns - create_driver_geofence_states_table: per-driver/geofence state machine (is_inside, entered_at, exited_at, dwell_job_id) - create_geofence_events_log_table: persistent audit log with full indexing ## New Backend Classes - GeofenceIntersectionService: spatial intersection engine using MySQL MBRContains (index-assisted bounding box pre-filter) + ST_Contains (exact polygon check) with per-driver state tracking - GeofenceEntered / GeofenceExited / GeofenceDwelled: Laravel broadcast events with full industry-standard JSON webhook payloads - HandleGeofenceEntered / HandleGeofenceExited / HandleGeofenceDwelled: queued listeners that write to the event log and send notifications - CheckGeofenceDwell: delayed queue job that fires GeofenceDwelled if the driver is still inside after the configured threshold - DriverArrivedAtGeofence: multi-channel notification (database, broadcast, mail) for geofence arrival alerts - GeofenceController: REST API for event log, real-time inventory, dwell report, and per-driver history - GeofenceEventLog: Eloquent model for the events log table ## Modified Backend Files - DriverController@track: integrated GeofenceIntersectionService after every location ping; dispatches events and schedules dwell jobs - Zone / ServiceArea models: added geofence config fields to fillable/casts - EventServiceProvider: registered all three geofence event-listener pairs - FleetOpsServiceProvider: singleton binding for GeofenceIntersectionService, registered DriverArrivedAtGeofence notification - routes.php: added /geofences/* API route group ## New Frontend Files - addon/services/geofence-event-bus.js: singleton service that subscribes to the company WebSocket channel and maintains a live event feed; exposes on/off pub-sub API and emits on the universe event bus - addon/components/map/toolbar/geofence-events-panel.js/.hbs: live map toolbar panel showing a real-time stream of geofence events with colour-coded entry/exit/dwell badges ## Modified Frontend Files - addon/components/zone/form.hbs: added Geofence Triggers ContentPanel with entry/exit toggles, dwell threshold, and speed limit inputs - addon/components/service-area/form.hbs: same Geofence Triggers panel - addon/components/map/leaflet-live-map.js: injected geofenceEventBus, subscribed to geofence.entered/exited universe events, added #handleGeofenceEntered / #handleGeofenceExited private methods that flash the matching polygon layer green/red on the live map
1 parent 4c46b3b commit d7e6623

27 files changed

Lines changed: 2518 additions & 5 deletions

addon/components/map/leaflet-live-map.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export default class MapLeafletLiveMapComponent extends Component {
2929
@service intl;
3030
@service universe;
3131
@service('universe/menu-service') menuService;
32+
@service geofenceEventBus;
3233

3334
/** properties */
3435
id = guidFor(this);
@@ -57,6 +58,12 @@ export default class MapLeafletLiveMapComponent extends Component {
5758

5859
// Ensure we have valid coordinates on initialization
5960
this.#updateCoordinatesFromLocation();
61+
62+
// Subscribe to geofence events so the live map can react to boundary crossings
63+
this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this);
64+
this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this);
65+
this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
66+
this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler);
6067
}
6168

6269
willDestroy() {
@@ -67,6 +74,14 @@ export default class MapLeafletLiveMapComponent extends Component {
6774
this.universe.off('user.located', this._locationUpdateHandler);
6875
this._locationUpdateHandler = null;
6976
}
77+
if (this._geofenceEnteredHandler) {
78+
this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
79+
this._geofenceEnteredHandler = null;
80+
}
81+
if (this._geofenceExitedHandler) {
82+
this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
83+
this._geofenceExitedHandler = null;
84+
}
7085
}
7186

7287
@action didLoad({ target: map }) {
@@ -259,6 +274,62 @@ export default class MapLeafletLiveMapComponent extends Component {
259274
return 14;
260275
}
261276

277+
/**
278+
* Handles a geofence.entered event from the GeofenceEventBus.
279+
* Briefly highlights the geofence layer on the map to provide visual feedback.
280+
*
281+
* @param {Object} event - Normalised geofence event object
282+
*/
283+
#handleGeofenceEntered(event) {
284+
debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
285+
this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green
286+
}
287+
288+
/**
289+
* Handles a geofence.exited event from the GeofenceEventBus.
290+
* Briefly highlights the geofence layer on the map to provide visual feedback.
291+
*
292+
* @param {Object} event - Normalised geofence event object
293+
*/
294+
#handleGeofenceExited(event) {
295+
debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
296+
this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red
297+
}
298+
299+
/**
300+
* Briefly changes the fill colour of a geofence polygon layer on the map
301+
* to provide visual feedback when a driver enters or exits.
302+
*
303+
* @param {string} geofenceUuid - UUID of the zone or service area
304+
* @param {string} flashColor - Hex colour to flash
305+
*/
306+
#flashGeofenceLayer(geofenceUuid, flashColor) {
307+
if (!geofenceUuid || !this.map) {
308+
return;
309+
}
310+
311+
// Iterate over all Leaflet layers to find the matching geofence polygon
312+
this.map.eachLayer((layer) => {
313+
const model = layer._model;
314+
if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') {
315+
const originalStyle = {
316+
color: layer.options.color,
317+
fillColor: layer.options.fillColor,
318+
weight: layer.options.weight,
319+
};
320+
321+
// Flash to the event colour
322+
layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 });
323+
324+
// Restore original style after 2 seconds
325+
setTimeout(() => {
326+
if (!layer._map) return; // layer may have been removed
327+
layer.setStyle(originalStyle);
328+
}, 2000);
329+
}
330+
});
331+
}
332+
262333
/**
263334
* Handles location updates from the location service
264335
* @param {Object} coordinates - The new coordinates
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<div class="geofence-events-panel flex flex-col h-full" ...attributes>
2+
{{! Panel header }}
3+
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
4+
<div class="flex items-center space-x-2">
5+
<FaIcon @icon="map-pin" class="text-blue-500 w-4 h-4" />
6+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Geofence Events</h3>
7+
{{#if this.events.length}}
8+
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
9+
{{this.events.length}}
10+
</span>
11+
{{/if}}
12+
</div>
13+
<button
14+
type="button"
15+
class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
16+
{{on "click" this.clearFeed}}
17+
>
18+
Clear
19+
</button>
20+
</div>
21+
22+
{{! Loading state }}
23+
{{#if this.isLoading}}
24+
<div class="flex items-center justify-center py-8">
25+
<Spinner class="w-5 h-5 text-blue-500" />
26+
<span class="ml-2 text-sm text-gray-500">Loading events…</span>
27+
</div>
28+
29+
{{! Empty state }}
30+
{{else if (eq this.events.length 0)}}
31+
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
32+
<FaIcon @icon="map-pin" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-3" />
33+
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No geofence events yet</p>
34+
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
35+
Events will appear here in real time as drivers cross zone and service area boundaries.
36+
</p>
37+
</div>
38+
39+
{{! Event feed }}
40+
{{else}}
41+
<div class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
42+
{{#each this.events as |event|}}
43+
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors {{if event.isNew 'bg-blue-50 dark:bg-blue-900/20'}}">
44+
<div class="flex items-start justify-between space-x-2">
45+
<div class="flex-1 min-w-0">
46+
{{! Event type badge }}
47+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{this.badgeColorFor event.eventType}}">
48+
{{this.labelFor event.eventType}}
49+
</span>
50+
51+
{{! Driver and geofence names }}
52+
<p class="mt-1 text-sm text-gray-900 dark:text-white truncate">
53+
<span class="font-medium">{{event.driverName}}</span>
54+
<span class="text-gray-500 dark:text-gray-400 mx-1">→</span>
55+
<span class="text-gray-700 dark:text-gray-300">{{event.geofenceName}}</span>
56+
</p>
57+
58+
{{! Dwell duration (only for exited/dwelled events) }}
59+
{{#if event.dwellMinutes}}
60+
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
61+
Dwell: {{event.dwellMinutes}} min
62+
</p>
63+
{{/if}}
64+
</div>
65+
66+
{{! Timestamp }}
67+
<div class="flex-shrink-0 text-right">
68+
<p class="text-xs text-gray-400 dark:text-gray-500">
69+
<TimeAgo @date={{event.occurredAt}} />
70+
</p>
71+
<p class="text-xs text-gray-300 dark:text-gray-600 capitalize">
72+
{{event.geofenceType}}
73+
</p>
74+
</div>
75+
</div>
76+
</div>
77+
{{/each}}
78+
</div>
79+
{{/if}}
80+
</div>
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import Component from '@glimmer/component';
2+
import { tracked } from '@glimmer/tracking';
3+
import { inject as service } from '@ember/service';
4+
import { action } from '@ember/object';
5+
import { later } from '@ember/runloop';
6+
7+
/**
8+
* GeofenceEventsPanel
9+
*
10+
* A live map toolbar panel that displays a real-time stream of geofence
11+
* events (entered, exited, dwelled) as they occur. Events are received
12+
* via the WebSocket channel and displayed in a scrollable feed with
13+
* colour-coded badges.
14+
*
15+
* The panel also provides a link to the full geofence event log.
16+
*/
17+
export default class GeofenceEventsPanelComponent extends Component {
18+
@service store;
19+
@service fetch;
20+
@service socket;
21+
22+
/**
23+
* Live event feed — most recent events at the top.
24+
* Capped at 50 entries to avoid unbounded memory growth.
25+
*
26+
* @type {Array}
27+
*/
28+
@tracked events = [];
29+
30+
/**
31+
* Whether the panel is currently loading historical events.
32+
*
33+
* @type {boolean}
34+
*/
35+
@tracked isLoading = false;
36+
37+
/**
38+
* Maximum number of events to keep in the live feed.
39+
*/
40+
MAX_EVENTS = 50;
41+
42+
constructor() {
43+
super(...arguments);
44+
this.loadRecentEvents();
45+
this.subscribeToGeofenceEvents();
46+
}
47+
48+
/**
49+
* Load the most recent geofence events from the API to populate the
50+
* feed on initial render.
51+
*/
52+
@action
53+
async loadRecentEvents() {
54+
this.isLoading = true;
55+
try {
56+
const response = await this.fetch.get('geofences/events', { per_page: 20 });
57+
if (response && response.data) {
58+
this.events = response.data.map(this.normalizeEvent);
59+
}
60+
} catch (error) {
61+
// Silently fail — the panel will populate as live events arrive
62+
} finally {
63+
this.isLoading = false;
64+
}
65+
}
66+
67+
/**
68+
* Subscribe to the geofence.* WebSocket events broadcast by the server.
69+
* Incoming events are prepended to the live feed.
70+
*/
71+
subscribeToGeofenceEvents() {
72+
if (!this.socket) {
73+
return;
74+
}
75+
76+
const channelId = this.args.channelId;
77+
if (!channelId) {
78+
return;
79+
}
80+
81+
// Listen for all three geofence event types
82+
['geofence.entered', 'geofence.exited', 'geofence.dwelled'].forEach((eventType) => {
83+
this.socket.listen(channelId, eventType, (data) => {
84+
this.onGeofenceEvent(data);
85+
});
86+
});
87+
}
88+
89+
/**
90+
* Handle an incoming geofence WebSocket event.
91+
*
92+
* @param {Object} data - The event payload from the server
93+
*/
94+
@action
95+
onGeofenceEvent(data) {
96+
const event = this.normalizeEvent(data);
97+
98+
// Prepend to the feed and cap at MAX_EVENTS
99+
this.events = [event, ...this.events].slice(0, this.MAX_EVENTS);
100+
101+
// Flash the new event row briefly to draw attention
102+
later(() => {
103+
event.isNew = false;
104+
}, 3000);
105+
}
106+
107+
/**
108+
* Normalise a raw event payload into a display-friendly object.
109+
*
110+
* @param {Object} raw
111+
* @returns {Object}
112+
*/
113+
normalizeEvent(raw) {
114+
return {
115+
id: raw.id ?? raw.uuid ?? Math.random().toString(36),
116+
eventType: raw.event_type,
117+
occurredAt: raw.occurred_at,
118+
driverName: raw.driver?.name ?? 'Unknown Driver',
119+
geofenceName: raw.geofence?.name ?? 'Unknown Geofence',
120+
geofenceType: raw.geofence?.type ?? 'zone',
121+
dwellMinutes: raw.dwell_duration_minutes ?? null,
122+
isNew: true,
123+
};
124+
}
125+
126+
/**
127+
* Returns a Tailwind CSS badge colour class for the given event type.
128+
*
129+
* @param {string} eventType
130+
* @returns {string}
131+
*/
132+
@action
133+
badgeColorFor(eventType) {
134+
const colors = {
135+
'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
136+
'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
137+
'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
138+
};
139+
return colors[eventType] ?? 'bg-gray-100 text-gray-800';
140+
}
141+
142+
/**
143+
* Returns a human-readable label for the given event type.
144+
*
145+
* @param {string} eventType
146+
* @returns {string}
147+
*/
148+
@action
149+
labelFor(eventType) {
150+
const labels = {
151+
'geofence.entered': 'Entered',
152+
'geofence.exited': 'Exited',
153+
'geofence.dwelled': 'Dwelled',
154+
};
155+
return labels[eventType] ?? eventType;
156+
}
157+
158+
/**
159+
* Clear the live feed.
160+
*/
161+
@action
162+
clearFeed() {
163+
this.events = [];
164+
}
165+
}

0 commit comments

Comments
 (0)