diff --git a/src/pages/stats.js b/src/pages/stats.js index ea82f711..ee428cbd 100644 --- a/src/pages/stats.js +++ b/src/pages/stats.js @@ -201,7 +201,6 @@ function RunForestRun(mp) { unit = []; console.log("passed array of units"); var hqsGiven = params.hq.split(","); - console.log(hqsGiven); hqsGiven.forEach(function(d) { BeaconClient.unit.getName(d, apiHost, params.userId, token, function(result, error) { if (typeof error == 'undefined') { @@ -812,9 +811,6 @@ function prepareCharts(jobs, start, end, firstRun) { let zoneChartFilters = zoneChart.filters(); let sectorChartFilters = sectorChart.filters(); - console.log(completionBellChart) - - //remove the filters statusChart.filter(null) agencyChart.filter(null) 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/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/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) { diff --git a/src/pages/tasking/components/job_popup.js b/src/pages/tasking/components/job_popup.js index 71c30060..e28f78b3 100644 --- a/src/pages/tasking/components/job_popup.js +++ b/src/pages/tasking/components/job_popup.js @@ -39,8 +39,9 @@ export function buildJobPopupKO() {
    - - -
  • - - -
  • @@ -2391,19 +2387,38 @@

    +
    + +
    + + +
    +
    + + +
    +
    +
    -
    - +
    + + +
    +

    + +

    + +
    +
    + +

    + 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. +

    + + +
    + + +
    + +
    + + +
    + + +
    + When enabled, suggestions use Lighthouse road routing for + actual driving times instead of crow-flies distance. + The dropdown loads instantly with haversine distances, + then updates when travel times arrive. +
    +
    + + +
    +
    + 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 highest-scored team. +
    • +
    • + Road routing: + When enabled, travel times from Lighthouse Location Service + replace crow-flies distance for more accurate ranking. +
    • +
    +
    +
    + +
    + + +
    +
    + + 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. +
    +
    +
    + +
    +
    + +
    +
    +
    + @@ -3133,7 +3314,7 @@

    Action Items
    Subject
    -
    Text
    - 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 81d78820..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 */ @@ -1996,10 +2023,59 @@ overflow: hidden; z-index: 3000 !important; } +.job-taskteam-dropdown .dropdown-item { + white-space: normal; + word-break: break-word; +} + .job-taskteam-dropdown.show { 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;