From 6ef33c241f5676eab2df8585e08cde723e93234b Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Mon, 16 Mar 2026 21:45:10 +1100 Subject: [PATCH 1/9] fixed wonky alerts re-open (#340) alerts would re expand when their under obs changed --- src/pages/tasking/components/alerts.js | 45 ++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/pages/tasking/components/alerts.js b/src/pages/tasking/components/alerts.js index 62eed616..b7f3bac2 100644 --- a/src/pages/tasking/components/alerts.js +++ b/src/pages/tasking/components/alerts.js @@ -40,18 +40,23 @@ function createLeafletControl(L) { function renderRules(container, rules, opts = {}) { const allowCollapse = opts.allowCollapse !== false; container.style.display = rules.length ? '' : 'none'; - container.innerHTML = ''; + + // Build a set of active rule IDs so we can remove stale DOM elements + const activeIds = new Set(rules.map(r => r.id)); + + // Remove DOM elements for rules that are no longer active + container.querySelectorAll('[data-rule-id]').forEach(el => { + if (!activeIds.has(el.getAttribute('data-rule-id'))) { + el.remove(); + } + }); for (const rule of rules) { - // --- restore / update state for this rule --- + // --- restore / initialise state for this rule --- let state = ruleState.get(rule.id); if (!state) { state = { collapsed: false, open: false, lastCount: rule.count }; } else { - // if the count changed while collapsed, auto-expand - if (state.collapsed && rule.count !== state.lastCount) { - state.collapsed = false; - } state.lastCount = rule.count; } if (!allowCollapse) { @@ -60,7 +65,33 @@ function renderRules(container, rules, opts = {}) { } ruleState.set(rule.id, state); - const div = document.createElement('div'); + // --- check if a DOM element already exists for this rule --- + let div = container.querySelector(`[data-rule-id="${rule.id}"]`); + + if (div) { + // --- IN-PLACE UPDATE: only patch count + items, preserve all user state --- + const countEl = div.querySelector('.alerts__count'); + if (countEl) countEl.textContent = rule.count; + + const ul = div.querySelector('.alerts__list'); + if (ul) { + ul.innerHTML = rule.items.map(it => `
  • ${it.label}
  • `).join(''); + // Re-attach item click handlers + if (typeof rule.onClick === 'function') { + ul.querySelectorAll('li[data-id]').forEach(li => { + li.style.cursor = 'pointer'; + li.addEventListener('mouseenter', () => li.style.textDecoration = 'underline'); + li.addEventListener('mouseleave', () => li.style.textDecoration = ''); + li.addEventListener('click', () => rule.onClick(li.getAttribute('data-id'))); + }); + } + } + continue; // skip full creation — DOM element (incl classes) stays as-is + } + + // --- FIRST-TIME CREATION for this rule --- + div = document.createElement('div'); + div.setAttribute('data-rule-id', rule.id); var width = '280px' div.className = `leaflet-control alerts alerts--${rule.level}`; if (state.collapsed) { From 7c95d7b735a1b7533a7ad8c2959a5e4628831d8d Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Mon, 16 Mar 2026 21:47:46 +1100 Subject: [PATCH 2/9] Lad robo task (#341) * first try at weights tasking suggest teams on some weights * first try at UI fixes --- src/pages/tasking/bindings/fastTooltip.js | 79 ++++++++ src/pages/tasking/components/job_popup.js | 9 +- src/pages/tasking/main.js | 2 + src/pages/tasking/models/Job.js | 3 +- .../tasking/utils/TeamSuggestionEngine.js | 174 ++++++++++++++++++ src/pages/tasking/viewmodels/Config.js | 62 +++++++ src/pages/tasking/viewmodels/InstantTask.js | 48 ++++- static/pages/tasking.html | 152 ++++++++++++++- styles/pages/tasking.css | 44 +++++ 9 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 src/pages/tasking/bindings/fastTooltip.js create mode 100644 src/pages/tasking/utils/TeamSuggestionEngine.js diff --git a/src/pages/tasking/bindings/fastTooltip.js b/src/pages/tasking/bindings/fastTooltip.js new file mode 100644 index 00000000..5acbbfed --- /dev/null +++ b/src/pages/tasking/bindings/fastTooltip.js @@ -0,0 +1,79 @@ +/** + * Knockout binding: fastTooltip + * + * Usage: data-bind="fastTooltip: someTextValue" + * + * Shows a fixed-position tooltip instantly on hover (0.1s fade). + * Works inside overflow:hidden / scroll containers. + */ +import ko from 'knockout'; + + +let bubble = null; + +function getBubble() { + if (!bubble) { + bubble = document.createElement('div'); + bubble.className = 'fast-tooltip-bubble'; + document.body.appendChild(bubble); + } + return bubble; +} + +function showTip(el, text) { + if (!text) return; + const b = getBubble(); + b.textContent = text; + b.classList.remove('show'); + + const rect = el.getBoundingClientRect(); + // Position off-screen to measure without flicker + b.style.left = '-9999px'; + b.style.top = '-9999px'; + b.style.display = 'block'; + + // measure + void b.offsetWidth; + const bRect = b.getBoundingClientRect(); + + let top = rect.top - bRect.height - 6; + let left = rect.right - bRect.width; + + if (top < 4) top = rect.bottom + 6; + if (left < 4) left = 4; + if (left + bRect.width > window.innerWidth - 4) { + left = window.innerWidth - bRect.width - 4; + } + + b.style.top = top + 'px'; + b.style.left = left + 'px'; + // Force reflow then fade in via class (no inline opacity override) + void b.offsetWidth; + b.classList.add('show'); +} + +function hideTip() { + if (bubble) { + bubble.classList.remove('show'); + bubble.style.display = 'none'; + } +} + +ko.bindingHandlers.fastTooltip = { + init: function (element, valueAccessor) { + element.addEventListener('mouseenter', function () { + const text = ko.unwrap(valueAccessor()); + showTip(element, text); + }); + element.addEventListener('mouseleave', function () { + hideTip(); + }); + + ko.utils.domNodeDisposal.addDisposeCallback(element, function () { + hideTip(); + }); + }, + update: function () { + // text is read live on mouseenter, nothing to do here + } +}; diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index 71c30060..f91f4f16 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -82,9 +82,14 @@ export function buildJobPopupKO() { class="dropdown-item text-start py-1" data-bind="click: taskTeamToJobWithConfirm, event: { mouseenter: mouseInTeamInInstantTaskPopup, mouseleave: mouseOutTeamInInstantTaskPopup }"> -
    - + + + + + +
    diff --git a/src/pages/tasking/main.js b/src/pages/tasking/main.js index 0d579408..3844e678 100644 --- a/src/pages/tasking/main.js +++ b/src/pages/tasking/main.js @@ -48,6 +48,7 @@ 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 { registerTransportCamerasLayer } from "./mapLayers/transport.js"; import { registerUnitBoundaryLayer } from "./mapLayers/geoservices.js"; @@ -965,6 +966,7 @@ function VM() { }, map: self.mapVM, filteredTeams: self.filteredTeams, + config: self.config, isIncidentPinned: (id) => self.isIncidentPinned(id), toggleIncidentPinned: (id) => self.toggleIncidentPinned(id), } 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/utils/TeamSuggestionEngine.js b/src/pages/tasking/utils/TeamSuggestionEngine.js new file mode 100644 index 00000000..64764974 --- /dev/null +++ b/src/pages/tasking/utils/TeamSuggestionEngine.js @@ -0,0 +1,174 @@ +/** + * 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 = (normalisedDistance × distanceWeight) + (normalisedTaskings × taskingWeight) + * Suggest the team with the lowest combined score. + * + * 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, 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) { + idle.sort((a, b) => a.distanceMeters - b.distanceMeters); + return idle.slice(0, count).map((t, rank) => { + const distKm = (t.distanceMeters / 1000).toFixed(1); + const prefix = rank === 0 ? 'Nearest' : `#${rank + 1} nearest`; + return { + index: t._idx, + reason: `${prefix} idle team — ${distKm} km, 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) => a.distanceMeters - b.distanceMeters); + return withDist.slice(0, count).map((t, rank) => { + const distKm = (t.distanceMeters / 1000).toFixed(1); + const prefix = rank === 0 ? 'Nearest' : `#${rank + 1} nearest`; + return { + index: t._idx, + reason: `${prefix} team — ${distKm} km, ${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); + + // min/max for normalisation + const distances = items.map(t => t.distanceMeters); + const taskings = items.map(t => t.taskingCount); + + const dMin = Math.min(...distances); + const dMax = Math.max(...distances); + const tMin = Math.min(...taskings); + const tMax = Math.max(...taskings); + + const dRange = dMax - dMin || 1; // avoid /0 + const tRange = tMax - tMin || 1; + + const scored = items.map(t => { + const normDist = (t.distanceMeters - dMin) / dRange; // 0 = closest + const normTask = (t.taskingCount - tMin) / tRange; // 0 = fewest + const score = normDist * dw + normTask * tw; + return { _idx: t._idx, score, normDist, normTask, raw: t }; + }); + + scored.sort((a, b) => a.score - b.score); + + return scored.slice(0, count).map((s, rank) => { + const parts = []; + if (dwPct > 0) parts.push(`dist ${dwPct}%`); + if (twPct > 0) parts.push(`task ${twPct}%`); + const weightDesc = parts.join(' / '); + const prefix = rank === 0 ? 'Best' : `#${rank + 1}`; + const distKm = (s.raw.distanceMeters / 1000).toFixed(1); + return { + index: s._idx, + reason: `${prefix} score ${s.score.toFixed(2)} (${weightDesc}) — ${distKm} km, ${s.raw.taskingCount} tasking(s) [${tag}]`, + }; + }); +} diff --git a/src/pages/tasking/viewmodels/Config.js b/src/pages/tasking/viewmodels/Config.js index ac7ea483..064ef544 100644 --- a/src/pages/tasking/viewmodels/Config.js +++ b/src/pages/tasking/viewmodels/Config.js @@ -96,6 +96,39 @@ 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); + + // 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()) { @@ -179,6 +212,11 @@ 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, }); // Helpers @@ -478,6 +516,23 @@ 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); + } + self.afterConfigLoad() @@ -618,6 +673,13 @@ 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.darkMode.subscribe((isDark) => { self._applyDarkMode(); diff --git a/src/pages/tasking/viewmodels/InstantTask.js b/src/pages/tasking/viewmodels/InstantTask.js index 58c6d7c7..24707860 100644 --- a/src/pages/tasking/viewmodels/InstantTask.js +++ b/src/pages/tasking/viewmodels/InstantTask.js @@ -1,11 +1,14 @@ var ko = require('knockout'); +import { suggestTeamIndices } from '../utils/TeamSuggestionEngine.js'; + export class InstantTaskViewModel { - constructor({ job, map, filteredTeams }) { + constructor({ job, map, filteredTeams, config }) { this.job = job; // expects KO observables on the job (latitude/longitude/name/etc.) this.map = map; this.filteredTeams = filteredTeams; // from main VM + this.config = config; // ConfigVM (may be null during early init) } @@ -34,7 +37,7 @@ export class InstantTaskViewModel { const term = (this.popupTeamFilter() || "").toLowerCase().trim(); const list = this.filteredTeams() || []; const job = this.job; - return list + const enriched = list .filter(tm => !term || (tm.callsign() || "").toLowerCase().includes(term) ) @@ -45,8 +48,9 @@ export class InstantTaskViewModel { ) .map(tm => { const { distance, backBearing } = bestDistanceAndBearing(tm, job); + const taskingCount = tm.filteredTaskings().length; const summaryLine = [ - tm.filteredTaskings().length + ' tasking(s)', + taskingCount + ' tasking(s)', distance != null ? fmtDist(distance) : null, backBearing != null ? fmtBearing(backBearing) : null, distance == null && backBearing == null ? "Location unknown" : null, @@ -54,9 +58,12 @@ export class InstantTaskViewModel { return { team: tm, taskings: tm.filteredTaskings, + taskingCount, currentTaskingSummary: tm.currentTaskingSummary, summaryLine, distanceMeters: distance, + isSuggested: false, // will be set below + suggestionReason: '', // will be set below mouseInTeamInInstantTaskPopup: () => { this.drawCrowsFliesToAssetPassedTeam(tm); }, @@ -80,6 +87,41 @@ export class InstantTaskViewModel { const db = b.distanceMeters ?? Number.POSITIVE_INFINITY; return da - db; }); + + // ── Suggestion engine ── + const cfg = this.config; + 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; }); } diff --git a/static/pages/tasking.html b/static/pages/tasking.html index bf194985..ded3c3ab 100644 --- a/static/pages/tasking.html +++ b/static/pages/tasking.html @@ -1245,9 +1245,14 @@ class="dropdown-item text-start py-1" data-bind="click: taskTeamToJobWithConfirm, event: { mouseenter: mouseInTeamInInstantTaskPopup, mouseleave: mouseOutTeamInInstantTaskPopup }"> -
    - + + + + + +
    @@ -2610,6 +2615,149 @@

    + + +
    +

    + +

    + +
    +
    + +

    + When enabled, the instant-task dropdown will suggest a team using a + rule-based engine. The suggested team appears at the top of the list with a + icon. +

    + + +
    + + +
    + +
    + + +
    +
    + Rules +
    +
    +
      +
    • + Rescue priority: + Prefer the nearest team with zero active taskings. + If all teams already have taskings, fall back to weighted scoring below. +
    • +
    • + All other priorities: + Score every team using a weighted combination of + distance and tasking count, then suggest the lowest-scored team. +
    • +
    +
    +
    + +
    + + +
    +
    + + Rescue Weights + + + +
    + Low + + High +
    + + +
    + Low + + High +
    + +
    + For Rescue jobs, idle teams (zero taskings) are always + preferred first. These weights apply when all teams have + at least one tasking. +
    +
    +
    + + +
    +
    + + Standard Weights + + + +
    + Low + + High +
    + + +
    + Low + + High +
    + +
    + For non-rescue jobs (Immediate, Priority, General). + Balances proximity with how busy each team already is. +
    +
    +
    + +
    +
    + +
    +
    +
    + diff --git a/styles/pages/tasking.css b/styles/pages/tasking.css index 81d78820..1c00a748 100644 --- a/styles/pages/tasking.css +++ b/styles/pages/tasking.css @@ -2000,6 +2000,50 @@ overflow: hidden; position: fixed !important; } +/* Instant task suggestion highlight */ +.instant-task-suggested { + background-color: rgba(13, 110, 253, 0.08); + border-left: 3px solid #0d6efd; + padding-left: 9px !important; +} +.instant-task-suggested:hover { + background-color: rgba(13, 110, 253, 0.15); +} +.dark-mode .instant-task-suggested { + background-color: rgba(100, 160, 255, 0.12); + border-left-color: #6ea8fe; +} +.dark-mode .instant-task-suggested:hover { + background-color: rgba(100, 160, 255, 0.22); +} + +/* Fast tooltip (no native delay) — positioned fixed via JS to escape overflow clipping */ +.fast-tooltip { + display: inline-flex; + cursor: default; +} +.fast-tooltip-bubble { + position: fixed; + padding: 5px 8px; + background: #333; + color: #fff; + font-size: 11px; + font-weight: 500; + line-height: 1.35; + white-space: nowrap; + border-radius: 3px; + pointer-events: none; + z-index: 9999; + opacity: 0; + transition: opacity 0.1s ease; +} +.fast-tooltip-bubble.show { + opacity: 1; +} +.dark-mode .fast-tooltip-bubble { + background: #555; +} + /* Sector dropdown button */ .sector-dropdown-btn { background: none; From a4e40e6e6052fb578b2f72d25f529be3d12e0bb6 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 17 Mar 2026 11:43:58 +1100 Subject: [PATCH 3/9] swapped the logic to 100 base --- src/pages/tasking/components/job_popup.js | 1 + src/pages/tasking/utils/TeamSuggestionEngine.js | 15 ++++++++------- static/pages/tasking.html | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index f91f4f16..0c4d5b9a 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -41,6 +41,7 @@ export function buildJobPopupKO() {
    From 40763a20cf7434a44cd5aedd4cca8229c3171c59 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 17 Mar 2026 21:28:28 +1100 Subject: [PATCH 5/9] Lad als routing (#342) * wording fixups * use amazon for pathing for suggested task --- src/pages/tasking/bindings/bsDropdownOpen.js | 33 +++ src/pages/tasking/components/job_popup.js | 8 +- src/pages/tasking/main.js | 1 + .../tasking/utils/TeamSuggestionEngine.js | 92 ++++-- src/pages/tasking/utils/batchRoute.js | 133 +++++++++ src/pages/tasking/viewmodels/Config.js | 13 + src/pages/tasking/viewmodels/InstantTask.js | 266 ++++++++++++++++-- src/pages/tasking/viewmodels/Map.js | 16 ++ static/pages/tasking.html | 34 ++- styles/pages/tasking.css | 5 + 10 files changed, 558 insertions(+), 43 deletions(-) create mode 100644 src/pages/tasking/bindings/bsDropdownOpen.js create mode 100644 src/pages/tasking/utils/batchRoute.js diff --git a/src/pages/tasking/bindings/bsDropdownOpen.js b/src/pages/tasking/bindings/bsDropdownOpen.js new file mode 100644 index 00000000..a6dc6589 --- /dev/null +++ b/src/pages/tasking/bindings/bsDropdownOpen.js @@ -0,0 +1,33 @@ +/** + * bsDropdownOpen – Knockout custom binding + * + * Bridges Bootstrap 5 dropdown show/hide events to a KO observable. + * Apply to the parent element that contains the toggle button and the + * `.dropdown-menu`. The observable is set to `true` on `shown.bs.dropdown` + * and `false` on `hidden.bs.dropdown`. + * + * Usage: + *
    + * + * @module bindings/bsDropdownOpen + */ + +var ko = require('knockout'); + +ko.bindingHandlers.bsDropdownOpen = { + init(element, valueAccessor) { + const obs = valueAccessor(); + + const onShown = () => obs(true); + const onHidden = () => obs(false); + + element.addEventListener('shown.bs.dropdown', onShown); + element.addEventListener('hidden.bs.dropdown', onHidden); + + // Clean up listeners when the element is removed from the DOM + ko.utils.domNodeDisposal.addDisposeCallback(element, () => { + element.removeEventListener('shown.bs.dropdown', onShown); + element.removeEventListener('hidden.bs.dropdown', onHidden); + }); + }, +}; diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index 0c4d5b9a..e28f78b3 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -39,7 +39,7 @@ export function buildJobPopupKO() {
    - diff --git a/styles/pages/darkmode.css b/styles/pages/darkmode.css index 12846812..979ae719 100644 --- a/styles/pages/darkmode.css +++ b/styles/pages/darkmode.css @@ -765,6 +765,16 @@ body.dark-mode .team-asset-row { color: #e0e0e0; } +body.dark-mode .team-asset-row button.asset-flyto-btn { + background: transparent; + border-color: #555; + color: #e0e0e0; +} + +body.dark-mode .team-asset-row button.asset-flyto-btn:hover { + background-color: rgba(255, 255, 255, 0.08); +} + /* Job popup taskings table */ body.dark-mode .job-popup, body.dark-mode .job-popup #JobDetails, diff --git a/styles/pages/tasking.css b/styles/pages/tasking.css index ba3907a9..99358112 100644 --- a/styles/pages/tasking.css +++ b/styles/pages/tasking.css @@ -990,6 +990,33 @@ tr.job:hover { border-bottom: none; } +/* Matched-asset flyTo button */ +.team-asset-row button.asset-flyto-btn { + background: transparent; + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px 6px; + text-align: left; + cursor: pointer; + color: inherit; + transition: background-color 0.15s; +} +.team-asset-row button.asset-flyto-btn:hover { + background-color: rgba(0, 0, 0, 0.06); +} + +/* Default-asset star toggle */ +.default-asset-btn { + font-size: 0.85rem; + line-height: 1; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s; +} +.default-asset-btn:hover { + opacity: 1; +} + /* Base Leaflet divIcon wrapper for your SVG markers */ .leaflet-marker-icon.job-svg-marker { display: block; /* kill inline baseline spacing */