From 6480140e79e0ec4f308d3bed4cf3232f10813c5d Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Mon, 24 Aug 2026 15:18:54 +1000 Subject: [PATCH 1/2] Add Recent Activations widget to Team Create, fix myAvailability wording (#421) Lets a team creator load the last 5 myAvailability activations for the assigned-to unit on the Beacon Team Create page, expand one to see who's responded, and click a name straight into the team - no manual Refresh by default, only fetches when a human asks, and refreshes automatically on Assigned To changes after that first load. Also standardises on "responses" over "responders" in the myAvailability UI text in jobs/view.js and teams/create.js. --- .gitignore | 1 + src/contentscripts/teams/create.js | 1 + src/injectscripts/jobs/view.js | 4 +- src/injectscripts/teams/create.js | 272 ++++++++++++++++++++++++++++- src/styles/teams.create.css | 130 ++++++++++++++ 5 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 src/styles/teams.create.css diff --git a/.gitignore b/.gitignore index de4551ac..4b45d2be 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ Lighthouse.zip *.DS_Store package-lock.json lambda/myavailability-incident/ +lambda/myavailability-unit-activations/ diff --git a/src/contentscripts/teams/create.js b/src/contentscripts/teams/create.js index 41797d68..e202650d 100644 --- a/src/contentscripts/teams/create.js +++ b/src/contentscripts/teams/create.js @@ -1,4 +1,5 @@ var inject = require('../../lib/inject.js'); +require('../../styles/teams.create.css'); //inject our JS resource inject('teams/create.js'); diff --git a/src/injectscripts/jobs/view.js b/src/injectscripts/jobs/view.js index 6411512d..fb057bfa 100644 --- a/src/injectscripts/jobs/view.js +++ b/src/injectscripts/jobs/view.js @@ -279,7 +279,7 @@ function lighthouseResponseGems() { ? '
  • +' + (data.Names.length - MAX_NAMES_SHOWN) + ' more
  • ' : '') + '' - : 'No responders'; + : 'No responses'; var closedNote = isClosed ? '
    Activation closed
    ' : ''; @@ -356,7 +356,7 @@ function buildCreateTeamPickerModal() { function renderCreateTeamPickerList(listSelector, people, checked) { var $list = $(listSelector); if (!people || !people.length) { - $list.html('
  • No responders
  • '); + $list.html('
  • No responses
  • '); return; } $list.html(_.map(people, function (person) { diff --git a/src/injectscripts/teams/create.js b/src/injectscripts/teams/create.js index 373e01d7..46afd9f0 100644 --- a/src/injectscripts/teams/create.js +++ b/src/injectscripts/teams/create.js @@ -1,4 +1,4 @@ -/* global teamViewModel, $, _, ko */ +/* global teamViewModel, $, _, ko, user, urls, lighthouseUrl */ //edit and create page. // background js fiddles with create page to expose same viewmodel as OutageDisplayType @@ -107,6 +107,276 @@ $(document).ready(function () { } }); +// ---- Recent Activations fieldset ---- +// Lets a team creator expand a recent activation for the assigned-to unit +// and click a name from its responses straight into the team, instead of +// only having the typeahead search. Built with plain jQuery (no data-bind) +// since this fieldset is inserted at runtime - it was never part of the +// page's own Knockout template, and knockout-secure-binding's restricted +// parser makes wiring ad-hoc bindings onto injected DOM more trouble than +// it's worth here. + +// The myavailability Lambdas only have data for production Beacon +// (apibeacon.ses.nsw.gov.au) - trainbeacon/devbeacon unit names don't exist +// in that database. Gate on urls.Base so we never send a real request +// outside prod. Same check as jobs/view.js's isProductionBeaconApi(). +function isProductionBeaconApi() { + return typeof urls !== 'undefined' && typeof urls.Base === 'string' && + urls.Base.indexOf('apibeacon.ses.nsw.gov.au') !== -1; +} + +// Only these three categories make sense to add to a team - mirrors the +// judgement call jobs/view.js already makes for its "Create Team" button +// (Unavailable/Unset responses aren't offered as a one-click add). +var ACTIVATION_PEOPLE_CATEGORIES = [ + { key: 'ActivationAccepted', label: 'Accepted', cssClass: 'lighthouse-activation-people-activationaccepted' }, + { key: 'Available', label: 'Available', cssClass: 'lighthouse-activation-people-available' }, + { key: 'Conditional', label: 'Conditional', cssClass: 'lighthouse-activation-people-conditional' }, +]; + +function fetchUnitActivations(unitName, cb) { + $.ajax({ + url: 'https://lambda.lighthouse-extension.com/myavailability/unit-activations', + method: 'GET', + data: { unitName: unitName }, + beforeSend: function (xhr) { xhr.setRequestHeader('Authorization', 'Bearer ' + user.accessToken); }, + dataType: 'json', + success: function (data) { cb(null, data); }, + error: function (xhr, status, error) { cb({ status: xhr.status, error: error }); }, + }); +} + +function fetchActivationResponses(activationId, cb) { + $.ajax({ + url: 'https://lambda.lighthouse-extension.com/myavailability/incident', + method: 'GET', + data: { activationId: activationId }, + beforeSend: function (xhr) { xhr.setRequestHeader('Authorization', 'Bearer ' + user.accessToken); }, + dataType: 'json', + success: function (data) { cb(null, data); }, + error: function (xhr, status, error) { cb({ status: xhr.status, error: error }); }, + }); +} + +function formatActivationTime(iso) { + if (!iso) return ''; + var d = new Date(iso); + if (isNaN(d.getTime())) return ''; + return d.toLocaleString('en-AU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); +} + +// entityAssignedTo().Name is just the town/suburb (e.g. "Parramatta") - +// mams's Unit.name includes the " Unit" suffix (e.g. "Parramatta Unit"), so +// the lookup has to add it back. Guarded in case an entity name ever +// already ends with "Unit" (e.g. some other suffix pattern), so this never +// sends a double "Unit Unit". +function toMamsUnitName(entityName) { + return /\bunit$/i.test(entityName.trim()) ? entityName.trim() : entityName.trim() + ' Unit'; +} + +function isMemberAlreadyInTeam(memberId) { + return _.some(teamViewModel.members.peek(), function (m) { + return m.Person && String(m.Person.RegistrationNumber) === String(memberId); + }); +} + +// Delegated (person rows are added long after this fires, inside an async +// panel render) - clicking adds via the same addTeamMemberById() the +// ?lhmembers= prefill path already uses, then checks teamViewModel.members +// afterwards to confirm it actually landed (addTeamMemberById swallows its +// own errors - logs and returns - so this is the only way to tell success +// from a silent lookup failure without changing that function). +$(document).off('click.lighthouseActivationPerson').on('click.lighthouseActivationPerson', '.lighthouse-activation-person', async function () { + var $person = $(this); + if ($person.hasClass('lighthouse-activation-person-added') || $person.hasClass('lighthouse-activation-person-adding')) return; + + var memberId = $person.attr('data-member-id'); + $person.addClass('lighthouse-activation-person-adding'); + + await addTeamMemberById(memberId); + + $person.removeClass('lighthouse-activation-person-adding'); + if (isMemberAlreadyInTeam(memberId)) { + $person.addClass('lighthouse-activation-person-added'); + } else { + $person.addClass('lighthouse-activation-person-error').attr('title', 'Could not add this person - see console for details'); + } +}); + +function renderActivationPeople($panel, activationId) { + $panel.html('
    Loading responses…
    '); + + fetchActivationResponses(activationId, function (err, summary) { + if (err || !summary) { + $panel.html('
    Could not load responses.
    '); + return; + } + + var categories = summary.categories || {}; + var $groups = $('
    '); + + $.each(ACTIVATION_PEOPLE_CATEGORIES, function (i, cat) { + var data = categories[cat.key]; + if (!data || !data.Names || !data.Names.length) return; + + var $group = $('
    '); + $group.append( + $('
    ') + .text(cat.label + ' (' + data.Count + ')') + ); + + var $names = $(''); + $.each(data.Names, function (j, person) { + var alreadyIn = isMemberAlreadyInTeam(person.MemberId); + var $li = $('
  • ') + .attr('data-member-id', person.MemberId) + .toggleClass('lighthouse-activation-person-added', alreadyIn) + .text(person.Name); + $names.append($li); + }); + $group.append($names); + $groups.append($group); + }); + + if (!$groups.children().length) { + $groups = $('
    No accepted, available or conditional responses yet.
    '); + } + + $panel.empty().append($groups); + }); +} + +function renderActivations($list, activations) { + $list.empty(); + + if (!activations || !activations.length) { + $list.append('
    No recent activations for this unit.
    '); + return; + } + + $.each(activations, function (i, activation) { + var $row = $('
    '); + var $header = $('
    '); + + $header.append(''); + $header.append($('').text(activation.title)); + $header.append($('').text(formatActivationTime(activation.start))); + if (activation.closedAt) { + $header.append(''); + } + + var $panel = $('
    ').hide(); + var loaded = false; + + $header.on('click', function () { + var willOpen = !$panel.is(':visible'); + $header.find('.lighthouse-recent-activation-toggle-icon') + .toggleClass('fa-caret-right', !willOpen) + .toggleClass('fa-caret-down', willOpen); + $panel.slideToggle(150); + if (willOpen && !loaded) { + loaded = true; + renderActivationPeople($panel, activation.activationId); + } + }); + + $row.append($header).append($panel); + $list.append($row); + }); +} + +// jobs/view.js's picker modal builds its logo before lighthouseUrl +// (set async via postMessage from the content script) is guaranteed to +// exist yet, then fills the src in once it's ready - same reasoning here. +function whenLighthouseIsReady(cb) { + if (typeof lighthouseUrl !== 'undefined') { + cb(); + } else { + var waiting = setInterval(function () { + if (typeof lighthouseUrl !== 'undefined') { + clearInterval(waiting); + cb(); + } + }, 200); + } +} + +function initRecentActivationsFieldset() { + if (!isProductionBeaconApi()) return; // no mams data outside prod + + var $existingFieldset = $('#teamMemberSearch').closest('fieldset'); + if (!$existingFieldset.length) return; + + var $fieldset = $( + '
    ' + + '' + + '' + + 'Recent myAvailability Activation Requests' + + '' + + '' + + '
    ' + + '
    ' + + '
    Click Refresh to load recent activations for the assigned unit.
    ' + + '
    ' + + '
    ' + + '
    ' + ); + $existingFieldset.before($fieldset); + + whenLighthouseIsReady(function () { + $fieldset.find('#lighthouseRecentActivationsLogo').attr('src', lighthouseUrl + 'icons/lh-black.png'); + }); + + var $list = $fieldset.find('.lighthouse-recent-activations-list'); + + function loadForEntity(entity) { + // Not every entityAssignedTo is an SES Unit (e.g. a Region or State HQ + // isn't a row in mams's Unit table) - a 404 here just means "nothing to + // show for this assignment", not a real failure. + if (!entity || !entity.Name) { + $list.html('
    Select an Assigned To HQ to see its recent activations.
    '); + return; + } + + $list.html('
    Loading recent activations…
    '); + + fetchUnitActivations(toMamsUnitName(entity.Name), function (err, data) { + if (err) { + if (err.status === 404) { + $list.html('
    No recent activations for ' + _.escape(entity.Name) + '.
    '); + } else if (err.status === 409) { + $list.html('
    More than one unit is named ' + _.escape(entity.Name) + ' - can\'t show activations for it.
    '); + } else { + $list.html('
    Could not load recent activations.
    '); + } + return; + } + renderActivations($list, data.activations); + }); + } + + // Deliberately not auto-loaded on page open - only fetches once a human + // has clicked Refresh at least once. After that first manual load, + // though, changing the Assigned To HQ re-fetches automatically so the + // list doesn't keep showing activations for a unit that's no longer + // selected. + var hasLoadedOnce = false; + + $fieldset.find('.lighthouse-recent-activations-refresh').on('click', function () { + hasLoadedOnce = true; + loadForEntity(teamViewModel.entityAssignedTo.peek()); + }); + + teamViewModel.entityAssignedTo.subscribe(function (entity) { + if (!hasLoadedOnce) return; + loadForEntity(entity); + }); +} + +initRecentActivationsFieldset(); + //when team members change teamViewModel.members.subscribe(function() { // auto set the first team member as TL diff --git a/src/styles/teams.create.css b/src/styles/teams.create.css new file mode 100644 index 00000000..5a48e57f --- /dev/null +++ b/src/styles/teams.create.css @@ -0,0 +1,130 @@ +#lighthouseRecentActivationsFieldset .lighthouse-recent-activations-refresh { + float: right; + margin-top: -3px; +} + +#lighthouseRecentActivationsFieldset .lighthouse-recent-activations-loading, +#lighthouseRecentActivationsFieldset .lighthouse-recent-activations-empty, +#lighthouseRecentActivationsFieldset .lighthouse-recent-activations-error { + color: #777777; + font-style: italic; + padding: 4px 0; +} + +#lighthouseRecentActivationsFieldset .lighthouse-recent-activations-error { + color: #d9534f; +} + +.lighthouse-recent-activation { + border-bottom: 1px solid #e5e5e5; +} + +.lighthouse-recent-activation-header { + display: flex; + align-items: center; + padding: 6px 0; + cursor: pointer; +} + +.lighthouse-recent-activation-toggle-icon { + flex: none; + width: 16px; + color: #777777; +} + +.lighthouse-recent-activation-title { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 10px; +} + +.lighthouse-recent-activation-time { + flex: none; + color: #777777; + font-size: 12px; + margin-right: 8px; +} + +.lighthouse-recent-activation-closed-icon { + flex: none; + color: #555555; + font-size: 12px; +} + +.lighthouse-recent-activation-panel { + padding: 4px 0 10px 24px; +} + +.lighthouse-activation-people-loading, +.lighthouse-activation-people-error, +.lighthouse-activation-people-empty { + color: #777777; + font-style: italic; +} + +.lighthouse-activation-people-error { + color: #d9534f; +} + +.lighthouse-activation-people-group { + margin-bottom: 8px; +} + +.lighthouse-activation-people-group-label { + display: inline-block; + color: #fff; + font-weight: bold; + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + margin-bottom: 3px; +} + +.lighthouse-activation-people-activationaccepted { + background: #337ab7; +} + +.lighthouse-activation-people-available { + background: #5cb85c; +} + +.lighthouse-activation-people-conditional { + background: #f0ad4e; +} + +.lighthouse-activation-people-names { + list-style: none; + margin: 0; + padding: 0; +} + +.lighthouse-activation-person { + display: inline-block; + padding: 2px 8px; + margin: 2px 4px 2px 0; + border-radius: 4px; + background: #eeeeee; + cursor: pointer; +} + +.lighthouse-activation-person:hover { + background: #dddddd; +} + +.lighthouse-activation-person-adding { + opacity: 0.5; + cursor: default; +} + +.lighthouse-activation-person-added { + background: #dff0d8; + color: #3c763d; + cursor: default; +} + +.lighthouse-activation-person-error { + background: #f2dede; + color: #a94442; +} From ff66af9336554f51dd6786d324d15afad2721a19 Mon Sep 17 00:00:00 2001 From: Tim Dykes Date: Tue, 25 Aug 2026 11:26:07 +1000 Subject: [PATCH 2/2] Show Recent Activations widget on train/dev with prod-only placeholder (#422) Previously the fieldset was hidden entirely outside production Beacon since the myAvailability Lambdas have no train/dev data. Now it always renders so the feature is discoverable, but shows a disabled Refresh button and an explanatory message on train/dev instead of fetching. Co-authored-by: Claude Sonnet 5 --- src/injectscripts/teams/create.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/injectscripts/teams/create.js b/src/injectscripts/teams/create.js index 46afd9f0..571f4ece 100644 --- a/src/injectscripts/teams/create.js +++ b/src/injectscripts/teams/create.js @@ -302,7 +302,7 @@ function whenLighthouseIsReady(cb) { } function initRecentActivationsFieldset() { - if (!isProductionBeaconApi()) return; // no mams data outside prod + var isProd = isProductionBeaconApi(); var $existingFieldset = $('#teamMemberSearch').closest('fieldset'); if (!$existingFieldset.length) return; @@ -312,13 +312,16 @@ function initRecentActivationsFieldset() { '' + '' + 'Recent myAvailability Activation Requests' + - '' + '' + '
    ' + '
    ' + - '
    Click Refresh to load recent activations for the assigned unit.
    ' + + '
    ' + + (isProd ? 'Click Refresh to load recent activations for the assigned unit.' : + 'myAvailability activation requests are only available on production Beacon (this is train/dev).') + + '
    ' + '
    ' + '
    ' + '' @@ -329,6 +332,8 @@ function initRecentActivationsFieldset() { $fieldset.find('#lighthouseRecentActivationsLogo').attr('src', lighthouseUrl + 'icons/lh-black.png'); }); + if (!isProd) return; // placeholder only - no mams data outside prod + var $list = $fieldset.find('.lighthouse-recent-activations-list'); function loadForEntity(entity) {