Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .github/workflows/publish_dev.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: Publish Dev Preview to Chrome Store
on:
workflow_run:
workflows: [Publish Dev]
branches:
- 'master-dev'
workflow_dispatch:
jobs:
lintandpublish:
name: Publish Dev
Expand Down
8 changes: 4 additions & 4 deletions src/pages/tasking/components/mapContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export function installMapContextMenu({
}

function extractUnitLevel(secondaryAddressComponents) {
// SecondaryAddressComponents: [{ Designator, Number }] :contentReference[oaicite:2]{index=2}
// SecondaryAddressComponents
const comps = Array.isArray(secondaryAddressComponents)
? secondaryAddressComponents
: [];
Expand Down Expand Up @@ -311,16 +311,16 @@ const strOrNull = (v) => (s(v) ? s(v) : null);
function toLadReverseGeocodeShape(item) {
if (!item) return null;

const addr = item.Address ?? null; // :contentReference[oaicite:5]{index=5}
const pos = Array.isArray(item.Position) ? item.Position : null; // [lon, lat] :contentReference[oaicite:6]{index=6}
const addr = item.Address ?? null;
const pos = Array.isArray(item.Position) ? item.Position : null; // [lon, lat]

const longitude = pos && Number.isFinite(pos[0]) ? pos[0] : null;
const latitude = pos && Number.isFinite(pos[1]) ? pos[1] : null;

const { flat, level } = extractUnitLevel(addr?.SecondaryAddressComponents);

return {
address_pid: null, // not returned by AWS ReverseGeocode :contentReference[oaicite:7]{index=7}
address_pid: null, // not returned by AWS ReverseGeocode
latitude,
longitude,
flat,
Expand Down
242 changes: 242 additions & 0 deletions src/pages/tasking/components/spotlightSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/* eslint-disable @typescript-eslint/no-this-alias */
import ko from "knockout";

function safeStr(v) {
if (v == null) return "";
try { return String(ko.unwrap(v) ?? ""); } catch { return String(v); }
}

function joinLower(...parts) {
return parts
.flatMap(p => (Array.isArray(p) ? p : [p]))
.map(safeStr)
.join(" ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}

function joinUpper(...parts) {
return parts
.flatMap(p => (Array.isArray(p) ? p : [p]))
.map(safeStr)
.join(" ")
.replace(/\s+/g, " ")
.trim()
.toUpperCase();
}

function buildTeamSearchText(t) {
// cheap + safe (avoids deep traversals)
const members = (() => {
try {
const arr = ko.unwrap(t.members) || [];
return arr.map(m => `${m?.Person?.FirstName ?? ""} ${m?.Person?.LastName ?? ""}`.trim());
} catch { return []; }
})();

const assets = (() => {
try {
const arr = ko.unwrap(t.assets) || [];
return arr.map(a => `${a?.name ?? ""} ${a?.markerLabel ?? ""}`.trim());
} catch { return []; }
})();

return joinLower(
assets,
t.callsign,
t.assignedTo()?.code,
t.assignedTo()?.name,

t.teamLeader,
members,
);
}

function buildJobSearchText(j) {
const tagNames = (() => { try { return j.tagsCsv?.(); } catch { return ""; } })();
const addr = (() => { try { return j.address?.prettyAddress?.(); } catch { return ""; } })();

return joinLower(
j.id, j.identifier,
j.typeName, j.type,
j.statusName,
() => { try { return j.entityAssignedTo?.code?.(); } catch { return ""; } },
() => { try { return j.entityAssignedTo?.name?.(); } catch { return ""; } },
j.lga,
j.sectorName,
addr,
j.contactFirstName, j.contactLastName, j.contactPhoneNumber,
j.callerFirstName, j.callerLastName, j.callerPhoneNumber,
tagNames,
j.situationOnScene
);
}

function decorateResults(items) {
return items.map((r) => ({
...r,
isActive: ko.observable(false),
}));
}




export function SpotlightSearchVM({ rootVm, getTeams, getJobs }) {
const self = this;

self.query = ko.observable("");
self.activeIndex = ko.observable(0);
self.results = ko.observableArray([]);

// lightweight cache rebuilt when registries change
let teamIndex = [];
let jobIndex = [];

self.positionText = ko.pureComputed(() => {
const n = self.results().length;
if (!n) return "";
return (self.activeIndex() + 1) + "/" + n;
});

function setActiveByIndex(idx) {
const arr = self.results();
const n = arr.length;
if (!n) {
self.activeIndex(0);
return;
}

const clamped = Math.max(0, Math.min(n - 1, idx));
self.activeIndex(clamped);

for (let i = 0; i < n; i++) {
arr[i].isActive(i === clamped);
}
}

self.isActiveIndex = function (indexFn) {
try {
const idx = typeof indexFn === "function" ? indexFn() : Number(indexFn);
return idx === self.activeIndex();
} catch (e) {
return false;
}
};

function rebuildIndex() {
const teams = getTeams() || [];
const jobs = getJobs() || [];

teamIndex = teams.map(t => {
const members = ko.unwrap(t.members) || [];
const memberNames = members.map(m => `${m?.Person?.FirstName ?? ""} ${m?.Person?.LastName ?? ""}`.trim());
return ({
kind: "Team",
ref: t,
searchText: buildTeamSearchText(t),
primary: `${safeStr(t.callsign)} - ${safeStr(t.assignedTo()?.name?.())} - ${safeStr(t.statusName)}`,
secondary: memberNames.join(", ").trim()
})
})

jobIndex = jobs.map(j => ({
kind: "Incident",
ref: j,
searchText: buildJobSearchText(j),
primary: `${safeStr(j.identifier)} - ${safeStr(j.typeShort)}${safeStr(j.categoriesNameNumberDash)} - ${safeStr(j.statusName)} - (${safeStr(j.entityAssignedTo?.name?.())})`,
secondary: joinUpper((() => { try { return j.address?.prettyAddress?.(); } catch { return ""; } })(), ".", (() => { try { return j.situationOnScene?.(); } catch { return ""; } })()).trim()
}));
}

// call once now; then callers can trigger again as data loads
rebuildIndex();

let timer = null;
function scheduleSearch() {
if (timer) clearTimeout(timer);
timer = setTimeout(runSearch, 60);
}

function runSearch() {
const q = (self.query() || "").trim().toLowerCase();
if (!q) {
self.results.removeAll();
self.activeIndex(0);
return;
}

// simple scoring: startsWith > includes
const scored = [];
const consider = (x) => {
const s = x.searchText;
const idx = s.indexOf(q);
if (idx === -1) return;
const score = (idx === 0 ? 1000 : 0) - idx;
scored.push({ ...x, score });
};

teamIndex.forEach(consider);
jobIndex.forEach(consider);

scored.sort((a, b) => b.score - a.score);

const raw = scored.slice(0, 40).map(({ _score, ...r }) => r);
self.results(decorateResults(raw));
setActiveByIndex(0);
}

self.query.subscribe(scheduleSearch);

self.openResult = (r) => {
if (!r?.ref) return;

// close modal first (Bootstrap owns DOM focus)
rootVm._closeSpotlight?.();

// open target
if (r.kind === "Team") {
r.ref.toggleAndExpand?.(); //if its a team without an asset still expand it. might be a bit racey?
r.ref.markerFocus?.();

} else {
r.ref.focusMap?.();

}
};

self.onInputKeyDown = (_, e) => {
const key = e.key;

if (key === "ArrowDown") {
e.preventDefault();
setActiveByIndex(self.activeIndex() + 1);
return true;
}
if (key === "ArrowUp") {
e.preventDefault();
setActiveByIndex(self.activeIndex() - 1);
return true;
}
if (key === "Enter") {
e.preventDefault();
const r = self.results()[self.activeIndex()];
if (r) self.openResult(r);
return true;
}

if (key === "Escape") {
e.preventDefault();
rootVm._closeSpotlight?.();
return true;
}
return true;
};

// called by root when new data arrives
self.rebuildIndex = () => {
rebuildIndex();
runSearch();
};
}
Loading