diff --git a/AGENTS.md b/AGENTS.md index 38a4d5ea4..eac2f1e17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ Tracks are lazy-loaded and cached, socket status updates prepend new positions o **UI** - **Patrols Feed** (`/patrols`): the patrol list, ordered start_overdue → ready_to_start → paused → active → scheduled → done → cancelled, with inline actions per row (start, resume, restore). Filters: text search, date range, patrol type, tracked-by, and status. - **New Patrol** (`/patrols/new`): the leg form. Creating a patrol means creating its first leg, so this is the same form the leg routes use, with the patrol's title and type set here. -- **Patrol Overview** (`/patrols/:patrolId`): header with the patrol's editable title, state, track/location/bounds actions and a kebab menu (copy link, print, download track); two tabs — **Overview** (the leg table, a new leg link under it, and the activity timeline) and **History** (audit trail from `updates`); footer actions to add notes, attachments and events, update the status (pause, cancel, end), or save. Mobile-provenance patrols hide the new leg link, and replace the status dropdown with a single end-patrol action. +- **Patrol Overview** (`/patrols/:patrolId`): header with the patrol's editable title, state, track/location/bounds actions and a kebab menu (copy link, print, download track); two tabs — **Overview** (the leg table, a new leg link under it, and the activity timeline headed by the patrol's stats) and **History** (audit trail from `updates`); footer actions to add notes, attachments and events, update the status (pause, cancel, end), or save. Mobile-provenance patrols hide the new leg link, and replace the status dropdown with a single end-patrol action. - **New Leg** (`/patrols/:patrolId/legs/new`): the leg form, adding a leg to an existing patrol. Saving it ends the current leg and starts this one. - **Leg Overview** (`/patrols/:patrolId/legs/:legId`): header with the leg's title, status and action buttons; the leg's saved plan (times, locations, objective, team and tracking, and the patrol type's fields); its activity timeline with duration, distance and event totals; footer actions to add notes, attachments and events, or edit the leg. - **Edit Leg** (`/patrols/:patrolId/legs/:legId/edit`): the leg form, pre-filled with the leg's current values. diff --git a/package.json b/package.json index 061ccff02..abcbe275b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "buildbranch": "generated_by_build", "buildnum": "also_generated_by_build", "dependencies": { - "@auth0/auth0-react": "^2.23.0", + "@auth0/auth0-react": "^2.24.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/helpers": "^0.5.0", @@ -94,14 +94,14 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.3", + "@testing-library/user-event": "^14.6.4", "@vitejs/plugin-react": "^6.0.5", "dotenv": "^17.4.2", "eslint-config-airbnb": "^19.0.4", - "eslint-plugin-jest": "^29.16.0", + "eslint-plugin-jest": "^29.16.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", - "globals": "^17.9.0", + "globals": "^17.11.0", "jest-environment-jsdom": "^30.4.1", "jest-fixed-jsdom": "^0.0.11", "jest-webgl-canvas-mock": "^2.5.3", @@ -124,6 +124,7 @@ "\\.svg": "/jest-config/mocks/svg.js" }, "resetMocks": true, + "restoreMocks": true, "setupFilesAfterEnv": [ "/src/setupTests.js" ], diff --git a/public/locales/en-US/patrols.json b/public/locales/en-US/patrols.json index 61f54cf39..2115dbfc7 100644 --- a/public/locales/en-US/patrols.json +++ b/public/locales/en-US/patrols.json @@ -110,24 +110,29 @@ "historyTabTitle": "History", "overview": { "activity": { - "activeTimeLabel": "Active Time", "activitySectionTitle": "Activity", "collapseAllButtonLabel": "Collapse all", - "distanceLabel": "Distance", - "durationLabel": "Duration", "emptyStateMessage": "Patrol activity will appear here", - "eventsLabel": "Events", "expandAllButtonLabel": "Expand all", "jumpToLocationButtonLabel": "Jump to location", "legTransitionTitle": "Leg {{endedLeg}} Ended, Leg {{startedLeg}} Started", "patrolEndedTitle": "Patrol Ended", "patrolStartedTitle": "Patrol Started", - "pausedTimeLabel": "Paused Time", "sortDirectionButtonLabel": { "down": "Sort activity in ascending order", "up": "Sort activity in descending order" }, "startPatrolButton": "Start Patrol", + "summaryStats": { + "activeTimeLabel": "Active Time", + "distanceLabel": "Distance", + "distanceSubjectButtonLabel": "Distance covered by {{subject}}. Change the tracked subject", + "distanceSubjectMenuLabel": "Tracked subjects", + "durationLabel": "Duration", + "eventsLabel": "Events", + "patrolLeaderIndicator": "Patrol leader", + "pausedTimeLabel": "Paused Time" + }, "timeLabel": "Time" }, "legs": { diff --git a/public/locales/en-US/utils.json b/public/locales/en-US/utils.json index 59cec882a..451c6a856 100644 --- a/public/locales/en-US/utils.json +++ b/public/locales/en-US/utils.json @@ -13,6 +13,7 @@ "displayTitleForPatrol": { "unknown": "Unknown patrol type" }, + "distanceInKilometers": "{{distance}}km", "filterDuplicateUploadFilenames": { "duplicationAlert": "Can not add {{fileName}}: file already exists" }, diff --git a/public/locales/es/patrols.json b/public/locales/es/patrols.json index 180b4a1f4..158c693a8 100644 --- a/public/locales/es/patrols.json +++ b/public/locales/es/patrols.json @@ -110,24 +110,29 @@ "historyTabTitle": "Historial", "overview": { "activity": { - "activeTimeLabel": "Tiempo activo", "activitySectionTitle": "Actividad", "collapseAllButtonLabel": "Colapsar todo", - "distanceLabel": "Distancia", - "durationLabel": "Duración", "emptyStateMessage": "La actividad de la patrulla aparecerá aquí", - "eventsLabel": "Eventos", "expandAllButtonLabel": "Expandir todo", "jumpToLocationButtonLabel": "Ir a la ubicación", "legTransitionTitle": "Tramo {{endedLeg}} finalizado, Tramo {{startedLeg}} iniciado", "patrolEndedTitle": "Patrullaje finalizado", "patrolStartedTitle": "Patrullaje iniciado", - "pausedTimeLabel": "Tiempo en pausa", "sortDirectionButtonLabel": { "down": "Ordenar actividad de forma ascendente", "up": "Ordenar actividad de forma descendente" }, "startPatrolButton": "Iniciar Patrullaje", + "summaryStats": { + "activeTimeLabel": "Tiempo activo", + "distanceLabel": "Distancia", + "distanceSubjectButtonLabel": "Distancia recorrida por {{subject}}. Cambiar el sujeto rastreado", + "distanceSubjectMenuLabel": "Sujetos rastreados", + "durationLabel": "Duración", + "eventsLabel": "Eventos", + "patrolLeaderIndicator": "Líder del patrullaje", + "pausedTimeLabel": "Tiempo en pausa" + }, "timeLabel": "Hora" }, "legs": { diff --git a/public/locales/es/utils.json b/public/locales/es/utils.json index 71dfd69e3..4f4c34e19 100644 --- a/public/locales/es/utils.json +++ b/public/locales/es/utils.json @@ -13,6 +13,7 @@ "displayTitleForPatrol": { "unknown": "Tipo de patrulla desconocido" }, + "distanceInKilometers": "{{distance}}km", "filterDuplicateUploadFilenames": { "duplicationAlert": "No se puede agregar {{fileName}}: el archivo ya existe" }, diff --git a/public/locales/fr/patrols.json b/public/locales/fr/patrols.json index 6b4094577..76b16fff5 100644 --- a/public/locales/fr/patrols.json +++ b/public/locales/fr/patrols.json @@ -110,24 +110,29 @@ "historyTabTitle": "Historique", "overview": { "activity": { - "activeTimeLabel": "Temps actif", "activitySectionTitle": "Activité", "collapseAllButtonLabel": "Tout réduire", - "distanceLabel": "Distance", - "durationLabel": "Durée", "emptyStateMessage": "L'activité de la patrouille apparaîtra ici", - "eventsLabel": "Événements", "expandAllButtonLabel": "Tout développer", "jumpToLocationButtonLabel": "Aller à l'emplacement", "legTransitionTitle": "Étape {{endedLeg}} terminée, Étape {{startedLeg}} commencée", "patrolEndedTitle": "Patrouille terminée", "patrolStartedTitle": "Patrouille commencée", - "pausedTimeLabel": "Temps en pause", "sortDirectionButtonLabel": { "down": "Trier l'activité par ordre croissant", "up": "Trier l'activité par ordre décroissant" }, "startPatrolButton": "Commencer la Patrouille", + "summaryStats": { + "activeTimeLabel": "Temps actif", + "distanceLabel": "Distance", + "distanceSubjectButtonLabel": "Distance parcourue par {{subject}}. Changer le sujet suivi", + "distanceSubjectMenuLabel": "Sujets suivis", + "durationLabel": "Durée", + "eventsLabel": "Événements", + "patrolLeaderIndicator": "Chef de la patrouille", + "pausedTimeLabel": "Temps en pause" + }, "timeLabel": "Heure" }, "legs": { diff --git a/public/locales/fr/utils.json b/public/locales/fr/utils.json index 5404af407..13e5c549a 100644 --- a/public/locales/fr/utils.json +++ b/public/locales/fr/utils.json @@ -13,6 +13,7 @@ "displayTitleForPatrol": { "unknown": "Type de patrouille inconnue" }, + "distanceInKilometers": "{{distance}}km", "filterDuplicateUploadFilenames": { "duplicationAlert": "Impossible d'ajouter {{fileName}}: le fichier existe déjà" }, diff --git a/public/locales/ne-NP/patrols.json b/public/locales/ne-NP/patrols.json index 99717f0be..3d5140ed0 100644 --- a/public/locales/ne-NP/patrols.json +++ b/public/locales/ne-NP/patrols.json @@ -110,24 +110,29 @@ "historyTabTitle": "इतिहास", "overview": { "activity": { - "activeTimeLabel": "सक्रिय समय", "activitySectionTitle": "गतिविधि", "collapseAllButtonLabel": "सबै बन्द गर्नुहोस्", - "distanceLabel": "दूरी", - "durationLabel": "अवधि", "emptyStateMessage": "गस्ती गतिविधि यहाँ देखा पर्नेछ", - "eventsLabel": "घटनाहरू", "expandAllButtonLabel": "सबै विस्तार गर्नुहोस्", "jumpToLocationButtonLabel": "स्थानमा जानुहोस्", "legTransitionTitle": "खण्ड {{endedLeg}} समाप्त भयो, खण्ड {{startedLeg}} सुरु भयो", "patrolEndedTitle": "गस्ती समाप्त भएको", "patrolStartedTitle": "गस्ती सुरु भएको", - "pausedTimeLabel": "रोकिएको समय", "sortDirectionButtonLabel": { "down": "गतिविधि आरोही क्रममा क्रमबद्ध गर्नुहोस्", "up": "गतिविधि अवरोही क्रममा क्रमबद्ध गर्नुहोस्" }, "startPatrolButton": "गस्ती सुरु गर्नुहोस्", + "summaryStats": { + "activeTimeLabel": "सक्रिय समय", + "distanceLabel": "दूरी", + "distanceSubjectButtonLabel": "{{subject}} ले तय गरेको दूरी। ट्र्याक गरिएको विषय परिवर्तन गर्नुहोस्", + "distanceSubjectMenuLabel": "ट्र्याक गरिएका विषयहरू", + "durationLabel": "अवधि", + "eventsLabel": "घटनाहरू", + "patrolLeaderIndicator": "गस्ती नेता", + "pausedTimeLabel": "रोकिएको समय" + }, "timeLabel": "समय" }, "legs": { diff --git a/public/locales/ne-NP/utils.json b/public/locales/ne-NP/utils.json index b05d21dc4..cf8b26884 100644 --- a/public/locales/ne-NP/utils.json +++ b/public/locales/ne-NP/utils.json @@ -13,6 +13,7 @@ "displayTitleForPatrol": { "unknown": "अज्ञात गस्ती" }, + "distanceInKilometers": "{{distance}}km", "filterDuplicateUploadFilenames": { "duplicationAlert": "थप गर्न सकिँदैन । {{fileName}}: नामको फाइल पहिले नै अवस्थित छ" }, diff --git a/public/locales/pt/patrols.json b/public/locales/pt/patrols.json index cee29c153..7d4e34cc4 100644 --- a/public/locales/pt/patrols.json +++ b/public/locales/pt/patrols.json @@ -110,24 +110,29 @@ "historyTabTitle": "Histórico", "overview": { "activity": { - "activeTimeLabel": "Tempo ativo", "activitySectionTitle": "Atividade", "collapseAllButtonLabel": "Minimizar tudo", - "distanceLabel": "Distância", - "durationLabel": "Duração", "emptyStateMessage": "A atividade da patrulha aparecerá aqui", - "eventsLabel": "Eventos", "expandAllButtonLabel": "Expandir tudo", "jumpToLocationButtonLabel": "Ir para a localização", "legTransitionTitle": "Etapa {{endedLeg}} finalizada, Etapa {{startedLeg}} iniciada", "patrolEndedTitle": "Patrulha finalizada", "patrolStartedTitle": "Patrulha iniciada", - "pausedTimeLabel": "Tempo pausado", "sortDirectionButtonLabel": { "down": "Ordenar atividade em ordem crescente", "up": "Ordenar atividade em ordem decrescente" }, "startPatrolButton": "Iniciar patrulha", + "summaryStats": { + "activeTimeLabel": "Tempo ativo", + "distanceLabel": "Distância", + "distanceSubjectButtonLabel": "Distância percorrida por {{subject}}. Alterar o sujeito rastreado", + "distanceSubjectMenuLabel": "Sujeitos rastreados", + "durationLabel": "Duração", + "eventsLabel": "Eventos", + "patrolLeaderIndicator": "Líder da patrulha", + "pausedTimeLabel": "Tempo pausado" + }, "timeLabel": "Hora" }, "legs": { diff --git a/public/locales/pt/utils.json b/public/locales/pt/utils.json index edc4da711..91e216b1b 100644 --- a/public/locales/pt/utils.json +++ b/public/locales/pt/utils.json @@ -13,6 +13,7 @@ "displayTitleForPatrol": { "unknown": "Tipo de patrulha desconhecido" }, + "distanceInKilometers": "{{distance}}km", "filterDuplicateUploadFilenames": { "duplicationAlert": "Can not add {{fileName}}: file already exists" }, diff --git a/public/locales/sw/patrols.json b/public/locales/sw/patrols.json index b0e761d5b..7749884a8 100644 --- a/public/locales/sw/patrols.json +++ b/public/locales/sw/patrols.json @@ -110,24 +110,29 @@ "historyTabTitle": "Historia", "overview": { "activity": { - "activeTimeLabel": "Muda Inatekelezwa", "activitySectionTitle": "Shughuli", "collapseAllButtonLabel": "Ficha yote", - "distanceLabel": "Umbali", - "durationLabel": "Muda", "emptyStateMessage": "Shughuli za doria zitaonekana hapa", - "eventsLabel": "Matukio", "expandAllButtonLabel": "Panua yote", "jumpToLocationButtonLabel": "Enda mahali", "legTransitionTitle": "Kipande {{endedLeg}} kimekamilika, Kipande {{startedLeg}} kimeanza", "patrolEndedTitle": "Doria imekamilika", "patrolStartedTitle": "Doria imeanza", - "pausedTimeLabel": "Muda wa Kusitisha", "sortDirectionButtonLabel": { "down": "Panga shughuli kwa mpangilio wa kupanda", "up": "Panga shughuli kwa mpangilio wa kushuka" }, "startPatrolButton": "Anza Doria", + "summaryStats": { + "activeTimeLabel": "Muda Inatekelezwa", + "distanceLabel": "Umbali", + "distanceSubjectButtonLabel": "Umbali uliofikiwa na {{subject}}. Badilisha mhusika anayefuatiliwa", + "distanceSubjectMenuLabel": "Wahusika wanaofuatiliwa", + "durationLabel": "Muda", + "eventsLabel": "Matukio", + "patrolLeaderIndicator": "Kiongozi wa doria", + "pausedTimeLabel": "Muda wa Kusitisha" + }, "timeLabel": "Wakati" }, "legs": { diff --git a/public/locales/sw/utils.json b/public/locales/sw/utils.json index 5984aa94e..465fee2f3 100644 --- a/public/locales/sw/utils.json +++ b/public/locales/sw/utils.json @@ -13,6 +13,7 @@ "displayTitleForPatrol": { "unknown": "Aina isiyojulikana ya doria" }, + "distanceInKilometers": "{{distance}}km", "filterDuplicateUploadFilenames": { "duplicationAlert": "Haiwezi kuongeza {{fileName}}: faili tayari ipo" }, diff --git a/src/PatrolDetailView/Header/index.js b/src/PatrolDetailView/Header/index.js index 7f80e2cd5..bf49f2b92 100644 --- a/src/PatrolDetailView/Header/index.js +++ b/src/PatrolDetailView/Header/index.js @@ -53,7 +53,7 @@ const Header = ({ onChangeTitle, patrol, setRedirectTo, printableContentRef }) = return {patrolElapsedTime} - + ; } diff --git a/src/PatrolDetailView/Header/index.test.js b/src/PatrolDetailView/Header/index.test.js index 1a586be17..a3c41bed8 100644 --- a/src/PatrolDetailView/Header/index.test.js +++ b/src/PatrolDetailView/Header/index.test.js @@ -71,6 +71,12 @@ describe('Header', () => { expect(buttons[0]).toHaveClass('toggle'); }); + test('shows the distance covered by an active patrol', async () => { + renderHeader({ patrol: activePatrol }); + + expect((await screen.findByTestId('patrol-drawer-header-details'))).toHaveTextContent('0km'); + }); + test('renders correctly case of an overdue patrol', async () => { renderHeader({ patrol: overduePatrol }); diff --git a/src/PatrolListItem/index.js b/src/PatrolListItem/index.js index 9f96aa529..41166a733 100644 --- a/src/PatrolListItem/index.js +++ b/src/PatrolListItem/index.js @@ -78,7 +78,7 @@ const PatrolListItem = ({ if (isPatrolActiveOrDone) { return {patrolElapsedTime} | - + ; } diff --git a/src/PatrolListItem/index.test.js b/src/PatrolListItem/index.test.js index d2aa06b95..fc0053196 100644 --- a/src/PatrolListItem/index.test.js +++ b/src/PatrolListItem/index.test.js @@ -228,6 +228,10 @@ describe('for active patrols', () => { await screen.findByTestId(`patrol-list-item-track-btn-${testPatrol.id}`); }); + test('showing the distance the patrol has covered', async () => { + expect(await screen.findByText('0km')).toBeInTheDocument(); + }); + test('canceling the patrol from the kebab menu', async () => { const kebabMenu = await screen.findByTestId(`patrol-list-item-kebab-menu-${testPatrol.id}`); const kebabButton = kebabMenu.querySelector('button'); diff --git a/src/PatrolTrackLegend/index.js b/src/PatrolTrackLegend/index.js index 3a93095a9..ab491361b 100644 --- a/src/PatrolTrackLegend/index.js +++ b/src/PatrolTrackLegend/index.js @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { displayTitleForPatrol, iconTypeForPatrol, patrolStateAllowsTrackDisplay } from '../utils/patrols'; +import { formatDistanceInKilometers } from '../utils/distance'; import { selectPatrolsWithTracksData } from '../selectors/patrols'; import { updatePatrolTrackState } from '../ducks/patrols'; @@ -15,23 +16,22 @@ import * as styles from './styles.module.scss'; const PatrolTrackLegend = () => { const dispatch = useDispatch(); const { t } = useTranslation('tracks', { keyPrefix: 'patrolTrackLegend' }); + const { t: tUtils } = useTranslation('utils'); const patrolTrackState = useSelector((state) => state.view.patrolTrackState); const patrolsWithTrackData = useSelector(selectPatrolsWithTracksData); // Calculate the total tracks length to show a description in the legend like "3km". - const description = useMemo(() => { - const totalTracksLength = patrolsWithTrackData + const description = useMemo(() => formatDistanceInKilometers( + tUtils, + patrolsWithTrackData .filter((patrolData) => !!patrolStateAllowsTrackDisplay(patrolData.patrol)) - .reduce((accumulator, patrolData) => { - const lineLength = patrolData.startStopGeometries?.lines ? length(patrolData.startStopGeometries.lines) : 0; - const trackLength = patrolData.trackData?.track ? length(patrolData.trackData.track) : 0; + .reduce((accumulator, { trackData }) => { + const trackLength = trackData ? length(trackData.track) : 0; - return accumulator + lineLength + trackLength; - }, 0); - - return `${totalTracksLength ? totalTracksLength.toFixed(2) : 0}km`; - }, [patrolsWithTrackData]); + return accumulator + trackLength; + }, 0) + ), [patrolsWithTrackData, tUtils]); // Build the items array with the description, icon, id and title of each tracked patrol. const items = useMemo(() => patrolsWithTrackData.map((patrolData) => { @@ -39,7 +39,7 @@ const PatrolTrackLegend = () => { const patrolTitle = displayTitleForPatrol(patrolData.patrol, patrolData.leader); return { - description: `${patrolData.trackData ? length(patrolData.trackData.track).toFixed(2): 0.00}km`, + description: formatDistanceInKilometers(tUtils, patrolData.trackData ? length(patrolData.trackData.track) : 0), icon: { id: patrolData.patrol.id, title: t('itemTitle', { patrolTitle }), }; - }), [patrolsWithTrackData, t]); + }), [patrolsWithTrackData, t, tUtils]); return { +const PatrolDistanceCovered = ({ patrolsData = [] }) => { + const { t } = useTranslation('utils'); const patrolTrackLength = useMemo(() => patrolsData .filter(({ patrol }) => !!patrolStateAllowsTrackDisplay(patrol)) - .reduce((accumulator, patrolData) => { - const { trackData, startStopGeometries } = patrolData; - - const lineLength = startStopGeometries?.lines ? length(startStopGeometries.lines) : 0; - const trackLength = trackData?.track ? length(trackData.track) : 0; - - return accumulator + lineLength + trackLength; + .reduce((accumulator, { trackData }) => { + const trackLength = trackData ? length(trackData.track) : 0; + return accumulator + trackLength; }, 0), [patrolsData]); - return `${patrolTrackLength ? patrolTrackLength.toFixed(2) : 0}${suffix}`; + return formatDistanceInKilometers(t, patrolTrackLength); }; export default memo(PatrolDistanceCovered); diff --git a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/index.js b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/index.js new file mode 100644 index 000000000..f9870bfed --- /dev/null +++ b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/index.js @@ -0,0 +1,242 @@ +import React, { memo, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'; +import Overlay from 'react-bootstrap/Overlay'; +import Popover from 'react-bootstrap/Popover'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as ArrowDownSmallIcon } from '../../../../../../common/images/icons/arrow-down-small.svg'; +import { ReactComponent as CheckIcon } from '../../../../../../common/images/icons/check-light.svg'; +import { ReactComponent as StarIcon } from '../../../../../../common/images/icons/star.svg'; + +import { + actualStartTimeForPatrol, + effectiveEndTimeForPatrol, + getElapsedTimeForPatrol, + getPausedTimeForPatrol, +} from '../../../../../../utils/patrols'; +import { calcUrlForImage } from '../../../../../../utils/img'; +import { formatDistanceInKilometers } from '../../../../../../utils/distance'; +import { longTermAbbreviatedDurationHumanizer } from '../../../../../../utils/datetime'; +import { selectPatrolTrackedSubjects } from '../../../../../../selectors/patrols'; +import { TrackerContext } from '../../../../../../utils/analytics'; +import useCurrentTime from '../../../../../../hooks/useCurrentTime'; + +import * as styles from './styles.module.scss'; + +const ELAPSED_TIME_REFRESH_INTERVAL = 30_000; + +const EMPTY_VALUE = '-'; + +const Stat = ({ label, value }) =>
+
{label}
+ +
{value}
+
; + +const SummaryStats = ({ eventCount, patrol }) => { + const { t } = useTranslation('patrols', { keyPrefix: 'patrolOverview.overview.activity.summaryStats' }); + const { t: tDates } = useTranslation('dates'); + const { t: tUtils } = useTranslation('utils'); + + const hasPatrolStarted = useMemo(() => !!actualStartTimeForPatrol(patrol), [patrol]); + const patrolEndTime = useMemo(() => effectiveEndTimeForPatrol(patrol), [patrol]); + + const currentTime = useCurrentTime(hasPatrolStarted && !patrolEndTime ? ELAPSED_TIME_REFRESH_INTERVAL : null); + + const tracker = useContext(TrackerContext); + + const patrolTrackedSubjects = useSelector((state) => selectPatrolTrackedSubjects(state, patrol)); + + const distanceSubjectMenuItemOptionRefs = useRef([]); + const wasDistanceSubjectMenuOpen = useRef(false); + + const distanceSubjectMenuPopoverId = useId(); + + const [distanceSubjectId, setDistanceSubjectId] = useState(null); + const [distanceSubjectMenuAnchorEl, setDistanceSubjectMenuAnchorEl] = useState(); + const [isDistanceSubjectMenuOpen, setIsDistanceSubjectMenuOpen] = useState(false); + + const distanceSubject = patrolTrackedSubjects.find(({ subject }) => subject.id === distanceSubjectId) + ?? patrolTrackedSubjects[0] + ?? null; + + const distance = hasPatrolStarted && distanceSubject?.distance != null + ? formatDistanceInKilometers(tUtils, distanceSubject.distance) + : EMPTY_VALUE; + + const humanizeDuration = useMemo(() => longTermAbbreviatedDurationHumanizer(tDates), [tDates]); + + const { activeTime, duration, pausedTime } = useMemo(() => { + const duration = getElapsedTimeForPatrol(patrol, currentTime); + const pausedTime = getPausedTimeForPatrol(patrol, currentTime); + + return { activeTime: duration - pausedTime, duration, pausedTime }; + }, [currentTime, patrol]); + + const formatElapsedTime = (elapsedTime) => hasPatrolStarted ? humanizeDuration(elapsedTime) : EMPTY_VALUE; + + const onDistanceSubjectMenuClose = () => { + setIsDistanceSubjectMenuOpen(false); + + distanceSubjectMenuAnchorEl?.focus(); + }; + + const onDistanceSubjectMenuHide = () => { + setIsDistanceSubjectMenuOpen(false); + + if (document.activeElement === document.body) { + distanceSubjectMenuAnchorEl?.focus(); + } + }; + + const onDistanceSubjectMenuKeyDown = (event) => { + // React leaves the slots of unmounted options behind, so only the mounted + // ones can take focus. + const menuItemOptions = distanceSubjectMenuItemOptionRefs.current.filter(Boolean); + const currentOptionIndex = menuItemOptions.findIndex((option) => option === document.activeElement); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + + menuItemOptions[(currentOptionIndex + 1) % menuItemOptions.length]?.focus(); + + break; + + case 'ArrowUp': + event.preventDefault(); + + menuItemOptions[(currentOptionIndex - 1 + menuItemOptions.length) % menuItemOptions.length]?.focus(); + + break; + + case 'End': + event.preventDefault(); + + menuItemOptions[menuItemOptions.length - 1]?.focus(); + + break; + + case 'Home': + event.preventDefault(); + + menuItemOptions[0]?.focus(); + + break; + + case 'Tab': + case 'Escape': + event.preventDefault(); + + onDistanceSubjectMenuClose(); + + break; + + default: + } + }; + + const onDistanceSubjectMenuOptionClick = (subjectId) => { + setDistanceSubjectId(subjectId); + + onDistanceSubjectMenuClose(); + + tracker.track('Select the subject of the distance stat in patrol overview'); + }; + + useEffect(() => { + const isDistanceSubjectMenuOpening = isDistanceSubjectMenuOpen && !wasDistanceSubjectMenuOpen.current; + wasDistanceSubjectMenuOpen.current = isDistanceSubjectMenuOpen; + + if (isDistanceSubjectMenuOpening) { + // Opening the menu focuses the checked subject menu item option. + const checkedDistanceSubjectMenuItemOptionIndex = patrolTrackedSubjects.findIndex( + ({ subject }) => subject.id === distanceSubject?.subject.id + ); + distanceSubjectMenuItemOptionRefs.current[checkedDistanceSubjectMenuItemOptionIndex]?.focus(); + } + }, [isDistanceSubjectMenuOpen, patrolTrackedSubjects, distanceSubject]); + + const distanceLabel = distanceSubject + ? <> + + + + + + + + + : t('distanceLabel'); + + return
+ + + + + + + + + +
; +}; + +export default memo(SummaryStats); diff --git a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/index.test.js b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/index.test.js new file mode 100644 index 000000000..d96bd610e --- /dev/null +++ b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/index.test.js @@ -0,0 +1,490 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; + +import { act, render, screen, waitFor } from '../../../../../../test-utils'; +import { mockStore } from '../../../../../../__test-helpers/MockStore'; +import { TrackerContext } from '../../../../../../utils/analytics'; + +import SummaryStats from './'; + +const RANGER = { + id: 'ranger-1', + image_url: '/static/ranger-black.svg', + name: 'Ranger Amara', +}; + +const DOG = { + id: 'dog-1', + image_url: null, + name: 'K9 Rex', +}; + +const PILOT = { + id: 'pilot-1', + image_url: null, + name: 'Pilot Zoe', +}; + +const FIRST_LEG_TIME_RANGE = { end_time: '2026-04-13T02:00:00.000Z', start_time: '2026-04-13T01:00:00.000Z' }; +const SECOND_LEG_TIME_RANGE = { end_time: '2026-04-13T03:30:00.000Z', start_time: '2026-04-13T02:00:00.000Z' }; +const THIRD_LEG_TIME_RANGE = { end_time: '2026-04-13T04:00:00.000Z', start_time: '2026-04-13T03:30:00.000Z' }; + +const REFRESH_INTERVAL = 30_000; + +// Tracks are stored most recent position first. +const trackFor = (coordinates, timeRange) => ({ + fetchedDateRange: { since: timeRange.start_time }, + points: { features: [], type: 'FeatureCollection' }, + track: { + features: [{ + geometry: { coordinates, type: 'LineString' }, + properties: { coordinateProperties: { times: [timeRange.end_time, timeRange.start_time] } }, + type: 'Feature', + }], + type: 'FeatureCollection', + }, +}); + +describe('SideBar - PatrolsManager - PatrolOverview - Overview - Activity - SummaryStats', () => { + const endedPatrol = { + id: 'patrol-1', + patrol_segments: [ + { id: 'leg-1', leader: RANGER, time_range: FIRST_LEG_TIME_RANGE }, + { id: 'leg-2', leader: DOG, time_range: SECOND_LEG_TIME_RANGE }, + ], + state: 'open', + }; + + const activePatrol = { + ...endedPatrol, + patrol_segments: [ + endedPatrol.patrol_segments[0], + { ...endedPatrol.patrol_segments[1], time_range: { ...SECOND_LEG_TIME_RANGE, end_time: null } }, + ], + }; + + let reduxStore, store, tracker, user; + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-04-13T04:00:00.000Z')); + + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, delay: null }); + + tracker = { track: jest.fn() }; + + store = { + data: { + tracks: { + [DOG.id]: trackFor([[0, 0.5], [0, 0]], SECOND_LEG_TIME_RANGE), + [RANGER.id]: trackFor([[1, 0], [0, 0]], FIRST_LEG_TIME_RANGE), + }, + }, + view: {}, + }; + + reduxStore = mockStore(() => store); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const summaryStats = (props) => + + + + ; + + const renderSummaryStats = (props) => { + const { rerender } = render(summaryStats(props)); + + return { rerenderSummaryStats: (nextProps) => rerender(summaryStats(nextProps)) }; + }; + + const readSummaryStats = () => Object.fromEntries( + [...document.querySelectorAll('dt')].map((label) => [label.textContent, label.nextElementSibling.textContent]) + ); + + const openTrackedSubjectsMenu = async (subjectName) => { + await user.click(screen.getByRole('button', { name: `Distance covered by ${subjectName}. Change the tracked subject` })); + }; + + test('shows the duration, paused time, active time, distance and event count of the patrol', () => { + renderSummaryStats({ eventCount: 3 }); + + expect(readSummaryStats()).toEqual({ + 'Active Time': '2h 30m', + 'Distance': '55.6km', + 'Duration': '2h 30m', + 'Events': '3', + 'Paused Time': '0m', + }); + }); + + test('measures the duration of an ongoing patrol up to the current time', () => { + renderSummaryStats({ patrol: activePatrol }); + + expect(readSummaryStats()['Duration']).toBe('3h'); + }); + + test('refreshes the elapsed times of an active patrol as time passes', () => { + renderSummaryStats({ patrol: activePatrol }); + + expect(readSummaryStats()).toMatchObject({ 'Active Time': '3h', 'Duration': '3h' }); + + act(() => { + jest.advanceTimersByTime(REFRESH_INTERVAL - 1); + }); + + expect(readSummaryStats()).toMatchObject({ 'Active Time': '3h', 'Duration': '3h' }); + + act(() => { + jest.advanceTimersByTime(30 * 60 * 1000); + }); + + expect(readSummaryStats()).toMatchObject({ 'Active Time': '3h 30m', 'Duration': '3h 30m' }); + }); + + test('does not refresh the elapsed times of a patrol that is no longer active', () => { + renderSummaryStats(); + + act(() => { + jest.advanceTimersByTime(30 * 60 * 1000); + }); + + expect(readSummaryStats()['Duration']).toBe('2h 30m'); + expect(jest.getTimerCount()).toBe(0); + }); + + test('measures a cancelled patrol up to the moment it was cancelled', () => { + const cancelledPatrol = { + ...activePatrol, + state: 'cancelled', + updates: [ + { message: 'Updated fields: State is cancelled', time: '2026-04-13T03:00:00.000Z', type: 'update_patrol_state' }, + ], + }; + + renderSummaryStats({ patrol: cancelledPatrol }); + + expect(readSummaryStats()['Duration']).toBe('2h'); + expect(jest.getTimerCount()).toBe(0); + }); + + test('measures a done patrol up to the moment it was marked done', () => { + const donePatrol = { + ...activePatrol, + state: 'done', + updates: [ + { message: 'Updated fields: State is done', time: '2026-04-13T03:00:00.000Z', type: 'update_patrol_state' }, + ], + }; + + renderSummaryStats({ patrol: donePatrol }); + + expect(readSummaryStats()['Duration']).toBe('2h'); + expect(jest.getTimerCount()).toBe(0); + }); + + test('stops measuring a cancelled patrol with no record of when it was cancelled', () => { + const cancelledPatrol = { ...activePatrol, state: 'cancelled', updates: [] }; + + renderSummaryStats({ patrol: cancelledPatrol }); + + expect(readSummaryStats()['Duration']).toBe('0m'); + expect(jest.getTimerCount()).toBe(0); + }); + + test('shows a dash on every measurement of a patrol that has not started', () => { + const scheduledPatrol = { + ...endedPatrol, + patrol_segments: [{ + id: 'leg-1', + leader: RANGER, + scheduled_start: '2026-04-14T01:00:00.000Z', + time_range: { end_time: null, start_time: null }, + }], + }; + + renderSummaryStats({ eventCount: 2, patrol: scheduledPatrol }); + + expect(readSummaryStats()).toEqual({ + 'Active Time': '-', + 'Distance': '-', + 'Duration': '-', + 'Events': '2', + 'Paused Time': '-', + }); + }); + + test('shows a dash as the distance of a patrol without tracked subjects', () => { + const patrolWithoutLeaders = { + ...endedPatrol, + patrol_segments: endedPatrol.patrol_segments.map((leg) => ({ ...leg, leader: null })), + }; + + renderSummaryStats({ patrol: patrolWithoutLeaders }); + + expect(readSummaryStats()['Distance']).toBe('-'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('shows a dash as the distance of a tracked subject whose track is not loaded yet', () => { + store.data.tracks = {}; + + renderSummaryStats(); + + expect(readSummaryStats()['Distance']).toBe('-'); + }); + + test('shows no distance covered by a tracked subject that did not move during the patrol', () => { + store.data.tracks = { [DOG.id]: trackFor([[0, 0], [0, 0]], SECOND_LEG_TIME_RANGE) }; + + renderSummaryStats(); + + expect(readSummaryStats()['Distance']).toBe('0km'); + }); + + test('adds up the distance a tracked subject covered across every leg it took part in', () => { + const singleSubjectPatrol = { + ...endedPatrol, + patrol_segments: [ + { id: 'leg-1', leader: RANGER, time_range: FIRST_LEG_TIME_RANGE }, + { id: 'leg-2', leader: RANGER, time_range: SECOND_LEG_TIME_RANGE }, + ], + }; + store.data.tracks = { + [RANGER.id]: { + fetchedDateRange: { since: FIRST_LEG_TIME_RANGE.start_time }, + points: { features: [], type: 'FeatureCollection' }, + track: { + features: [{ + geometry: { coordinates: [[2, 0], [1, 0], [0, 0]], type: 'LineString' }, + properties: { + coordinateProperties: { + times: [ + SECOND_LEG_TIME_RANGE.end_time, + SECOND_LEG_TIME_RANGE.start_time, + FIRST_LEG_TIME_RANGE.start_time, + ], + }, + }, + type: 'Feature', + }], + type: 'FeatureCollection', + }, + }, + }; + + renderSummaryStats({ patrol: singleSubjectPatrol }); + + expect(readSummaryStats()['Distance']).toBe('222.4km'); + }); + + test('shows the distance covered by the patrol leader by default', () => { + renderSummaryStats(); + + expect(screen.getByRole('button', { name: 'Distance covered by K9 Rex. Change the tracked subject' })) + .toBeInTheDocument(); + expect(readSummaryStats()['Distance']).toBe('55.6km'); + }); + + test('lists every tracked subject of the patrol, the patrol leader first', async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + + expect(screen.getByRole('menu', { name: 'Tracked subjects' })).toBeInTheDocument(); + expect(screen.getAllByRole('menuitemradio').map((option) => option.title)).toEqual(['K9 Rex', 'Ranger Amara']); + }); + + test('checks the tracked subject the distance belongs to', async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toBeChecked(); + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })).not.toBeChecked(); + }); + + test('shows the icon of the tracked subjects that have one', async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' }).querySelector('img')).toBeInTheDocument(); + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' }).querySelector('img')).toBeNull(); + }); + + test('shows the distance covered by the tracked subject chosen from the menu', async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + await user.click(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })); + + expect(readSummaryStats()['Distance']).toBe('111.2km'); + expect(screen.getByRole('button', { name: 'Distance covered by Ranger Amara. Change the tracked subject' })) + .toBeInTheDocument(); + }); + + test('closes the menu and gives the focus back to its button after choosing a tracked subject', async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + await user.click(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })); + + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Distance covered by Ranger Amara. Change the tracked subject' })) + .toHaveFocus(); + }); + + test('reports choosing a tracked subject to the analytics tracker', async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + + expect(tracker.track).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })); + + expect(tracker.track).toHaveBeenCalledWith('Select the subject of the distance stat in patrol overview'); + }); + + test('moves the focus among the tracked subjects that are left after the patrol changes', async () => { + const threeLeggedPatrol = { + ...endedPatrol, + patrol_segments: [ + ...endedPatrol.patrol_segments, + { id: 'leg-3', leader: PILOT, time_range: THIRD_LEG_TIME_RANGE }, + ], + }; + + const { rerenderSummaryStats } = renderSummaryStats({ patrol: threeLeggedPatrol }); + + await openTrackedSubjectsMenu('Pilot Zoe'); + await user.keyboard('{End}'); + + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex' })).toHaveFocus(); + + rerenderSummaryStats({ patrol: endedPatrol }); + + await user.keyboard('{End}'); + + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })).toHaveFocus(); + }); + + describe('with the tracked subjects menu open', () => { + let distanceSubjectButton; + beforeEach(async () => { + renderSummaryStats(); + + await openTrackedSubjectsMenu('K9 Rex'); + + distanceSubjectButton = screen.getByRole('button', { + name: 'Distance covered by K9 Rex. Change the tracked subject', + }); + }); + + test('reports the menu as expanded', () => { + expect(distanceSubjectButton).toHaveAttribute('aria-expanded', 'true'); + expect(distanceSubjectButton).toHaveAttribute('aria-controls', screen.getByRole('menu').id); + }); + + test('closes the menu with its own button', async () => { + await user.click(distanceSubjectButton); + + expect(distanceSubjectButton).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + }); + + test('focuses the checked tracked subject', () => { + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toHaveFocus(); + }); + + test('moves the focus to the next tracked subject with the down arrow key, wrapping around', async () => { + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toHaveFocus(); + }); + + test('moves the focus to the previous tracked subject with the up arrow key, wrapping around', async () => { + await user.keyboard('{ArrowUp}'); + + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toHaveFocus(); + }); + + test('moves the focus to the last tracked subject with the end key', async () => { + await user.keyboard('{End}'); + + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })).toHaveFocus(); + }); + + test('moves the focus to the first tracked subject with the home key', async () => { + await user.keyboard('{End}'); + await user.keyboard('{Home}'); + + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toHaveFocus(); + }); + + test('leaves the checked tracked subject alone while moving the focus around', async () => { + await user.keyboard('{ArrowDown}'); + + expect(readSummaryStats()['Distance']).toBe('55.6km'); + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toBeChecked(); + }); + + test('closes the menu and gives the focus back to its button with the escape key', async () => { + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + expect(distanceSubjectButton).toHaveFocus(); + }); + + test('closes the menu and gives the focus back to its button with the tab key', async () => { + await user.keyboard('{Tab}'); + + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + expect(distanceSubjectButton).toHaveFocus(); + }); + + test('ignores any other key', async () => { + await user.keyboard('a'); + + expect(screen.getByRole('menu', { name: 'Tracked subjects' })).toBeInTheDocument(); + expect(screen.getByRole('menuitemradio', { name: 'K9 Rex Patrol leader' })).toHaveFocus(); + }); + + test('closes the menu and gives the focus back to its button when clicking outside of it', async () => { + await user.click(document.body); + + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + expect(distanceSubjectButton).toHaveFocus(); + }); + + test('leaves the focus alone when the track of a tracked subject updates', async () => { + await user.keyboard('{ArrowDown}'); + + store = { + ...store, + data: { + ...store.data, + tracks: { ...store.data.tracks, [DOG.id]: trackFor([[0, 1], [0, 0]], SECOND_LEG_TIME_RANGE) }, + }, + }; + act(() => { + reduxStore.dispatch({ type: 'SOCKET_STATUS_UPDATE' }); + }); + + expect(readSummaryStats()['Distance']).toBe('111.2km'); + expect(screen.getByRole('menuitemradio', { name: 'Ranger Amara' })).toHaveFocus(); + }); + }); +}); diff --git a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/styles.module.scss b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/styles.module.scss new file mode 100644 index 000000000..08809605a --- /dev/null +++ b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/SummaryStats/styles.module.scss @@ -0,0 +1,152 @@ +@use 'sass:color'; + +@use '../../../../../../common/styles/inputs'; +@use '../../../../../../common/styles/layout'; +@use '../../../../../../common/styles/vars/colors'; + +.summaryStats { + display: grid; + gap: 1rem; + grid-auto-rows: 1fr; + grid-template-columns: repeat(5, 1fr); + margin: 0; + + @media (max-width: layout.$sm-layout-width-max) { + grid-template-columns: repeat(3, 1fr); + } + + .statItem { + border: 1px solid colors.$light-gray-lines; + border-radius: 0.3125rem; + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + padding: 0.25rem 0.5rem; + + .statLabel { + align-items: center; + color: colors.$secondary-medium-gray; + display: flex; + font-size: 0.875rem; + font-weight: 400; + margin: 0; + + .distanceSubjectButton { + align-items: center; + background: none; + border: none; + border-radius: 0.25rem; + color: inherit; + display: flex; + gap: 0.5rem; + padding: 0; + + &:hover { + color: colors.$off-black; + + .statLabelIcon path { + fill: currentcolor; + } + } + + &:focus-visible { + outline: 2px solid colors.$bright-blue; + } + + .statLabelIcon { + flex-shrink: 0; + height: 0.3125rem; + width: 0.625rem; + } + } + } + + .statValue { + font-size: 1rem; + font-weight: 500; + margin: 0; + } + } +} + +.distanceSubjectMenuPopover { + @include inputs.boxShadow; + + border: none !important; + border-radius: 0.25rem !important; + + [class*='popover-arrow'] { + display: none; + } + + .distanceSubjectMenu { + list-style: none; + margin: 0; + padding: 0; + + .distanceSubjectMenuItem { + .distanceSubjectMenuItemOption { + align-items: center; + background-color: white; + border: none; + display: flex; + gap: 0.5rem; + outline: none; + padding: 0.5rem 0.75rem; + text-align: left; + width: 100%; + + &:hover { + background-color: color.adjust(colors.$light-gray-background, $lightness: 5%); + } + + &:focus { + background-color: colors.$light-gray-background; + } + + &[aria-checked="true"] { + background-color: colors.$blue-highlight; + + &:hover, &:focus { + background-color: color.adjust(colors.$blue-highlight, $lightness: -5%); + } + } + + &[aria-checked="false"] { + padding-left: 2rem; + } + + .checkIcon { + color: colors.$bright-blue; + flex-shrink: 0; + height: 0.75rem; + width: 0.75rem; + } + + .subjectIcon { + flex-shrink: 0; + height: 1.25rem; + width: 1.25rem; + } + + .patrolLeaderIcon { + color: colors.$secondary-medium-gray; + flex-shrink: 0; + height: 0.875rem; + width: 0.875rem; + } + } + + &:first-child .distanceSubjectMenuItemOption { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; + } + + &:last-child .distanceSubjectMenuItemOption { + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + } + } + } +} diff --git a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.js b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.js index b98b8adbf..b3863ed30 100644 --- a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.js +++ b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.js @@ -3,7 +3,6 @@ import Button from 'react-bootstrap/Button'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ArrowDownIcon } from '../../../../../common/images/icons/arrow-down.svg'; -import { ReactComponent as ArrowDownSmallIcon } from '../../../../../common/images/icons/arrow-down-small.svg'; import { ReactComponent as ArrowUpIcon } from '../../../../../common/images/icons/arrow-up.svg'; import { ReactComponent as PlayIcon } from '../../../../../common/images/icons/play.svg'; @@ -12,6 +11,8 @@ import { DESCENDING_SORT_ORDER } from '../../../../../constants'; import { getEventIdsForCollection } from '../../../../../utils/events'; import useActivityFeed from '../../../../../DetailViewComponents/ActivitySection/useActivityFeed'; +import SummaryStats from './SummaryStats'; + import * as styles from './styles.module.scss'; const CustomSortButton = ({ disabled, sortOrder, testId, toggleSortFn }) => { @@ -120,41 +121,7 @@ const Activity = ({ -
-
-
{t('durationLabel')}
- -
-
-
- -
-
{t('pausedTimeLabel')}
- -
-
-
- -
-
{t('activeTimeLabel')}
- -
-
-
- -
-
- {t('distanceLabel')} - -
- -
-
-
- -
-
{t('eventsLabel')}
- -
-
-
-
+ {hasItems ?
    diff --git a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.test.js b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.test.js index 19f4c82e7..e4fd6da32 100644 --- a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.test.js +++ b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/index.test.js @@ -27,6 +27,7 @@ describe('SideBar - PatrolsManager - PatrolOverview - Overview - Activity', () = eventStore: {}, eventTypes: [], patrolTypes: [], + tracks: {}, }, view: { systemConfig: { @@ -106,6 +107,22 @@ describe('SideBar - PatrolsManager - PatrolOverview - Overview - Activity', () = expect(screen.queryByTestId('activitySection-collapse-contained-event')).not.toBeInTheDocument(); }); + test('leaves the events already contained in an incident collection out of the summary stats event count', () => { + const containedEvent = { ...events[1], id: 'contained-event' }; + const collectionEvent = { ...events[0], id: 'collection-event', is_collection: true, contains: [{ related_event: { id: 'contained-event' } }] }; + + const patrolWithCollection = { + ...patrols[0], + patrol_segments: patrols[0].patrol_segments.map( + (segment) => ({ ...segment, events: [collectionEvent, containedEvent] }) + ), + }; + + renderActivity(patrolWithCollection); + + expect(screen.getByText('Events').nextElementSibling).toHaveTextContent('1'); + }); + test('shows a leg transition milestone between two legs, and the patrol started milestone, but not the patrol ended milestone while the patrol has not ended', () => { renderActivity(multiLegPatrol); diff --git a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/styles.module.scss b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/styles.module.scss index 06bfe97b7..f4b977039 100644 --- a/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/styles.module.scss +++ b/src/SideBar/PatrolsManager/PatrolOverview/Overview/Activity/styles.module.scss @@ -81,44 +81,6 @@ } } - .statsRow { - display: flex; - gap: 1rem; - margin: 0; - - .statItem { - border: 1px solid colors.$light-gray-lines; - border-radius: 0.3125rem; - display: flex; - flex: 1; - flex-direction: column; - gap: 0.125rem; - min-width: 0; - padding: 0.25rem 0.5rem; - - .statLabel { - align-items: center; - color: colors.$secondary-medium-gray; - display: flex; - font-size: 0.875rem; - gap: 0.125rem; - margin: 0; - - .statLabelIcon { - flex-shrink: 0; - height: 0.3125rem; - width: 0.625rem; - } - } - - .statValue { - font-size: 1rem; - font-weight: 500; - margin: 0; - } - } - } - .activityList { background: white; border: 1px solid colors.$light-gray-lines; diff --git a/src/SideBar/PatrolsManager/PatrolOverview/index.test.js b/src/SideBar/PatrolsManager/PatrolOverview/index.test.js index 8c287bd6a..a30cc180b 100644 --- a/src/SideBar/PatrolsManager/PatrolOverview/index.test.js +++ b/src/SideBar/PatrolsManager/PatrolOverview/index.test.js @@ -619,7 +619,9 @@ describe('SideBar - PatrolsManager - PatrolOverview', () => { await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('The patrol could not be saved. Please try again.'); }); - expect(screen.getByTestId('test-location')).toHaveTextContent('/events/new'); + await waitFor(() => { + expect(screen.getByTestId('test-location')).toHaveTextContent('/events/new'); + }); }); describe('saving', () => { diff --git a/src/TimeAgo/index.js b/src/TimeAgo/index.js index c70d0cfdd..cca15a935 100644 --- a/src/TimeAgo/index.js +++ b/src/TimeAgo/index.js @@ -5,6 +5,7 @@ import { durationHumanizer, generateCurrentTimeZoneTitle, HUMANIZED_DURATION_CONFIGS, + longTermAbbreviatedDurationHumanizer, resolveDurationHumanizerLanguage, } from '../utils/datetime'; @@ -22,16 +23,7 @@ const TimeAgo = ({ className, date, prefix = null, suffix = null }) => { const durationStringGenerator = useMemo(() => { if (olderThanAnHour){ - const abbreviations = { - y: () => t('timeUnitAbbreviations.year'), - mo: () => t('timeUnitAbbreviations.month'), - w: () => t('timeUnitAbbreviations.week'), - d: () => t('timeUnitAbbreviations.day'), - h: () => t('timeUnitAbbreviations.hour'), - m: () => t('timeUnitAbbreviations.minute'), - }; - - return durationHumanizer(HUMANIZED_DURATION_CONFIGS.LONG_TERM_ABRREVIATED(abbreviations)); + return longTermAbbreviatedDurationHumanizer(t); } if (olderThanAMinute) { diff --git a/src/common/images/icons/star.svg b/src/common/images/icons/star.svg new file mode 100644 index 000000000..fb1ed66c9 --- /dev/null +++ b/src/common/images/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/hooks/useCurrentTime.js b/src/hooks/useCurrentTime.js new file mode 100644 index 000000000..565b02f1c --- /dev/null +++ b/src/hooks/useCurrentTime.js @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +const useCurrentTime = (refreshInterval) => { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + + useEffect(() => { + if (refreshInterval) { + const interval = setInterval(() => setCurrentTime(Date.now()), refreshInterval); + + return () => window.clearInterval(interval); + } + }, [refreshInterval]); + + return currentTime; +}; + +export default useCurrentTime; diff --git a/src/hooks/useCurrentTime.test.js b/src/hooks/useCurrentTime.test.js new file mode 100644 index 000000000..0f1f561a2 --- /dev/null +++ b/src/hooks/useCurrentTime.test.js @@ -0,0 +1,102 @@ +import { act, renderHook } from '../test-utils'; + +import useCurrentTime from './useCurrentTime'; + +describe('useCurrentTime', () => { + const NOW = new Date('2026-04-13T01:00:00.000Z').getTime(); + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(NOW); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('returns the current time right away', () => { + const { result } = renderHook(() => useCurrentTime(1000)); + + expect(result.current).toBe(NOW); + }); + + test('refreshes the current time once every refresh interval', () => { + const { result } = renderHook(() => useCurrentTime(1000)); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current).toBe(NOW + 1000); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(result.current).toBe(NOW + 3000); + }); + + test('does not refresh the current time before the refresh interval elapses', () => { + const { result } = renderHook(() => useCurrentTime(1000)); + + act(() => { + jest.advanceTimersByTime(999); + }); + + expect(result.current).toBe(NOW); + }); + + test('never refreshes the current time without a refresh interval', () => { + const { result } = renderHook(() => useCurrentTime(null)); + + act(() => { + jest.advanceTimersByTime(60_000); + }); + + expect(result.current).toBe(NOW); + }); + + test('stops refreshing the current time once the refresh interval is cleared', () => { + const { rerender, result } = renderHook(({ refreshInterval }) => useCurrentTime(refreshInterval), { + initialProps: { refreshInterval: 1000 }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + rerender({ refreshInterval: null }); + + act(() => { + jest.advanceTimersByTime(60_000); + }); + + expect(result.current).toBe(NOW + 1000); + }); + + test('restarts the refreshes when the refresh interval changes', () => { + const { rerender, result } = renderHook(({ refreshInterval }) => useCurrentTime(refreshInterval), { + initialProps: { refreshInterval: 10_000 }, + }); + + rerender({ refreshInterval: 1000 }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current).toBe(NOW + 1000); + }); + + test('clears the interval when unmounting', () => { + const { result, unmount } = renderHook(() => useCurrentTime(1000)); + + unmount(); + + act(() => { + jest.advanceTimersByTime(60_000); + }); + + expect(result.current).toBe(NOW); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/src/hooks/usePatrol/index.js b/src/hooks/usePatrol/index.js index 9e00c5985..46e764303 100644 --- a/src/hooks/usePatrol/index.js +++ b/src/hooks/usePatrol/index.js @@ -12,6 +12,7 @@ import { displayTitleForPatrol, formatPatrolStateTitleDate, getBoundsForPatrol, + getCancellationTimeForPatrol, iconTypeForPatrol, patrolHasGeoDataToDisplay, patrolStateDetailsEndTime, @@ -60,16 +61,10 @@ const usePatrol = (patrol) => { const theme = useMemo(() => calcColorThemeForPatrolState(patrolState), [patrolState]); const patrolCancellationTime = useMemo(() => { - if (!isPatrolCancelled) return null; + const cancellationTimeForPatrol = getCancellationTimeForPatrol(patrol); - const cancellation = patrol?.updates - ?.find(update => update.type === 'update_patrol_state' && update.message.includes('cancelled')) - ?? null; - if (!cancellation) return null; - - return formatPatrolStateTitleDate(new Date(cancellation.time)); - - }, [isPatrolCancelled, patrol.updates]); + return cancellationTimeForPatrol ? formatPatrolStateTitleDate(cancellationTimeForPatrol) : null; + }, [patrol]); const dateComponentDateString = useMemo(() => { if (isPatrolCancelled) return patrolCancellationTime; diff --git a/src/i18n.js b/src/i18n.js index b3b53d860..d9d28cf4c 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -7,7 +7,7 @@ import LocalStorageBackend from 'i18next-localstorage-backend'; import { SUPPORTED_LANGUAGES } from './constants'; -const I18N_FILES_VERSION = '1.51'; +const I18N_FILES_VERSION = '1.52'; const preloadNamespaces = [ 'components', diff --git a/src/selectors/patrols/index.js b/src/selectors/patrols/index.js index f717fd19e..acc302ec0 100644 --- a/src/selectors/patrols/index.js +++ b/src/selectors/patrols/index.js @@ -7,11 +7,12 @@ import { drawLinesBetweenPatrolTrackAndPatrolPoints, extractLegPatrolPoints, finalizeCombinedPatrolPoints, + getTrackedSubjectsForPatrolSegment, isSegmentActiveForPatrol, patrolStateAllowsTrackDisplay, } from '../../utils/patrols'; import { selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod, selectTrackTimeEnvelope } from '../tracks'; -import { trackHasDataWithinTimeRange, trimTrackDataToTimeRange } from '../../utils/tracks'; +import { trackHasDataWithinTimeRange, trackLengthWithinTimeRange, trimTrackDataToTimeRange } from '../../utils/tracks'; const clampLegEndTime = (endTime, envelopeUntil) => { if (!envelopeUntil) { @@ -168,6 +169,66 @@ export const selectPatrolTrackData = createSelector( buildPatrolData(patrol, timeSliderState, trackTimeEnvelope.until, tracks) ); +const selectPatrolTrackedSubjectTracks = createSelector( + [selectTracks, (_, patrol) => patrol], + (tracks, patrol) => patrol.patrol_segments.reduce((patrolTrackedSubjectTracks, segment) => { + getTrackedSubjectsForPatrolSegment(segment).forEach((subject) => { + if (tracks[subject.id]) { + patrolTrackedSubjectTracks[subject.id] = tracks[subject.id]; + } + }); + + return patrolTrackedSubjectTracks; + }, {}), + { memoizeOptions: { resultEqualityCheck: shallowEqual } } +); + +// The kilometers a subject covered during a leg: its own track, bounded to the leg's time range. +const distanceCoveredInSegment = (segment, subjectTrack) => { + if (!segment.time_range?.start_time + || !trackHasDataWithinTimeRange(subjectTrack, segment.time_range.start_time, segment.time_range.end_time) + ) { + return 0; + } + + return trackLengthWithinTimeRange(subjectTrack, segment.time_range.start_time, segment.time_range.end_time); +}; + +// Every subject the patrol tracks, along with the total distance it covered across the legs it +// took part in, which stays unknown until that subject's track has been loaded. +export const selectPatrolTrackedSubjects = createSelector( + [selectPatrolTrackedSubjectTracks, (_, patrol) => patrol], + (patrolTrackedSubjectTracks, patrol) => { + const patrolLeaderId = patrol.patrol_segments[patrol.patrol_segments.length - 1]?.leader?.id ?? null; + + const patrolTrackedSubjectsMap = new Map(); + patrol.patrol_segments.forEach((segment) => { + getTrackedSubjectsForPatrolSegment(segment).forEach((subject) => { + const trackedSubject = patrolTrackedSubjectsMap.get(subject.id) ?? { segments: [], subject }; + + trackedSubject.segments.push(segment); + + patrolTrackedSubjectsMap.set(subject.id, trackedSubject); + }); + }); + + return [...patrolTrackedSubjectsMap.values()] + .map(({ segments, subject }) => { + const subjectTrack = patrolTrackedSubjectTracks[subject.id]; + + return { + distance: subjectTrack + ? segments.reduce((distance, segment) => distance + distanceCoveredInSegment(segment, subjectTrack), 0) + : null, + isPatrolLeader: subject.id === patrolLeaderId, + subject, + }; + }) + // The patrol leader comes first. + .sort((a, b) => b.isPatrolLeader - a.isPatrolLeader); + } +); + export const selectPatrolLeadersWithLastPosition = createSelector( [selectPatrolLeaders, selectSubjectStore], (patrolLeaders, subjectStore) => patrolLeaders ? patrolLeaders.map((patrolLeader) => { diff --git a/src/selectors/patrols/index.test.js b/src/selectors/patrols/index.test.js index be10e4f71..c28b41aee 100644 --- a/src/selectors/patrols/index.test.js +++ b/src/selectors/patrols/index.test.js @@ -5,6 +5,7 @@ import { selectPatrolsWithTracks, selectPatrolsWithTracksData, selectPatrolTrackData, + selectPatrolTrackedSubjects, selectSubjectTracksWithPatrolTrackShownFlag, } from './'; @@ -645,6 +646,160 @@ describe('Selectors - Patrols', () => { }); }); + describe('selectPatrolTrackedSubjects', () => { + const RANGER = { id: 'subject111', name: 'Ranger Amara' }; + const DOG = { id: 'subject222', name: 'K9 Rex' }; + + const FIRST_LEG_TIME_RANGE = { end_time: '2020-01-05T00:00:00.000Z', start_time: '2020-01-01T00:00:00.000Z' }; + const SECOND_LEG_TIME_RANGE = { end_time: '2020-01-09T00:00:00.000Z', start_time: '2020-01-05T00:00:00.000Z' }; + + // A degree of longitude at the equator, so every leg covers the same distance. + const ONE_DEGREE_IN_KILOMETERS = 111.19; + + // Tracks are stored most recent position first. + const trackFor = (coordinates, times) => ({ + fetchedDateRange: { since: times[times.length - 1] }, + points: { features: [] }, + track: { + features: [{ + geometry: { coordinates, type: 'LineString' }, + properties: { coordinateProperties: { times } }, + type: 'Feature', + }], + type: 'FeatureCollection', + }, + }); + + const twoLeggedPatrol = { + patrol_segments: [ + { leader: RANGER, time_range: FIRST_LEG_TIME_RANGE }, + { leader: DOG, time_range: SECOND_LEG_TIME_RANGE }, + ], + }; + + beforeEach(() => { + state.data.tracks = { + [DOG.id]: trackFor( + [[1, 0], [0, 0]], + [SECOND_LEG_TIME_RANGE.end_time, SECOND_LEG_TIME_RANGE.start_time] + ), + [RANGER.id]: trackFor( + [[1, 0], [0, 0]], + [FIRST_LEG_TIME_RANGE.end_time, FIRST_LEG_TIME_RANGE.start_time] + ), + }; + }); + + test('lists the subject tracked by every leg with the distance it covered, the patrol leader first', () => { + const trackedSubjects = selectPatrolTrackedSubjects(state, twoLeggedPatrol); + + expect(trackedSubjects.map(({ isPatrolLeader, subject }) => [subject.id, isPatrolLeader])).toEqual([ + [DOG.id, true], + [RANGER.id, false], + ]); + trackedSubjects.forEach(({ distance }) => expect(distance).toBeCloseTo(ONE_DEGREE_IN_KILOMETERS, 1)); + }); + + test('adds up the distance a subject covered across every leg it took part in', () => { + const singleSubjectPatrol = { + patrol_segments: [ + { leader: RANGER, time_range: FIRST_LEG_TIME_RANGE }, + { leader: RANGER, time_range: SECOND_LEG_TIME_RANGE }, + ], + }; + state.data.tracks = { + [RANGER.id]: trackFor( + [[2, 0], [1, 0], [0, 0]], + [SECOND_LEG_TIME_RANGE.end_time, FIRST_LEG_TIME_RANGE.end_time, FIRST_LEG_TIME_RANGE.start_time] + ), + }; + + const trackedSubjects = selectPatrolTrackedSubjects(state, singleSubjectPatrol); + + expect(trackedSubjects).toHaveLength(1); + expect(trackedSubjects[0].distance).toBeCloseTo(2 * ONE_DEGREE_IN_KILOMETERS, 1); + }); + + test('counts only the stretch of the track that falls within the leg time range', () => { + const patrolWithinALongerTrack = { + patrol_segments: [{ leader: RANGER, time_range: FIRST_LEG_TIME_RANGE }], + }; + state.data.tracks = { + [RANGER.id]: trackFor( + [[3, 0], [2, 0], [1, 0], [0, 0]], + [ + SECOND_LEG_TIME_RANGE.end_time, + FIRST_LEG_TIME_RANGE.end_time, + FIRST_LEG_TIME_RANGE.start_time, + '2019-12-31T00:00:00.000Z', + ] + ), + }; + + expect(selectPatrolTrackedSubjects(state, patrolWithinALongerTrack)[0].distance) + .toBeCloseTo(ONE_DEGREE_IN_KILOMETERS, 1); + }); + + test('leaves the distance unknown for a subject whose track is not loaded', () => { + state.data.tracks = {}; + + expect(selectPatrolTrackedSubjects(state, twoLeggedPatrol).map(({ distance }) => distance)) + .toEqual([null, null]); + }); + + test('leaves the distance unknown only for the subjects whose track is not loaded', () => { + state.data.tracks = { [RANGER.id]: state.data.tracks[RANGER.id] }; + + const trackedSubjects = selectPatrolTrackedSubjects(state, twoLeggedPatrol); + + expect(trackedSubjects[0].distance).toBeNull(); + expect(trackedSubjects[1].distance).toBeCloseTo(ONE_DEGREE_IN_KILOMETERS, 1); + }); + + test('counts no distance for a leg without a start time', () => { + const patrolWithoutLegStartTime = { + patrol_segments: [{ leader: RANGER, time_range: { end_time: FIRST_LEG_TIME_RANGE.end_time } }], + }; + + expect(selectPatrolTrackedSubjects(state, patrolWithoutLegStartTime)[0].distance).toBe(0); + }); + + test('counts no distance for a leg whose time range falls outside the fetched track', () => { + const patrolOutsideTheFetchedTrack = { + patrol_segments: [{ + leader: RANGER, + time_range: { end_time: '2019-12-05T00:00:00.000Z', start_time: '2019-12-01T00:00:00.000Z' }, + }], + }; + + expect(selectPatrolTrackedSubjects(state, patrolOutsideTheFetchedTrack)[0].distance).toBe(0); + }); + + test('returns an empty list for a patrol whose legs have no leader', () => { + const patrolWithoutLeaders = { patrol_segments: [{ leader: null, time_range: FIRST_LEG_TIME_RANGE }] }; + + expect(selectPatrolTrackedSubjects(state, patrolWithoutLeaders)).toEqual([]); + }); + + test('returns an empty list for a patrol without legs', () => { + expect(selectPatrolTrackedSubjects(state, { patrol_segments: [] })).toEqual([]); + }); + + test('does not recompute the distances when an unrelated subject\'s track updates', () => { + const firstResult = selectPatrolTrackedSubjects(state, twoLeggedPatrol); + + state = { + ...state, + data: { + ...state.data, + tracks: { ...state.data.tracks, subject999: { unrelated: true } }, + }, + }; + + expect(selectPatrolTrackedSubjects(state, twoLeggedPatrol)).toBe(firstResult); + }); + }); + describe('selectPatrolLeadersWithLastPosition', () => { test('gets the patrol leaders and their last positions', () => { state.data.patrolLeaderSchema = { diff --git a/src/utils/datetime.js b/src/utils/datetime.js index 9cd0dc936..b3664f616 100644 --- a/src/utils/datetime.js +++ b/src/utils/datetime.js @@ -138,6 +138,18 @@ export const HUMANIZED_DURATION_CONFIGS = { export const durationHumanizer = (config = HUMANIZED_DURATION_CONFIGS.FULL_FORMAT()) => humanizeDuration.humanizer(config); +// `t` must be bound to the `dates` namespace. +export const longTermAbbreviatedDurationHumanizer = (t) => durationHumanizer( + HUMANIZED_DURATION_CONFIGS.LONG_TERM_ABRREVIATED({ + d: () => t('timeUnitAbbreviations.day'), + h: () => t('timeUnitAbbreviations.hour'), + m: () => t('timeUnitAbbreviations.minute'), + mo: () => t('timeUnitAbbreviations.month'), + w: () => t('timeUnitAbbreviations.week'), + y: () => t('timeUnitAbbreviations.year'), + }) +); + const SUPPORTED_DURATION_HUMANIZER_LANGUAGES = humanizeDuration.getSupportedLanguages(); export const resolveDurationHumanizerLanguage = (i18nLanguage) => { diff --git a/src/utils/distance.js b/src/utils/distance.js new file mode 100644 index 000000000..33290b171 --- /dev/null +++ b/src/utils/distance.js @@ -0,0 +1,9 @@ +import i18next from 'i18next'; + +const MAXIMUM_FRACTION_DIGITS = 1; + +// `t` must be bound to the `utils` namespace. +export const formatDistanceInKilometers = (t, kilometers) => t('distanceInKilometers', { + distance: new Intl.NumberFormat(i18next.language, { maximumFractionDigits: MAXIMUM_FRACTION_DIGITS }) + .format(kilometers), +}); diff --git a/src/utils/distance.test.js b/src/utils/distance.test.js new file mode 100644 index 000000000..56cc5b28d --- /dev/null +++ b/src/utils/distance.test.js @@ -0,0 +1,45 @@ +import i18next from 'i18next'; + +import '../i18nForTests'; + +import { formatDistanceInKilometers } from './distance'; + +describe('Utils - Distance', () => { + describe('formatDistanceInKilometers', () => { + const formatDistance = (kilometers) => formatDistanceInKilometers(i18next.getFixedT(null, 'utils'), kilometers); + + afterEach(async () => { + await i18next.changeLanguage('en-US'); + }); + + test('appends the kilometers unit to a whole number without adding decimals', () => { + expect(formatDistance(12)).toBe('12km'); + }); + + test('formats zero', () => { + expect(formatDistance(0)).toBe('0km'); + }); + + test('keeps a single fraction digit', () => { + expect(formatDistance(12.3456)).toBe('12.3km'); + }); + + test('rounds up to the single fraction digit', () => { + expect(formatDistance(12.36)).toBe('12.4km'); + }); + + test('rounds a distance below the fraction digit precision down to zero', () => { + expect(formatDistance(0.04)).toBe('0km'); + }); + + test('groups thousands', () => { + expect(formatDistance(1234.5)).toBe('1,234.5km'); + }); + + test('formats the number following the conventions of the active language', async () => { + await i18next.changeLanguage('de-DE'); + + expect(formatDistance(1234.5)).toBe('1.234,5km'); + }); + }); +}); diff --git a/src/utils/download.test.js b/src/utils/download.test.js index 31d2df2db..896626076 100644 --- a/src/utils/download.test.js +++ b/src/utils/download.test.js @@ -11,18 +11,22 @@ jest.mock('axios', () => { describe('utils - download', () => { let clickSpy; + let createObjectURLSpy; beforeEach(() => { - jest.clearAllMocks(); clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + createObjectURLSpy = jest.spyOn(window.URL, 'createObjectURL'); + createObjectURLSpy.mockClear(); }); afterEach(() => { - clickSpy.mockRestore(); + jest.restoreAllMocks(); }); const clickedLink = () => clickSpy.mock.instances[0]; + const downloadedBlob = () => createObjectURLSpy.mock.calls[0][0]; + describe('downloadFileFromUrl', () => { test('requests the url as a blob, with the given params', async () => { axios.get.mockResolvedValue({ data: 'file contents', headers: {} }); @@ -59,13 +63,11 @@ describe('utils - download', () => { test('downloads a blob built from the response body using its content type', async () => { axios.get.mockResolvedValue({ data: 'file contents', headers: { 'Content-Type': 'text/plain' } }); - const createObjectURLSpy = jest.spyOn(window.URL, 'createObjectURL'); - await downloadFileFromUrl('/some/url', { filename: 'my-file.txt' }); - const [blob] = createObjectURLSpy.mock.calls[0]; - expect(blob.type).toBe('text/plain'); - await expect(blob.text()).resolves.toBe('file contents'); + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + expect(downloadedBlob().type).toBe('text/plain'); + await expect(downloadedBlob().text()).resolves.toBe('file contents'); }); }); @@ -79,13 +81,12 @@ describe('utils - download', () => { }); test('downloads a JSON blob serialized from the given data', async () => { - const createObjectURLSpy = jest.spyOn(window.URL, 'createObjectURL'); - downloadJsonAsFile({ type: 'FeatureCollection', features: [] }, 'track.geojson'); - const [blob] = createObjectURLSpy.mock.calls[0]; - expect(blob.type).toBe('application/json'); - await expect(blob.text()).resolves.toBe(JSON.stringify({ type: 'FeatureCollection', features: [] })); + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + expect(downloadedBlob().type).toBe('application/json'); + await expect(downloadedBlob().text()) + .resolves.toBe(JSON.stringify({ type: 'FeatureCollection', features: [] })); }); }); }); diff --git a/src/utils/eventMapIcons.test.js b/src/utils/eventMapIcons.test.js index e11d77175..bc3c1282f 100644 --- a/src/utils/eventMapIcons.test.js +++ b/src/utils/eventMapIcons.test.js @@ -33,8 +33,8 @@ describe('eventMapIcons registry', () => { loadCallbacks = []; - jest.spyOn(global.URL, 'createObjectURL').mockImplementation(() => {}); - jest.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {}); + jest.spyOn(URL, 'createObjectURL').mockImplementation(() => undefined); + jest.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); global.Image = jest.fn(() => ({ setAttribute: jest.fn(), diff --git a/src/utils/img.test.js b/src/utils/img.test.js index 370f213a9..f606763ea 100644 --- a/src/utils/img.test.js +++ b/src/utils/img.test.js @@ -1,7 +1,13 @@ import { imgElFromSrc, calcImgIdFromUrlForMapImages, calcUrlForImage, ImageCache } from './img'; -jest.spyOn(global.URL, 'createObjectURL').mockImplementation(() => {}); -jest.spyOn(global.URL, 'revokeObjectURL').mockImplementation(() => {}); +const { createObjectURL, revokeObjectURL } = URL; + +afterAll(() => { + Object.assign(URL, { createObjectURL, revokeObjectURL }); +}); + +global.URL.createObjectURL = jest.fn(); +global.URL.revokeObjectURL = jest.fn(); describe('img utility functions', () => { afterAll(() => { @@ -253,11 +259,7 @@ describe('img utility functions', () => { mockImage.onerror(new Error('test error')); - try { - await loadPromise; - } catch (e) { - - } + await expect(loadPromise).rejects.toBeDefined(); expect(mapDeleteSpy).toHaveBeenCalled(); diff --git a/src/utils/patrols.js b/src/utils/patrols.js index 3dc6c01d1..e43bc76a3 100644 --- a/src/utils/patrols.js +++ b/src/utils/patrols.js @@ -245,6 +245,70 @@ export const actualEndTimeForPatrol = (patrol) => { : null; }; +// TODO: Include the leg's team members and tracked assets once they're part of the data model. +export const getTrackedSubjectsForPatrolSegment = (patrolSegment) => patrolSegment.leader ? [patrolSegment.leader] : []; + +// TODO: Recognize pause legs by their own flag once the data model supports it. +const isPatrolSegmentAPause = () => false; + +const getElapsedTimeForPatrolSegment = (patrolSegment, fallbackEndTime) => { + if (!patrolSegment.time_range?.start_time) { + return 0; + } + + const startTime = new Date(patrolSegment.time_range.start_time).getTime(); + const endTime = patrolSegment.time_range.end_time + ? new Date(patrolSegment.time_range.end_time).getTime() + : fallbackEndTime; + return Math.max(0, endTime - startTime); +}; + +const getLastStateChangeTimeForPatrol = (patrol) => { + const stateChangeTimes = (patrol.updates ?? []) + .filter((update) => update.type === 'update_patrol_state') + .map((update) => new Date(update.time).getTime()); + + return stateChangeTimes.length ? new Date(Math.max(...stateChangeTimes)) : null; +}; + +export const getCancellationTimeForPatrol = (patrol) => isPatrolCancelled(patrol) + ? getLastStateChangeTimeForPatrol(patrol) + : null; + +export const effectiveEndTimeForPatrol = (patrol) => { + const legsEndTime = actualEndTimeForPatrol(patrol); + + if (legsEndTime || !(isPatrolCancelled(patrol) || isPatrolDone(patrol))) { + return legsEndTime; + } + + return getLastStateChangeTimeForPatrol(patrol) ?? actualStartTimeForPatrol(patrol); +}; + +const endTimeForPatrolOrFallback = (patrol, fallbackEndTime) => + effectiveEndTimeForPatrol(patrol)?.getTime() ?? fallbackEndTime; + +export const getElapsedTimeForPatrol = (patrol, fallbackEndTime = Date.now()) => { + const startDate = actualStartTimeForPatrol(patrol); + + if (!startDate) { + return 0; + } + + return Math.max(0, endTimeForPatrolOrFallback(patrol, fallbackEndTime) - startDate.getTime()); +}; + +export const getPausedTimeForPatrol = (patrol, fallbackEndTime = Date.now()) => { + const endTime = endTimeForPatrolOrFallback(patrol, fallbackEndTime); + + return patrol.patrol_segments.reduce( + (totalPausedTime, patrolSegment) => isPatrolSegmentAPause(patrolSegment) + ? totalPausedTime + getElapsedTimeForPatrolSegment(patrolSegment, endTime) + : totalPausedTime, + 0 + ); +}; + export const getPatrolsForLeaderId = (leaderId) => { const { data: { patrolStore } } = store.getState(); diff --git a/src/utils/patrols.test.js b/src/utils/patrols.test.js index 3347c7ab3..bcf530414 100644 --- a/src/utils/patrols.test.js +++ b/src/utils/patrols.test.js @@ -11,13 +11,18 @@ import { displayStartTimeForPatrol, displayStartTimeForPatrolSegment, displayTitleForPatrol, + effectiveEndTimeForPatrol, extractLegPatrolPoints, finalizeCombinedPatrolPoints, getActivePatrolsForLeaderId, getBoundsForPatrol, + getCancellationTimeForPatrol, + getElapsedTimeForPatrol, getPatrolLocationCoordinates, getPatrolsForLeaderId, + getPausedTimeForPatrol, getReportsForPatrol, + getTrackedSubjectsForPatrolSegment, iconIdForPatrolSegment, iconIdForPatrolType, iconTypeForPatrol, @@ -767,6 +772,200 @@ describe('Patrols utils', () => { }); }); + describe('getTrackedSubjectsForPatrolSegment', () => { + test('returns the leg\'s leader', () => { + const leader = { id: 'leader-a', name: 'Ranger Amara' }; + + expect(getTrackedSubjectsForPatrolSegment({ leader })).toEqual([leader]); + }); + + test('returns an empty list for a leg without a leader', () => { + expect(getTrackedSubjectsForPatrolSegment({ leader: null })).toEqual([]); + }); + }); + + describe('effectiveEndTimeForPatrol', () => { + test('returns the end time of the last leg', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: '2022-06-15T13:00:00.000Z', start_time: '2022-06-15T10:00:00.000Z' } }], + }; + + expect(effectiveEndTimeForPatrol(patrol)).toEqual(new Date('2022-06-15T13:00:00.000Z')); + }); + + test('falls back to the moment a cancelled patrol was cancelled', () => { + expect(effectiveEndTimeForPatrol(cancelledPatrol)).toEqual(new Date('2022-01-18T22:42:04.843502+00:00')); + }); + + test('falls back to the moment a done patrol was marked done', () => { + const patrol = { + ...donePatrol, + patrol_segments: [{ + ...donePatrol.patrol_segments[0], + time_range: { ...donePatrol.patrol_segments[0].time_range, end_time: null }, + }], + }; + + expect(effectiveEndTimeForPatrol(patrol)).toEqual(new Date('2022-01-18T22:12:24.207505+00:00')); + }); + + test('falls back to the last state change of a finished patrol however it is worded', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-06-15T10:00:00.000Z' } }], + state: 'cancelled', + updates: [ + { message: 'Estado actualizado', time: '2022-06-15T13:00:00.000Z', type: 'update_patrol_state' }, + { message: 'Updated fields: State is open', time: '2022-06-15T10:00:00.000Z', type: 'update_patrol_state' }, + ], + }; + + expect(effectiveEndTimeForPatrol(patrol)).toEqual(new Date('2022-06-15T13:00:00.000Z')); + }); + + test('falls back to the start time of a finished patrol with nothing recording when it stopped', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-06-15T10:00:00.000Z' } }], + state: 'cancelled', + updates: [{ message: 'Patrol Added', time: '2022-06-15T09:00:00.000Z', type: 'add_patrol' }], + }; + + expect(effectiveEndTimeForPatrol(patrol)).toEqual(new Date('2022-06-15T10:00:00.000Z')); + }); + + test('returns null for a patrol that has not ended', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-06-15T10:00:00.000Z' } }], + }; + + expect(effectiveEndTimeForPatrol(patrol)).toBeNull(); + }); + + test('ignores the state changes of a patrol that is still running', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-06-15T10:00:00.000Z' } }], + state: 'open', + updates: [ + { message: 'Updated fields: State is open', time: '2022-06-15T13:00:00.000Z', type: 'update_patrol_state' }, + ], + }; + + expect(effectiveEndTimeForPatrol(patrol)).toBeNull(); + }); + }); + + describe('getCancellationTimeForPatrol', () => { + test('returns the time of the most recent update that cancelled the patrol', () => { + expect(getCancellationTimeForPatrol(cancelledPatrol)) + .toEqual(new Date('2022-01-18T22:42:04.843502+00:00')); + }); + + test('returns the time of the last state change however it is worded', () => { + const patrol = { + ...cancelledPatrol, + updates: [{ message: 'Estado actualizado', time: '2022-01-19T10:00:00.000Z', type: 'update_patrol_state' }], + }; + + expect(getCancellationTimeForPatrol(patrol)).toEqual(new Date('2022-01-19T10:00:00.000Z')); + }); + + test('returns null for a patrol that is not cancelled', () => { + expect(getCancellationTimeForPatrol({ ...cancelledPatrol, state: 'open' })).toBeNull(); + }); + + test('returns null for a cancelled patrol without a state update recording it', () => { + expect(getCancellationTimeForPatrol({ ...cancelledPatrol, updates: [] })).toBeNull(); + }); + }); + + describe('getElapsedTimeForPatrol', () => { + const HOUR = 60 * 60 * 1000; + + test('measures from the first leg\'s start time to the last leg\'s end time', () => { + const patrol = { + patrol_segments: [ + { time_range: { end_time: '2022-06-15T11:00:00.000Z', start_time: '2022-06-15T10:00:00.000Z' } }, + { time_range: { end_time: '2022-06-15T13:00:00.000Z', start_time: '2022-06-15T11:00:00.000Z' } }, + ], + }; + + expect(getElapsedTimeForPatrol(patrol)).toBe(3 * HOUR); + }); + + test('measures up to the given fallback end time while the patrol has not ended', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-06-15T10:00:00.000Z' } }], + }; + + expect(getElapsedTimeForPatrol(patrol, new Date('2022-06-15T12:00:00.000Z').getTime())).toBe(2 * HOUR); + }); + + test('measures an ongoing patrol up to now by default', () => { + jest.useFakeTimers().setSystemTime(new Date('2022-06-15T12:00:00.000Z')); + + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-06-15T10:00:00.000Z' } }], + }; + + expect(getElapsedTimeForPatrol(patrol)).toBe(2 * HOUR); + + jest.useRealTimers(); + }); + + test('measures a cancelled patrol up to the moment it was cancelled, not the fallback end time', () => { + const patrol = { + ...cancelledPatrol, + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-01-18T21:42:04.843502+00:00' } }], + }; + + expect(getElapsedTimeForPatrol(patrol, new Date('2022-01-20T00:00:00.000Z').getTime())).toBe(HOUR); + }); + + test('stops measuring a finished patrol with nothing recording when it stopped', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: null, start_time: '2022-01-18T21:42:04.843502+00:00' } }], + state: 'cancelled', + updates: [], + }; + + expect(getElapsedTimeForPatrol(patrol, new Date('2022-01-20T00:00:00.000Z').getTime())).toBe(0); + }); + + test('returns zero for a patrol that has not started', () => { + const patrol = { patrol_segments: [{ time_range: { end_time: null, start_time: null } }] }; + + expect(getElapsedTimeForPatrol(patrol)).toBe(0); + }); + + test('returns zero for a patrol without legs', () => { + expect(getElapsedTimeForPatrol({ patrol_segments: [] })).toBe(0); + }); + + test('never returns a negative elapsed time', () => { + const patrol = { + patrol_segments: [{ time_range: { end_time: '2022-06-15T09:00:00.000Z', start_time: '2022-06-15T10:00:00.000Z' } }], + }; + + expect(getElapsedTimeForPatrol(patrol)).toBe(0); + }); + }); + + describe('getPausedTimeForPatrol', () => { + test('returns zero while no leg can be recognized as a pause', () => { + const patrol = { + patrol_segments: [ + { time_range: { end_time: '2022-06-15T11:00:00.000Z', start_time: '2022-06-15T10:00:00.000Z' } }, + { time_range: { end_time: null, start_time: '2022-06-15T11:00:00.000Z' } }, + ], + }; + + expect(getPausedTimeForPatrol(patrol)).toBe(0); + }); + + test('returns zero for a patrol without legs', () => { + expect(getPausedTimeForPatrol({ patrol_segments: [] })).toBe(0); + }); + }); + describe('iconTypeForPatrol', () => { test('returns the patrol-level icon_id when set', () => { const patrol = { icon_id: 'custom-icon', patrol_segments: [{ icon_id: 'first-leg-icon' }] }; diff --git a/src/utils/tracks.js b/src/utils/tracks.js index 3a371c68d..e5b64cfad 100644 --- a/src/utils/tracks.js +++ b/src/utils/tracks.js @@ -1,4 +1,4 @@ -import { bearing, explode, featureCollection } from '@turf/turf'; +import { bearing, explode, featureCollection, length, lineString } from '@turf/turf'; import isEqual from 'react-fast-compare'; import { CancelToken } from 'axios'; import { @@ -358,6 +358,31 @@ export const trimTrackDataToTimeRange = (trackData, from = null, until = null) = }; +// The length in kilometers of the stretch of a track within a time range. +export const trackLengthWithinTimeRange = (trackData, from = null, until = null) => { + const [trackFeature] = trackData.track.features; + + if (!trackFeature?.geometry) { + return 0; + } + + const { coordinates } = trackFeature.geometry; + + const measure = (positions) => positions.length > 1 ? length(lineString(positions)) : 0; + + if (!from && !until) { + return measure(coordinates); + } + + const indices = findTimeEnvelopeIndices( + trackFeature.properties.coordinateProperties.times, + from ? new Date(from) : null, + until ? new Date(until) : null + ); + + return measure(trimArrayWithEnvelopeIndices(coordinates, indices)); +}; + export const addSocketStatusUpdateToTrack = (tracks, newData) => { const { track, points, ...rest } = tracks; diff --git a/src/utils/tracks.test.js b/src/utils/tracks.test.js index 848617738..140281424 100644 --- a/src/utils/tracks.test.js +++ b/src/utils/tracks.test.js @@ -1,4 +1,9 @@ -import { buildTrackSegments, getTimeOfDayPeriodBasedOnTime, fixAntimeridianCrossing } from './tracks'; +import { + buildTrackSegments, + fixAntimeridianCrossing, + getTimeOfDayPeriodBasedOnTime, + trackLengthWithinTimeRange, +} from './tracks'; import { TIME_OF_DAY_PERIODS } from '../constants'; @@ -195,6 +200,65 @@ describe('utils - tracks', () => { }); + describe('trackLengthWithinTimeRange', () => { + // A degree of longitude at the equator. + const ONE_DEGREE_IN_KILOMETERS = 111.19; + + // Tracks are stored most recent position first. + const trackData = { + track: { + features: [{ + geometry: { coordinates: [[3, 0], [2, 0], [1, 0], [0, 0]], type: 'LineString' }, + properties: { + coordinateProperties: { + times: [ + '2026-04-13T04:00:00.000Z', + '2026-04-13T03:00:00.000Z', + '2026-04-13T02:00:00.000Z', + '2026-04-13T01:00:00.000Z', + ], + }, + }, + type: 'Feature', + }], + type: 'FeatureCollection', + }, + }; + + test('measures only the positions within the time range', () => { + expect(trackLengthWithinTimeRange(trackData, '2026-04-13T02:00:00.000Z', '2026-04-13T03:00:00.000Z')) + .toBeCloseTo(ONE_DEGREE_IN_KILOMETERS, 1); + }); + + test('measures up to the most recent position when there is no end of the range', () => { + expect(trackLengthWithinTimeRange(trackData, '2026-04-13T02:00:00.000Z')) + .toBeCloseTo(2 * ONE_DEGREE_IN_KILOMETERS, 1); + }); + + test('measures the whole track when there is no time range', () => { + expect(trackLengthWithinTimeRange(trackData)).toBeCloseTo(3 * ONE_DEGREE_IN_KILOMETERS, 1); + }); + + test('leaves the track untouched', () => { + const originalTrackData = JSON.parse(JSON.stringify(trackData)); + + trackLengthWithinTimeRange(trackData, '2026-04-13T02:00:00.000Z', '2026-04-13T03:00:00.000Z'); + + expect(trackData).toEqual(originalTrackData); + }); + + test('measures no length for a time range holding a single position', () => { + expect(trackLengthWithinTimeRange(trackData, '2026-04-13T02:00:00.000Z', '2026-04-13T02:00:00.000Z')).toBe(0); + }); + + test('measures no length for a track without geometry', () => { + const trackWithoutGeometry = { track: { features: [{ properties: {}, type: 'Feature' }] } }; + + expect(trackLengthWithinTimeRange(trackWithoutGeometry)).toBe(0); + expect(trackLengthWithinTimeRange(trackWithoutGeometry, '2026-04-13T02:00:00.000Z')).toBe(0); + }); + }); + describe('fixAntimeridianCrossing', () => { test('returns original collection when input is null or undefined', () => { diff --git a/yarn.lock b/yarn.lock index 7a6669a51..60f2496ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,15 +47,15 @@ __metadata: languageName: node linkType: hard -"@auth0/auth0-react@npm:^2.23.0": - version: 2.23.0 - resolution: "@auth0/auth0-react@npm:2.23.0" +"@auth0/auth0-react@npm:^2.24.0": + version: 2.24.0 + resolution: "@auth0/auth0-react@npm:2.24.0" dependencies: "@auth0/auth0-spa-js": "npm:^2.24.1" peerDependencies: react: ^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 react-dom: ^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 - checksum: 10c0/4f23ff39deb2ad451ceea6f931697601a7058ef497ebfe3051d8355e9c7d88786f4c327781be3a0df76bc48c62ce5949bede0dce8657e9bac55238dcbac64a25 + checksum: 10c0/f096271ed279f8b63c8b82e4acc666392cfd891a2a8e47302b39170bad5fab41cd192d664602fbae066a4e5ad8793dffb8fa2fdd166793ed90937b7802135dff languageName: node linkType: hard @@ -4519,12 +4519,12 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.6.3": - version: 14.6.3 - resolution: "@testing-library/user-event@npm:14.6.3" +"@testing-library/user-event@npm:^14.6.4": + version: 14.6.4 + resolution: "@testing-library/user-event@npm:14.6.4" peerDependencies: "@testing-library/dom": ">=7.21.4" - checksum: 10c0/60c401edcc1b39f98aefd0e236af69c1a0454e3a7b91b92d62e2d654b044c51d39abc4a7ee0f5675566e7246dc5fc5e91af872a49da7e30a5f0e9e52f5a83a82 + checksum: 10c0/2460efb88c16ce6e56f64e403cc3e1905d8378a95003933924f31e3c724f5a42dd28d6fe51a9881ecd2e3b5b2e0b12363f3c7a816df90845553180b2a219b647 languageName: node linkType: hard @@ -7977,7 +7977,7 @@ __metadata: version: 0.0.0-use.local resolution: "das-web-react@workspace:." dependencies: - "@auth0/auth0-react": "npm:^2.23.0" + "@auth0/auth0-react": "npm:^2.24.0" "@babel/core": "npm:^8.0.1" "@babel/preset-env": "npm:^8.0.2" "@babel/preset-react": "npm:^8.0.1" @@ -7993,7 +7993,7 @@ __metadata: "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^7.0.1" "@testing-library/react": "npm:^16.3.2" - "@testing-library/user-event": "npm:^14.6.3" + "@testing-library/user-event": "npm:^14.6.4" "@turf/buffer": "npm:^7.4.0" "@turf/turf": "npm:^7.4.0" "@vitejs/plugin-react": "npm:^6.0.5" @@ -8011,11 +8011,11 @@ __metadata: epsg-index: "npm:^2.0.0" eslint: "npm:^10.8.1" eslint-config-airbnb: "npm:^19.0.4" - eslint-plugin-jest: "npm:^29.16.0" + eslint-plugin-jest: "npm:^29.16.1" eslint-plugin-react: "npm:^7.37.5" eslint-plugin-react-hooks: "npm:^7.1.1" geodesy: "npm:^2.4.0" - globals: "npm:^17.9.0" + globals: "npm:^17.11.0" humanize-duration: "npm:^3.34.0" i18next: "npm:^26.3.6" i18next-browser-languagedetector: "npm:^8.2.1" @@ -8686,9 +8686,9 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jest@npm:^29.16.0": - version: 29.16.0 - resolution: "eslint-plugin-jest@npm:29.16.0" +"eslint-plugin-jest@npm:^29.16.1": + version: 29.16.1 + resolution: "eslint-plugin-jest@npm:29.16.1" dependencies: "@typescript-eslint/utils": "npm:^8.0.0" peerDependencies: @@ -8703,7 +8703,7 @@ __metadata: optional: true typescript: optional: true - checksum: 10c0/ac3b0b0502c48285063a968219852979b0526f814bee90c65ef417e495b270a5aee437961ab51941caa1d0ae4ae5387a55374946165c17cf5e4f001972e0de44 + checksum: 10c0/6aca236a8db9e9e736322f991ad6f33d776b9fabd9c3ddb1bb24515191a4438b2908ec88c5768dcfbb2323f48e6105fab91ee41d35574fa0bdd7631d53986506 languageName: node linkType: hard @@ -9483,10 +9483,10 @@ __metadata: languageName: node linkType: hard -"globals@npm:^17.9.0": - version: 17.9.0 - resolution: "globals@npm:17.9.0" - checksum: 10c0/776116bffb916e462d548c26a44c9a5d8f4e551935a883991e9f5d68cbcb21b63e4c688e36bbc564f97d3025e304d8b2bb0a611ecf944825aeee164898dcfbdd +"globals@npm:^17.11.0": + version: 17.11.0 + resolution: "globals@npm:17.11.0" + checksum: 10c0/5e1be3d816e04d4d0d01b1cdd40e46b5fb6b5e9f15aece9a5919b060e0fb1b3f1b9e7327dce97ef19e05513a6d65872e0834999ddea251557556dddd0f5fde30 languageName: node linkType: hard