diff --git a/.github/workflows/publish_dev.yml b/.github/workflows/publish_dev.yml index 85e8be3f..2d5154a0 100644 --- a/.github/workflows/publish_dev.yml +++ b/.github/workflows/publish_dev.yml @@ -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 diff --git a/src/pages/tasking/components/mapContextMenu.js b/src/pages/tasking/components/mapContextMenu.js index 6c6c5b98..0deff4f2 100644 --- a/src/pages/tasking/components/mapContextMenu.js +++ b/src/pages/tasking/components/mapContextMenu.js @@ -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 : []; @@ -311,8 +311,8 @@ 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; @@ -320,7 +320,7 @@ function toLadReverseGeocodeShape(item) { 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, diff --git a/src/pages/tasking/components/spotlightSearch.js b/src/pages/tasking/components/spotlightSearch.js new file mode 100644 index 00000000..df9ce611 --- /dev/null +++ b/src/pages/tasking/components/spotlightSearch.js @@ -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(); + }; +} diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index bca8ee58..66c48547 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -27,6 +27,8 @@ import IncidentImagesModalVM from "./viewmodels/IncidentImagesModalVM"; import { installAlerts } from './components/alerts.js'; import { LegendControl } from './components/legend.js'; +import { SpotlightSearchVM } from "./components/spotlightSearch.js"; + import { Asset } from './models/Asset.js'; import { Tasking } from './models/Tasking.js'; @@ -182,21 +184,21 @@ const map = L.map('map', { installMapContextMenu({ - map, - geocodeEndpoint: 'https://lambda.lighthouse-extension.com/lad/geocode', - geocodeMarkerIcon: defaultSvgIcon, - geocodeRedMarkerIcon: defaultRedSvgIcon, - geocodeMaxResults: 10, - onGeocodeResultClicked: (r) => { - // TODO: replace with real action - console.log("TODO: handle reverse-geocode pick", r); - }, + map, + geocodeEndpoint: 'https://lambda.lighthouse-extension.com/lad/geocode', + geocodeMarkerIcon: defaultSvgIcon, + geocodeRedMarkerIcon: defaultRedSvgIcon, + geocodeMaxResults: 10, + onGeocodeResultClicked: (r) => { + // TODO: replace with real action + console.log("TODO: handle reverse-geocode pick", r); + }, }); const polylineMeasure = L.control.polylineMeasure({ position: 'topleft', - + measureControlLabel: '', // FontAwesome ruler icon unit: 'kilometres', showBearings: true, clearMeasurementsOnStop: false, @@ -328,6 +330,7 @@ function VM() { BeaconClient }); + self.openIncidentImages = function (job, e) { if (e) { e.stopPropagation?.(); e.preventDefault?.(); } if (!job || typeof job.id !== "function") return false; @@ -580,6 +583,13 @@ function VM() { start.setDate(end.getDate() - self.config.fetchPeriod()); + // Add 5 minutes to the start time just to account for drift + start.setMinutes(start.getMinutes() + 5); + + end.setDate(end.getDate() + self.config.fetchForward()); + + + return ko.utils.arrayFilter(this.jobs(), jb => { const statusName = jb.statusName(); @@ -643,7 +653,9 @@ function VM() { // Team filtering/searching self.teamSearch = ko.observable(''); - self.filteredTeams = ko.pureComputed(() => { + + //just filtered against config not against UI searching + self.filteredTeamsAgainstConfig = ko.pureComputed(() => { const allowed = self.config.teamStatusFilter(); // allow-list @@ -652,16 +664,17 @@ function VM() { start.setDate(end.getDate() - self.config.fetchPeriod()); - const pinnedOnlyTeams = self.showPinnedTeamsOnly(); - const pinnedTeamIds = (self.config && self.config.pinnedTeamIds) ? self.config.pinnedTeamIds() : []; + // Add 5 minutes to the start time just to account for drift + start.setMinutes(start.getMinutes() + 5); + + end.setDate(end.getDate() + self.config.fetchForward()); + + return ko.utils.arrayFilter(self.teams(), tm => { const status = tm.teamStatusType()?.Name; - // pinned-only filter - if (pinnedOnlyTeams && !pinnedTeamIds.includes(String(tm.id()))) { - return false; - } + const hqMatch = self.config.teamFilters().length === 0 || self.config.teamFilters().some((f) => f.id == tm.assignedTo().id()); if (status == null) { return false; @@ -682,16 +695,31 @@ function VM() { return false; } + return true; + }); + }).extend({ trackArrayChanges: true, rateLimit: 50 }); + + self.filteredTeams = ko.pureComputed(() => { + const pinnedOnlyTeams = self.showPinnedTeamsOnly(); + const pinnedTeamIds = (self.config && self.config.pinnedTeamIds) ? self.config.pinnedTeamIds() : []; + + return ko.utils.arrayFilter(self.filteredTeamsAgainstConfig(), tm => { + + // pinned-only filter + if (pinnedOnlyTeams && !pinnedTeamIds.includes(String(tm.id()))) { + return false; + } + const term = self.teamSearch().toLowerCase(); if (tm.callsign().toLowerCase().includes(term)) { return true; } - return false; - }); - }).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); + }) + }).extend({ trackArrayChanges: true, rateLimit: { timeout: 100, method: 'notifyWhenChangesStop' } }); + self.filteredTrackableAssets = ko.pureComputed(() => { @@ -1300,7 +1328,7 @@ function VM() { return matchedAssets; } - // Replaces the old pairwise matcher :contentReference[oaicite:1]{index=1} + // Replaces the old pairwise matcher self._assetMatchesTeam = function (_asset, _team) { // no longer used as the primary mechanism; keep for safety if anything external calls it // (fall back to the new computed set for correctness). @@ -1313,7 +1341,7 @@ function VM() { }; - // recompute one team's asset list :contentReference[oaicite:2]{index=2} + // recompute one team's asset list self._refreshTeamTrackableAssets = function (team) { console.log("Refreshing trackable assets for team:", team?.callsign?.()); if (!team || typeof team.trackableAssets !== 'function') return; @@ -1379,6 +1407,49 @@ function VM() { return t; }; + self.spotlightSearchVM = new SpotlightSearchVM({ + rootVm: self, + getTeams: () => ko.unwrap(self.filteredTeamsAgainstConfig()), + getJobs: () => ko.unwrap(self.filteredJobsAgainstConfig()), + }); + + self._spotlightModalEl = null; + self._spotlightModal = null; + + self._openSpotlight = () => { + + self.teamSearch(''); // clear team search to avoid confusion + self.jobSearch(''); // clear job search to avoid confusion + + const el = document.getElementById("SpotlightSearchModal"); + if (!el) return; + + self._spotlightModalEl = el; + self._spotlightModal = bootstrap.Modal.getOrCreateInstance(el); + + self.spotlightSearchVM.query(""); + self.spotlightSearchVM.results.removeAll(); + self.spotlightSearchVM.activeIndex(0); + self.spotlightSearchVM.rebuildIndex?.(); + + self._spotlightModal.show(); + + // focus input after show + document.getElementById("spotlightSearchInput")?.focus(); + + + installModalHotkeys({ + modalEl: el, + onSave: () => { /* empty */ }, // enter handled in VM keydown + onClose: () => self._closeSpotlight(), + allowInInputs: true + }); + }; + + self._closeSpotlight = () => { + try { self._spotlightModal?.hide(); } catch { /* empty */ } + }; + self.initialFitDone = false; let initialFetchesPending = 3; // teams, jobs, assets @@ -1435,6 +1506,8 @@ function VM() { }; + + self.markerLayersControl = null; // optional Leaflet layer control self.collapseAllIncidentPanels = function () { @@ -1525,6 +1598,10 @@ function VM() { }) } + //update spotlight index on team/job filter changes + self.filteredTeamsAgainstConfig.subscribe(() => { self.spotlightSearchVM.rebuildIndex?.() }, null, "arrayChange"); + self.filteredJobsAgainstConfig.subscribe(() => { self.spotlightSearchVM.rebuildIndex?.() }, null, "arrayChange"); + //fetch tasking if a team is added self.filteredTeams.subscribe((changes) => { changes.forEach(ch => { @@ -1841,6 +1918,10 @@ function VM() { start.setDate(end.getDate() - myViewModel.config.fetchPeriod()); + start.setMinutes(start.getMinutes() + 5); // slight overlap to catch late updates and drift + end.setDate(end.getDate() + self.config.fetchForward()); + + myViewModel.jobsLoading(true); const t = await getToken(); // blocks here until token is ready @@ -1899,6 +1980,8 @@ function VM() { var end = new Date(); var start = new Date(); start.setDate(end.getDate() - myViewModel.config.fetchPeriod()); + start.setMinutes(start.getMinutes() + 5); // slight overlap to catch late updates and drift + end.setDate(end.getDate() + self.config.fetchForward()); myViewModel.teamsLoading(true); const t = await getToken(); // blocks here until token is ready BeaconClient.team.teamSearch(hqsFilter, apiHost, start, end, params.userId, t, function (teams) { @@ -2521,11 +2604,30 @@ document.addEventListener('DOMContentLoaded', function () { installModalHotkeys({ modalEl: configModalEl, - onSave: () => bootstrap.Modal.getInstance(configModalEl).hide(), - onClose: () => bootstrap.Modal.getInstance(configModalEl).hide(), + onSave: () => myViewModel.config.saveAndCloseAndLoad(), + onClose: () => myViewModel.config.saveAndCloseAndLoad(), allowInInputs: true // text-heavy modal }); + document.addEventListener("keydown", (e) => { + // Cmd+K / Ctrl+K to open Spotlight Search + const isK = (e.key || "").toLowerCase() === "k"; + if (!isK) return; + + const isCmd = e.metaKey === true; + const isCtrl = e.ctrlKey === true; + + if (!(isCmd || isCtrl)) return; + + // don't stack if already open + const open = document.getElementById("SpotlightSearchModal")?.classList.contains("show"); + if (open) return; + + e.preventDefault(); + myViewModel._openSpotlight(); + }, { capture: true }); + + //large amount of bs to fix this chrome aria hidden warning that wont go away const configTrigger = () => document.querySelector('[data-bs-target="#configModal"]'); diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index 5e3c460a..7701cfd4 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -70,7 +70,8 @@ export function ConfigVM(root, deps) { // Other settings self.refreshInterval = ko.observable(60); - self.fetchPeriod = ko.observable(7); + self.fetchPeriod = ko.observable(7).extend({ min: 0, max: 31, digit: true }); + self.fetchForward = ko.observable(0).extend({ min: 0, max: 31, digit: true }); self.showAdvanced = ko.observable(false); //blown away on load @@ -142,6 +143,7 @@ export function ConfigVM(root, deps) { const buildConfig = () => ({ refreshInterval: Number(self.refreshInterval()), fetchPeriod: Number(self.fetchPeriod()), + fetchForward: Number(self.fetchForward()), showAdvanced: !!self.showAdvanced(), locationFilters: { teams: ko.toJS(self.teamFilters), @@ -360,6 +362,7 @@ export function ConfigVM(root, deps) { console.log('Using defaults.'); cfg.refreshInterval = self.refreshInterval(); cfg.fetchPeriod = self.fetchPeriod(); + cfg.fetchForward = self.fetchForward(); cfg.showAdvanced = self.showAdvanced(); cfg.teamStatusFilter = self.teamStatusFilterDefaults; cfg.jobStatusFilter = self.jobStatusFilterDefaults; @@ -379,6 +382,9 @@ export function ConfigVM(root, deps) { if (typeof cfg.fetchPeriod === 'number') { self.fetchPeriod(cfg.fetchPeriod); } + if (typeof cfg.fetchForward === 'number') { + self.fetchForward(cfg.fetchForward); + } if (typeof cfg.showAdvanced === 'boolean') { self.showAdvanced(cfg.showAdvanced); } diff --git a/static/pages/tasking.html b/static/pages/tasking.html index 2ba091ed..903723fa 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -1847,27 +1847,44 @@

- - + +
+ + +
+
How often to fully refresh data.
- - + +
+ + +
+
How many days in the past to fetch.
- -
- - + +
+ + +
+
How many days into the future to fetch.
@@ -3154,6 +3171,46 @@
+ + + + +