@@ -92,6 +98,12 @@ export function buildJobPopupKO() {
+
+
+
+ Calculating travel time…
+
+
diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js
index 0d579408..3fba8a55 100644
--- a/src/pages/tasking/main.js
+++ b/src/pages/tasking/main.js
@@ -33,13 +33,15 @@ import { registerAcronymTextBinding } from "./components/acronymText.js";
import { Asset } from './models/Asset.js';
import { Tasking } from './models/Tasking.js';
-import { Team } from './models/Team.js';
+import { Team, bumpDefaultAssetTick, setDefaultAssetApiUrl } from './models/Team.js';
import { Job } from './models/Job.js';
import { Sector } from './models/Sector.js';
import { Tag } from "./models/Tag.js";
import { Enum } from './utils/enum.js';
+import { fetchSharedDefaults } from './utils/defaultAssetSync.js';
+
import { ConfigVM } from './viewmodels/Config.js';
import { installSlideVisibleBinding } from "./bindings/slideVisible.js";
@@ -48,6 +50,8 @@ import { installRowVisibilityBindings } from "./bindings/rowVisibility.js";
import { installDragDropRowBindings } from "./bindings/dragDropRows.js";
import { installSortableArrayBindings } from "./bindings/sortableArray.js";
import { noBubbleFromDisabledButtonsBindings } from "./bindings/noBubble.js"
+import "./bindings/fastTooltip.js"; // registers ko.bindingHandlers.fastTooltip
+import "./bindings/bsDropdownOpen.js"; // registers ko.bindingHandlers.bsDropdownOpen
import { registerTransportCamerasLayer } from "./mapLayers/transport.js";
import { registerUnitBoundaryLayer } from "./mapLayers/geoservices.js";
@@ -191,6 +195,9 @@ const params = getSearchParameters();
const apiHost = params.host
const sourceUrl = params.source
+// Tell Team model which API URL to use for shared default-asset pushes
+setDefaultAssetApiUrl(sourceUrl);
+
var ko;
var myViewModel;
@@ -220,9 +227,8 @@ installMapContextMenu({
geocodeMarkerIcon: defaultSvgIcon,
geocodeRedMarkerIcon: defaultRedSvgIcon,
geocodeMaxResults: 10,
- onGeocodeResultClicked: (r) => {
+ onGeocodeResultClicked: (_r) => {
// TODO: replace with real action
- console.log("TODO: handle reverse-geocode pick", r);
},
});
@@ -659,17 +665,20 @@ function VM() {
return ko.utils.arrayFilter(this.jobs(), jb => {
const statusName = jb.statusName();
const jobHqId = String(jb.entityAssignedTo.id());
- const sectorId = String(jb.sector().id());
const hqMatch = hqIds.size === 0 || hqIds.has(jobHqId);
- const sectorMatch = sectorIds.size === 0 || sectorIds.has(sectorId);
- //must match sector filter
- //if no sector and config says to exclude, filter out
- if (!jb.sector().id() && self.config.includeIncidentsWithoutSector() === false) {
- return false;
- }
+ // Sector filtering — only when scope includes incidents
+ if (self.config.applySectorsToIncidents() && sectorIds.size > 0) {
+ const sectorId = String(jb.sector().id());
+ const sectorMatch = sectorIds.has(sectorId);
- if (jb.sector().id() && !sectorMatch) return false;
+ //if no sector and config says to exclude, filter out
+ if (!jb.sector().id() && self.config.includeIncidentsWithoutSector() === false) {
+ return false;
+ }
+
+ if (jb.sector().id() && !sectorMatch) return false;
+ }
// If allow-list non-empty, only show jobs whose status is in it
if (allowedStatusSet.size > 0 && !allowedStatusSet.has(statusName)) {
@@ -726,6 +735,8 @@ function VM() {
const allowed = self.config.teamStatusFilter(); // allow-list
const allowedSet = new Set(allowed || []);
const hqFilterIds = new Set((self.config.teamFilters() || []).map(f => String(f.id)));
+ const applySectorsToTeams = self.config.applySectorsToTeams();
+ const sectorIds = new Set((self.config.sectorFilters() || []).map(s => String(s.id)));
var start = new Date();
var end = new Date();
@@ -757,6 +768,13 @@ function VM() {
return false;
}
+ // Sector filtering — only when scope includes teams
+ if (applySectorsToTeams && sectorIds.size > 0) {
+ const teamSectorId = String(tm.sector()?.id?.() || '');
+ if (teamSectorId && !sectorIds.has(teamSectorId)) return false;
+ if (!teamSectorId && self.config.includeIncidentsWithoutSector() === false) return false;
+ }
+
const statusDate = tm.statusDate();
if (statusDate < start || statusDate > end) {
return false;
@@ -770,7 +788,7 @@ function VM() {
const pinnedOnlyTeams = self.showPinnedTeamsOnly();
const pinnedTeamIds = (self.config && self.config.pinnedTeamIds) ? self.config.pinnedTeamIds() : [];
const pinnedTeamSet = new Set((pinnedTeamIds || []).map(id => String(id)));
-
+ console.log("Filtering teams... pinnedOnly:", pinnedOnlyTeams, "pinnedTeamIds:", pinnedTeamIds, "filteredTeamsAgainstConfig count:", self.filteredTeamsAgainstConfig().length);
return ko.utils.arrayFilter(self.filteredTeamsAgainstConfig(), tm => {
// pinned-only filter
@@ -783,6 +801,7 @@ function VM() {
return true;
}
+
})
@@ -794,7 +813,18 @@ function VM() {
// No assets? Return nothing
if (!self.trackableAssets) return [];
// for each filtered team, get their trackable assets and flatten to single array
- return self.filteredTeams().flatMap(t => t.trackableAssets() || []);
+ // Deduplicate: the same asset can be matched to multiple teams, so flatMap
+ // may include duplicates. Without dedup, toggling pinned-only causes KO's
+ // trackArrayChanges to emit a 'deleted' change for the duplicate entry even
+ // though the asset is still present (retained from the pinned team), which
+ // removes the marker with no corresponding 'added' to restore it.
+ const seen = new Set();
+ return self.filteredTeams().flatMap(t => t.trackableAssets() || []).filter(a => {
+ const id = a.id?.();
+ if (id == null || seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ });
}).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } });
@@ -811,23 +841,31 @@ function VM() {
// --- Fetch sectors for current filters
self.fetchAllSectors = async function (hqs) {
- console.log("Fetching sectors for HQs:", hqs);
self.sectorsLoading(true);
const t = await getToken(); // blocks here until token is ready
BeaconClient.sectors.search(hqs, apiHost, params.userId, t, (res) => {
+ // Clear stale sectors from previous HQ selection
+ self.sectorsById.clear();
+ self.sectors.removeAll();
+
+ const returnedIds = new Set();
(res?.Results || []).forEach(
(sectorJson) => {
- let sector = self.sectorsById.get(sectorJson.Id);
- if (sector) {
- sector.updateFromJson(sectorJson);
- } else {
- // new sector
- sector = new Sector(sectorJson);
- self.sectorsById.set(sector.id(), sector);
- self.sectors.push(sector);
- }
+ returnedIds.add(String(sectorJson.Id));
+ let sector = new Sector(sectorJson);
+ self.sectorsById.set(sector.id(), sector);
+ self.sectors.push(sector);
}
);
+
+ // Remove any active sector filters that no longer exist
+ const staleFilters = (self.config.sectorFilters() || []).filter(
+ sf => !returnedIds.has(String(sf.id))
+ );
+ if (staleFilters.length > 0) {
+ self.config.sectorFilters.removeAll(staleFilters);
+ }
+
self.sectorsLoading(false);
}, (count, total) => {
if (count != -1 && total != -1) {
@@ -965,6 +1003,7 @@ function VM() {
},
map: self.mapVM,
filteredTeams: self.filteredTeams,
+ config: self.config,
isIncidentPinned: (id) => self.isIncidentPinned(id),
toggleIncidentPinned: (id) => self.toggleIncidentPinned(id),
}
@@ -996,7 +1035,9 @@ function VM() {
BeaconClient.team.getTasking(teamId, apiHost, params.userId, token, resolve, reject);
}),
makeTeamLink: (id) => `${params.source}/Teams/${id}/Edit`,
- flyToAsset: (asset) => {
+
+ flyToAsset: (assetOrEntry) => {
+ const asset = assetOrEntry && assetOrEntry.asset ? assetOrEntry.asset : assetOrEntry;
const lat = asset.latitude(), lng = asset.longitude();
if (Number.isFinite(lat) && Number.isFinite(lng)) {
map.flyTo([lat, lng], 14, { animate: true, duration: 0.10 });
@@ -1016,7 +1057,6 @@ function VM() {
teamTaskStatusFilter: () => self.config.teamTaskStatusFilter(),
openSMSTeamModal: (team, tasking) => {
- console.log("Opening SMS modal for team:", team, " tasking:", tasking);
self.attachSendSMSModal([], team, tasking);
},
@@ -1049,7 +1089,6 @@ function VM() {
},
entity: async (id) => {
const t = await getToken();
- console.log("Fetching entity for config:", id, t);
return new Promise((resolve) => {
BeaconClient.entities.fetch(id, apiHost, params.userId, t, (data) => resolve(data));
});
@@ -1153,7 +1192,6 @@ function VM() {
// If team provided, use its members as recipients
if (team) {
msgRecipients = team.members().map(t => {
- console.log("Mapping team member for SMS:", t);
return {
id: t.Person.Id,
name: t.Person.FirstName + ' ' + t.Person.LastName,
@@ -1166,7 +1204,6 @@ function VM() {
// if a task was provided, use its job info to prefill
if (tasking) {
- console.log("Opening SMS modal for tasking:", tasking);
taskId = tasking.job.id();
headerLabel = `Send SMS - Incident: ${tasking.job.identifier()}`;
initialText = `Re: Inc ${tasking.job.identifier()} at ${tasking.job.address.prettyAddress()}: `;
@@ -1174,7 +1211,6 @@ function VM() {
// if a job was provided, use its info to prefill and assume its a new tasking
if (job) {
- console.log("Opening SMS modal for job:", job);
headerLabel = `Send SMS - Incident: ${job.identifier()}`;
initialText = [
job.priorityName(),
@@ -1544,6 +1580,27 @@ function VM() {
(self.teams?.() || []).forEach(team => self._refreshTeamTrackableAssets(team));
};
+ // ── Shared default-asset mapping fetch ──
+ // Called after asset↔team matching completes. Makes a single
+ // Lambda request for all teams that have >1 trackable asset, then
+ // bumps the tick so every Team.defaultAsset() computed re-evaluates.
+ self._fetchSharedDefaultAssets = function () {
+ const multiAssetTeamIds = (self.filteredTeams?.() || [])
+ .filter(t => (t.trackableAssets?.() || []).length > 1)
+ .map(t => String(t.id()));
+
+ if (multiAssetTeamIds.length === 0) return;
+
+ fetchSharedDefaults(sourceUrl, multiAssetTeamIds)
+ .then(() => {
+ // Force all Team.defaultAsset() computeds to re-evaluate
+ bumpDefaultAssetTick();
+ })
+ .catch(err => {
+ console.warn('[main] shared default-asset fetch failed:', err);
+ });
+ };
+
// Tasking registry/upsert (NEW magical 2.0 way of doing it)
self.upsertTaskingFromPayload = function (taskingJson, { teamContext = null } = {}) {
if (!taskingJson || taskingJson.Id == null) return null;
@@ -1814,7 +1871,6 @@ function VM() {
self.assignJobToTeam = async function (teamVm, jobVm, cb) {
const t = await getToken(); // blocks here until token is ready
BeaconClient.tasking.task(teamVm.id(), jobVm.id(), apiHost, params.userId, t, function (r) {
- console.log(r)
if (r && r.length > 0) {
showAlert(`Incident ${jobVm.identifier()} assigned to team ${teamVm.callsign()}.`, 'success', 3000);
} else {
@@ -1967,8 +2023,10 @@ function VM() {
changes.forEach(ch => {
const a = ch.value;
if (ch.status === 'added') {
+ console.log("Attaching marker for asset filtered in:", a.name());
matchedAssetMarkerBatcher.scheduleAdd(a);
} else if (ch.status === 'deleted') {
+ console.log("Asset filtered out:", a.name());
//console.log("Detaching marker for asset no longer filtered in:", a.id());
// keep the asset in registry, but remove map marker + subs
matchedAssetMarkerBatcher.scheduleRemove(a);
@@ -2244,6 +2302,11 @@ function VM() {
});
//Update Asset/Team mappings only once after all changes
self._attachAssetsToMatchingTeams();
+
+ // Fetch shared default-asset mappings (runs after matching
+ // so we know which teams have multiple assets)
+ self._fetchSharedDefaultAssets();
+
myViewModel._markInitialFetchDone();
assetDataRefreshInterlock = false;
}, function (err) {
diff --git a/src/pages/tasking/models/Job.js b/src/pages/tasking/models/Job.js
index 456e659e..db4d3aea 100644
--- a/src/pages/tasking/models/Job.js
+++ b/src/pages/tasking/models/Job.js
@@ -34,6 +34,7 @@ export function Job(data = {}, deps = {}) {
openJobStatusConfirmModal = (_job, _newStatus) => { /* noop */ },
map = null,
filteredTeams = ko.observableArray([]),
+ config = null,
isIncidentPinned = () => false,
toggleIncidentPinned = () => false,
} = deps;
@@ -104,7 +105,7 @@ export function Job(data = {}, deps = {}) {
return Array.isArray(self.unacceptedNotifications()) && self.unacceptedNotifications().length > 0;
});
- self.instantTask = new InstantTaskViewModel({ job: self, map: map, filteredTeams: filteredTeams });
+ self.instantTask = new InstantTaskViewModel({ job: self, map: map, filteredTeams: filteredTeams, config: config });
//refs to other obs
diff --git a/src/pages/tasking/models/Tasking.js b/src/pages/tasking/models/Tasking.js
index c61eb7e2..8dd483aa 100644
--- a/src/pages/tasking/models/Tasking.js
+++ b/src/pages/tasking/models/Tasking.js
@@ -147,7 +147,7 @@ export function Tasking(data = {}) {
if (!t) return null;
const assets = t.trackableAssets && t.trackableAssets();
if (assets && assets.length > 0) {
- const a = assets[0];
+ const a = t.defaultAsset ? t.defaultAsset() : assets[0];
const lat = +ko.unwrap(a.latitude), lng = +ko.unwrap(a.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) return L.latLng(lat, lng);
}
diff --git a/src/pages/tasking/models/Team.js b/src/pages/tasking/models/Team.js
index d9129860..aff5c19b 100644
--- a/src/pages/tasking/models/Team.js
+++ b/src/pages/tasking/models/Team.js
@@ -2,16 +2,81 @@
import ko from "knockout";
import { Entity } from "./Entity.js";
+import { Sector } from "./Sector.js";
import { openURLInBeacon } from '../utils/chromeRunTime.js';
import { Enum } from '../utils/enum.js';
+import { loadSharedMapping, saveSharedMapping, pushSharedDefault, fetchSharedDefaults } from '../utils/defaultAssetSync.js';
+
// Shared across all Team instances — single localStorage key
const _capKey = 'lh_showCapabilities';
const _showCapabilities = ko.observable(localStorage.getItem(_capKey) !== 'false');
_showCapabilities.subscribe(v => localStorage.setItem(_capKey, v ? 'true' : 'false'));
+// ── Default-asset persistence (shared across all teams) ──
+// Uses the shared mapping (lh_sharedDefaultAssets) backed by Lambda/S3.
+// An asset can only be the default for one team.
+
+/**
+ * Bumped whenever any team's default-asset changes so that all
+ * `defaultAsset` computeds in every Team re-evaluate.
+ * Can also be bumped externally after a shared-defaults fetch.
+ * @type {ko.Observable
}
+ */
+const _defaultAssetTick = ko.observable(0);
+
+/** Allow main.js to force re-evaluation after a shared-defaults fetch. */
+export function bumpDefaultAssetTick() {
+ _defaultAssetTick(_defaultAssetTick() + 1);
+}
+
+/**
+ * Set the default asset for a team. Enforces the constraint that an
+ * asset may only be default for one team — if the same asset was
+ * previously claimed by another team, that mapping is removed.
+ *
+ * @param {string} teamId
+ * @param {string|null} assetId Pass `null` to clear.
+ */
+/**
+ * The Beacon API URL, set once via `setDefaultAssetApiUrl()` so that
+ * shared pushes are namespaced correctly.
+ * @type {string|null}
+ */
+let _apiUrl = null;
+
+/** Called once from main.js after params are resolved. */
+export function setDefaultAssetApiUrl(url) { _apiUrl = url; }
+
+function _setDefaultAsset(teamId, assetId) {
+ const map = loadSharedMapping();
+
+ // Remove any existing mapping pointing to this asset (one-asset-one-team)
+ if (assetId != null) {
+ for (const [tid, aid] of Object.entries(map)) {
+ if (String(aid) === String(assetId)) {
+ delete map[tid];
+ }
+ }
+ }
+
+ if (assetId != null) {
+ map[String(teamId)] = String(assetId);
+ } else {
+ delete map[String(teamId)];
+ }
+
+ saveSharedMapping(map);
+ _defaultAssetTick(_defaultAssetTick() + 1);
+
+ // Push to Lambda / S3 backend so other browsers pick it up (fire-and-forget)
+ if (_apiUrl) {
+ pushSharedDefault(_apiUrl, teamId, assetId);
+ }
+}
+
export function Team(data = {}, deps = {}) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
@@ -63,6 +128,7 @@ export function Team(data = {}, deps = {}) {
self.callsign = ko.observable(data.Callsign ?? "");
self.assignedTo = ko.observable(new Entity(data.AssignedTo || data.CreatedAt)); //safety code for beacon bug
self.teamStatusType = ko.observable(data.TeamStatusType || null); // {Id,Name,Description}
+ self.sector = ko.observable(new Sector(data.Sector || {}));
self.members = ko.observableArray(data.Members);
self.taskedJobCount = ko.observable(data.TaskedJobCount || 0);
self.teamLeader = ko.computed(function () {
@@ -87,10 +153,99 @@ export function Team(data = {}, deps = {}) {
self.trackableAssets = ko.observableArray([]);
+ // When this team first gets multiple assets, fetch its shared default
+ // mapping so the correct asset is selected without waiting for the
+ // next refresh cycle.
+ let _hadMultipleAssets = false;
+ self.trackableAssets.subscribe(assets => {
+ if (assets.length > 1 && !_hadMultipleAssets && _apiUrl) {
+ _hadMultipleAssets = true;
+ fetchSharedDefaults(_apiUrl, [String(self.id())])
+ .then(() => _defaultAssetTick(_defaultAssetTick() + 1))
+ .catch(() => { /* im not empty i promise */ });
+ } else if (assets.length <= 1) {
+ _hadMultipleAssets = false;
+ }
+ });
+
self.trackableAssetsWithMultipleTeams = ko.pureComputed(() => {
return self.trackableAssets().filter(a => a.matchingTeamsInView().length > 1);
});
+ /**
+ * The user-chosen default asset for this team, or falls back to the
+ * first trackable asset. Returns `null` if no assets exist.
+ * @type {ko.PureComputed}
+ */
+ self.defaultAsset = ko.pureComputed(() => {
+ _defaultAssetTick(); // re-evaluate when any default changes
+ const assets = self.trackableAssets();
+ if (!assets || assets.length === 0) return null;
+ if (assets.length === 1) return assets[0];
+
+ const teamId = String(self.id());
+
+ // Check shared (Lambda/S3-backed) mapping
+ const sharedMap = loadSharedMapping();
+ const sharedId = sharedMap[teamId];
+ if (sharedId != null) {
+ const found = assets.find(a => String(ko.unwrap(a.id)) === String(sharedId));
+ if (found) return found;
+ }
+
+ return assets[0]; // fallback
+ });
+
+ /**
+ * Returns true if the given asset is the current default for this team.
+ * @param {Asset} asset
+ * @returns {boolean}
+ */
+ self.isDefaultAsset = function (asset) {
+ return self.defaultAsset() === asset;
+ };
+
+ /**
+ * Returns true if the given asset is NOT the current default for this team.
+ * Used in secure-binding templates where inline negation is unavailable.
+ * @param {Asset} asset
+ * @returns {boolean}
+ */
+ self.isNotDefaultAsset = function (asset) {
+ return self.defaultAsset() !== asset;
+ };
+
+ /**
+ * Computed array of wrapper objects for the template foreach that exposes
+ * per-asset `isDefault` / `isNotDefault` observables. This avoids
+ * function-call-with-arguments in secure-binding data-bind expressions.
+ * @type {ko.PureComputed>}
+ */
+ self.trackableAssetEntries = ko.pureComputed(() => {
+ const def = self.defaultAsset();
+ return self.trackableAssets().map(a => ({
+ asset: a,
+ isDefault: a === def,
+ isNotDefault: a !== def,
+ }));
+ });
+
+ /**
+ * Set (or toggle off) the default asset for this team.
+ * Accepts either an Asset or an entry wrapper `{asset}` from trackableAssetEntries.
+ * @param {Asset|{asset: Asset}} assetOrEntry
+ */
+ self.setDefaultAsset = function (assetOrEntry) {
+ const asset = assetOrEntry && assetOrEntry.asset ? assetOrEntry.asset : assetOrEntry;
+ const currentDefault = self.defaultAsset();
+ if (currentDefault === asset && self.trackableAssets().length > 1) {
+ // Clicking the current default clears it (reverts to [0] fallback)
+ _setDefaultAsset(String(self.id()), null);
+ } else {
+ _setDefaultAsset(String(self.id()), String(ko.unwrap(asset.id)));
+ }
+ };
+
self.toggleAndExpand = function () {
const wasExpanded = self.expanded();
self.expanded(!wasExpanded);
@@ -120,6 +275,13 @@ export function Team(data = {}, deps = {}) {
self.refreshDataAndTasking = function () {
self.fetchTasking();
self.refreshData();
+
+ // If this team has multiple assets, refresh shared default mapping
+ if (_apiUrl && (self.trackableAssets?.() || []).length > 1) {
+ fetchSharedDefaults(_apiUrl, [String(self.id())])
+ .then(() => _defaultAssetTick(_defaultAssetTick() + 1))
+ .catch(() => {/* im not empty i promise */});
+ }
}
self.focusAndExpandInList = function () {
@@ -462,7 +624,6 @@ export function Team(data = {}, deps = {}) {
};
self.flyToAsset = function (asset) {
- console.log("Team.flyToAsset", asset);
flyToAsset(asset);
}
@@ -486,6 +647,13 @@ export function Team(data = {}, deps = {}) {
if (d.TeamStatusType !== undefined) this.teamStatusType(d.TeamStatusType);
if (d.Members !== undefined) this.members(d.Members);
if (d.AssignedTo !== undefined && d.CreatedAt !== undefined) this.assignedTo(new Entity(d.AssignedTo || d.CreatedAt)); //safety for beacon bug
+ if (d.Sector !== undefined) {
+ if (d.Sector === null) {
+ this.sector(new Sector({}));
+ } else {
+ this.sector().updateFromJson(d.Sector);
+ }
+ }
if (d.statusId !== undefined) this.updateStatusById(d.statusId);
if (d.TeamStatusStartDate !== undefined) this.statusDate(new Date(d.TeamStatusStartDate));
}
diff --git a/src/pages/tasking/utils/TeamSuggestionEngine.js b/src/pages/tasking/utils/TeamSuggestionEngine.js
new file mode 100644
index 00000000..e7a50c6e
--- /dev/null
+++ b/src/pages/tasking/utils/TeamSuggestionEngine.js
@@ -0,0 +1,232 @@
+/**
+ * TeamSuggestionEngine
+ *
+ * Rule-based scoring system for suggesting which team to task to a job.
+ *
+ * Rules:
+ * RESCUE priority jobs →
+ * Prefer the nearest team with ZERO active taskings.
+ * If every team has at least one tasking, fall back to the nearest team.
+ *
+ * ALL OTHER priorities →
+ * Score = ((1 − normalisedDistance) × distanceWeight + (1 − normalisedTaskings) × taskingWeight) × 100
+ * 100 = best possible match, 0 = worst. Suggest the team with the
+ * highest combined score.
+ *
+ * When `travelTimeSeconds` is available on a team object (from Amazon
+ * Location Service routing), it is used as the proximity dimension instead
+ * of haversine distance. This gives more accurate suggestions because road
+ * travel time accounts for road network topology and speed limits.
+ *
+ * The user can tweak the distance vs tasking weights per priority class
+ * via the config modal.
+ */
+
+/**
+ * @typedef {Object} SuggestionWeights
+ * @property {number} rescueDistanceWeight 0–100 slider value
+ * @property {number} rescueTaskingWeight 0–100 slider value
+ * @property {number} normalDistanceWeight 0–100 slider value (non-rescue)
+ * @property {number} normalTaskingWeight 0–100 slider value (non-rescue)
+ * @property {boolean} enabled master on/off toggle
+ */
+
+const DEFAULT_WEIGHTS = {
+ enabled: true,
+ rescueDistanceWeight: 90,
+ rescueTaskingWeight: 10,
+ normalDistanceWeight: 50,
+ normalTaskingWeight: 50,
+};
+
+/**
+ * Given a list of enriched team objects (with `distanceMeters` and
+ * `taskingCount`) and the job's priority ID, returns an array of
+ * { index, reason } objects for the top-N suggested teams (best first),
+ * or [] if disabled/impossible.
+ *
+ * @param {{ distanceMeters: number|null, travelTimeSeconds: number|null, taskingCount: number }[]} teams
+ * @param {number|null} priorityId Enum.JobPriorityType id (1 = Rescue)
+ * @param {SuggestionWeights} weights
+ * @param {number} [count=2] how many suggestions to return
+ * @returns {{ index: number, reason: string }[]} best first
+ */
+export function suggestTeamIndices(teams, priorityId, weights, count = 2) {
+ const w = { ...DEFAULT_WEIGHTS, ...weights };
+
+ if (!w.enabled || !teams || teams.length === 0) return [];
+
+ const isRescue = priorityId === 1; // Enum.JobPriorityType.Rescue.Id
+
+ if (isRescue) {
+ return rescueStrategyN(teams, w, count);
+ }
+ return generalStrategyN(teams, w, count);
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Rescue strategy (top N) */
+/* ------------------------------------------------------------------ */
+function rescueStrategyN(teams, w, count) {
+ const withDist = teams
+ .map((t, i) => ({ ...t, _idx: i }))
+ .filter(t => t.distanceMeters != null && Number.isFinite(t.distanceMeters));
+
+ if (withDist.length === 0) return [];
+
+ // Prefer teams with zero taskings
+ const idle = withDist.filter(t => t.taskingCount === 0);
+
+ if (idle.length > 0) {
+ // Sort by travel time if available, otherwise by haversine distance
+ idle.sort((a, b) => proximityValue(a) - proximityValue(b));
+ return idle.slice(0, count).map((t, rank) => {
+ const prefix = rank === 0 ? 'Nearest' : `#${rank + 1} nearest`;
+ return {
+ index: t._idx,
+ reason: `${prefix} idle team — ${fmtProximity(t)}, 0 taskings (rescue)`,
+ };
+ });
+ }
+
+ // If user has set non-trivial weights, use weighted scoring even for rescue
+ const dw = Math.max(0, w.rescueDistanceWeight);
+ const tw = Math.max(0, w.rescueTaskingWeight);
+
+ if (tw > 0) {
+ return scoredIndices(withDist, dw, tw, count, 'rescue, all teams busy');
+ }
+
+ // Pure distance fallback
+ withDist.sort((a, b) => proximityValue(a) - proximityValue(b));
+ return withDist.slice(0, count).map((t, rank) => {
+ const prefix = rank === 0 ? 'Nearest' : `#${rank + 1} nearest`;
+ return {
+ index: t._idx,
+ reason: `${prefix} team — ${fmtProximity(t)}, ${t.taskingCount} tasking(s) (rescue, all busy)`,
+ };
+ });
+}
+
+
+/* ------------------------------------------------------------------ */
+/* General (non-rescue) strategy (top N) */
+/* ------------------------------------------------------------------ */
+function generalStrategyN(teams, w, count) {
+ const withDist = teams
+ .map((t, i) => ({ ...t, _idx: i }))
+ .filter(t => t.distanceMeters != null && Number.isFinite(t.distanceMeters));
+
+ if (withDist.length === 0) return [];
+
+ const dw = Math.max(0, w.normalDistanceWeight);
+ const tw = Math.max(0, w.normalTaskingWeight);
+
+ if (dw === 0 && tw === 0) return []; // both zeroed → no suggestion
+
+ return scoredIndices(withDist, dw, tw, count, 'standard');
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Shared min-max normalised scoring */
+/* ------------------------------------------------------------------ */
+function scoredIndices(items, distWeight, taskWeight, count, tag) {
+ if (items.length === 0) return [];
+
+ const totalWeight = distWeight + taskWeight;
+ if (totalWeight === 0) return [];
+
+ const dw = distWeight / totalWeight; // normalise to 0-1
+ const tw = taskWeight / totalWeight;
+ const dwPct = Math.round(dw * 100);
+ const twPct = Math.round(tw * 100);
+
+ // Use travel time as proximity dimension when available, fall back to distance
+ const proxValues = items.map(t => proximityValue(t));
+ const taskings = items.map(t => t.taskingCount);
+
+ const dMin = Math.min(...proxValues);
+ const dMax = Math.max(...proxValues);
+ const tMin = Math.min(...taskings);
+ const tMax = Math.max(...taskings);
+
+ const dRange = dMax - dMin || 1; // avoid /0
+ const tRange = tMax - tMin || 1;
+
+ // Are any teams using road-routing data?
+ const hasAnyRouting = items.some(t => t.travelTimeSeconds != null && Number.isFinite(t.travelTimeSeconds));
+ const proxLabel = hasAnyRouting ? 'travel' : 'dist';
+
+ const scored = items.map((t, i) => {
+ const normDist = 1 - (proxValues[i] - dMin) / dRange; // 1 = closest/fastest, 0 = farthest/slowest
+ const normTask = 1 - (t.taskingCount - tMin) / tRange; // 1 = fewest, 0 = most
+ const score = (normDist * dw + normTask * tw) * 100; // 0–100 scale
+ return { _idx: t._idx, score, normDist, normTask, raw: t };
+ });
+
+ scored.sort((a, b) => b.score - a.score); // highest (best) first
+
+ return scored.slice(0, count).map((s, rank) => {
+ const parts = [];
+ if (dwPct > 0) parts.push(`${proxLabel} ${dwPct}%`);
+ if (twPct > 0) parts.push(`task ${twPct}%`);
+ const weightDesc = parts.join(' / ');
+ const prefix = rank === 0 ? 'Best' : `#${rank + 1}`;
+ return {
+ index: s._idx,
+ reason: `${prefix} match ${s.score.toFixed(0)}% (${weightDesc}) — ${fmtProximity(s.raw)}, ${s.raw.taskingCount} tasking(s) [${tag}]`,
+ };
+ });
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Proximity helpers */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Returns a single numeric "proximity" value for sorting / normalisation.
+ *
+ * Prefers `travelTimeSeconds` (road routing) when available because it
+ * accounts for road topology and speed limits. Falls back to haversine
+ * `distanceMeters`. Lower value = closer/faster.
+ *
+ * @param {{ travelTimeSeconds?: number|null, distanceMeters: number }} t
+ * @returns {number}
+ */
+function proximityValue(t) {
+ if (t.travelTimeSeconds != null && Number.isFinite(t.travelTimeSeconds)) {
+ return t.travelTimeSeconds;
+ }
+ return t.distanceMeters ?? Infinity;
+}
+
+/**
+ * Human-readable proximity string for reason tooltips.
+ * Shows travel time + road distance when routing data is available,
+ * otherwise just the haversine distance.
+ *
+ * @param {{ travelTimeSeconds?: number|null, distanceMeters: number }} t
+ * @returns {string}
+ */
+function fmtProximity(t) {
+ if (t.distanceMeters == null || !Number.isFinite(t.distanceMeters)) {
+ return 'distance unknown';
+ }
+ const distKm = (t.distanceMeters / 1000).toFixed(1);
+ if (t.travelTimeSeconds != null && Number.isFinite(t.travelTimeSeconds)) {
+ const totalMin = Math.round(t.travelTimeSeconds / 60);
+ let timeStr;
+ if (totalMin < 60) {
+ timeStr = `${totalMin} min`;
+ } else {
+ const hr = Math.floor(totalMin / 60);
+ const min = totalMin % 60;
+ timeStr = min > 0 ? `${hr} hr ${min} min` : `${hr} hr`;
+ }
+ return `${timeStr}, ${distKm} km road`;
+ }
+ return `${distKm} km`;
+}
diff --git a/src/pages/tasking/utils/batchRoute.js b/src/pages/tasking/utils/batchRoute.js
new file mode 100644
index 00000000..05de6bb7
--- /dev/null
+++ b/src/pages/tasking/utils/batchRoute.js
@@ -0,0 +1,133 @@
+/**
+ * batchRoute.js
+ *
+ * Lightweight helper that fetches road-routing summaries (distance + travel
+ * time) for multiple origin→destination pairs via the same Amazon Location
+ * Service Lambda used by the map routing controls.
+ *
+ * Each pair produces a single POST to the route endpoint. All requests
+ * run in parallel and are individually fault-tolerant — a failed route
+ * resolves to `null` rather than rejecting the batch.
+ *
+ * @module batchRoute
+ */
+
+const ROUTE_URL = "https://lambda.lighthouse-extension.com/lad/route";
+
+/**
+ * @typedef {Object} RouteSummary
+ * @property {number} distanceMeters Road distance in metres.
+ * @property {number} travelTimeSeconds Estimated travel time in seconds.
+ * @property {number[][]|null} geometry Route polyline as [[lat,lng], …] or null.
+ */
+
+/**
+ * Fetch road-routing summaries for an array of origin→destination pairs.
+ *
+ * @param {{ fromLat: number, fromLng: number, toLat: number, toLng: number }[]} pairs
+ * Array of coordinate pairs to route between.
+ * @param {Object} [opts]
+ * @param {string} [opts.travelMode="Car"] ALS travel mode.
+ * @param {number} [opts.timeoutMs=10000] Per-request timeout.
+ * @param {AbortSignal} [opts.signal] Optional abort signal to
+ * cancel all in-flight requests.
+ * @returns {Promise<(RouteSummary|null)[]>}
+ * Array aligned with `pairs`. Each entry is either a summary object or
+ * `null` if that individual route failed.
+ */
+export async function batchRoute(pairs, opts = {}) {
+ const { travelMode = "Car", timeoutMs = 10000, signal } = opts;
+
+ const promises = pairs.map(({ fromLat, fromLng, toLat, toLng }) => {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ // Honour external abort as well
+ if (signal) {
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
+ }
+
+ const payload = {
+ coordinates: [
+ [fromLng, fromLat], // ALS expects [lng, lat]
+ [toLng, toLat],
+ ],
+ travelMode,
+ };
+
+ return fetch(ROUTE_URL, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ })
+ .then(res => {
+ clearTimeout(timer);
+ if (!res.ok) return null;
+ return res.json();
+ })
+ .then(json => {
+ if (!json) return null;
+ return extractSummary(json);
+ })
+ .catch(() => {
+ clearTimeout(timer);
+ return null; // swallow per-route failures
+ });
+ });
+
+ return Promise.all(promises);
+}
+
+/**
+ * Pulls distance (metres) and time (seconds) from the ALS Lambda response.
+ *
+ * The Lambda returns the raw Amazon Routes API shape:
+ * `{ Routes: [{ Summary: { Overview: { Distance, Duration } } }] }`
+ *
+ * Falls back through several structural variants for robustness.
+ *
+ * @param {Object} json Parsed API response.
+ * @returns {RouteSummary|null}
+ */
+function extractSummary(json) {
+ const routes = json?.Routes ?? json?.routes ?? (Array.isArray(json) ? json : null);
+ if (!routes || !routes.length) return null;
+
+ const r = routes[0];
+ const src =
+ r?.Summary?.Overview ??
+ r?.summary?.overview ??
+ r?.Summary ??
+ r?.summary ??
+ null;
+
+ if (!src) return null;
+
+ const distanceMeters = typeof src.Distance === "number" ? Math.round(src.Distance) : null;
+ const travelTimeSeconds = typeof src.Duration === "number" ? Math.round(src.Duration) : null;
+
+ if (distanceMeters == null && travelTimeSeconds == null) return null;
+
+ // Extract route geometry from Legs[].Geometry.LineString => [[lat,lng], …]
+ const legs = r?.Legs ?? r?.legs ?? [];
+ let geometry = null;
+ if (Array.isArray(legs) && legs.length) {
+ const coords = [];
+ for (const leg of legs) {
+ const geom = leg?.Geometry ?? leg?.geometry;
+ const ls = geom?.LineString ?? geom?.lineString;
+ if (Array.isArray(ls) && ls.length) {
+ for (let i = 0; i < ls.length; i++) {
+ // Skip duplicate join-point between legs
+ if (coords.length && i === 0) continue;
+ // ALS returns [lng, lat] — flip to [lat, lng] for Leaflet
+ coords.push([ls[i][1], ls[i][0]]);
+ }
+ }
+ }
+ if (coords.length >= 2) geometry = coords;
+ }
+
+ return { distanceMeters, travelTimeSeconds, geometry };
+}
diff --git a/src/pages/tasking/utils/defaultAssetSync.js b/src/pages/tasking/utils/defaultAssetSync.js
new file mode 100644
index 00000000..a7e60c57
--- /dev/null
+++ b/src/pages/tasking/utils/defaultAssetSync.js
@@ -0,0 +1,127 @@
+/**
+ * defaultAssetSync.js
+ *
+ * Client-side helper for fetching and pushing shared default-asset
+ * mappings via the Lambda / S3 backend.
+ *
+ * A single GET request is made per refresh cycle, shared across all
+ * teams. Results are stored in localStorage so they survive page
+ * reloads and are available immediately on next open.
+ *
+ * The request only fires if there is at least one team with multiple
+ * trackable assets. Only those team IDs are sent, so the Lambda
+ * doesn't pull the entire universe.
+ */
+
+const LAMBDA_BASE = 'https://lambda.lighthouse-extension.com/lad/default-assets';
+const LS_KEY = 'lh_sharedDefaultAssets'; // localStorage key for cached mapping
+const LS_TS_KEY = 'lh_sharedDefaultAssets_ts'; // timestamp of last successful fetch
+
+// ── Local cache helpers ─────────────────────────────────────────────
+
+/**
+ * Read the cached shared mapping from localStorage.
+ * @returns {Object} teamId → assetId
+ */
+export function loadSharedMapping() {
+ try {
+ return JSON.parse(localStorage.getItem(LS_KEY)) || {};
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Persist a mapping into localStorage.
+ * @param {Object} mapping
+ */
+export function saveSharedMapping(mapping) {
+ localStorage.setItem(LS_KEY, JSON.stringify(mapping));
+ localStorage.setItem(LS_TS_KEY, String(Date.now()));
+}
+
+// ── Fetch (GET) ─────────────────────────────────────────────────────
+
+/**
+ * Fetch shared default-asset mappings for the given teams from the
+ * Lambda backend. Only team IDs with >1 trackable asset should be
+ * passed in — the caller is responsible for filtering.
+ *
+ * The result is merged into localStorage so that subsequent calls to
+ * `loadSharedMapping()` return the latest data.
+ *
+ * @param {string} apiUrl The Beacon source URL (namespace).
+ * @param {string[]} teamIds Array of team ID strings.
+ * @returns {Promise>} teamId → assetId map
+ */
+export async function fetchSharedDefaults(apiUrl, teamIds) {
+ if (!teamIds || teamIds.length === 0) return loadSharedMapping();
+
+ const url = `${LAMBDA_BASE}?apiUrl=${encodeURIComponent(apiUrl)}&teamIds=${teamIds.join(',')}`;
+
+ try {
+ const res = await fetch(url, { method: 'GET' });
+ if (!res.ok) {
+ console.warn('[defaultAssetSync] GET failed:', res.status);
+ return loadSharedMapping(); // fall back to cache
+ }
+
+ const { mapping } = await res.json();
+
+ // Merge into existing cache (replace only the IDs we asked about,
+ // keep any cached IDs we didn't ask about so they remain available).
+ const cached = loadSharedMapping();
+ const merged = { ...cached };
+
+ // Overwrite with fresh data for requested IDs
+ for (const id of teamIds) {
+ if (mapping[id] !== undefined) {
+ merged[id] = mapping[id];
+ } else {
+ // Lambda didn't return this ID → no shared default exists
+ delete merged[id];
+ }
+ }
+
+ saveSharedMapping(merged);
+ return merged;
+ } catch (err) {
+ console.warn('[defaultAssetSync] fetch error:', err);
+ return loadSharedMapping();
+ }
+}
+
+// ── Push (PUT) ──────────────────────────────────────────────────────
+
+/**
+ * Push a single team's default-asset mapping to the Lambda backend.
+ * Also updates localStorage immediately.
+ *
+ * @param {string} apiUrl The Beacon source URL (namespace).
+ * @param {string} teamId
+ * @param {string} assetId
+ * @returns {Promise}
+ */
+export async function pushSharedDefault(apiUrl, teamId, assetId) {
+ if (!assetId) return; // nothing to push
+
+ // Optimistic local update
+ const cached = loadSharedMapping();
+ cached[String(teamId)] = String(assetId);
+ saveSharedMapping(cached);
+
+ // Fire-and-forget remote write
+ try {
+ await fetch(LAMBDA_BASE, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ apiUrl,
+ teamId: String(teamId),
+ assetId: String(assetId),
+ }),
+ });
+ } catch (err) {
+ console.warn('[defaultAssetSync] push error:', err);
+ }
+}
diff --git a/src/pages/tasking/viewmodels/AssetPopUp.js b/src/pages/tasking/viewmodels/AssetPopUp.js
index 719b88a5..5a7f01ae 100644
--- a/src/pages/tasking/viewmodels/AssetPopUp.js
+++ b/src/pages/tasking/viewmodels/AssetPopUp.js
@@ -108,7 +108,7 @@ export class AssetPopupViewModel {
// Auto-update route when the team's first asset or job location moves
const team = tasking.team;
if (team && team.trackableAssets && team.trackableAssets().length > 0) {
- const a = team.trackableAssets()[0];
+ const a = team.defaultAsset ? team.defaultAsset() : team.trackableAssets()[0];
this._routeSubs.push(a.latLng.subscribe(() => {
if (!this.api.isRouteActive(routeControl)) return;
diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js
index ac7ea483..ed37594c 100644
--- a/src/pages/tasking/viewmodels/Config.js
+++ b/src/pages/tasking/viewmodels/Config.js
@@ -40,6 +40,8 @@ export function ConfigVM(root, deps) {
//Sectors
self.sectorFilters = ko.observableArray([]); // [{id, name}]
self.includeIncidentsWithoutSector = ko.observable(true);
+ self.applySectorsToIncidents = ko.observable(true);
+ self.applySectorsToTeams = ko.observable(false);
//Map layer order
@@ -96,6 +98,47 @@ export function ConfigVM(root, deps) {
self.pinnedTeamIds = ko.observableArray([]);
self.pinnedIncidentIds = ko.observableArray([]);
+ // ── Instant Task Suggestion Engine ──
+ self.suggestionEnabled = ko.observable(true);
+ self.rescueDistanceWeight = ko.observable(90);
+ self.rescueTaskingWeight = ko.observable(10);
+ self.normalDistanceWeight = ko.observable(50);
+ self.normalTaskingWeight = ko.observable(50);
+
+ /**
+ * When true, the suggestion engine fetches road travel times from
+ * Amazon Location Service to rank teams by actual driving time
+ * instead of crow-flies distance.
+ * @type {ko.Observable}
+ */
+ self.suggestionUseRouting = ko.observable(false);
+
+ // Keep rescue sliders summing to ~100 (optional visual aid)
+ self.rescueDistanceWeightDisplay = ko.pureComputed(() => {
+ const d = Number(self.rescueDistanceWeight()) || 0;
+ const t = Number(self.rescueTaskingWeight()) || 0;
+ const total = d + t || 1;
+ return Math.round(d / total * 100);
+ });
+ self.rescueTaskingWeightDisplay = ko.pureComputed(() => {
+ const d = Number(self.rescueDistanceWeight()) || 0;
+ const t = Number(self.rescueTaskingWeight()) || 0;
+ const total = d + t || 1;
+ return Math.round(t / total * 100);
+ });
+ self.normalDistanceWeightDisplay = ko.pureComputed(() => {
+ const d = Number(self.normalDistanceWeight()) || 0;
+ const t = Number(self.normalTaskingWeight()) || 0;
+ const total = d + t || 1;
+ return Math.round(d / total * 100);
+ });
+ self.normalTaskingWeightDisplay = ko.pureComputed(() => {
+ const d = Number(self.normalDistanceWeight()) || 0;
+ const t = Number(self.normalTaskingWeight()) || 0;
+ const total = d + t || 1;
+ return Math.round(t / total * 100);
+ });
+
// Dark mode helper (defined early so it can be called in afterConfigLoad)
self._applyDarkMode = () => {
if (self.darkMode()) {
@@ -172,6 +215,8 @@ export function ConfigVM(root, deps) {
teamTaskStatusFilter: ko.toJS(self.teamTaskStatusFilter),
sectorFilters: ko.toJS(self.sectorFilters),
includeIncidentsWithoutSector: !!self.includeIncidentsWithoutSector(),
+ applySectorsToIncidents: !!self.applySectorsToIncidents(),
+ applySectorsToTeams: !!self.applySectorsToTeams(),
pinnedTeamIds: ko.toJS(self.pinnedTeamIds),
pinnedIncidentIds: ko.toJS(self.pinnedIncidentIds),
paneOrder: self.paneOrder().map(p => p.id),
@@ -179,6 +224,12 @@ export function ConfigVM(root, deps) {
clusterRadius: Number(self.clusterRadius()) || 60,
clusterRescueJobs: !!self.clusterRescueJobs(),
alertsCollapsibleRules: !!self.alertsCollapsibleRules(),
+ suggestionEnabled: !!self.suggestionEnabled(),
+ rescueDistanceWeight: Number(self.rescueDistanceWeight()) || 0,
+ rescueTaskingWeight: Number(self.rescueTaskingWeight()) || 0,
+ normalDistanceWeight: Number(self.normalDistanceWeight()) || 0,
+ normalTaskingWeight: Number(self.normalTaskingWeight()) || 0,
+ suggestionUseRouting: !!self.suggestionUseRouting(),
});
// Helpers
@@ -336,7 +387,6 @@ export function ConfigVM(root, deps) {
self.save = () => {
const cfg = buildConfig();
- console.log('Saving config:', cfg);
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
};
@@ -390,6 +440,8 @@ export function ConfigVM(root, deps) {
cfg.teamTaskStatusFilter = self.teamTaskStatusFilterDefaults;
cfg.sectorFilters = [];
cfg.includeIncidentsWithoutSector = true;
+ cfg.applySectorsToIncidents = true;
+ cfg.applySectorsToTeams = false;
cfg.pinnedTeamIds = [];
cfg.pinnedIncidentIds = [];
@@ -407,7 +459,6 @@ export function ConfigVM(root, deps) {
});
}
}
- console.log('Loaded config:', cfg);
// scalar settings
if (typeof cfg.refreshInterval === 'number') {
self.refreshInterval(cfg.refreshInterval);
@@ -427,6 +478,12 @@ export function ConfigVM(root, deps) {
if (typeof cfg.includeIncidentsWithoutSector === 'boolean') {
self.includeIncidentsWithoutSector(cfg.includeIncidentsWithoutSector);
}
+ if (typeof cfg.applySectorsToIncidents === 'boolean') {
+ self.applySectorsToIncidents(cfg.applySectorsToIncidents);
+ }
+ if (typeof cfg.applySectorsToTeams === 'boolean') {
+ self.applySectorsToTeams(cfg.applySectorsToTeams);
+ }
// filters
if (cfg.locationFilters) {
@@ -478,6 +535,26 @@ export function ConfigVM(root, deps) {
self.alertsCollapsibleRules(cfg.alertsCollapsibleRules);
}
+ // Instant Task Suggestion Engine weights
+ if (typeof cfg.suggestionEnabled === 'boolean') {
+ self.suggestionEnabled(cfg.suggestionEnabled);
+ }
+ if (typeof cfg.rescueDistanceWeight === 'number') {
+ self.rescueDistanceWeight(cfg.rescueDistanceWeight);
+ }
+ if (typeof cfg.rescueTaskingWeight === 'number') {
+ self.rescueTaskingWeight(cfg.rescueTaskingWeight);
+ }
+ if (typeof cfg.normalDistanceWeight === 'number') {
+ self.normalDistanceWeight(cfg.normalDistanceWeight);
+ }
+ if (typeof cfg.normalTaskingWeight === 'number') {
+ self.normalTaskingWeight(cfg.normalTaskingWeight);
+ }
+ if (typeof cfg.suggestionUseRouting === 'boolean') {
+ self.suggestionUseRouting(cfg.suggestionUseRouting);
+ }
+
self.afterConfigLoad()
@@ -569,8 +646,33 @@ export function ConfigVM(root, deps) {
self.loadShared(id);
};
+ /**
+ * Collect the HQ IDs relevant for sector lookup based on which
+ * scope toggles are active (incidents, teams, or both).
+ * @returns {Array}
+ */
+ self._sectorHqIds = () => {
+ const ids = new Set();
+ if (self.applySectorsToIncidents()) {
+ (self.incidentFilters() || []).forEach(f => ids.add(f.id));
+ }
+ if (self.applySectorsToTeams()) {
+ (self.teamFilters() || []).forEach(f => ids.add(f.id));
+ }
+ return [...ids];
+ };
+
+ /** Trigger a sector refresh using the current scope-aware HQ list. */
+ self._refreshSectors = () => {
+ if (self._suppressSectorRefresh) return;
+ if (!self.applySectorsToIncidents() && !self.applySectorsToTeams()) return;
+ const ids = self._sectorHqIds();
+ if (ids.length === 0) return; // no HQs selected — nothing to search
+ deps.fetchAllSectors(ids);
+ };
+
self.afterConfigLoad = () => {
- deps.fetchAllSectors(self.incidentFilters().map(i => i.id));
+ self._refreshSectors();
root.mapVM?.applyPaneOrder?.(self.paneOrder().map(p => p.id));
root.mapVM?.applyClusterRadius?.(Number(self.clusterRadius()) || 60);
root.mapVM?.applyClusterEnabled?.(!!self.clusterEnabled());
@@ -583,13 +685,22 @@ export function ConfigVM(root, deps) {
}
- // run once on construction
+ // run once on construction — suppress sector refresh until afterConfigLoad
+ self._suppressSectorRefresh = true;
self.loadFromStorage()
+ self._suppressSectorRefresh = false;
self.incidentFilters.subscribe(() => {
- deps.fetchAllSectors(self.incidentFilters().map(i => i.id));
+ self._refreshSectors();
+ }, null, "arrayChange");
+
+ self.teamFilters.subscribe(() => {
+ self._refreshSectors();
}, null, "arrayChange");
+ self.applySectorsToIncidents.subscribe(() => self._refreshSectors());
+ self.applySectorsToTeams.subscribe(() => self._refreshSectors());
+
self.includeIncidentsWithoutSector.subscribe(() => {
root.fetchAllJobsData();
})
@@ -618,6 +729,14 @@ export function ConfigVM(root, deps) {
self.save();
})
+ // Auto-save suggestion engine settings
+ self.suggestionEnabled.subscribe(() => { self.save(); });
+ self.rescueDistanceWeight.subscribe(() => { self.save(); });
+ self.rescueTaskingWeight.subscribe(() => { self.save(); });
+ self.normalDistanceWeight.subscribe(() => { self.save(); });
+ self.normalTaskingWeight.subscribe(() => { self.save(); });
+ self.suggestionUseRouting.subscribe(() => { self.save(); });
+
self.darkMode.subscribe((isDark) => {
self._applyDarkMode();
diff --git a/src/pages/tasking/viewmodels/InstantTask.js b/src/pages/tasking/viewmodels/InstantTask.js
index 58c6d7c7..da4b3846 100644
--- a/src/pages/tasking/viewmodels/InstantTask.js
+++ b/src/pages/tasking/viewmodels/InstantTask.js
@@ -1,16 +1,93 @@
var ko = require('knockout');
-
+import { suggestTeamIndices } from '../utils/TeamSuggestionEngine.js';
+import { batchRoute } from '../utils/batchRoute.js';
+
+
+/**
+ * ViewModel for the "instant task" dropdown attached to each job row and
+ * map popup. Provides a filtered, distance-sorted, suggestion-enriched
+ * list of teams that can be tasked to the parent job with a single click.
+ *
+ * When the dropdown opens the list renders immediately using haversine
+ * (crow-flies) distances. If road-routing is enabled in config, Amazon
+ * Location Service travel times are fetched in the background. When
+ * results arrive the computed re-evaluates, the summary lines update with
+ * actual travel times, and the suggestion engine re-ranks using road
+ * distance instead of haversine.
+ */
export class InstantTaskViewModel {
- constructor({ job, map, filteredTeams }) {
- this.job = job; // expects KO observables on the job (latitude/longitude/name/etc.)
+ constructor({ job, map, filteredTeams, config }) {
+ this.job = job;
this.map = map;
- this.filteredTeams = filteredTeams; // from main VM
+ this.filteredTeams = filteredTeams;
+ this.config = config;
+
+ /**
+ * In-memory cache of route results keyed by `"teamId"`.
+ * Each value stores the route summary plus the origin/destination
+ * coordinates that were used, so stale entries can be detected
+ * when teams or jobs move.
+ *
+ * Shape: `{ distanceMeters, travelTimeSeconds, fromLat, fromLng, toLat, toLng }` or `null` (in-flight).
+ * @type {Map}
+ */
+ this._routeCache = new Map();
+
+ /**
+ * Last-known job coordinates used for route lookups.
+ * When the job moves, the entire cache is flushed.
+ * @type {{ lat: number|null, lon: number|null }}
+ */
+ this._lastJobCoords = { lat: null, lon: null };
+
+ /**
+ * Minimum movement (in metres) of either endpoint before a cached
+ * route entry is considered stale and evicted.
+ * @type {number}
+ */
+ this._movementThreshold = 100;
+
+ /**
+ * Bumped after route results arrive so `popupFilteredTeams` re-evaluates.
+ * @type {ko.Observable}
+ */
+ this._routeCacheTick = ko.observable(0);
+
+ /**
+ * AbortController for the current in-flight batch route request.
+ * Allows cancellation when the dropdown closes before results arrive.
+ * @type {AbortController|null}
+ */
+ this._routeAbort = null;
+
+ /**
+ * Set to `true` after routes are kicked off for the current
+ * dropdown session. Reset to `false` when the dropdown closes
+ * so the next open triggers a fresh fetch.
+ * @type {boolean}
+ */
+ this._routesFetchedThisOpen = false;
+
+ // When the instant-task dropdown closes, cancel pending route
+ // requests and reset the fetch-once flag for the next open.
+ this.dropdownOpen.subscribe(open => {
+ if (!open) {
+ if (this._routeAbort) {
+ this._routeAbort.abort();
+ this._routeAbort = null;
+ }
+ this._routesFetchedThisOpen = false;
+ }
+ });
}
popupActive = ko.observable(false); // gate to stop it from processing every time something changes under it
+ /** True only while the instant-task Bootstrap dropdown is shown. */
+ dropdownOpen = ko.observable(false);
+
popupTeamFilter = ko.observable('');
@@ -19,6 +96,20 @@ export class InstantTaskViewModel {
this.map.drawCrowsFliesToAssetPassedTeam(team, this.job)
}
+ /**
+ * Draw the cached road-route polyline for a team, or fall back to
+ * crow-flies if no geometry is available.
+ */
+ drawRouteOrCrowsFlies = (tm, teamId) => {
+ const cached = this._routeCache.get(teamId);
+ if (cached && cached.geometry) {
+ this.map.clearCrowFliesLine();
+ this.map.drawRoutePolyline(cached.geometry);
+ } else {
+ this.drawCrowsFliesToAssetPassedTeam(tm);
+ }
+ }
+
removeCrowsFlies = () => {
this.map.clearCrowFliesLine();
}
@@ -31,10 +122,39 @@ export class InstantTaskViewModel {
if (!this.popupActive()) return []; // short-circuit
+ // Read the route-cache tick so this computed re-fires after routes arrive
+ this._routeCacheTick();
+
const term = (this.popupTeamFilter() || "").toLowerCase().trim();
const list = this.filteredTeams() || [];
const job = this.job;
- return list
+
+ const jLat = unwrapNum(job?.address?.latitude);
+ const jLon = unwrapNum(job?.address?.longitude);
+
+ const cfg = this.config;
+ const useRouting = cfg && cfg.suggestionUseRouting ? !!ko.unwrap(cfg.suggestionUseRouting) : false;
+ const dropdownIsOpen = this.dropdownOpen();
+
+ // ── Invalidate entire cache when the job/incident has moved ──
+ if (jLat != null && jLon != null) {
+ const prev = this._lastJobCoords;
+ if (prev.lat != null && prev.lon != null) {
+ const jobDrift = haversineMeters(prev.lat, prev.lon, jLat, jLon);
+ if (jobDrift > this._movementThreshold) {
+ this._routeCache.clear();
+ }
+ }
+ this._lastJobCoords = { lat: jLat, lon: jLon };
+ }
+
+ /** Max number of route lookups per evaluation (nearest teams only). */
+ const ROUTE_LIMIT = 5;
+
+ // Teams that could use a route lookup (collected during map, trimmed after)
+ const routeCandidates = [];
+
+ const enriched = list
.filter(tm =>
!term || (tm.callsign() || "").toLowerCase().includes(term)
)
@@ -44,34 +164,67 @@ export class InstantTaskViewModel {
)
)
.map(tm => {
- const { distance, backBearing } = bestDistanceAndBearing(tm, job);
- const summaryLine = [
- tm.filteredTaskings().length + ' tasking(s)',
- distance != null ? fmtDist(distance) : null,
+ const { distance, backBearing, assetLat, assetLon } = bestDistanceAndBearing(tm, job);
+ const taskingCount = tm.filteredTaskings().length;
+
+ // Check route cache — evict if team's asset has moved
+ const teamId = String(ko.unwrap(tm.id));
+ let cached = this._routeCache.get(teamId);
+ if (cached && assetLat != null && assetLon != null) {
+ const drift = haversineMeters(cached.fromLat, cached.fromLng, assetLat, assetLon);
+ if (drift > this._movementThreshold) {
+ this._routeCache.delete(teamId);
+ cached = undefined;
+ }
+ }
+ const hasRoute = cached && cached.travelTimeSeconds != null;
+
+ // Build summary line — include travel time when available
+ const noJobLocation = jLat == null || jLon == null;
+ const summaryParts = [
+ taskingCount + ' tasking(s)',
+ hasRoute ? fmtTime(cached.travelTimeSeconds) : null,
+ hasRoute ? fmtDist(cached.distanceMeters) + ' road' : (distance != null ? fmtDist(distance) : null),
backBearing != null ? fmtBearing(backBearing) : null,
- distance == null && backBearing == null ? "Location unknown" : null,
+ noJobLocation ? "No incident location" :
+ (distance == null && backBearing == null ? "Team location unknown" : null),
].filter(Boolean).join(' • ');
+
+ // For the suggestion engine: prefer road distance, fall back to haversine
+ const effectiveDistance = hasRoute ? cached.distanceMeters : distance;
+
+ // Collect candidate for route fetching (only on first eval after dropdown opens)
+ if (useRouting && dropdownIsOpen && !this._routesFetchedThisOpen && !this._routeCache.has(teamId) && assetLat != null && jLat != null) {
+ routeCandidates.push({
+ teamId, fromLat: assetLat, fromLng: assetLon,
+ toLat: jLat, toLng: jLon,
+ _haversine: distance ?? Number.POSITIVE_INFINITY,
+ });
+ }
+
return {
team: tm,
taskings: tm.filteredTaskings,
+ taskingCount,
currentTaskingSummary: tm.currentTaskingSummary,
- summaryLine,
- distanceMeters: distance,
+ summaryLine: summaryParts,
+ distanceMeters: effectiveDistance,
+ travelTimeSeconds: hasRoute ? cached.travelTimeSeconds : null,
+ isSuggested: false,
+ suggestionReason: '',
+ routeLoading: false, // updated below after trimming to nearest N
mouseInTeamInInstantTaskPopup: () => {
- this.drawCrowsFliesToAssetPassedTeam(tm);
+ this.drawRouteOrCrowsFlies(tm, teamId);
},
mouseOutTeamInInstantTaskPopup: () => {
this.removeCrowsFlies();
},
taskTeamToJobWithConfirm: (d, e) => {
- console.log(e);
const dropdown = e.target.closest('.dropdown-menu');
if (dropdown) {
dropdown.classList.remove('show');
}
this.taskTeamToJobWithConfirm(tm);
- // Close the dropdown
-
}
};
})
@@ -80,8 +233,102 @@ export class InstantTaskViewModel {
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
return da - db;
});
+
+ // ── Kick off async route fetching for nearest uncached teams (once per open) ──
+ if (routeCandidates.length > 0) {
+ // Sort candidates by haversine so we only route the closest teams
+ routeCandidates.sort((a, b) => a._haversine - b._haversine);
+ const routeNeeded = routeCandidates.slice(0, ROUTE_LIMIT);
+
+ // Mark those teams as loading in the enriched list
+ const routeSet = new Set(routeNeeded.map(r => r.teamId));
+ for (const item of enriched) {
+ if (routeSet.has(String(ko.unwrap(item.team.id)))) {
+ item.routeLoading = true;
+ }
+ }
+
+ this._routesFetchedThisOpen = true;
+ this._fetchRoutes(routeNeeded);
+ }
+
+ // ── Suggestion engine ──
+ if (cfg && enriched.length > 0) {
+ const weights = {
+ enabled: cfg.suggestionEnabled ? !!ko.unwrap(cfg.suggestionEnabled) : true,
+ rescueDistanceWeight: cfg.rescueDistanceWeight ? Number(ko.unwrap(cfg.rescueDistanceWeight)) : 90,
+ rescueTaskingWeight: cfg.rescueTaskingWeight ? Number(ko.unwrap(cfg.rescueTaskingWeight)) : 10,
+ normalDistanceWeight: cfg.normalDistanceWeight ? Number(ko.unwrap(cfg.normalDistanceWeight)) : 50,
+ normalTaskingWeight: cfg.normalTaskingWeight ? Number(ko.unwrap(cfg.normalTaskingWeight)) : 50,
+ };
+
+ const priorityId = job.priorityId ? ko.unwrap(job.priorityId) : null;
+ const results = suggestTeamIndices(enriched, priorityId, weights, 2);
+
+ // Mark suggested teams with reason, then move them to the top (best first)
+ const suggested = [];
+ for (const { index, reason } of results) {
+ if (index >= 0 && index < enriched.length) {
+ enriched[index].isSuggested = true;
+ enriched[index].suggestionReason = reason;
+ }
+ }
+ // Pull them out in reverse index order to avoid shifting
+ const sortedIdxDesc = results
+ .map(r => r.index)
+ .filter(i => i >= 0 && i < enriched.length)
+ .sort((a, b) => b - a);
+ for (const idx of sortedIdxDesc) {
+ suggested.unshift(enriched.splice(idx, 1)[0]);
+ }
+ enriched.unshift(...suggested);
+ }
+
+ return enriched;
});
+ /**
+ * Fires off parallel route requests for teams not yet in the cache.
+ * When results arrive the cache is populated, the tick is bumped, and
+ * `popupFilteredTeams` re-evaluates automatically.
+ *
+ * Safe to call multiple times — teams already in-flight or cached are
+ * skipped because the caller only passes uncached team IDs.
+ *
+ * @param {{ teamId: string, fromLat: number, fromLng: number, toLat: number, toLng: number }[]} pairs
+ */
+ _fetchRoutes = (pairs) => {
+ // Mark these as "in-flight" in cache so we don't re-request
+ for (const p of pairs) {
+ this._routeCache.set(p.teamId, null);
+ }
+
+ // Abort any previous batch
+ if (this._routeAbort) this._routeAbort.abort();
+ const controller = new AbortController();
+ this._routeAbort = controller;
+
+ batchRoute(pairs, { signal: controller.signal })
+ .then(results => {
+ if (controller.signal.aborted) return;
+
+ for (let i = 0; i < pairs.length; i++) {
+ const summary = results[i];
+ const p = pairs[i];
+ // Store the coordinates alongside the result for staleness checks
+ this._routeCache.set(p.teamId, summary
+ ? { ...summary, fromLat: p.fromLat, fromLng: p.fromLng, toLat: p.toLat, toLng: p.toLng }
+ : summary);
+ }
+
+ // Bump tick so the computed re-evaluates with route data
+ this._routeCacheTick(this._routeCacheTick() + 1);
+ })
+ .catch(() => {
+ // Silently ignore — haversine fallback remains in place
+ });
+ };
+
}
@@ -124,8 +371,8 @@ function backBearingDegrees(bearing) {
function bearingToCardinal(bearing) {
if (bearing == null || !Number.isFinite(bearing)) return null;
const dirs = [
- "North", "North-East", "East", "South-East",
- "South", "South-West", "West", "North-West"
+ "↑", "↗", "→", "↘",
+ "↓", "↙", "←", "↖"
];
const idx = Math.round(bearing / 45) % 8;
return dirs[idx];
@@ -134,11 +381,15 @@ function bearingToCardinal(bearing) {
function bestDistanceAndBearing(team, job) {
const jLat = unwrapNum(job?.address?.latitude);
const jLon = unwrapNum(job?.address?.longitude);
- if (jLat == null || jLon == null) return { distance: null, bearing: null };
+ if (jLat == null || jLon == null) return { distance: null, bearing: null, assetLat: null, assetLon: null };
- const assets = ko.unwrap(team?.trackableAssets) || [];
+ // Use the team's default asset (user-chosen or first) for distance/bearing
+ const defAsset = team.defaultAsset ? team.defaultAsset() : null;
+ const assets = defAsset ? [defAsset] : (ko.unwrap(team?.trackableAssets) || []);
let best = null;
let bestBearing = null;
+ let bestAssetLat = null;
+ let bestAssetLon = null;
for (const a of assets) {
let lat = unwrapNum(a?.latitude), lon = unwrapNum(a?.longitude);
@@ -152,17 +403,35 @@ function bestDistanceAndBearing(team, job) {
if (best == null || d < best) {
best = d;
bestBearing = bearingDegrees(lat, lon, jLat, jLon);
+ bestAssetLat = lat;
+ bestAssetLon = lon;
}
}
const backBearing = bestBearing != null ? backBearingDegrees(bestBearing) : null;
- return { distance: best, bearing: bestBearing, backBearing };
+ return { distance: best, bearing: bestBearing, backBearing, assetLat: bestAssetLat, assetLon: bestAssetLon };
}
const fmtBearing = b =>
- b == null ? "-" : `${bearingToCardinal(b)} (${Math.round(b)}°)`;
+ b == null ? "-" : `${bearingToCardinal(b)} ${Math.round(b)}°`;
const fmtDist = m =>
m == null ? "-" : (m < 950 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`);
+/**
+ * Formats a travel time in seconds for display.
+ * Examples: "3 min", "1 hr 25 min".
+ *
+ * @param {number|null} s Travel time in seconds.
+ * @returns {string}
+ */
+const fmtTime = s => {
+ if (s == null || !Number.isFinite(s)) return "-";
+ const totalMin = Math.round(s / 60);
+ if (totalMin < 60) return `${totalMin} min`;
+ const hr = Math.floor(totalMin / 60);
+ const min = totalMin % 60;
+ return min > 0 ? `${hr} hr ${min} min` : `${hr} hr`;
+};
+
diff --git a/src/pages/tasking/viewmodels/JobPopUp.js b/src/pages/tasking/viewmodels/JobPopUp.js
index a9aa43bb..356270d8 100644
--- a/src/pages/tasking/viewmodels/JobPopUp.js
+++ b/src/pages/tasking/viewmodels/JobPopUp.js
@@ -116,7 +116,7 @@ export class JobPopupViewModel {
// Auto-update route when the team's first asset or job location moves
const team = tasking.team;
if (team && team.trackableAssets && team.trackableAssets().length > 0) {
- const a = team.trackableAssets()[0];
+ const a = team.defaultAsset ? team.defaultAsset() : team.trackableAssets()[0];
this._routeSubs.push(a.latitude.subscribe(() => {
if (!this.api.isRouteActive(routeControl)) return;
const s = tasking.getTeamLatLng(); const d = tasking.getJobLatLng();
diff --git a/src/pages/tasking/viewmodels/JobTimeline.js b/src/pages/tasking/viewmodels/JobTimeline.js
index 2f57381a..fa5ebd17 100644
--- a/src/pages/tasking/viewmodels/JobTimeline.js
+++ b/src/pages/tasking/viewmodels/JobTimeline.js
@@ -181,7 +181,6 @@ export function JobTimeline(parentVm) {
// toggle when a button is clicked
self.toggleTagFilter = function (tagItem) {
- console.log("Toggling tag filter:", tagItem);
if (!tagItem || !tagItem.isSelected) return;
tagItem.isSelected(!tagItem.isSelected());
};
diff --git a/src/pages/tasking/viewmodels/Map.js b/src/pages/tasking/viewmodels/Map.js
index e9dbf27b..d4d9b3f8 100644
--- a/src/pages/tasking/viewmodels/Map.js
+++ b/src/pages/tasking/viewmodels/Map.js
@@ -685,18 +685,36 @@ export function MapVM(Lmap, root) {
self.crowFliesLine.addTo(self.map);
};
+ /**
+ * Draw a road-route polyline on the map (reuses the crowFliesLine slot
+ * so it is cleared by the same `clearCrowFliesLine` call).
+ *
+ * @param {number[][]} latLngs Array of [lat, lng] pairs.
+ */
+ self.drawRoutePolyline = (latLngs) => {
+ self.clearCrowFliesLine();
+ if (!latLngs || latLngs.length < 2) return;
+ self.crowFliesLine = L.polyline(latLngs, {
+ weight: 4,
+ color: '#2196F3',
+ opacity: 0.85,
+ }).addTo(self.map);
+ };
+
self.drawCrowsFliesToAssetPassedTeam = (team, job) => {
self.clearCrowFliesLine();
if (!team || !job) return;
- // pick the team’s first asset coordinates
+ // pick the team’s default asset coordinates
let fromLat = null, fromLng = null;
if (team.trackableAssets && team.trackableAssets().length > 0) {
- const a = team.trackableAssets()[0];
- fromLat = +ko.unwrap(a.latitude);
- fromLng = +ko.unwrap(a.longitude);
+ const a = team.defaultAsset ? team.defaultAsset() : team.trackableAssets()[0];
+ const aLat = ko.unwrap(a.latitude), aLng = ko.unwrap(a.longitude);
+ if (aLat != null && aLng != null) { fromLat = +aLat; fromLng = +aLng; }
}
- const toLat = +ko.unwrap(job.address.latitude);
- const toLng = +ko.unwrap(job.address.longitude);
+ const rawToLat = ko.unwrap(job.address?.latitude);
+ const rawToLng = ko.unwrap(job.address?.longitude);
+ const toLat = rawToLat != null ? +rawToLat : NaN;
+ const toLng = rawToLng != null ? +rawToLng : NaN;
if (!(Number.isFinite(fromLat) && Number.isFinite(fromLng) &&
Number.isFinite(toLat) && Number.isFinite(toLng))) return;
@@ -722,15 +740,17 @@ export function MapVM(Lmap, root) {
self.clearCrowFliesLine();
if (!tasking) return;
- // pick the team’s first asset coordinates
+ // pick the team’s default asset coordinates
let fromLat = null, fromLng = null;
if (tasking.team.trackableAssets && tasking.team.trackableAssets().length > 0) {
- const a = asset || tasking.team.trackableAssets()[0];
- fromLat = +ko.unwrap(a.latitude);
- fromLng = +ko.unwrap(a.longitude);
+ const a = asset || (tasking.team.defaultAsset ? tasking.team.defaultAsset() : tasking.team.trackableAssets()[0]);
+ const aLat = ko.unwrap(a.latitude), aLng = ko.unwrap(a.longitude);
+ if (aLat != null && aLng != null) { fromLat = +aLat; fromLng = +aLng; }
}
- const toLat = +ko.unwrap(tasking.job.address.latitude);
- const toLng = +ko.unwrap(tasking.job.address.longitude);
+ const rawToLat = ko.unwrap(tasking.job.address?.latitude);
+ const rawToLng = ko.unwrap(tasking.job.address?.longitude);
+ const toLat = rawToLat != null ? +rawToLat : NaN;
+ const toLng = rawToLng != null ? +rawToLng : NaN;
if (!(Number.isFinite(fromLat) && Number.isFinite(fromLng) &&
Number.isFinite(toLat) && Number.isFinite(toLng))) return;
diff --git a/src/styles/all.css b/src/styles/all.css
index 77c55fdd..1505f1e2 100644
--- a/src/styles/all.css
+++ b/src/styles/all.css
@@ -3,8 +3,6 @@
background-color: #3CF !important;
border-color: black !important;
}
-.tag-lighthouse > .tag-text:before {
-}
.tag-lighthouse > .tag-text:after {
content:"";
}
diff --git a/static/pages/tasking.html b/static/pages/tasking.html
index bf194985..22b2055a 100644
--- a/static/pages/tasking.html
+++ b/static/pages/tasking.html
@@ -335,13 +335,25 @@