diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 53ccdf1a..8e7e996e 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -154,12 +154,34 @@ jobs: sudo apt-get clean || true sudo rm -rf /var/lib/apt/lists/* - # Remove swap + # Replace the image's swap with a larger one. The native (CMake/clang) and Kotlin + # compiles run alongside 4 GB Gradle and Node heaps; with no swap the runner starves + # and GitHub drops it ("hosted runner lost communication"). Prefer the /mnt temp disk + # so swap doesn't eat into the root disk freed above. sudo swapoff -a || true - sudo rm -f /swapfile || true + sudo rm -f /swapfile /mnt/swapfile || true + SWAP_FILE=/swapfile + if mountpoint -q /mnt; then SWAP_FILE=/mnt/swapfile; fi + if sudo fallocate -l 8G "$SWAP_FILE" && sudo chmod 600 "$SWAP_FILE" && sudo mkswap "$SWAP_FILE" && sudo swapon "$SWAP_FILE"; then + echo "Enabled 8G swap at $SWAP_FILE" + else + echo "::warning::Could not enable swap at $SWAP_FILE" + fi echo "Disk space after cleanup:" df -h + free -h + + # ubuntu-26.04 mounts /tmp as an ~8 GB RAM-backed tmpfs, and eas build --local works + # under /tmp by default, so the Gradle/CMake output fills it ("Disk quota exceeded"). + # Move EAS's working dir and the general temp dir onto the runner's disk. + - name: 📂 Move build temp dirs off tmpfs (Android) + if: matrix.platform == 'android' + run: | + mkdir -p "${RUNNER_TEMP}/tmp" "${RUNNER_TEMP}/eas-build" + echo "TMPDIR=${RUNNER_TEMP}/tmp" >> "$GITHUB_ENV" + echo "EAS_LOCAL_BUILD_WORKINGDIR=${RUNNER_TEMP}/eas-build" >> "$GITHUB_ENV" + df -h /tmp "${RUNNER_TEMP}" - name: 🏗 Setup Node.js uses: actions/setup-node@v4 @@ -167,6 +189,16 @@ jobs: node-version: '24' cache: 'yarn' + # Pin the JDK instead of taking the runner default: ubuntu-latest (26.04) defaults to JDK 25, + # where AGP's prefab step emits "A restricted method in java.lang.System has been called" and + # fails configureCMake (react-native-worklets). + - name: ☕ Setup Java + if: matrix.platform == 'android' + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Expo uses: expo/expo-github-action@v8 with: @@ -283,6 +315,9 @@ jobs: run: | mkdir -p ~/.gradle/ printf '%s\n' "$GRADLE_PROPERTIES" > ~/.gradle/gradle.properties + # Each Gradle worker can drive its own CMake/ninja native build, so cap workers to + # keep concurrent clang processes within the runner's memory. + echo "org.gradle.workers.max=2" >> ~/.gradle/gradle.properties - name: 📱 Build Development APK if: (matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'dev')) diff --git a/src/api/mapping/__tests__/map-icon-legend.test.ts b/src/api/mapping/__tests__/map-icon-legend.test.ts new file mode 100644 index 00000000..6cdd2196 --- /dev/null +++ b/src/api/mapping/__tests__/map-icon-legend.test.ts @@ -0,0 +1,70 @@ +import { createCachedApiEndpoint } from '../../common/cached-client'; +import { createApiEndpoint } from '../../common/client'; +import { getMapIconLegend } from '../mapping'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + return { + // Each endpoint reports its own path with the call, so a test can tell which endpoint was hit. + createApiEndpoint: jest.fn((endpoint: string) => ({ get: (...args: unknown[]) => get(endpoint, ...args) })), + __mockGet: get, + }; +}); + +jest.mock('../../common/cached-client', () => ({ + createCachedApiEndpoint: jest.fn(() => ({ get: jest.fn() })), +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.example.test/api/v4'), +})); + +const { __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockGet: jest.Mock }; + +// Endpoints are created at module load; capture them before beforeEach clears the mocks. +const plainPaths = (createApiEndpoint as jest.Mock).mock.calls.map((call) => call[0]); +const cachedPaths = (createCachedApiEndpoint as jest.Mock).mock.calls.map((call) => call[0]); + +describe('getMapIconLegend', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('targets Mapping/GetMapIconLegend without the response cache, so every open asks the server', () => { + expect(plainPaths).toContain('/Mapping/GetMapIconLegend'); + expect(cachedPaths).not.toContain('/Mapping/GetMapIconLegend'); + }); + + it('GETs the endpoint with no parameters and returns the v4 envelope as sent', async () => { + const payload = { + PageSize: 3, + Status: 'success', + Environment: 'test', + RequestId: 'abc', + Version: 'v4', + Timestamp: '2026-10-25T00:00:00Z', + Node: 'n1', + Data: { StatusStyles: [], PriorityStyles: [], Calls: [{ Id: null, Name: 'Calls with no type', States: [] }], Units: [], Personnel: [], PoiTypes: [] }, + }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getMapIconLegend()).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith('/Mapping/GetMapIconLegend', undefined, undefined); + }); + + it('forwards the abort signal', async () => { + const controller = new AbortController(); + mockGet.mockResolvedValueOnce({ data: { Data: null } }); + + await getMapIconLegend(controller.signal); + + expect(mockGet).toHaveBeenCalledWith('/Mapping/GetMapIconLegend', undefined, controller.signal); + }); + + it('passes request failures to the caller', async () => { + mockGet.mockRejectedValueOnce(new Error('Request failed with status code 404')); + + await expect(getMapIconLegend()).rejects.toThrow('404'); + }); +}); diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts index 06548eb4..c6b3eaca 100644 --- a/src/api/mapping/mapping.ts +++ b/src/api/mapping/mapping.ts @@ -2,6 +2,7 @@ import { type FeatureCollection } from 'geojson'; import { getBaseApiUrl } from '@/lib/storage/app'; import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult'; +import { type GetMapIconLegendResult } from '@/models/v4/mapping/getMapIconLegendResult'; import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult'; import { type GetAllActiveLayersResult, @@ -23,6 +24,8 @@ import { createApiEndpoint } from '../common/client'; const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers'); const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers'); +// Not cached: the legend follows the department's types, statuses, priorities and uploads, so every open asks again. +const getMapIconLegendApi = createApiEndpoint('/Mapping/GetMapIconLegend'); const getPoiApi = createCachedApiEndpoint('/Mapping/GetPoi', { ttl: 5 * 60 * 1000, enabled: true, @@ -72,6 +75,12 @@ export const getMapDataAndMarkers = async (signal?: AbortSignal) => { return response.data; }; +/** The department's map icon legend: every call, unit, personnel and POI icon the map can draw, and the colour styles. */ +export const getMapIconLegend = async (signal?: AbortSignal) => { + const response = await getMapIconLegendApi.get(undefined, signal); + return response.data; +}; + export const getPoiTypes = async () => { const response = await getPoiTypesApi.get(); return response.data; diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index ddd4cb31..517586eb 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,5 +1,5 @@ import { router, Stack, useFocusEffect } from 'expo-router'; -import { NavigationIcon } from 'lucide-react-native'; +import { InfoIcon, NavigationIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { Loading } from '@/components/common/loading'; +import { MapLegendSheet } from '@/components/maps/map-legend-sheet'; import MapPins from '@/components/maps/map-pins'; import Mapbox from '@/components/maps/mapbox'; import PinDetailModal from '@/components/maps/pin-detail-modal'; @@ -23,7 +24,7 @@ import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner'; import { logger } from '@/lib/logging'; import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera'; import { getDepartmentMapCenter } from '@/lib/map-center'; -import { getPinEntityId } from '@/lib/map-pin-ids'; +import { getPinEntityId, MapPinType } from '@/lib/map-pin-ids'; import { useDepartmentMapStyle } from '@/lib/map-style'; import { getMapboxAccessToken } from '@/lib/mapbox-token'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -84,6 +85,7 @@ function MapContent() { const [mapPins, setMapPins] = useState([]); const [selectedPin, setSelectedPin] = useState(null); const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false); + const [isLegendOpen, setIsLegendOpen] = useState(false); const { isActive } = useAppLifecycle(); const locationLatitude = useLocationStore((state) => state.latitude); const locationLongitude = useLocationStore((state) => state.longitude); @@ -536,6 +538,17 @@ function MapContent() { setSelectedPin(null); }; + const handleOpenLegend = useCallback(() => { + setIsLegendOpen(true); + }, []); + + const handleCloseLegend = useCallback(() => { + setIsLegendOpen(false); + }, []); + + // The legend shows the hydrant marker only while the map is drawing hydrants. + const showHydrantsInLegend = useMemo(() => mapPins.some((pin) => pin.Type === MapPinType.Hydrant), [mapPins]); + // Show recenter button only when map is not locked and user has moved the map const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude != null && locationLongitude != null; @@ -562,6 +575,10 @@ function MapContent() { shadowOpacity: isDark ? 0.1 : 0.25, shadowRadius: 3.84, }, + // The legend button is the same control as the recenter button, one slot above it so the two never overlap. + legendButtonOffset: { + bottom: 20 + insets.bottom + 48 + 12, + }, }; }, [colorScheme, insets.bottom]); @@ -682,10 +699,24 @@ function MapContent() { ) : null} + + {/* Map legend button - what each icon on the map means */} + + + {/* Pin Detail Modal */} + + {/* Map Icon Legend */} + ); } diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index d2839df5..dfbc1c08 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -45,6 +45,7 @@ import { HtmlRenderer } from '@/components/ui/html-renderer'; import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon'; import { useAnalytics } from '@/hooks/use-analytics'; import { useRecordsContext } from '@/hooks/use-records-context'; import { getUnitTypeCheckInBadge } from '@/lib/check-in-timer-utils'; @@ -491,7 +492,11 @@ export default function CallDetail() { {callExtraData.Dispatches.map((dispatched, index) => ( - {dispatched.Name} + + {/* Unit rows carry the unit type's icon; other rows have none and render nothing here. */} + + {dispatched.Name} + {t('call_detail.group')}: {dispatched.Group} @@ -634,16 +639,20 @@ export default function CallDetail() { {/* Header */} - - {/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */} - {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? ( - - ) : ( - <> - {call.Name} ({call.Number}) - - )} - + + {/* The call type's icon, when the department gave the type one. */} + + + {/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */} + {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? ( + + ) : ( + <> + {call.Name} ({call.Number}) + + )} + + {/* Show "Set Active" button if this call is not the active call and there is an active unit */} {activeUnit && activeCall?.CallId !== call.CallId ? ( + + ) : null} + + {state.status === 'loaded' ? renderLegend(state.legend) : null} + + + ); +}; + +const styles = StyleSheet.create({ + iconBox: { + width: PIN_SIZE, + height: PIN_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + poiBox: { + width: POI_SIZE, + alignItems: 'center', + justifyContent: 'center', + }, + swatch: { + width: 16, + height: 16, + borderRadius: 4, + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(0, 0, 0, 0.25)', + }, + swatchPlaceholder: { + width: 16, + height: 16, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + }, +}); + +export default MapLegendSheet; diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx index 10181558..2a7cf3e7 100644 --- a/src/components/maps/map-pins.tsx +++ b/src/components/maps/map-pins.tsx @@ -28,7 +28,10 @@ function isCallPin(pin: MapMakerInfoData): boolean { * based on whether the marker is a POI or a legacy (call/unit/station/personnel) marker. * * POI markers use the SVG shape + icon rendering (per the "POI Map Icon Renderer" - * reference document). Non-POI markers use PNG images from the MAP_ICONS lookup. + * reference document). Non-POI markers use PNG images from the MAP_ICONS lookup, or the + * department's uploaded icon (`IconUrl`), or the status/priority-coloured pin (`PinIconUrl`). + * The memo compares the pin object, so a refetch whose pin only changed its `PinIconUrl` + * (a status or priority change) still redraws it. */ const MapPin = React.memo(({ pin, onPinPress, isActiveCall }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void; isActiveCall?: boolean }) => { const handlePress = useCallback(() => { @@ -48,9 +51,18 @@ const MapPin = React.memo(({ pin, onPinPress, isActiveCall }: { pin: MapMakerInf allowOverlap={true} > {poi ? ( - + ) : ( - + )} ); diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index 4e712815..e40153a1 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -3,13 +3,22 @@ import React from 'react'; import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import type { PointAnnotation } from '@/components/maps/mapbox'; +import { UnitTypeIcon } from '@/components/units/unit-type-icon'; import { MAP_ICONS } from '@/constants/map-icons'; +import { getTypeIconUrl } from '@/lib/type-icons'; type MapIconKey = keyof typeof MAP_ICONS; interface PinMarkerProps { imagePath?: MapIconKey; poiImage?: MapIconKey; + /** The department's uploaded icon for the pin's unit type, call type or (personnel pins) role; the bundled icon is used when absent or it fails to load. */ + iconUrl?: string | null; + /** + * The bundled pin recoloured to the call's priority or the unit's/person's status (`PinIconUrl`). Drawn in place of the + * bundled pin, in the same box and fit, when there is no `iconUrl`; the bundled pin is used when absent or it fails to load. + */ + pinIconUrl?: string | null; title: string; size?: number; markerRef?: React.ComponentRef | null; @@ -18,27 +27,58 @@ interface PinMarkerProps { onPress?: () => void; } -const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, isActive = false, onPress }) => { - const { colorScheme } = useColorScheme(); +export interface PinMarkerIconProps { + /** Bundled `MAP_ICONS` name (any case); unknown or missing names draw the neutral flag pin. */ + imagePath?: string | null; + /** Preferred over `imagePath` when set. */ + poiImage?: string | null; + /** The department's uploaded icon; see `PinMarkerProps.iconUrl`. */ + iconUrl?: string | null; + /** The status/priority-coloured pin; see `PinMarkerProps.pinIconUrl`. */ + pinIconUrl?: string | null; + size?: number; +} +/** + * The image a map pin draws: the uploaded icon (`iconUrl`, contain fit) when there is one, else the status/priority-coloured + * pin (`pinIconUrl`), else the bundled pin. A failed upload falls back to the styled pin, a failed styled pin to the bundled one. + * Shared by the map's pins and the map legend, so a legend icon is exactly what the map draws. + */ +export const PinMarkerIcon: React.FC = React.memo(({ imagePath, poiImage, iconUrl, pinIconUrl, size = 32 }) => { // Prefer poiImage (new field) over imagePath (null for POIs after backend fix), // with final fallback to default 'call' icon const resolvedPath = poiImage || imagePath; const iconKey = resolvedPath?.toLowerCase() as MapIconKey; // Unknown markers fall back to a neutral pin, not the call icon -- that one is a flame. - const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['flag']; + const icon = iconKey && Object.prototype.hasOwnProperty.call(MAP_ICONS, iconKey) ? MAP_ICONS[iconKey] : MAP_ICONS['flag']; + + const bundledImage = ; + // The status/priority-coloured copy of the bundled pin: same shape, box and cover fit, no tint (it already carries the + // colour). Falls back to the bundled pin when the server sends none or it fails to load. + const styledPinUrl = getTypeIconUrl(pinIconUrl); + const styledPinImage = styledPinUrl ? : bundledImage; + // An uploaded icon wins. Same footprint as the bundled pin; falls back to the styled pin, then the bundled one, when it fails to load. + return iconUrl ? : styledPinImage; +}); + +PinMarkerIcon.displayName = 'PinMarkerIcon'; + +const PinMarker: React.FC = React.memo(({ imagePath, poiImage, iconUrl, pinIconUrl, title, size = 32, isActive = false, onPress }) => { + const { colorScheme } = useColorScheme(); const ringSize = size + 16; + const pinImage = ; + return ( {isActive ? ( - + {pinImage} ) : ( - + pinImage )} {title} diff --git a/src/components/maps/poi-marker.tsx b/src/components/maps/poi-marker.tsx index 1533388b..6fd63461 100644 --- a/src/components/maps/poi-marker.tsx +++ b/src/components/maps/poi-marker.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Svg, { Path } from 'react-native-svg'; +import { TypeIcon } from '@/components/units/unit-type-icon'; import { getMapIconKey, getPoiMarkerColor, getPoiMarkerIconClass, getPoiMarkerShapePath, POI_ICON_LAYOUT, POI_MARKER_DIMENSIONS } from '@/lib/poi-marker-utils'; import PoiMarkerIcon from './poi-marker-icon'; @@ -19,6 +20,8 @@ export interface PoiMarkerProps { color?: string; /** The marker shape type, e.g. "MAP_PIN", "SHIELD" */ marker?: string; + /** The department's uploaded icon for the POI type; drawn instead of the shape + icon, which remain the fallback when absent or it fails to load. */ + iconUrl?: string | null; /** Display title for the marker label */ title: string; /** Overall marker size in pixels (width). Height scales proportionally. */ @@ -40,11 +43,36 @@ export interface PoiMarkerProps { * - White icon centered horizontally, 10px from top * - Title label below the marker */ -const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, color, marker, title, size = 36, onPress }) => { +const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, color, marker, iconUrl, title, size = 36, onPress }) => { + return ( + + + + + ); +}); + +PoiMarker.displayName = 'PoiMarker'; + +export interface PoiMarkerGraphicProps { + poiImage?: string | null; + imagePath?: string | null; + color?: string | null; + marker?: string | null; + iconUrl?: string | null; + size?: number; +} + +/** + * The drawing of a POI marker without its label: the POI type's uploaded icon when there is one, else the coloured shape with + * its white icon (also the fallback when the upload fails to load). Shared by the map's POI markers and the map legend, so a + * legend icon is exactly what the map draws. Props as for `PoiMarker`. + */ +export const PoiMarkerGraphic: React.FC = React.memo(({ poiImage, imagePath, color, marker, iconUrl, size = 36 }) => { // Resolve rendering properties (with defaults per the reference document) const shapePath = getPoiMarkerShapePath(marker); const fillColor = getPoiMarkerColor(color); - const iconClass = getPoiMarkerIconClass({ PoiImage: poiImage, ImagePath: imagePath }); + const iconClass = getPoiMarkerIconClass({ PoiImage: poiImage ?? undefined, ImagePath: imagePath ?? undefined }); const iconKey = getMapIconKey(iconClass); // Height scales proportionally: width 36 → height 48, so height = width * (48/36) = width * 4/3 @@ -55,38 +83,44 @@ const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, c const iconTop = (POI_ICON_LAYOUT.topOffset / POI_MARKER_DIMENSIONS.height) * svgHeight; const iconFontSize = (POI_ICON_LAYOUT.fontSize / POI_MARKER_DIMENSIONS.width) * svgWidth; - return ( - - {/* SVG background shape with drop shadow */} - - - - - - {/* White icon overlaid on the shape */} - - - + // SVG background shape with drop shadow + const shape = ( + + + + + + {/* White icon overlaid on the shape */} + + - - + + ); + + return iconUrl ? ( + // Same footprint as the shape, sitting on its bottom edge (the marker is anchored at bottom-center). + + + + ) : ( + shape ); }); -PoiMarker.displayName = 'PoiMarker'; +PoiMarkerGraphic.displayName = 'PoiMarkerGraphic'; /** * Renders the title label below the POI marker shape. @@ -127,6 +161,10 @@ const styles = StyleSheet.create({ }, }), }, + uploadedIconContainer: { + alignItems: 'center', + justifyContent: 'flex-end', + }, iconContainer: { position: 'absolute', left: 0, diff --git a/src/components/routes/__tests__/poi-card.test.tsx b/src/components/routes/__tests__/poi-card.test.tsx new file mode 100644 index 00000000..68a972b3 --- /dev/null +++ b/src/components/routes/__tests__/poi-card.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + +import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; + +import { PoiCard } from '../poi-card'; + +const poi = { + PoiId: 9, + PoiTypeId: 3, + PoiTypeName: 'Hospital', + Name: 'General Hospital', + Address: '1 Main St', + Note: '', + Latitude: 40.7128, + Longitude: -74.006, + Color: '', + ImagePath: '', + Marker: '', + IsDestination: true, +} as PoiResultData; + +// The icon is decorative (hidden from accessibility services), so queries must opt in to hidden elements. +const hidden = { includeHiddenElements: true }; + +describe('PoiCard', () => { + it('shows the POI type icon next to the name', () => { + render(); + + expect(screen.getByText('General Hospital')).toBeTruthy(); + expect(screen.getByTestId('poi-card-type-icon-9', hidden).props.source).toEqual({ uri: 'https://cdn.example.test/hospital.png' }); + }); + + it('shows no icon when the POI type has none (older servers)', () => { + render(); + + expect(screen.getByText('General Hospital')).toBeTruthy(); + expect(screen.queryByTestId('poi-card-type-icon-9', hidden)).toBeNull(); + }); +}); diff --git a/src/components/routes/poi-card.tsx b/src/components/routes/poi-card.tsx index f1a97b8f..e9cd2147 100644 --- a/src/components/routes/poi-card.tsx +++ b/src/components/routes/poi-card.tsx @@ -8,23 +8,27 @@ import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { TypeIcon } from '@/components/units/unit-type-icon'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; interface PoiCardProps { poi: PoiResultData; poiTypeLabel: string; displayName: string; + /** The POI type's uploaded icon (see getPoiIconUrl); nothing is drawn when absent. */ + iconUrl?: string | null; isDestinationEnabled: boolean; onPress: () => void; } -export const PoiCard: React.FC = ({ poi, poiTypeLabel, displayName, isDestinationEnabled, onPress }) => { +export const PoiCard: React.FC = ({ poi, poiTypeLabel, displayName, iconUrl, isDestinationEnabled, onPress }) => { const { t } = useTranslation(); return ( + {displayName} diff --git a/src/components/routes/poi-list-content.tsx b/src/components/routes/poi-list-content.tsx index 2df3bd83..9d56e662 100644 --- a/src/components/routes/poi-list-content.tsx +++ b/src/components/routes/poi-list-content.tsx @@ -14,7 +14,7 @@ import { HStack } from '@/components/ui/hstack'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; -import { filterPois, getPoiDisplayName, getPoiTypeName, isPoiDestinationEnabled, sortPois } from '@/lib/poi-utils'; +import { filterPois, getPoiDisplayName, getPoiIconUrl, getPoiTypeName, isPoiDestinationEnabled, sortPois } from '@/lib/poi-utils'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { usePoisStore } from '@/stores/pois/store'; @@ -125,6 +125,7 @@ export const PoiListContent: React.FC = () => { poi={item} poiTypeLabel={getPoiTypeName(item, poiTypesById) || t('routes.poi_type_unknown')} displayName={getPoiDisplayName(item, poiTypesById)} + iconUrl={getPoiIconUrl(item, poiTypesById)} isDestinationEnabled={isPoiDestinationEnabled(item, poiTypesById)} onPress={() => router.push({ pathname: '/routes/poi/[id]', params: { id: item.PoiId } })} /> diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx index 0b84c73d..8f729a42 100644 --- a/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx +++ b/src/components/settings/__tests__/unit-selection-bottom-sheet-simple.test.tsx @@ -5,6 +5,12 @@ jest.mock('react-i18next', () => ({ }), })); +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + // Mock logging module to prevent Platform.OS undefined error jest.mock('@/lib/logging', () => ({ logger: { diff --git a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx index 4b11acdb..a0618bf0 100644 --- a/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/unit-selection-bottom-sheet.test.tsx @@ -32,6 +32,12 @@ jest.mock('@/lib/logging', () => ({ }, })); +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + // Mock lucide icons to avoid SVG issues in tests jest.mock('lucide-react-native', () => ({ Check: ({ size, className, testID, ...props }: any) => { @@ -153,6 +159,7 @@ jest.mock('@/components/ui/center', () => ({ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react-native'; import React from 'react'; +import { MAP_ICONS } from '@/constants/map-icons'; import { type UnitResultData } from '@/models/v4/units/unitResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useRolesStore } from '@/stores/roles/store'; @@ -605,4 +612,31 @@ describe('UnitSelectionBottomSheet', () => { expect(screen.getAllByText('Ladder')).toBeTruthy(); expect(screen.getAllByText('Rescue')).toBeTruthy(); }); + + it('shows each unit type icon in the list and the current unit box', () => { + const iconUnits = [ + { ...mockUnits[0], TypeIconUrl: 'https://cdn.example.test/engine.png', TypeMapIcon: 'engine_available' }, + { ...mockUnits[1], TypeIconUrl: null, TypeMapIcon: 'truck' }, + // Rescue 1 comes from an older server without the icon fields. + mockUnits[2], + ] as UnitResultData[]; + + mockUseCoreStore.mockImplementation((selector: any) => { + const state = { activeUnit: iconUnits[0], setActiveUnit: mockSetActiveUnit }; + return typeof selector === 'function' ? selector(state as any) : (state as any); + }); + mockUseUnitsStore.mockImplementation((selector: any) => { + const state = { units: iconUnits, fetchUnits: mockFetchUnits, isLoading: false, error: null, hasLoaded: true }; + return typeof selector === 'function' ? selector(state as any) : (state as any); + }); + + render(); + + // The icon is decorative (hidden from accessibility services), so queries must opt in to hidden elements. + const hidden = { includeHiddenElements: true }; + expect(screen.getByTestId('unit-type-icon-1', hidden).props.source).toEqual({ uri: 'https://cdn.example.test/engine.png' }); + expect(screen.getByTestId('unit-type-icon-2', hidden).props.source).toBe(MAP_ICONS.truck.uri); + expect(screen.queryByTestId('unit-type-icon-3', hidden)).toBeNull(); + expect(screen.getByTestId('current-unit-type-icon', hidden).props.source).toEqual({ uri: 'https://cdn.example.test/engine.png' }); + }); }); diff --git a/src/components/settings/background-geolocation-item.tsx b/src/components/settings/background-geolocation-item.tsx index 47c4fce0..9194aea2 100644 --- a/src/components/settings/background-geolocation-item.tsx +++ b/src/components/settings/background-geolocation-item.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useBackgroundGeolocation } from '@/lib/hooks/use-background-geolocation'; -import { locationService } from '@/services/location'; +import { logger } from '@/lib/logging'; import { useLocationStore } from '@/stores/app/location-store'; import { Alert, AlertIcon, AlertText } from '../ui/alert'; @@ -22,19 +22,18 @@ export const BackgroundGeolocationItem = () => { const handleToggle = React.useCallback( async (value: boolean) => { try { + // Registers or unregisters the OS-managed background task through the location service. + // Starting the watcher-based fallback here as well would run a second watcher in the + // foreground whenever background permission is missing, posting every fix twice. await setBackgroundGeolocationEnabled(value); // Update the location store state setLocationBackgroundEnabled(value); - - // Start or stop background location updates based on the value - if (value) { - await locationService.startBackgroundUpdates(); - } else { - await locationService.stopBackgroundUpdates(); - } } catch (error) { - console.error('Failed to toggle background geolocation:', error); + logger.error({ + message: 'Failed to toggle background geolocation', + context: { error, enabled: value }, + }); } }, [setBackgroundGeolocationEnabled, setLocationBackgroundEnabled] diff --git a/src/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx index d2c62796..6dc901f3 100644 --- a/src/components/settings/unit-selection-bottom-sheet.tsx +++ b/src/components/settings/unit-selection-bottom-sheet.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Check } from '@/components/ui/lucide-icons'; +import { UnitTypeIcon } from '@/components/units/unit-type-icon'; import { logger } from '@/lib/logging'; import { type UnitResultData } from '@/models/v4/units/unitResultData'; import { useCoreStore } from '@/stores/app/core-store'; @@ -34,6 +35,7 @@ const UnitItem = React.memo(({ unit, isSelected, isLoading, onSel return ( + {unit.Name} @@ -177,9 +179,12 @@ export const UnitSelectionBottomSheet = React.memo {t('settings.current_unit')} - - {activeUnit.Name} - + + + + {activeUnit.Name} + + )} diff --git a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx index 305425ab..b7097870 100644 --- a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx +++ b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx @@ -13,6 +13,12 @@ const mockPlatform = { // Mock react-native Platform jest.mock('react-native/Libraries/Utilities/Platform', () => mockPlatform); +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + // Mock react-native-svg to avoid Platform.OS issues jest.mock('react-native-svg', () => { const React = require('react'); diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx index 9eda8b90..7668dac4 100644 --- a/src/components/sidebar/__tests__/unit-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/unit-sidebar.test.tsx @@ -27,6 +27,12 @@ jest.mock('@/components/audio-stream/audio-stream-bottom-sheet', () => ({ AudioStreamBottomSheet: () => null, })); +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + // Mock lucide-react-native icons jest.mock('lucide-react-native', () => ({ Lock: () => null, @@ -216,4 +222,34 @@ describe('SidebarUnitCard', () => { expect(mockSetMapLocked).toHaveBeenCalledWith(false); }); + + describe('unit type icon', () => { + // The icon is decorative (hidden from accessibility services), so queries must opt in to hidden elements. + const hidden = { includeHiddenElements: true }; + + it('shows the active unit type icon next to its name', () => { + const mockActiveUnit = { Name: 'Ambulance 1', Type: 'Ambulance', GroupName: 'Station 1', TypeIconUrl: 'https://cdn.example.test/ambulance.png', TypeMapIcon: 'ambulance' } as any; + mockUseCoreStore.mockImplementation((selector) => selector({ activeUnit: mockActiveUnit } as any)); + + render(); + + expect(screen.getByText('Ambulance 1')).toBeTruthy(); + expect(screen.getByTestId('unit-sidebar-type-icon', hidden).props.source).toEqual({ uri: 'https://cdn.example.test/ambulance.png' }); + }); + + it('shows no icon without an active unit', () => { + render(); + + expect(screen.queryByTestId('unit-sidebar-type-icon', hidden)).toBeNull(); + }); + + it('shows no icon when the active unit type has none (older servers)', () => { + mockUseCoreStore.mockImplementation((selector) => selector({ activeUnit: { Name: 'Engine 1', Type: 'Engine', GroupName: 'Station 1' } } as any)); + + render(); + + expect(screen.getByText('Engine 1')).toBeTruthy(); + expect(screen.queryByTestId('unit-sidebar-type-icon', hidden)).toBeNull(); + }); + }); }); \ No newline at end of file diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx index 4af5b509..ae9d4a38 100644 --- a/src/components/sidebar/unit-sidebar.tsx +++ b/src/components/sidebar/unit-sidebar.tsx @@ -5,6 +5,7 @@ import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; import { Text } from '@/components/ui/text'; +import { UnitTypeIcon } from '@/components/units/unit-type-icon'; import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; import { useCoreStore } from '@/stores/app/core-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; @@ -57,7 +58,10 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn return ( {displayType} - {displayName} + + + {displayName} + {displayGroup} {/* Call button and status */} @@ -111,6 +115,11 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn }; const styles = StyleSheet.create({ + nameRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, callContainer: { marginTop: 8, flexDirection: 'row', diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index 896b823c..8b9a4ad6 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -9,6 +9,12 @@ jest.mock('@/hooks/use-keyboard-height', () => ({ useKeyboardHeight: () => mockKeyboardHeight, })); +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + // Mock all UI components based on actual imports jest.mock('@/components/ui/actionsheet', () => { const mockReact = require('react'); @@ -1693,6 +1699,51 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('poi'); }); + it('should show POI type icons on POI destination rows', () => { + const poiWithIcon = { PoiId: 42, PoiTypeId: 7, PoiTypeName: 'Hospital', Name: 'Mercy Hospital', Address: '789 Care Way', Note: '' }; + const poiWithoutIcon = { PoiId: 43, PoiTypeId: 8, PoiTypeName: 'Shelter', Name: 'North Shelter', Address: '1 North Rd', Note: '' }; + + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { + const store = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus: { Id: 'status-1', Text: 'Transporting', Detail: 4, Note: 0 }, + availablePois: [poiWithIcon, poiWithoutIcon], + availablePoiTypes: [ + { PoiTypeId: 7, Name: 'Hospital', IsDestination: true, IconUrl: 'https://cdn.example.test/hospital.png' }, + { PoiTypeId: 8, Name: 'Shelter', IsDestination: true }, + ], + }; + return selector ? selector(store) : store; + }); + + render(); + + // The icon is decorative (hidden from accessibility services), so queries must opt in to hidden elements. + expect(screen.getByTestId('status-poi-type-icon-42', { includeHiddenElements: true }).props.source).toEqual({ uri: 'https://cdn.example.test/hospital.png' }); + expect(screen.queryByTestId('status-poi-type-icon-43', { includeHiddenElements: true })).toBeNull(); + }); + + it('should show call type icons on call destination rows', () => { + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { + const store = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus: { Id: 'status-1', Text: 'Responding', Detail: 2, Note: 0 }, + availableCalls: [ + { CallId: 'call-1', Number: 'C001', Name: 'Structure Fire', Address: '1 Main St', TypeIconUrl: 'https://cdn.example.test/fire.png', TypeMapIcon: 'fire' }, + { CallId: 'call-2', Number: 'C002', Name: 'Medical', Address: '2 Main St' }, + ], + }; + return selector ? selector(store) : store; + }); + + render(); + + expect(screen.getByTestId('status-call-type-icon-call-1', { includeHiddenElements: true }).props.source).toEqual({ uri: 'https://cdn.example.test/fire.png' }); + expect(screen.queryByTestId('status-call-type-icon-call-2', { includeHiddenElements: true })).toBeNull(); + }); + it('should render many items without height constraints for proper scrolling', () => { // Create many mock calls to test scrolling const manyCalls = Array.from({ length: 10 }, (_, index) => ({ diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index cd1ab94f..2a334ea4 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -7,7 +7,7 @@ import { InteractionManager, ScrollView, StyleSheet, TouchableOpacity, View } fr import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { logger } from '@/lib/logging'; -import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; +import { createPoiTypeMap, getPoiIconUrl, getPoiSelectionLabel } from '@/lib/poi-utils'; import { getUnitStatusCallDestinationId, resolveDefaultStatusCall } from '@/lib/status-destination'; import { canSubmitStatusWithoutInput, getOfferedStatuses, resolveCurrentStatusId } from '@/lib/status-flow'; import { readableColors } from '@/lib/utils'; @@ -34,6 +34,7 @@ import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { Textarea, TextareaInput } from '../ui/textarea'; import { VStack } from '../ui/vstack'; +import { TypeIcon } from '../units/unit-type-icon'; import { CURRENT_STATUS_RING_OUTSET, CurrentStatusRing } from './current-status-ring'; import { HoldToConfirmButton } from './hold-to-confirm-button'; @@ -1220,6 +1221,7 @@ export const StatusBottomSheet = () => { > + {call.Number} - {call.Name} @@ -1285,6 +1287,7 @@ export const StatusBottomSheet = () => { > + {getPoiSelectionLabel(poi, poiTypesById)} {poiSecondaryText ? {poiSecondaryText} : null} diff --git a/src/components/units/__tests__/unit-type-icon.test.tsx b/src/components/units/__tests__/unit-type-icon.test.tsx new file mode 100644 index 00000000..2c1dd157 --- /dev/null +++ b/src/components/units/__tests__/unit-type-icon.test.tsx @@ -0,0 +1,128 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + +import { MAP_ICONS } from '@/constants/map-icons'; + +import { getUnitTypeMapIconKey, TypeIcon, UnitTypeIcon } from '../unit-type-icon'; + +const ICON_URL = 'https://cdn.example.test/unit-types/7/icon-abc.png'; +// The icon is decorative (hidden from accessibility services), so queries must opt in to hidden elements. +const HIDDEN = { includeHiddenElements: true }; + +describe('UnitTypeIcon', () => { + it('renders the uploaded icon when a URL is given', () => { + render(); + + const image = screen.getByTestId('icon', HIDDEN); + expect(image.props.source).toEqual({ uri: ICON_URL }); + expect(image.props.contentFit).toBe('contain'); + expect(image.props.cachePolicy).toBe('memory-disk'); + expect(image.props.style).toEqual({ width: 30, height: 30 }); + }); + + it('renders the bundled map icon when there is no upload, matching the name case-insensitively', () => { + render(); + + const image = screen.getByTestId('icon', HIDDEN); + expect(image.props.source).toBe(MAP_ICONS.ambulance.uri); + expect(image.props.contentFit).toBe('contain'); + expect(image.props.style).toEqual({ width: 24, height: 24 }); + }); + + it('renders the fallback when there is no upload and no known map icon', () => { + render(U} testID="icon" />); + + expect(screen.queryByTestId('icon', HIDDEN)).toBeNull(); + expect(screen.getByText('U')).toBeTruthy(); + }); + + it('renders nothing by default when there is nothing to show', () => { + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); + + it('falls back to the bundled map icon when the upload fails to load', () => { + render(); + + act(() => { + fireEvent(screen.getByTestId('icon', HIDDEN), 'error', { error: 'boom' }); + }); + + expect(screen.getByTestId('icon', HIDDEN).props.source).toBe(MAP_ICONS.cartwo.uri); + }); + + it('falls back to the fallback when the upload fails to load and there is no map icon', () => { + render(U} testID="icon" />); + + act(() => { + fireEvent(screen.getByTestId('icon', HIDDEN), 'error', { error: 'boom' }); + }); + + expect(screen.queryByTestId('icon', HIDDEN)).toBeNull(); + expect(screen.getByText('U')).toBeTruthy(); + }); + + it('fits the image with the given contentFit, keeping the square box', () => { + const { unmount } = render(); + + const image = screen.getByTestId('icon', HIDDEN); + expect(image.props.contentFit).toBe('cover'); + expect(image.props.style).toEqual({ width: 32, height: 32 }); + unmount(); + }); + + it('tries a new upload URL after an earlier one failed', () => { + const { rerender } = render(U} testID="icon" />); + + act(() => { + fireEvent(screen.getByTestId('icon', HIDDEN), 'error', { error: 'boom' }); + }); + expect(screen.queryByTestId('icon', HIDDEN)).toBeNull(); + + const nextUrl = 'https://cdn.example.test/unit-types/7/icon-def.png'; + rerender(U} testID="icon" />); + + expect(screen.getByTestId('icon', HIDDEN).props.source).toEqual({ uri: nextUrl }); + }); + + it('is hidden from accessibility services', () => { + render(); + + const image = screen.getByTestId('icon', HIDDEN); + expect(image.props.accessible).toBe(false); + expect(image.props.accessibilityElementsHidden).toBe(true); + expect(image.props.importantForAccessibility).toBe('no-hide-descendants'); + }); +}); + +describe('TypeIcon', () => { + it('is the same component, for call and POI type icons', () => { + expect(TypeIcon).toBe(UnitTypeIcon); + + render(); + expect(screen.getByTestId('icon', HIDDEN).props.source).toBe(MAP_ICONS.fire.uri); + }); +}); + +describe('getUnitTypeMapIconKey', () => { + it('returns the lowercase bundled key for a known name', () => { + expect(getUnitTypeMapIconKey('FourByFour')).toBe('fourbyfour'); + expect(getUnitTypeMapIconKey(' ambulance ')).toBe('ambulance'); + }); + + it('returns null for missing, unknown or prototype names', () => { + expect(getUnitTypeMapIconKey(undefined)).toBeNull(); + expect(getUnitTypeMapIconKey(null)).toBeNull(); + expect(getUnitTypeMapIconKey('')).toBeNull(); + expect(getUnitTypeMapIconKey('spaceship')).toBeNull(); + expect(getUnitTypeMapIconKey('constructor')).toBeNull(); + }); +}); diff --git a/src/components/units/unit-type-icon.tsx b/src/components/units/unit-type-icon.tsx new file mode 100644 index 00000000..c2a46ed5 --- /dev/null +++ b/src/components/units/unit-type-icon.tsx @@ -0,0 +1,75 @@ +import { Image, type ImageContentFit } from 'expo-image'; +import React, { useCallback, useState } from 'react'; + +import { MAP_ICONS } from '@/constants/map-icons'; + +type MapIconKey = keyof typeof MAP_ICONS; + +export interface UnitTypeIconProps { + /** Absolute URL of the department's uploaded icon for the unit's type (`TypeIconUrl`). */ + iconUrl?: string | null; + /** Built-in icon name the unit's type picked (`TypeMapIcon`), a `MAP_ICONS` key. */ + mapIcon?: string | null; + /** Width and height of the square the icon is fitted into. */ + size?: number; + /** How the image is fitted into that square: `contain` (the default) for square uploads, `cover` to match an image drawn the way the bundled map pins are. */ + contentFit?: ImageContentFit; + /** Rendered when there is no icon to show (no upload, unknown built-in name, or the upload failed to load). */ + fallback?: React.ReactNode; + testID?: string; +} + +/** The bundled `MAP_ICONS` key for a unit type's built-in icon name, or null when it is not one. */ +export const getUnitTypeMapIconKey = (mapIcon?: string | null): MapIconKey | null => { + if (typeof mapIcon !== 'string') return null; + const key = mapIcon.trim().toLowerCase(); + return key && Object.prototype.hasOwnProperty.call(MAP_ICONS, key) ? (key as MapIconKey) : null; +}; + +/** + * The icon a department gave a unit's type, shown next to the unit's name: the uploaded image when there + * is one, else the type's built-in map icon, else `fallback`. A failed upload falls through to the + * built-in icon. Decorative: the unit name next to it already says what it is. + */ +export const UnitTypeIcon: React.FC = React.memo(({ iconUrl, mapIcon, size = 24, contentFit = 'contain', fallback = null, testID }) => { + const url = typeof iconUrl === 'string' ? iconUrl.trim() : ''; + // Keyed by URL so a new upload (new URL) gets a fresh attempt. + const [failedUrl, setFailedUrl] = useState(null); + const handleError = useCallback(() => setFailedUrl(url), [url]); + + const showUrl = url.length > 0 && failedUrl !== url; + const mapIconKey = getUnitTypeMapIconKey(mapIcon); + + if (!showUrl && !mapIconKey) { + return <>{fallback}; + } + + return ( + is draggable by default, so grabbing a map pin would drag a ghost image instead of + // panning the map. react-native-web's Image (the bundled pins) already renders draggable={false}. + draggable={false} + accessible={false} + accessibilityElementsHidden={true} + importantForAccessibility="no-hide-descendants" + /> + ); +}); + +UnitTypeIcon.displayName = 'UnitTypeIcon'; + +/** + * Type-neutral name for the same component, for call type and POI type icons: `iconUrl` is the type's + * uploaded icon, `mapIcon` its built-in `MAP_ICONS` name (call types; POI types have none). + */ +export const TypeIcon = UnitTypeIcon; + +export default UnitTypeIcon; diff --git a/src/lib/__tests__/live-locations.test.ts b/src/lib/__tests__/live-locations.test.ts index 0d6cf844..376fbc30 100644 --- a/src/lib/__tests__/live-locations.test.ts +++ b/src/lib/__tests__/live-locations.test.ts @@ -198,6 +198,20 @@ describe('applyLiveLocations', () => { expect(pins[1]).toBe(unitPin); }); + it("keeps a moved personnel pin's role icon and every other field", () => { + const iconPersonPin = makePin({ Id: 'pAB12-CD', Type: 3, Latitude: 30, Longitude: -80, ImagePath: 'Person_RespondingCall', Title: 'Jane Smith', IconUrl: 'https://cdn.example.test/roles/5/icon.png' }); + const iconUnitPin = makePin({ Id: 'u1', Type: 1, Latitude: 40, Longitude: -74, IconUrl: 'https://cdn.example.test/unit-types/7/icon.png' }); + + const result = applyLiveLocations([iconUnitPin, iconPersonPin], { + 'pab12-cd': makeLocation({ pinId: 'pab12-cd', latitude: 31, longitude: -81 }), + u1: makeLocation({ pinId: 'u1', latitude: 41, longitude: -73 }), + }); + + expect(result.pins[1]).toEqual({ ...iconPersonPin, Latitude: 31, Longitude: -81 }); + expect(result.pins[1].IconUrl).toBe('https://cdn.example.test/roles/5/icon.png'); + expect(result.pins[0].IconUrl).toBe('https://cdn.example.test/unit-types/7/icon.png'); + }); + it('matches personnel pins case-insensitively', () => { const result = applyLiveLocations(pins, { 'pab12-cd': makeLocation({ pinId: 'pab12-cd', latitude: 31, longitude: -81 }) }); diff --git a/src/lib/__tests__/location-transmission-gate.test.ts b/src/lib/__tests__/location-transmission-gate.test.ts new file mode 100644 index 00000000..9a722133 --- /dev/null +++ b/src/lib/__tests__/location-transmission-gate.test.ts @@ -0,0 +1,103 @@ +import { distanceInMeters, LocationTransmissionGate } from '@/lib/location-transmission-gate'; + +describe('distanceInMeters', () => { + it('is zero for the same point', () => { + expect(distanceInMeters({ latitude: 37.7749, longitude: -122.4194 }, { latitude: 37.7749, longitude: -122.4194 })).toBe(0); + }); + + it('measures about 111km per degree of latitude', () => { + expect(distanceInMeters({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 })).toBeCloseTo(111195, -1); + }); + + it('shrinks a degree of longitude towards the poles', () => { + const atEquator = distanceInMeters({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 }); + const at60North = distanceInMeters({ latitude: 60, longitude: 0 }, { latitude: 60, longitude: 1 }); + + expect(at60North).toBeCloseTo(atEquator / 2, -2); + }); +}); + +describe('LocationTransmissionGate', () => { + const options = { minIntervalMs: 15_000, minDistanceMeters: 25, stationaryIntervalMs: 300_000 }; + const origin = { latitude: 37.7749, longitude: -122.4194 }; + // ~111m north of origin. + const moved = { latitude: 37.7759, longitude: -122.4194 }; + // ~11m north of origin: GPS jitter. + const jitter = { latitude: 37.775, longitude: -122.4194 }; + + let gate: LocationTransmissionGate; + + beforeEach(() => { + gate = new LocationTransmissionGate(options); + }); + + it('sends the first fix', () => { + expect(gate.evaluate('unit-1', origin, 0)).toEqual({ action: 'send' }); + }); + + it('defers a moving fix inside the interval until the interval expires', () => { + gate.record('unit-1', origin, 0); + + expect(gate.evaluate('unit-1', moved, 4_000)).toEqual({ action: 'defer', retryInMs: 11_000 }); + }); + + it('sends a moving fix once the interval has elapsed', () => { + gate.record('unit-1', origin, 0); + + expect(gate.evaluate('unit-1', moved, 15_000)).toEqual({ action: 'send' }); + }); + + it('skips jitter until the stationary interval elapses', () => { + gate.record('unit-1', origin, 0); + + expect(gate.evaluate('unit-1', jitter, 60_000)).toEqual({ action: 'skip' }); + expect(gate.evaluate('unit-1', jitter, 299_999)).toEqual({ action: 'skip' }); + expect(gate.evaluate('unit-1', jitter, 300_000)).toEqual({ action: 'send' }); + }); + + it('sends immediately for a different unit', () => { + gate.record('unit-1', origin, 0); + + expect(gate.evaluate('unit-2', origin, 1_000)).toEqual({ action: 'send' }); + }); + + it('starts over when the clock goes backwards', () => { + gate.record('unit-1', origin, 100_000); + + expect(gate.evaluate('unit-1', origin, 50_000)).toEqual({ action: 'send' }); + }); + + it('measures distance from the last recorded transmission', () => { + gate.record('unit-1', origin, 0); + gate.record('unit-1', moved, 15_000); + + // Back at origin is a move relative to the last transmission. + expect(gate.evaluate('unit-1', origin, 30_000)).toEqual({ action: 'send' }); + expect(gate.evaluate('unit-1', moved, 30_000)).toEqual({ action: 'skip' }); + }); + + describe('after a rejection', () => { + it('retries an unmoved position once the interval allows, instead of treating it as delivered', () => { + const record = gate.record('unit-1', origin, 0); + gate.markRejected(record); + + expect(gate.evaluate('unit-1', origin, 5_000)).toEqual({ action: 'defer', retryInMs: 10_000 }); + expect(gate.evaluate('unit-1', origin, 15_000)).toEqual({ action: 'send' }); + }); + + it('ignores a rejection for a transmission that has since been superseded', () => { + const first = gate.record('unit-1', origin, 0); + gate.record('unit-1', moved, 15_000); + gate.markRejected(first); + + expect(gate.evaluate('unit-1', moved, 20_000)).toEqual({ action: 'skip' }); + }); + }); + + it('forgets everything on reset', () => { + gate.record('unit-1', origin, 0); + gate.reset(); + + expect(gate.evaluate('unit-1', origin, 1_000)).toEqual({ action: 'send' }); + }); +}); diff --git a/src/lib/__tests__/poi-utils.test.ts b/src/lib/__tests__/poi-utils.test.ts index 74e4e5b5..e3e7952f 100644 --- a/src/lib/__tests__/poi-utils.test.ts +++ b/src/lib/__tests__/poi-utils.test.ts @@ -1,4 +1,4 @@ -import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, groupPoisByType, isPoiDestinationEnabled, sortPois } from '../poi-utils'; +import { createPoiTypeMap, getPoiDisplayName, getPoiIconUrl, getPoiSelectionLabel, groupPoisByType, isPoiDestinationEnabled, sortPois } from '../poi-utils'; describe('poi-utils', () => { const poiTypes = [ @@ -138,4 +138,18 @@ describe('poi-utils', () => { expect(sorted.map((poi) => poi.PoiId)).toEqual([21, 20]); }); + + it('resolves the POI type icon from the POI, then its type', () => { + const typesWithIcons = createPoiTypeMap([ + { PoiTypeId: 1, Name: 'Hospital', IsDestination: true, IconUrl: 'https://cdn.example.test/hospital.png' }, + { PoiTypeId: 2, Name: 'Shelter', IsDestination: false }, + ] as any); + + expect(getPoiIconUrl({ PoiTypeId: 1, IconUrl: 'https://cdn.example.test/own.png' }, typesWithIcons)).toBe('https://cdn.example.test/own.png'); + expect(getPoiIconUrl({ PoiTypeId: 1, IconUrl: null }, typesWithIcons)).toBe('https://cdn.example.test/hospital.png'); + expect(getPoiIconUrl({ PoiTypeId: 1 }, typesWithIcons)).toBe('https://cdn.example.test/hospital.png'); + expect(getPoiIconUrl({ PoiTypeId: 2 }, typesWithIcons)).toBeNull(); + expect(getPoiIconUrl({ PoiTypeId: 3, IconUrl: ' ' }, typesWithIcons)).toBeNull(); + expect(getPoiIconUrl({ PoiTypeId: 1 })).toBeNull(); + }); }); diff --git a/src/lib/__tests__/type-icons.test.ts b/src/lib/__tests__/type-icons.test.ts new file mode 100644 index 00000000..bef95938 --- /dev/null +++ b/src/lib/__tests__/type-icons.test.ts @@ -0,0 +1,25 @@ +import { getTypeIconUrl } from '../type-icons'; + +describe('getTypeIconUrl', () => { + it('returns an absolute http(s) URL, trimmed', () => { + expect(getTypeIconUrl('https://api.example.test/api/v4/TypeIcons/Pins/Engine-responding?v=1')).toBe('https://api.example.test/api/v4/TypeIcons/Pins/Engine-responding?v=1'); + expect(getTypeIconUrl(' http://api.example.test/pin.png ')).toBe('http://api.example.test/pin.png'); + expect(getTypeIconUrl('HTTPS://API.EXAMPLE.TEST/PIN.PNG')).toBe('HTTPS://API.EXAMPLE.TEST/PIN.PNG'); + }); + + it.each([ + undefined, + null, + '', + ' ', + '/api/v4/TypeIcons/Pins/Engine-responding?v=1', + 'api.example.test/pin.png', + 'javascript:alert(1)', + 'data:image/png;base64,AAAA', + 'ftp://api.example.test/pin.png', + 'https://', + 'https://api.example.test/a pin.png', + ])('returns null for %p', (value) => { + expect(getTypeIconUrl(value)).toBeNull(); + }); +}); diff --git a/src/lib/location-transmission-gate.ts b/src/lib/location-transmission-gate.ts new file mode 100644 index 00000000..4153c36b --- /dev/null +++ b/src/lib/location-transmission-gate.ts @@ -0,0 +1,107 @@ +/** + * Decides which location fixes are worth transmitting to the unit-location endpoint. + * + * The OS does not pace fixes the way the request asks: iOS ignores `timeInterval` entirely and + * reports on every `distanceInterval` of travel, so a unit at highway speed produces several fixes a + * second. The OS-managed task and the foreground watcher can also both be running at once. Posting + * every one of those keeps the cellular radio awake for nothing — dispatch only needs the latest + * position at a steady cadence. + * + * Pure state machine; the caller owns the clock and the actual send. + */ + +export interface LocationTransmissionGateOptions { + /** Never transmit more often than this. */ + minIntervalMs: number; + /** Movement below this since the last transmission is GPS jitter, not travel. */ + minDistanceMeters: number; + /** A unit that has not moved still re-confirms its position this often, when a fix arrives. */ + stationaryIntervalMs: number; +} + +export type TransmissionDecision = + /** Transmit now. */ + | { action: 'send' } + /** The unit moved, but the last transmission was too recent: transmit after `retryInMs`. */ + | { action: 'defer'; retryInMs: number } + /** Nothing new to say: the unit is where it was when last transmitted. */ + | { action: 'skip' }; + +export interface TransmissionCoordinates { + latitude: number; + longitude: number; +} + +interface TransmittedFix extends TransmissionCoordinates { + unitId: string; + transmittedAt: number; + /** False once the server refused it: the position is not on the server, only the pacing stands. */ + accepted: boolean; +} + +/** Opaque handle for one recorded transmission. */ +export type TransmissionRecord = Readonly; + +const EARTH_RADIUS_METERS = 6371008.8; + +const toRadians = (degrees: number): number => (degrees * Math.PI) / 180; + +/** Great-circle distance in meters (haversine). */ +export const distanceInMeters = (from: TransmissionCoordinates, to: TransmissionCoordinates): number => { + const deltaLatitude = toRadians(to.latitude - from.latitude); + const deltaLongitude = toRadians(to.longitude - from.longitude); + const a = Math.sin(deltaLatitude / 2) ** 2 + Math.cos(toRadians(from.latitude)) * Math.cos(toRadians(to.latitude)) * Math.sin(deltaLongitude / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +export class LocationTransmissionGate { + private last: TransmittedFix | null = null; + + constructor(private readonly options: LocationTransmissionGateOptions) {} + + evaluate(unitId: string, coordinates: TransmissionCoordinates, now: number): TransmissionDecision { + const last = this.last; + + // A different unit has never been transmitted; a clock that went backwards makes the elapsed + // time meaningless. Either way, start over. + if (!last || last.unitId !== unitId || now < last.transmittedAt) { + return { action: 'send' }; + } + + const elapsed = now - last.transmittedAt; + + // A refused position never reached the server, so any fix is news — but still no faster than + // the interval, or a failing server would be retried on every fix. + if (last.accepted && distanceInMeters(last, coordinates) < this.options.minDistanceMeters) { + return elapsed >= this.options.stationaryIntervalMs ? { action: 'send' } : { action: 'skip' }; + } + + return elapsed >= this.options.minIntervalMs ? { action: 'send' } : { action: 'defer', retryInMs: this.options.minIntervalMs - elapsed }; + } + + /** Record a transmission attempt. Recorded before the request resolves, so concurrent fixes see it. */ + record(unitId: string, coordinates: TransmissionCoordinates, now: number): TransmissionRecord { + const record: TransmittedFix = { unitId, latitude: coordinates.latitude, longitude: coordinates.longitude, transmittedAt: now, accepted: true }; + this.last = record; + return record; + } + + /** + * The server refused `record`. Ignored when a newer transmission has been recorded since: that + * one's outcome is what decides the next fix. + */ + markRejected(record: TransmissionRecord): void { + if (this.last === record) { + this.last = { ...record, accepted: false }; + } + } + + /** When the last transmission was recorded (epoch ms), or null if none has been. */ + getLastTransmittedAt(): number | null { + return this.last?.transmittedAt ?? null; + } + + reset(): void { + this.last = null; + } +} diff --git a/src/lib/poi-utils.ts b/src/lib/poi-utils.ts index a40a84ce..a947edae 100644 --- a/src/lib/poi-utils.ts +++ b/src/lib/poi-utils.ts @@ -23,6 +23,11 @@ export const getPoiTypeName = (poi: Pick, poiTypesById?: Record): string | null => { + return normalizeText(poi.IconUrl) || normalizeText(poiTypesById?.[poi.PoiTypeId]?.IconUrl) || null; +}; + export const getPoiDisplayName = (poi: PoiResultData, poiTypesById?: Record): string => { return normalizeText(poi.Name) || normalizeText(poi.Address) || normalizeText(poi.Note) || getPoiTypeName(poi, poiTypesById); }; diff --git a/src/lib/type-icons.ts b/src/lib/type-icons.ts new file mode 100644 index 00000000..884eb753 --- /dev/null +++ b/src/lib/type-icons.ts @@ -0,0 +1,10 @@ +/** + * An icon URL from the server (a department upload, or a map pin's status/priority-coloured `PinIconUrl`) when it is + * usable, otherwise null. Only absolute http(s) URLs are accepted: the server sends an absolute, anonymous, immutable + * image URL, and anything else (blank, relative, another scheme) is treated as "none". + */ +export const getTypeIconUrl = (iconUrl?: string | null): string | null => { + if (typeof iconUrl !== 'string') return null; + const trimmed = iconUrl.trim(); + return /^https?:\/\/\S+$/i.test(trimmed) ? trimmed : null; +}; diff --git a/src/models/v4/callTypes/callTypeResultData.ts b/src/models/v4/callTypes/callTypeResultData.ts index 47395119..a3b85f30 100644 --- a/src/models/v4/callTypes/callTypeResultData.ts +++ b/src/models/v4/callTypes/callTypeResultData.ts @@ -3,4 +3,8 @@ export class CallTypeResultData { public Name: string = ''; /** What kind of call the type is (CallTypeClasses); -1 none, undefined from older servers. */ public BaseType?: number; + /** Absolute URL of the department's uploaded icon for this call type; null/absent when none or on older servers. */ + public IconUrl?: string | null = null; + /** Built-in icon this call type picked, lowercase `MAP_ICONS` key (e.g. "fire"); null/absent when none or on older servers. */ + public MapIcon?: string | null = null; } diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts index 0824c329..86b6ff62 100644 --- a/src/models/v4/calls/callResultData.ts +++ b/src/models/v4/calls/callResultData.ts @@ -32,6 +32,10 @@ export class CallResultData { public Type: string = 'No Type'; /** Id of the department call type the call was logged with (survives a rename); null/undefined when none or older servers. */ public TypeId?: string | null; + /** Absolute URL of the department's uploaded icon for the call's type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public TypeIconUrl?: string | null = null; + /** Built-in icon the call's type picked, lowercase `MAP_ICONS` key (e.g. "fire"); null/absent when none or on older servers. */ + public TypeMapIcon?: string | null = null; public LoggedOnUtc: string = ''; public DispatchedOn: string = ''; public DispatchedOnUtc: string = ''; diff --git a/src/models/v4/calls/dispatchedEventResultData.ts b/src/models/v4/calls/dispatchedEventResultData.ts index 136e5c31..daaa53a0 100644 --- a/src/models/v4/calls/dispatchedEventResultData.ts +++ b/src/models/v4/calls/dispatchedEventResultData.ts @@ -19,4 +19,9 @@ export class DispatchedEventResultData { * call). Null or absent on older rows and non-status entries. */ public DestinationSource?: number | null; + + /** Unit rows only: uploaded icon URL for the unit's type; null/absent otherwise or on older servers. */ + public TypeIconUrl?: string | null = null; + /** Unit rows only: built-in map icon name (lowercase `MAP_ICONS` key) for the unit's type. */ + public TypeMapIcon?: string | null = null; } diff --git a/src/models/v4/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts index d3dc4772..fa661c72 100644 --- a/src/models/v4/mapping/getMapDataAndMarkersData.ts +++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts @@ -26,4 +26,17 @@ export class MapMakerInfoData { public Note?: string = ''; public LayerId?: string = ''; public LayerName?: string = ''; + /** + * Uploaded icon URL: for unit, call and POI pins (Types 1, 0 and 4) the pin's type's icon; for a personnel pin (Type 3) + * the icon of the first role the person holds, in the department's role order, that has one. Null/absent when none or on older servers. + */ + public IconUrl?: string | null = null; + /** + * Absolute URL of the built-in pin recoloured to the call's priority or the unit's/person's status: the same 32x37 shape, + * size and anchor as the bundled `ImagePath` pin. Null for stations, POIs and hydrants, when the priority/status has no + * meaning, or on older servers. + */ + public PinIconUrl?: string | null = null; + /** Style key behind `PinIconUrl` (a status such as `responding`, or a priority such as `high`); null whenever `PinIconUrl` is. */ + public IconStyle?: string | null = null; } diff --git a/src/models/v4/mapping/getMapIconLegendResult.ts b/src/models/v4/mapping/getMapIconLegendResult.ts new file mode 100644 index 00000000..b67efe3b --- /dev/null +++ b/src/models/v4/mapping/getMapIconLegendResult.ts @@ -0,0 +1,6 @@ +import { BaseV4Request } from '../baseV4Request'; +import { GetMapIconLegendResultData } from './getMapIconLegendResultData'; + +export class GetMapIconLegendResult extends BaseV4Request { + public Data: GetMapIconLegendResultData = new GetMapIconLegendResultData(); +} diff --git a/src/models/v4/mapping/getMapIconLegendResultData.ts b/src/models/v4/mapping/getMapIconLegendResultData.ts new file mode 100644 index 00000000..bbb4584d --- /dev/null +++ b/src/models/v4/mapping/getMapIconLegendResultData.ts @@ -0,0 +1,89 @@ +/** + * Payload of `GET /api/v4/Mapping/GetMapIconLegend`: every icon the department map can draw for calls, units and personnel, + * built from the department's own call types, priorities, unit types, status sets and roles (names and uploads included). + * Nothing here is fixed by the app: render what the server sends. + */ +export class GetMapIconLegendResultData { + /** The status styles in legend order, then `unknown` last. */ + public StatusStyles: MapLegendStyleData[] = []; + /** The priority styles (low, medium, high, emergency), then `unknown` last. */ + public PriorityStyles: MapLegendStyleData[] = []; + /** First row (`Id` null) = calls with no type; then the call types by name. */ + public Calls: MapLegendRowData[] = []; + /** First row (`Id` null) = units with no type; then the unit types by name. */ + public Units: MapLegendRowData[] = []; + /** First row (`Id` null) = members with no role icon; then the roles that have an uploaded icon, in role order. */ + public Personnel: MapLegendRowData[] = []; + /** The department's POI types by name. */ + public PoiTypes: MapLegendPoiTypeData[] = []; +} + +/** A colour style: one per status meaning group, or one per priority level; `unknown` = no meaning (or no status). */ +export class MapLegendStyleData { + /** Style key: `available`, `dispatched`, `responding`, `onscene`, `transporting`, `returning`, `staging`, `delayed`, `unavailable`, `low`, `medium`, `high`, `emergency` or `unknown`. */ + public Key: string = ''; + /** The style colour, `#RRGGBB`. */ + public Color: string = ''; + /** The ActionBaseTypes the status style groups (not shown). */ + public Meanings: number[] = []; + /** Status styles: the department's own unit status names (all status sets) drawn in this style. */ + public UnitStatuses: string[] = []; + /** Status styles: the department's own personnel status names drawn in this style. */ + public PersonnelStatuses: string[] = []; + /** Priority styles: the department's own priority names drawn in this style. */ + public Priorities: string[] = []; + /** Status styles: the default unit pin in this style (absolute URL). */ + public UnitPinIconUrl: string | null = null; + /** Status styles: the default person pin in this style (absolute URL). */ + public PersonPinIconUrl: string | null = null; + /** Priority styles: the default call pin in this style (absolute URL). */ + public CallPinIconUrl: string | null = null; +} + +/** One call type, unit type or role, with the icon it shows in each priority or status. */ +export class MapLegendRowData { + /** The call type / unit type / role id; null for the "none" row (no type, no role icon), which the app labels itself. */ + public Id: number | null = null; + /** The department's name for the call type / unit type / role (ignored for the `Id` null row). */ + public Name: string = ''; + /** Units: the type's custom status set name; null = the built-in unit statuses. */ + public StatusSetName: string | null = null; + /** The type's or role's own uploaded icon (no status), or null. */ + public IconUrl: string | null = null; + /** Always null for rows. */ + public PinIconUrl: string | null = null; + /** The bundled icon the type picked (`fire`, `ambulance`) or the default (`Call`, `Engine_Responding`, `Person_RespondingCall`). */ + public ImagePath: string = ''; + public States: MapLegendStateData[] = []; +} + +/** The icon a row shows in one priority or status: the same `IconUrl` / `PinIconUrl` / `ImagePath` a map marker carries. */ +export class MapLegendStateData { + /** The stored priority id / status id. */ + public Id: number = 0; + /** The department's own name for the priority / status. */ + public Name: string = ''; + /** The priority's / status's own colour (hex), or null. */ + public Color: string | null = null; + /** Style key (`unknown` when the priority / status has no meaning). */ + public IconStyle: string = ''; + /** Upload for this exact state, or the type's own upload with a badge; null = none. */ + public IconUrl: string | null = null; + /** Styled built-in pin (absolute URL): the fallback when `IconUrl` is null or fails to load. */ + public PinIconUrl: string | null = null; + /** Bundled fallback icon name. */ + public ImagePath: string = ''; + /** True when `IconUrl` is an upload made for this exact state. */ + public IsVariant: boolean = false; +} + +/** A department POI type, with the same icon fields its POI markers carry. */ +export class MapLegendPoiTypeData { + public PoiTypeId: number = 0; + public Name: string = ''; + public Color: string | null = null; + public PoiImage: string | null = null; + public ImagePath: string | null = null; + public Marker: string | null = null; + public IconUrl: string | null = null; +} diff --git a/src/models/v4/mapping/poiResultData.ts b/src/models/v4/mapping/poiResultData.ts index d9a1c5fb..1872fa98 100644 --- a/src/models/v4/mapping/poiResultData.ts +++ b/src/models/v4/mapping/poiResultData.ts @@ -6,6 +6,8 @@ export class PoiTypeResultData { public PoiImage: string = ''; public Marker: string = ''; public IsDestination: boolean = false; + /** Absolute URL of the department's uploaded icon for the POI type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public IconUrl?: string | null = null; } export class PoiResultData { @@ -21,6 +23,8 @@ export class PoiResultData { public ImagePath: string = ''; public Marker: string = ''; public IsDestination: boolean = false; + /** Absolute URL of the department's uploaded icon for the POI type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public IconUrl?: string | null = null; } export class PoiLayerData { @@ -31,4 +35,6 @@ export class PoiLayerData { public PoiImage: string = ''; public Marker: string = ''; public IsDestination: boolean = false; + /** Absolute URL of the department's uploaded icon for the POI type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public IconUrl?: string | null = null; } diff --git a/src/models/v4/messages/recipientsResultData.ts b/src/models/v4/messages/recipientsResultData.ts index fd4cec5e..ab3caa5c 100644 --- a/src/models/v4/messages/recipientsResultData.ts +++ b/src/models/v4/messages/recipientsResultData.ts @@ -3,4 +3,8 @@ export class RecipientsResultData { public Type: string = ''; public Name: string = ''; public Selected: boolean = false; + /** Rows with Type "Unit" only: uploaded icon URL for the unit's type; null/absent otherwise or on older servers. */ + public TypeIconUrl?: string | null = null; + /** Rows with Type "Unit" only: built-in map icon name (lowercase `MAP_ICONS` key) for the unit's type. */ + public TypeMapIcon?: string | null = null; } diff --git a/src/models/v4/personnel/personnelInfoResultData.ts b/src/models/v4/personnel/personnelInfoResultData.ts index 636172f8..927954b6 100644 --- a/src/models/v4/personnel/personnelInfoResultData.ts +++ b/src/models/v4/personnel/personnelInfoResultData.ts @@ -25,4 +25,6 @@ export class PersonnelInfoResultData { /** What the current staffing level means (StaffingBaseTypes); -1 none, undefined from older servers. */ public StaffingBaseType?: number; public Roles: string[] = []; + /** Uploaded icon of the first role this person holds, in the department's role order, that has one (the same icon as their map pin); null/absent when none or on older servers. */ + public RoleIconUrl?: string | null = null; } diff --git a/src/models/v4/roles/roleResultData.ts b/src/models/v4/roles/roleResultData.ts index 90d626fd..4aa2e824 100644 --- a/src/models/v4/roles/roleResultData.ts +++ b/src/models/v4/roles/roleResultData.ts @@ -1,4 +1,8 @@ export class RoleResultData { public RoleId: string = ''; public Name: string = ''; + /** The department's uploaded icon for this role; null/absent when none or on older servers. */ + public IconUrl?: string | null = null; + /** The role's position in the department's role order (lowest first); absent on older servers. */ + public SortOrder?: number; } diff --git a/src/models/v4/unitStatus/unitStatusResultData.ts b/src/models/v4/unitStatus/unitStatusResultData.ts index 883a50bc..d8af3cba 100644 --- a/src/models/v4/unitStatus/unitStatusResultData.ts +++ b/src/models/v4/unitStatus/unitStatusResultData.ts @@ -20,4 +20,8 @@ export class UnitStatusResultData { public GroupName: string = ''; public GroupId: number | string = ''; public Eta: string = ''; + /** Absolute URL of the department's uploaded icon for this unit's type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public TypeIconUrl?: string | null = null; + /** Built-in map icon the unit's type picked, lowercase `MAP_ICONS` key (e.g. "ambulance"); null/absent when none or on older servers. */ + public TypeMapIcon?: string | null = null; } diff --git a/src/models/v4/units/unitInfoResultData.ts b/src/models/v4/units/unitInfoResultData.ts index 3f405615..a79f0708 100644 --- a/src/models/v4/units/unitInfoResultData.ts +++ b/src/models/v4/units/unitInfoResultData.ts @@ -25,4 +25,8 @@ export class UnitInfoResultData { public Longitude: string = ''; public Note: string = ''; public Roles: UnitRoleData[] = []; + /** Absolute URL of the department's uploaded icon for this unit's type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public TypeIconUrl?: string | null = null; + /** Built-in map icon the unit's type picked, lowercase `MAP_ICONS` key (e.g. "ambulance"); null/absent when none or on older servers. */ + public TypeMapIcon?: string | null = null; } diff --git a/src/models/v4/units/unitResultData.ts b/src/models/v4/units/unitResultData.ts index 09345af4..6e4df521 100644 --- a/src/models/v4/units/unitResultData.ts +++ b/src/models/v4/units/unitResultData.ts @@ -17,4 +17,8 @@ export class UnitResultData { public Latitude: string = ''; public Longitude: string = ''; public Note: string = ''; + /** Absolute URL of the department's uploaded icon for this unit's type (128x128 PNG, anonymous, immutable); null/absent when none or on older servers. */ + public TypeIconUrl?: string | null = null; + /** Built-in map icon the unit's type picked, lowercase `MAP_ICONS` key (e.g. "ambulance"); null/absent when none or on older servers. */ + public TypeMapIcon?: string | null = null; } diff --git a/src/services/__tests__/location-foreground-permissions.test.ts b/src/services/__tests__/location-foreground-permissions.test.ts index b6ac1000..8fba6885 100644 --- a/src/services/__tests__/location-foreground-permissions.test.ts +++ b/src/services/__tests__/location-foreground-permissions.test.ts @@ -98,6 +98,7 @@ jest.mock('expo-location', () => { jest.mock('expo-task-manager', () => ({ defineTask: jest.fn(), isTaskRegisteredAsync: jest.fn(), + getTaskOptionsAsync: jest.fn(), })); jest.mock('react-native', () => ({ @@ -126,6 +127,7 @@ import { isNetworkError } from '@/utils/network'; // Import the service after mocks are set up let locationService: any; +let resetLocationTransmission: () => void; // Mock types const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; @@ -167,14 +169,22 @@ describe('LocationService - Foreground-Only Permissions', () => { beforeAll(() => { // Import the service after all mocks are set up - const { locationService: service } = require('../location'); + const { locationService: service, _resetLocationTransmissionForTests } = require('../location'); locationService = service; + resetLocationTransmission = _resetLocationTransmissionForTests; + }); + + afterEach(() => { + resetLocationTransmission(); }); beforeEach(() => { // Clear all mock call history jest.clearAllMocks(); + // Pacing state lives in the module; every test starts with nothing transmitted. + resetLocationTransmission(); + // Reset mock functions in store states mockLocationStoreState.setLocation = jest.fn(); mockLocationStoreState.setBackgroundEnabled = jest.fn(); @@ -252,6 +262,7 @@ describe('LocationService - Foreground-Only Permissions', () => { timeInterval: 15000, distanceInterval: 10, }, + expect.any(Function), expect.any(Function) ); @@ -439,8 +450,8 @@ describe('LocationService - Foreground-Only Permissions', () => { 'location-updates', expect.objectContaining({ accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, + timeInterval: 30000, + distanceInterval: 0, }) ); }); @@ -462,7 +473,8 @@ describe('LocationService - Foreground-Only Permissions', () => { mockLocationObject.coords.longitude, mockLocationObject.coords.accuracy, mockLocationObject.coords.heading, - mockLocationObject.coords.speed + mockLocationObject.coords.speed, + mockLocationObject.timestamp ); }); diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index 21905d9a..3511976c 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -65,6 +65,8 @@ jest.mock('@/stores/app/location-store', () => ({ jest.mock('expo-location', () => { const mockRequestForegroundPermissions = jest.fn(); const mockRequestBackgroundPermissions = jest.fn(); + const mockGetForegroundPermissions = jest.fn(); + const mockGetLastKnownPosition = jest.fn(); const mockGetBackgroundPermissions = jest.fn(); const mockWatchPositionAsync = jest.fn(); const mockStartLocationUpdatesAsync = jest.fn(); @@ -72,6 +74,8 @@ jest.mock('expo-location', () => { return { requestForegroundPermissionsAsync: mockRequestForegroundPermissions, requestBackgroundPermissionsAsync: mockRequestBackgroundPermissions, + getForegroundPermissionsAsync: mockGetForegroundPermissions, + getLastKnownPositionAsync: mockGetLastKnownPosition, getBackgroundPermissionsAsync: mockGetBackgroundPermissions, watchPositionAsync: mockWatchPositionAsync, startLocationUpdatesAsync: mockStartLocationUpdatesAsync, @@ -98,6 +102,7 @@ jest.mock('expo-task-manager', () => ({ registeredTasks[name] = handler; }), isTaskRegisteredAsync: jest.fn(), + getTaskOptionsAsync: jest.fn(), })); jest.mock('react-native', () => ({ @@ -128,6 +133,28 @@ import { isNetworkError } from '@/utils/network'; // Import the service after mocks are set up let locationService: any; +let resetLocationTransmission: () => void; + +// What the service registers the background task with. TaskManager persists these, so they are +// also what a current install reports back from getTaskOptionsAsync. +const expectedBackgroundTaskOptions = { + accuracy: 'balanced', + // Android computes a fix at the reporting cadence, not twice as often. + timeInterval: 30000, + // No distance filter: a parked unit must keep delivering so its 5-minute keep-alive can go out. + distanceInterval: 0, + // Fixes are held natively while backgrounded instead of waking the app per fix. + deferredUpdatesInterval: 25000, + deferredUpdatesDistance: 0, + // iOS must not pause a parked apparatus off dispatch's map. + pausesUpdatesAutomatically: false, + activityType: 2, + showsBackgroundLocationIndicator: true, + foregroundService: { + notificationTitle: 'location.tracking_notification_title', + notificationBody: 'location.tracking_notification_body', + }, +}; // Mock types const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; @@ -172,14 +199,23 @@ describe('LocationService', () => { beforeAll(() => { // Import the service after all mocks are set up - const { locationService: service } = require('../location'); + const { locationService: service, _resetLocationTransmissionForTests } = require('../location'); locationService = service; + resetLocationTransmission = _resetLocationTransmissionForTests; + }); + + afterEach(() => { + // Cancels any held-back fix, whose timer would otherwise post into the next test. + resetLocationTransmission(); }); beforeEach(() => { // Clear all mock call history jest.clearAllMocks(); + // Pacing state lives in the module; every test starts with nothing transmitted. + resetLocationTransmission(); + // Reset mock functions in store states - recreate the mock functions mockLocationStoreState.setLocation = jest.fn(); mockLocationStoreState.setBackgroundEnabled = jest.fn(); @@ -213,12 +249,20 @@ describe('LocationService', () => { canAskAgain: true, }); + mockLocation.getForegroundPermissionsAsync.mockResolvedValue({ + status: 'granted' as any, + expires: 'never', + granted: true, + canAskAgain: true, + }); + mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); mockLocation.startLocationUpdatesAsync.mockResolvedValue(); mockLocation.stopLocationUpdatesAsync.mockResolvedValue(); // Setup TaskManager mocks mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); + mockTaskManager.getTaskOptionsAsync.mockResolvedValue(expectedBackgroundTaskOptions); // Setup storage mock mockLoadBackgroundGeolocationState.mockResolvedValue(false); @@ -242,6 +286,9 @@ describe('LocationService', () => { (locationService as any).isRealtimeGeolocationEnabled = true; (locationService as any).startPromise = null; (locationService as any).startBackgroundPromise = null; + (locationService as any).resumePromise = null; + (locationService as any).watcherPromise = null; + (locationService as any).isTrackingRequested = false; }); describe('Concurrent start/stop safety', () => { @@ -396,6 +443,7 @@ describe('LocationService', () => { timeInterval: 15000, distanceInterval: 10, }, + expect.any(Function), expect.any(Function) ); @@ -409,6 +457,18 @@ describe('LocationService', () => { }); }); + it('should log foreground watcher errors instead of dropping them', async () => { + await locationService.startLocationUpdates(); + + const onError = mockLocation.watchPositionAsync.mock.calls[0][2] as (reason: string) => void; + onError('Location provider is unavailable'); + + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Foreground location watcher failed', + context: { error: 'Location provider is unavailable' }, + }); + }); + it('should start foreground updates even when background permissions are denied', async () => { mockLocation.getBackgroundPermissionsAsync.mockResolvedValue({ status: 'denied' as any, @@ -467,20 +527,9 @@ describe('LocationService', () => { await locationService.startLocationUpdates(); expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith('location-updates', { + ...expectedBackgroundTaskOptions, accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - // Android batches fixes while the screen is off instead of waking the app per fix. - deferredUpdatesInterval: 30000, - deferredUpdatesDistance: 25, - // iOS must not pause a parked apparatus off dispatch's map. - pausesUpdatesAutomatically: false, activityType: Location.LocationActivityType.AutomotiveNavigation, - showsBackgroundLocationIndicator: true, - foregroundService: { - notificationTitle: 'location.tracking_notification_title', - notificationBody: 'location.tracking_notification_body', - }, }); expect(mockLogger.info).toHaveBeenCalledWith({ @@ -516,6 +565,38 @@ describe('LocationService', () => { expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); }); + it('should re-register a task whose stored options are out of date', async () => { + // TaskManager persists the options a task was first registered with, so an upgraded install + // keeps the old pacing until the task is registered again. + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); + mockTaskManager.getTaskOptionsAsync.mockResolvedValue({ ...expectedBackgroundTaskOptions, timeInterval: 15000, distanceInterval: 10, deferredUpdatesInterval: 30000 }); + + await locationService.startLocationUpdates(); + + expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith('location-updates', expect.objectContaining({ timeInterval: 30000, distanceInterval: 0 })); + }); + + it('should treat booleans stored as 0/1 as current options', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); + mockTaskManager.getTaskOptionsAsync.mockResolvedValue({ ...expectedBackgroundTaskOptions, pausesUpdatesAutomatically: 0, showsBackgroundLocationIndicator: 1 }); + + await locationService.startLocationUpdates(); + + expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); + }); + + it('should re-register when the stored options cannot be read', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(true); + mockTaskManager.getTaskOptionsAsync.mockRejectedValue(new Error('unavailable')); + + await locationService.startLocationUpdates(); + + expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledTimes(1); + }); + it('should handle location updates and send to store and API', async () => { await locationService.startLocationUpdates(); @@ -551,6 +632,7 @@ describe('LocationService', () => { timeInterval: 60000, distanceInterval: 20, }, + expect.any(Function), expect.any(Function) ); @@ -709,7 +791,9 @@ describe('LocationService', () => { mockLocationObject.coords.longitude, mockLocationObject.coords.accuracy, mockLocationObject.coords.heading, - mockLocationObject.coords.speed + mockLocationObject.coords.speed, + // The fix's own time, so the replay records when the unit was there. + mockLocationObject.timestamp ); }); @@ -808,7 +892,15 @@ describe('LocationService', () => { const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; await locationCallback(locationWithSentinels); - expect(mockQueueLocationUpdateEvent).toHaveBeenCalledWith('unit-123', locationWithSentinels.coords.latitude, locationWithSentinels.coords.longitude, undefined, undefined, undefined); + expect(mockQueueLocationUpdateEvent).toHaveBeenCalledWith( + 'unit-123', + locationWithSentinels.coords.latitude, + locationWithSentinels.coords.longitude, + undefined, + undefined, + undefined, + locationWithSentinels.timestamp + ); }); }); @@ -835,12 +927,15 @@ describe('LocationService', () => { mockCoreStoreState.activeUnitId = 'unit-123'; }); - const sendLocation = async (): Promise => { + const sendLocation = async (location: Location.LocationObject = mockLocationObject): Promise => { await locationService.startLocationUpdates(); const locationCallback = mockLocation.watchPositionAsync.mock.calls.at(-1)![1] as Function; - await locationCallback(mockLocationObject); + await locationCallback(location); }; + // ~1.1km north of mockLocationObject: well past the transmission distance threshold. + const movedLocation: Location.LocationObject = { ...mockLocationObject, coords: { ...mockLocationObject.coords, latitude: mockLocationObject.coords.latitude + 0.01 } }; + it('logs the response status and body so the rejection can be diagnosed', async () => { mockCoreStoreState.activeUnitId = 'unit-reject-status'; mockSetUnitLocation.mockRejectedValue(createAxiosError(400, { Message: 'Heading out of range' })); @@ -912,9 +1007,11 @@ describe('LocationService', () => { mockSetUnitLocation.mockResolvedValue(mockApiResponse); await sendLocation(); + // Moved and past the transmission interval, so the next fix is actually sent. + now += 16_000; mockLogger.warn.mockClear(); mockSetUnitLocation.mockRejectedValue(createAxiosError(400)); - await sendLocation(); + await sendLocation(movedLocation); // Counter restarted, so this is a first rejection again, not a third. expect(mockLogger.warn).toHaveBeenCalledWith( @@ -927,6 +1024,9 @@ describe('LocationService', () => { mockSetUnitLocation.mockRejectedValue(createAxiosError(503)); await sendLocation(); + // Only the ordinary transmission interval applies, and the refused (unmoved) position is + // retried rather than skipped as already delivered. + now += 15_000; await sendLocation(); expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); @@ -999,6 +1099,461 @@ describe('LocationService', () => { }); }); + describe('Background task batches', () => { + const fixAt = (timestamp: number, latitude: number): Location.LocationObject => ({ ...mockLocationObject, timestamp, coords: { ...mockLocationObject.coords, latitude } }); + + const runTask = async (locations: Location.LocationObject[], appState: string) => { + (AppState as any).currentState = appState; + await registeredTasks['location-updates']({ data: { locations }, error: null }); + }; + + afterEach(() => { + (AppState as any).currentState = 'active'; + }); + + it('records and transmits the newest fix of a deferred batch, not the oldest', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + // The OS delivers a deferred batch oldest first; the first entry is a whole window old. + const oldest = fixAt(1_000, 37.7); + const middle = fixAt(15_000, 37.71); + const newest = fixAt(30_000, 37.72); + + await runTask([oldest, middle, newest], 'background'); + + expect(mockLocationStoreState.setLocation).toHaveBeenCalledTimes(1); + expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(newest); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.objectContaining({ Latitude: '37.72', Timestamp: new Date(30_000).toISOString() })); + }); + + it('picks the newest fix by timestamp even if the batch is out of order', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + const newest = fixAt(30_000, 37.72); + + await runTask([newest, fixAt(1_000, 37.7)], 'background'); + + expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(newest); + }); + + it('ignores task fixes while the foreground watcher is running', async () => { + // The task keeps delivering in the foreground; the watcher already handles those positions. + await locationService.startLocationUpdates(); + + await runTask([mockLocationObject], 'active'); + + expect(mockLocationStoreState.setLocation).not.toHaveBeenCalled(); + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + }); + + it('still handles foreground task fixes when there is no watcher', async () => { + await runTask([mockLocationObject], 'active'); + + expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('handles background task fixes even while a foreground watcher exists', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + await locationService.startLocationUpdates(); + + await runTask([mockLocationObject], 'background'); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + }); + + describe('Transmission pacing', () => { + const START = 1_700_000_000_000; + + // Offsets in degrees of latitude: 0.001° ≈ 111m, well past the 25m threshold. + const fix = (latitudeOffset: number): Location.LocationObject => ({ + ...mockLocationObject, + timestamp: Date.now(), + coords: { ...mockLocationObject.coords, latitude: mockLocationObject.coords.latitude + latitudeOffset }, + }); + + const startWatcher = async (): Promise<(location: Location.LocationObject) => Promise> => { + await locationService.startLocationUpdates(); + return mockLocation.watchPositionAsync.mock.calls.at(-1)![1] as (location: Location.LocationObject) => Promise; + }; + + beforeEach(() => { + jest.useFakeTimers({ now: START }); + }); + + afterEach(() => { + resetLocationTransmission(); + jest.useRealTimers(); + }); + + it('transmits the first fix immediately', async () => { + const onLocation = await startWatcher(); + + await onLocation(fix(0)); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('holds back a moving fix inside the interval and sends the newest one when it expires', async () => { + // iOS ignores timeInterval and reports every distanceInterval of travel — several fixes a + // second at highway speed. Only one POST per interval may go out. + const onLocation = await startWatcher(); + await onLocation(fix(0)); + + jest.setSystemTime(START + 5_000); + await onLocation(fix(0.001)); + jest.setSystemTime(START + 8_000); + await onLocation(fix(0.002)); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(10_000); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + expect(mockSetUnitLocation).toHaveBeenLastCalledWith(expect.objectContaining({ Latitude: (mockLocationObject.coords.latitude + 0.002).toString() })); + }); + + it('does not transmit an unmoved fix until the stationary interval elapses', async () => { + const onLocation = await startWatcher(); + await onLocation(fix(0)); + + jest.setSystemTime(START + 60_000); + await onLocation(fix(0.0001)); // ~11m: jitter + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + jest.setSystemTime(START + 5 * 60_000); + await onLocation(fix(0.0001)); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + }); + + it('drops a held-back fix once a newer one shows the unit back where it was', async () => { + const onLocation = await startWatcher(); + await onLocation(fix(0)); + + jest.setSystemTime(START + 5_000); + await onLocation(fix(0.001)); + jest.setSystemTime(START + 8_000); + await onLocation(fix(0)); + + await jest.advanceTimersByTimeAsync(20_000); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('does not retry a fix lost to the network on every fix', async () => { + // The offline queue holds it; retrying per fix would keep the radio busy in a dead zone. + mockIsNetworkError.mockReturnValue(true); + mockSetUnitLocation.mockRejectedValue(new Error('Network Error')); + const onLocation = await startWatcher(); + + await onLocation(fix(0)); + jest.setSystemTime(START + 20_000); + await onLocation(fix(0)); + // Let the rejected POST reach its catch block. + await jest.advanceTimersByTimeAsync(0); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + expect(mockQueueLocationUpdateEvent).toHaveBeenCalledTimes(1); + }); + + it('cancels a held-back fix when tracking stops', async () => { + const onLocation = await startWatcher(); + await onLocation(fix(0)); + jest.setSystemTime(START + 5_000); + await onLocation(fix(0.001)); + + await locationService.stopLocationUpdates(); + await jest.advanceTimersByTimeAsync(20_000); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('cancels a held-back fix when realtime geolocation is switched off', async () => { + const onLocation = await startWatcher(); + await onLocation(fix(0)); + jest.setSystemTime(START + 5_000); + await onLocation(fix(0.001)); + + await locationService.updateRealtimeGeolocationSetting(false); + await jest.advanceTimersByTimeAsync(20_000); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('transmits for a newly selected unit without waiting', async () => { + const onLocation = await startWatcher(); + await onLocation(fix(0)); + + mockCoreStoreState.activeUnitId = 'unit-456'; + jest.setSystemTime(START + 1_000); + await onLocation(fix(0)); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + expect(mockSetUnitLocation).toHaveBeenLastCalledWith(expect.objectContaining({ UnitId: 'unit-456' })); + }); + }); + + describe('Stationary keep-alive', () => { + // A parked unit produces no fixes (the distance filter suppresses them), yet the server drops its + // ping from the map once the ping's Timestamp is older than the department's TTL. A unit that is + // trapped or unresponsive must not vanish from dispatch's map. + const START = 1_700_000_000_000; + const FIVE_MINUTES = 5 * 60_000; + // What the OS last saw: an older fix, since a parked unit produces no new ones. + const lastKnown: Location.LocationObject = { ...mockLocationObject, timestamp: START - 60_000 }; + + const flush = async () => { + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } + }; + + const advance = async (ms: number) => { + await jest.advanceTimersByTimeAsync(ms); + await flush(); + }; + + const startWatcher = async (): Promise<(location: Location.LocationObject) => Promise> => { + await locationService.startLocationUpdates(); + return mockLocation.watchPositionAsync.mock.calls.at(-1)![1] as (location: Location.LocationObject) => Promise; + }; + + const runTask = async (location: Location.LocationObject) => { + (AppState as any).currentState = 'background'; + await registeredTasks['location-updates']({ data: { locations: [location] }, error: null }); + }; + + beforeEach(() => { + jest.useFakeTimers({ now: START }); + mockLocation.getLastKnownPositionAsync.mockResolvedValue(lastKnown); + }); + + afterEach(() => { + resetLocationTransmission(); + jest.useRealTimers(); + (AppState as any).currentState = 'active'; + ['latitude', 'longitude', 'heading', 'accuracy', 'speed', 'altitude', 'timestamp'].forEach((key) => delete (mockLocationStoreState as any)[key]); + }); + + it('re-reports a parked unit every 5 minutes, stamped with the time of the report', async () => { + const onLocation = await startWatcher(); + await onLocation({ ...mockLocationObject, timestamp: START }); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + await advance(FIVE_MINUTES - 1); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + await advance(1); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + // The fix's own time would let the marker expire on schedule; the report says "still here". + expect(mockSetUnitLocation).toHaveBeenLastCalledWith(expect.objectContaining({ Latitude: lastKnown.coords.latitude.toString(), Timestamp: new Date(START + FIVE_MINUTES).toISOString() })); + + await advance(FIVE_MINUTES); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(3); + }); + + it('restarts the countdown on every transmission, so a moving unit gets no extra reports', async () => { + const onLocation = await startWatcher(); + await onLocation({ ...mockLocationObject, timestamp: START }); + + await advance(4 * 60_000); + await onLocation({ ...mockLocationObject, timestamp: Date.now(), coords: { ...mockLocationObject.coords, latitude: mockLocationObject.coords.latitude + 0.01 } }); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + + // Five minutes after the first report, but only one after the last. + await advance(60_000 + 1); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + + await advance(4 * 60_000); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(3); + }); + + it('reports a unit that never produced a fix, from what the OS last knew', async () => { + await startWatcher(); + + await advance(FIVE_MINUTES); + + expect(mockLocation.getLastKnownPositionAsync).toHaveBeenCalled(); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('falls back to the last recorded position when the OS has nothing cached', async () => { + // A unit that lost its signal still has a last known position worth keeping on the map. + mockLocation.getLastKnownPositionAsync.mockResolvedValue(null); + Object.assign(mockLocationStoreState, { latitude: 40.1, longitude: -75.2, heading: null, accuracy: 12, speed: null, altitude: null, timestamp: START - 3_600_000 }); + await startWatcher(); + + await advance(FIVE_MINUTES); + + expect(mockSetUnitLocation).toHaveBeenCalledWith(expect.objectContaining({ Latitude: '40.1', Longitude: '-75.2', Timestamp: new Date(START + FIVE_MINUTES).toISOString() })); + }); + + it('sends nothing when neither the OS nor the app holds a position', async () => { + mockLocation.getLastKnownPositionAsync.mockResolvedValue(null); + await startWatcher(); + + await advance(FIVE_MINUTES); + + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + }); + + it('does not report while transmission is switched off, and stops counting', async () => { + mockLoadRealtimeGeolocationState.mockResolvedValue(false); + await startWatcher(); + + await advance(FIVE_MINUTES); + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + // Nothing left to wake the app for. + expect(jest.getTimerCount()).toBe(0); + }); + + it('follows the background setting for a report made while backgrounded', async () => { + mockLoadRealtimeGeolocationState.mockResolvedValue(false); + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + await startWatcher(); + (AppState as any).currentState = 'background'; + + await advance(FIVE_MINUTES); + + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + }); + + it('stops once tracking stops', async () => { + await startWatcher(); + await locationService.stopLocationUpdates(); + + await advance(2 * FIVE_MINUTES); + + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + }); + + it('does not restart counting when tracking stops while the keep-alive reads its setting', async () => { + await startWatcher(); + let resolveSetting: (enabled: boolean) => void = () => {}; + mockLoadRealtimeGeolocationState.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSetting = resolve; + }) + ); + + await advance(FIVE_MINUTES); + await locationService.stopLocationUpdates(); + resolveSetting(true); + await flush(); + + // A timer left here would be inherited by the next start in place of a fresh countdown. + expect(jest.getTimerCount()).toBe(0); + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + }); + + it('leaves no countdown behind from a start still in flight when tracking stopped', async () => { + const start = locationService.startLocationUpdates(); + const stop = locationService.stopLocationUpdates(); + await Promise.all([start, stop]); + + expect(jest.getTimerCount()).toBe(0); + }); + + it('reports at once on return to the foreground when the last report is overdue', async () => { + // Background reporting is off, so the countdown stops while the app is backgrounded. + mockLoadBackgroundGeolocationState.mockResolvedValue(false); + const onLocation = await startWatcher(); + await onLocation({ ...mockLocationObject, timestamp: START }); + (AppState as any).currentState = 'background'; + + await advance(2 * FIVE_MINUTES); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + (AppState as any).currentState = 'active'; + await (locationService as any).handleAppStateChange('active'); + await advance(0); + + // Ten minutes since the last report: due now, not in another five. + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + }); + + it('keeps a parked unit fresh from background task fixes, where JS timers do not run on Android', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + + await runTask({ ...mockLocationObject, timestamp: START }); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + // The task keeps delivering while parked; those fixes cost no network until one is due. + jest.setSystemTime(START + 60_000); + await runTask({ ...mockLocationObject, timestamp: START + 60_000 }); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(1); + + jest.setSystemTime(START + FIVE_MINUTES); + await runTask({ ...mockLocationObject, timestamp: START + FIVE_MINUTES }); + expect(mockSetUnitLocation).toHaveBeenCalledTimes(2); + }); + }); + + describe('Resuming tracking on app foreground', () => { + const denied = { status: 'denied' as any, expires: 'never' as const, granted: false, canAskAgain: true }; + + it('starts the watcher without prompting once permission was granted while away', async () => { + mockLocation.requestForegroundPermissionsAsync.mockResolvedValue(denied); + await expect(locationService.startLocationUpdates()).rejects.toThrow('Location permissions not granted'); + expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); + + // Granted from the OS settings app while this app was in the background. + await (locationService as any).handleAppStateChange('active'); + + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1); + expect(mockLocation.requestForegroundPermissionsAsync).toHaveBeenCalledTimes(1); + }); + + it('does not prompt or start anything while permission is still missing', async () => { + mockLocation.requestForegroundPermissionsAsync.mockResolvedValue(denied); + mockLocation.getForegroundPermissionsAsync.mockResolvedValue(denied); + await expect(locationService.startLocationUpdates()).rejects.toThrow(); + + await (locationService as any).handleAppStateChange('active'); + + expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); + expect(mockLocation.requestForegroundPermissionsAsync).toHaveBeenCalledTimes(1); + }); + + it('registers the background task once background access was granted while away', async () => { + mockLoadBackgroundGeolocationState.mockResolvedValue(true); + mockLocation.getBackgroundPermissionsAsync.mockResolvedValueOnce(denied); + await locationService.startLocationUpdates(); + expect(mockLocation.startLocationUpdatesAsync).not.toHaveBeenCalled(); + + await (locationService as any).handleAppStateChange('active'); + + expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledTimes(1); + // Requesting background access on Android 11+ opens the settings app; a resume must never. + expect(mockLocation.requestBackgroundPermissionsAsync).toHaveBeenCalledTimes(1); + }); + + it('does nothing when tracking was never started', async () => { + await (locationService as any).handleAppStateChange('active'); + + expect(mockLocation.getForegroundPermissionsAsync).not.toHaveBeenCalled(); + expect(mockLocation.watchPositionAsync).not.toHaveBeenCalled(); + }); + + it('does not restart tracking after it was stopped', async () => { + await locationService.startLocationUpdates(); + await locationService.stopLocationUpdates(); + + await (locationService as any).handleAppStateChange('active'); + + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1); + }); + + it('does not create a second watcher when one is running', async () => { + await locationService.startLocationUpdates(); + + await (locationService as any).handleAppStateChange('active'); + + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1); + }); + }); + describe('Background Geolocation Setting Updates', () => { it('should enable background tracking and register task when permissions are granted', async () => { await locationService.updateBackgroundGeolocationSetting(true); @@ -1007,8 +1562,8 @@ describe('LocationService', () => { 'location-updates', expect.objectContaining({ accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, + timeInterval: 30000, + distanceInterval: 0, }) ); }); diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index 95c68809..1326cf5d 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -127,6 +127,7 @@ describe('OfflineEventManager', () => { addEvent: jest.fn().mockReturnValue('test-event-id'), updateEventStatus: jest.fn(), removeEvent: jest.fn(), + getEventsByType: jest.fn().mockReturnValue([]), getPendingEvents: jest.fn().mockReturnValue([]), getFailedEvents: jest.fn().mockReturnValue([]), pruneFailedEvents: jest.fn(), @@ -228,6 +229,126 @@ describe('OfflineEventManager', () => { }) ); }); + + it('records when the fix was taken, not when it was queued', () => { + offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006, 10, 45, 25, Date.UTC(2026, 9, 25, 12, 0, 0)); + + expect(mockStoreState.addEvent).toHaveBeenCalledWith(QueuedEventType.LOCATION_UPDATE, expect.objectContaining({ timestamp: '2026-10-25T12:00:00.000Z' })); + }); + + it('replaces positions still waiting for the same unit', () => { + // A dead zone must leave one position to replay, not one per fix. + mockStoreState.getEventsByType.mockReturnValue([ + { id: 'waiting', status: QueuedEventStatus.PENDING, data: { unitId: 'unit-1' } }, + { id: 'retrying', status: QueuedEventStatus.FAILED, data: { unitId: 'unit-1' } }, + { id: 'in-flight', status: QueuedEventStatus.PROCESSING, data: { unitId: 'unit-1' } }, + { id: 'other-unit', status: QueuedEventStatus.PENDING, data: { unitId: 'unit-2' } }, + ]); + + offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006); + + expect(mockStoreState.getEventsByType).toHaveBeenCalledWith(QueuedEventType.LOCATION_UPDATE); + expect(mockStoreState.removeEvent).toHaveBeenCalledTimes(2); + expect(mockStoreState.removeEvent).toHaveBeenCalledWith('waiting'); + expect(mockStoreState.removeEvent).toHaveBeenCalledWith('retrying'); + expect(mockStoreState.addEvent).toHaveBeenCalledTimes(1); + }); + + describe('a fix older than one already queued for the unit', () => { + // POSTs in flight together can fail in either order, and a deferred background batch can be older + // than the last foreground fix, so the queue sees fixes out of order. + const queuedFix = (id: string, status: QueuedEventStatus) => ({ id, type: QueuedEventType.LOCATION_UPDATE, status, data: { unitId: 'unit-1', timestamp: '2026-10-25T12:00:10.000Z' }, retryCount: 0, maxRetries: 3, createdAt: 1_000 }); + + it.each([ + ['waiting', QueuedEventStatus.PENDING], + ['in flight', QueuedEventStatus.PROCESSING], + ['waiting to retry', QueuedEventStatus.FAILED], + ])('is not queued while the newer one is %s', (_label, status) => { + mockStoreState.getEventsByType.mockReturnValue([queuedFix('newer', status)]); + + const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006, 10, 45, 25, Date.UTC(2026, 9, 25, 12, 0, 0)); + + expect(eventId).toBe('newer'); + expect(mockStoreState.removeEvent).not.toHaveBeenCalled(); + expect(mockStoreState.addEvent).not.toHaveBeenCalled(); + }); + + it('replaces a newer one that has run out of retries', () => { + // It will never be delivered, so it holds nothing back. + mockStoreState.getEventsByType.mockReturnValue([{ ...queuedFix('exhausted', QueuedEventStatus.FAILED), retryCount: 3 }]); + + offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006, 10, 45, 25, Date.UTC(2026, 9, 25, 12, 0, 0)); + + expect(mockStoreState.removeEvent).toHaveBeenCalledWith('exhausted'); + expect(mockStoreState.addEvent).toHaveBeenCalledTimes(1); + }); + + it('does not stop a newer fix from replacing an older waiting one', () => { + mockStoreState.getEventsByType.mockReturnValue([queuedFix('older', QueuedEventStatus.PENDING)]); + + offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006, 10, 45, 25, Date.UTC(2026, 9, 25, 12, 0, 20)); + + expect(mockStoreState.removeEvent).toHaveBeenCalledWith('older'); + expect(mockStoreState.addEvent).toHaveBeenCalledWith(QueuedEventType.LOCATION_UPDATE, expect.objectContaining({ timestamp: '2026-10-25T12:00:20.000Z' })); + }); + }); + + describe('a replay in flight when a newer position is queued', () => { + const inFlight = { + id: 'in-flight', + type: QueuedEventType.LOCATION_UPDATE, + status: QueuedEventStatus.PROCESSING, + data: { unitId: 'unit-1', latitude: 40.7128, longitude: -74.006, timestamp: '2026-10-25T12:00:00.000Z' }, + retryCount: 0, + maxRetries: 3, + createdAt: 1_000, + }; + const newer = { ...inFlight, id: 'newer', status: QueuedEventStatus.PENDING, data: { ...inFlight.data, timestamp: '2026-10-25T12:00:10.000Z' }, createdAt: 2_000 }; + + const processEvent = (event: typeof inFlight) => (offlineEventManager as any).processEvent(event); + + it('is retried as usual when the fix queued after it was taken before it', async () => { + // Queued later, but older: it must not displace the newer position in flight. + mockStoreState.getEventsByType.mockReturnValue([inFlight, { ...newer, data: { ...newer.data, timestamp: '2026-10-25T11:59:50.000Z' } }]); + mockSetUnitLocation.mockRejectedValueOnce(new Error('Network Error')); + + await processEvent(inFlight); + + expect(mockStoreState.removeEvent).not.toHaveBeenCalled(); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('in-flight', QueuedEventStatus.FAILED, 'Network Error'); + }); + + it('is dropped instead of retried when it fails', async () => { + // Retrying it would replay both positions on reconnect, the old one first. + mockStoreState.getEventsByType.mockReturnValue([inFlight, newer]); + mockSetUnitLocation.mockRejectedValueOnce(new Error('Network Error')); + + await processEvent(inFlight); + + expect(mockStoreState.removeEvent).toHaveBeenCalledWith('in-flight'); + expect(mockStoreState.updateEventStatus).not.toHaveBeenCalledWith('in-flight', QueuedEventStatus.FAILED, expect.anything()); + }); + + it('is retried as usual when it fails with nothing newer queued', async () => { + mockStoreState.getEventsByType.mockReturnValue([inFlight]); + mockSetUnitLocation.mockRejectedValueOnce(new Error('Network Error')); + + await processEvent(inFlight); + + expect(mockStoreState.removeEvent).not.toHaveBeenCalled(); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('in-flight', QueuedEventStatus.FAILED, 'Network Error'); + }); + + it('is retried as usual when the newer position is for another unit', async () => { + mockStoreState.getEventsByType.mockReturnValue([inFlight, { ...newer, data: { ...newer.data, unitId: 'unit-2' } }]); + mockSetUnitLocation.mockRejectedValueOnce(new Error('Network Error')); + + await processEvent(inFlight); + + expect(mockStoreState.removeEvent).not.toHaveBeenCalled(); + expect(mockStoreState.updateEventStatus).toHaveBeenLastCalledWith('in-flight', QueuedEventStatus.FAILED, 'Network Error'); + }); + }); }); describe('queueCallImageUploadEvent', () => { diff --git a/src/services/location.ts b/src/services/location.ts index 56d61304..8764eac8 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -7,6 +7,7 @@ import { setUnitLocation } from '@/api/units/unitLocation'; import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolocation'; import { registerLocationServiceRealtimeUpdater } from '@/lib/hooks/use-realtime-geolocation'; import { translate } from '@/lib/i18n/utils'; +import { LocationTransmissionGate, type TransmissionRecord } from '@/lib/location-transmission-gate'; import { logger } from '@/lib/logging'; import { isWeb } from '@/lib/platform'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; @@ -26,12 +27,34 @@ const LOCATION_TASK_NAME = 'location-updates'; const REJECTION_BACKOFF_BASE_MS = 30 * 1000; const REJECTION_BACKOFF_MAX_MS = 15 * 60 * 1000; +// Transmission pacing. The OS delivers fixes far faster than dispatch needs them (iOS ignores +// `timeInterval` and reports every `distanceInterval` of travel), and every POST wakes the cellular +// radio. A moving unit is reported at most every 15s. +// +// A parked unit is not silent, though: it re-reports every 5 minutes. The server drops a unit's +// ping from the map once its Timestamp is older than the department's location TTL, and a unit +// that is trapped or unresponsive is exactly the one dispatch must still be able to see. +const MIN_TRANSMIT_INTERVAL_MS = 15 * 1000; +const MIN_TRANSMIT_DISTANCE_METERS = 25; +const STATIONARY_TRANSMIT_INTERVAL_MS = 5 * 60 * 1000; + /** * Options for the OS-managed background task. * - * `deferredUpdates*` is the significant battery lever on Android: it lets the OS collect fixes - * while the screen is off and hand them over in one batch, instead of waking the JS runtime for - * every individual fix. iOS ignores it. + * Background AVL is paced at roughly one fix every 25–30s, which is what dispatch's map needs, and + * each part of the request is set so the device does no more work than that: + * + * - `timeInterval` (Android only — iOS ignores it) is how often the fused provider computes a fix. + * Computing at the reporting cadence instead of twice as often halves the location work while + * the screen is off. + * - `distanceInterval` and `deferredUpdatesDistance` are 0 so a parked unit keeps delivering. Any + * distance filter would silence it completely, and backgrounded JS has no other way to wake: + * Android pauses JS timers there. These fixes are what let the transmission gate send the + * parked unit's 5-minute keep-alive; the gate discards the rest without touching the network. + * - `deferredUpdatesInterval` holds fixes natively and wakes the JS runtime once per window + * instead of once per fix. Both platforms apply it only while backgrounded. The interval sits + * just under `timeInterval` so each Android fix still clears it rather than being held for a + * second one. * * `pausesUpdatesAutomatically` is deliberately false. iOS will otherwise stop updates once it * decides the device has been stationary for a while and only resume on significant motion — an @@ -39,13 +62,16 @@ const REJECTION_BACKOFF_MAX_MS = 15 * 60 * 1000; * * `activityType` is AutomotiveNavigation here (Responder uses Other): this app runs on a * vehicle-mounted device, and telling iOS so lets it tune the GPS duty cycle for road travel. + * + * TaskManager persists these with the registration, so a change here only reaches installed apps + * because `registerBackgroundTask` re-registers whenever the stored options differ. */ const getBackgroundTaskOptions = (): Location.LocationTaskOptions => ({ accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - deferredUpdatesInterval: 30000, - deferredUpdatesDistance: 25, + timeInterval: 30000, + distanceInterval: 0, + deferredUpdatesInterval: 25000, + deferredUpdatesDistance: 0, pausesUpdatesAutomatically: false, activityType: Location.LocationActivityType.AutomotiveNavigation, showsBackgroundLocationIndicator: true, @@ -55,6 +81,47 @@ const getBackgroundTaskOptions = (): Location.LocationTaskOptions => ({ }, }); +/** + * Whether the options TaskManager holds for the task already match `desired`. Only the keys we set + * are compared, and booleans are allowed to round-trip through native storage as 0/1. + */ +const hasSameTaskOptions = (current: unknown, desired: unknown): boolean => { + if (desired !== null && typeof desired === 'object') { + if (current === null || typeof current !== 'object') { + return false; + } + return Object.entries(desired as Record).every(([key, value]) => hasSameTaskOptions((current as Record)[key], value)); + } + return current === desired || (typeof desired === 'boolean' && Number(current) === Number(desired)); +}; + +/** + * Register the background task, or re-register it when its stored options are out of date. + * Returns true when it called into the OS. + * + * Must run while the app is in the foreground: Android refuses to start the location foreground + * service from the background. + */ +const registerBackgroundTask = async (): Promise => { + const options = getBackgroundTaskOptions(); + + if (await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME)) { + let currentOptions: unknown = null; + try { + currentOptions = await TaskManager.getTaskOptionsAsync(LOCATION_TASK_NAME); + } catch (error) { + // Unreadable options are treated as stale: re-registering only updates them in place. + logger.debug({ message: 'Could not read background location task options', context: { error } }); + } + if (hasSameTaskOptions(currentOptions, options)) { + return false; + } + } + + await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, options); + return true; +}; + let rejectedUnitId: string | null = null; let consecutiveRejections = 0; let nextAttemptAtMs = 0; @@ -65,6 +132,66 @@ const resetRejectionBackoff = (): void => { nextAttemptAtMs = 0; }; +const transmissionGate = new LocationTransmissionGate({ + minIntervalMs: MIN_TRANSMIT_INTERVAL_MS, + minDistanceMeters: MIN_TRANSMIT_DISTANCE_METERS, + stationaryIntervalMs: STATIONARY_TRANSMIT_INTERVAL_MS, +}); + +// The newest fix held back by the transmission interval. Without it, a unit that stops moving just +// after a transmission would leave dispatch with the position it had up to 15s before it parked — +// a stationary device produces no further fixes to correct that. +let pendingTransmission: { location: Location.LocationObject; isTransmissionEnabled: boolean } | null = null; +let pendingTransmissionTimer: ReturnType | null = null; + +const cancelPendingTransmission = (): void => { + if (pendingTransmissionTimer) { + clearTimeout(pendingTransmissionTimer); + pendingTransmissionTimer = null; + } + pendingTransmission = null; +}; + +// Foreground keep-alive for a parked unit. The foreground watcher's distance filter means a unit +// that is not moving delivers no fixes, so nothing would ever re-report it. Restarted by every +// transmission, so it only fires after 5 minutes of silence. JS timers do not run while Android is +// backgrounded; there the background task's fixes carry the keep-alive instead (see +// `getBackgroundTaskOptions`), and an overdue timer fires as soon as the app is foregrounded. +let heartbeatTimer: ReturnType | null = null; +// Set by LocationService, which owns the settings and the last known position. +let heartbeatHandler: (() => void) | null = null; + +const cancelHeartbeat = (): void => { + if (heartbeatTimer) { + clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } +}; + +/** Restart the keep-alive countdown: by default a full interval from now. */ +const scheduleHeartbeat = (delayMs: number = STATIONARY_TRANSMIT_INTERVAL_MS): void => { + cancelHeartbeat(); + heartbeatTimer = setTimeout(() => { + heartbeatTimer = null; + heartbeatHandler?.(); + }, delayMs); +}; + +/** + * Start the countdown unless one is already running, measured from the last transmission: a unit + * that last reported 10 minutes ago — say, before a stretch in the background where it was not + * allowed to — is overdue now, not in another 5 minutes. + */ +const ensureHeartbeatScheduled = (): void => { + if (heartbeatTimer) { + return; + } + const lastTransmittedAt = transmissionGate.getLastTransmittedAt(); + // Clamped: a clock that went backwards must not push the keep-alive out past its interval. + const sinceLastTransmission = lastTransmittedAt === null ? 0 : Math.max(0, Date.now() - lastTransmittedAt); + scheduleHeartbeat(Math.max(0, STATIONARY_TRANSMIT_INTERVAL_MS - sinceLastTransmission)); +}; + /** * iOS reports -1 for course, speed and the accuracy fields when the value is * unavailable — a stationary unit (parked at the station, screen off) reports @@ -73,6 +200,13 @@ const resetRejectionBackoff = (): void => { */ const nonNegativeOrUndefined = (value: number | null | undefined): number | undefined => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined); +/** + * The OS hands the background task a batch: every fix collected while deferring, oldest first. Only + * the newest one says where the unit is now — the first can be a whole deferral window old. + */ +const getNewestLocation = (locations: Location.LocationObject[] | undefined): Location.LocationObject | undefined => + locations?.reduce((newest, candidate) => (!newest || candidate.timestamp >= newest.timestamp ? candidate : newest), undefined); + /** * Post a fix to the unit-location endpoint. * @@ -82,6 +216,8 @@ const nonNegativeOrUndefined = (value: number | null | undefined): number | unde */ const sendLocationToAPI = async (location: Location.LocationObject, isTransmissionEnabled: boolean): Promise => { if (!isTransmissionEnabled) { + // The newest fix says not to transmit; an older one held back must not go out after it. + cancelPendingTransmission(); logger.debug({ message: 'Location transmission disabled for this fix, skipping API call', }); @@ -89,6 +225,7 @@ const sendLocationToAPI = async (location: Location.LocationObject, isTransmissi } const { activeUnitId } = useCoreStore.getState(); + let transmission: TransmissionRecord | null = null; try { if (!activeUnitId) { logger.warn({ @@ -111,6 +248,38 @@ const sendLocationToAPI = async (location: Location.LocationObject, isTransmissi return; } + const now = Date.now(); + const decision = transmissionGate.evaluate(activeUnitId, location.coords, now); + + if (decision.action === 'skip') { + // This fix is newer than anything held back and no further from the last transmitted + // position, so whatever was held back is already out of date. + cancelPendingTransmission(); + return; + } + + if (decision.action === 'defer') { + // Only the newest held-back fix matters; it replaces any older one under the same timer. + pendingTransmission = { location, isTransmissionEnabled }; + if (!pendingTransmissionTimer) { + pendingTransmissionTimer = setTimeout(() => { + pendingTransmissionTimer = null; + const pending = pendingTransmission; + pendingTransmission = null; + if (pending) { + void sendLocationToAPI(pending.location, pending.isTransmissionEnabled); + } + }, decision.retryInMs); + } + return; + } + + cancelPendingTransmission(); + // Recorded before the request resolves: iOS can deliver several fixes while one POST is in + // flight, and each of them must see this one. + transmission = transmissionGate.record(activeUnitId, location.coords, now); + scheduleHeartbeat(); + const { latitude, longitude, altitude, accuracy, altitudeAccuracy, speed, heading } = location.coords; const locationInput = new SaveUnitLocationInput(); @@ -167,10 +336,19 @@ const sendLocationToAPI = async (location: Location.LocationObject, isTransmissi }); } + const isOffline = isNetworkError(error); + + // A position the server refused is not on the server, so the next fix must not be skipped as + // "unchanged". One lost to the network is different: it goes to the offline queue below, and + // retrying it on every fix would only keep the radio busy in a dead zone. + if (transmission && !isOffline) { + transmissionGate.markRejected(transmission); + } + // Queue the position for offline replay on genuine network failures so the // unit's location on the server does not silently go stale. Server // rejections (4xx) are NOT queued — replaying them would fail forever. - if (isNetworkError(error) && activeUnitId) { + if (isOffline && activeUnitId) { try { offlineEventManager.queueLocationUpdateEvent( activeUnitId, @@ -178,7 +356,8 @@ const sendLocationToAPI = async (location: Location.LocationObject, isTransmissi location.coords.longitude, nonNegativeOrUndefined(location.coords.accuracy), nonNegativeOrUndefined(location.coords.heading), - nonNegativeOrUndefined(location.coords.speed) + nonNegativeOrUndefined(location.coords.speed), + location.timestamp ); } catch (queueError) { logger.warn({ @@ -190,6 +369,14 @@ const sendLocationToAPI = async (location: Location.LocationObject, isTransmissi } }; +/** Clears all transmission pacing and backoff state. Test-only. */ +export function _resetLocationTransmissionForTests(): void { + cancelPendingTransmission(); + cancelHeartbeat(); + transmissionGate.reset(); + resetRejectionBackoff(); +} + // Define the background task (native only — TaskManager is unsupported on web) if (!isWeb) { TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }) => { @@ -202,24 +389,33 @@ if (!isWeb) { } if (data) { const { locations } = data as { locations: Location.LocationObject[] }; - const location = locations[0]; + const location = getNewestLocation(locations); if (location) { + const isForeground = AppState.currentState === 'active'; + + // The OS task keeps delivering while the app is foregrounded, at the same time as the + // foreground watcher. When the watcher is running it already records and transmits every + // fix; handling this one as well would post each position twice. + if (isForeground && locationService.isForegroundWatcherActive()) { + return; + } + logger.info({ message: 'Background location update received', context: { latitude: location.coords.latitude, longitude: location.coords.longitude, heading: location.coords.heading, + batchSize: locations.length, }, }); // Update local store useLocationStore.getState().setLocation(location); - // The OS task keeps delivering while the app is foregrounded, so the fix has to be - // attributed to the right setting before it can be sent: foreground fixes belong to - // Realtime Geolocation, background ones to Background Geolocation. - const isTransmissionEnabled = AppState.currentState === 'active' ? await loadRealtimeGeolocationState() : await loadBackgroundGeolocationState(); + // The fix has to be attributed to the right setting before it can be sent: foreground + // fixes belong to Realtime Geolocation, background ones to Background Geolocation. + const isTransmissionEnabled = isForeground ? await loadRealtimeGeolocationState() : await loadBackgroundGeolocationState(); await sendLocationToAPI(location, isTransmissionEnabled); } @@ -234,6 +430,9 @@ class LocationService { private appStateSubscription: { remove: () => void } | null = null; private isBackgroundGeolocationEnabled = false; private isRealtimeGeolocationEnabled = true; + // Set once something has asked for tracking (unit selected, map mounted) and cleared by a stop, + // so a resume only re-establishes tracking that is supposed to be running. + private isTrackingRequested = false; // Single-flight guards. The subscription fields are only assigned after an // await, so a plain "already subscribed?" check lets two concurrent starts // both pass it and create duplicate watchers — and lets a stop run in the gap @@ -241,12 +440,17 @@ class LocationService { // promise instead, and stop* waits for it before tearing anything down. private startPromise: Promise | null = null; private startBackgroundPromise: Promise | null = null; + private resumePromise: Promise | null = null; + private watcherPromise: Promise | null = null; private constructor() { this.initializeAppStateListener(); // Register this service's update function to avoid circular dependency registerLocationServiceUpdater(this.updateBackgroundGeolocationSetting.bind(this)); registerLocationServiceRealtimeUpdater(this.updateRealtimeGeolocationSetting.bind(this)); + heartbeatHandler = () => { + void this.sendHeartbeat(); + }; } static getInstance(): LocationService { @@ -256,6 +460,78 @@ class LocationService { return LocationService.instance; } + /** True while the foreground watcher is delivering fixes to the store and the API. */ + isForegroundWatcherActive(): boolean { + return this.locationSubscription !== null; + } + + /** + * Re-report the unit's last known position after 5 minutes without a transmission, so a parked — + * or trapped — unit's marker never ages off the department's map. + * + * Stamped with the current time on purpose. The server ages a ping out by its Timestamp, and the + * report means "still here as of now"; the fix's own time is when the unit last moved, which + * would leave the marker expiring on schedule. + */ + private async sendHeartbeat(): Promise { + if (!this.isTrackingRequested) { + return; + } + + // Read from storage, as the background task does: the setting that governs a report depends on + // the app state it is sent in, and a headless launch has not loaded the service's copies. + const isTransmissionEnabled = AppState.currentState === 'active' ? await loadRealtimeGeolocationState() : await loadBackgroundGeolocationState(); + // Re-checked after the await: a stop that landed during it has already cancelled the countdown, + // and rearming it here would leave a stale timer for the next start to inherit. + if (!isTransmissionEnabled || !this.isTrackingRequested) { + // Nothing may be sent in this state, so stop counting; the next transmission, a resume or a + // restart of tracking starts the countdown again. + return; + } + + // Keep counting even if this attempt finds nothing to send. A transmission below restarts the + // countdown from the moment it is sent. + scheduleHeartbeat(); + + const location = await this.getLastKnownLocation(); + if (!location || !this.isTrackingRequested) { + return; + } + + logger.info({ + message: 'Sending location keep-alive for a stationary unit', + context: { latitude: location.coords.latitude, longitude: location.coords.longitude, fixTimestamp: location.timestamp }, + }); + + await sendLocationToAPI({ ...location, timestamp: Date.now() }, isTransmissionEnabled); + } + + /** The freshest position the device already holds, without powering up the location hardware. */ + private async getLastKnownLocation(): Promise { + if (!isWeb) { + try { + const lastKnown = await Location.getLastKnownPositionAsync(); + if (lastKnown) { + return lastKnown; + } + } catch (error) { + logger.debug({ message: 'Could not read last known position for keep-alive', context: { error } }); + } + } + + // Nothing cached by the OS (or web, which has no such cache): fall back to the last fix this + // app recorded. A unit that has lost its signal still has a last known position worth keeping + // on the map. + const { latitude, longitude, heading, accuracy, speed, altitude, timestamp } = useLocationStore.getState(); + if (typeof latitude !== 'number' || typeof longitude !== 'number') { + return null; + } + return { + coords: { latitude, longitude, heading, accuracy, speed, altitude, altitudeAccuracy: null }, + timestamp: timestamp ?? Date.now(), + }; + } + private initializeAppStateListener(): void { this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); } @@ -274,6 +550,7 @@ class LocationService { await this.startBackgroundUpdates(); } else if (nextAppState === 'active') { await this.stopBackgroundUpdates(); + await this.resumeTracking(); } } catch (error) { logger.warn({ @@ -321,6 +598,8 @@ class LocationService { } private async performStartLocationUpdates(): Promise { + this.isTrackingRequested = true; + // On web, use a lightweight browser geolocation watcher instead of expo-location/TaskManager if (isWeb) { if (!('geolocation' in navigator)) { @@ -358,6 +637,7 @@ class LocationService { this.locationSubscription = { remove: () => navigator.geolocation.clearWatch(watchId) } as unknown as Location.LocationSubscription; logger.info({ message: 'Foreground location updates started' }); } + ensureHeartbeatScheduled(); return; } @@ -388,10 +668,7 @@ class LocationService { const shouldEnableBackground = this.isBackgroundGeolocationEnabled && hasBackgroundPermissions; if (shouldEnableBackground) { - // Check if task is already registered for background updates - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (!isTaskRegistered) { - await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, getBackgroundTaskOptions()); + if (await registerBackgroundTask()) { logger.info({ message: 'Background location task registered', }); @@ -408,34 +685,16 @@ class LocationService { // Start foreground updates (idempotent - check if already subscribed) if (!this.locationSubscription) { - this.locationSubscription = await Location.watchPositionAsync( - { - accuracy: Location.Accuracy.Balanced, - timeInterval: 15000, - distanceInterval: 10, - }, - (location) => { - logger.info({ - message: 'Foreground location update received', - context: { - latitude: location.coords.latitude, - longitude: location.coords.longitude, - heading: location.coords.heading, - }, - }); - useLocationStore.getState().setLocation(location); - // Foreground transmission is governed by Realtime Geolocation. The watcher itself keeps - // running either way: the map, route ETAs and the status sheet all read the location - // store, and switching transmission off must not blind the app to its own position. - sendLocationToAPI(location, this.isRealtimeGeolocationEnabled); - } - ); + await this.startForegroundWatcher(); } else { logger.info({ message: 'Foreground location subscription already active, skipping duplicate subscription', }); } + // A unit that starts out parked may never produce a fix; the keep-alive still reports it. + ensureHeartbeatScheduled(); + logger.info({ message: 'Foreground location updates started', context: { @@ -446,6 +705,124 @@ class LocationService { }); } + /** + * Create the foreground watcher unless one exists or is already being created. + * + * The OS pauses it natively while the app is backgrounded (Android pauses every watcher, iOS + * watchers do not allow background updates), so it costs nothing there; the background task + * covers that state. + */ + private startForegroundWatcher(): Promise { + if (this.locationSubscription) { + return Promise.resolve(); + } + if (this.watcherPromise) { + return this.watcherPromise; + } + + const promise = Location.watchPositionAsync( + { + accuracy: Location.Accuracy.Balanced, + timeInterval: 15000, + distanceInterval: 10, + }, + (location) => { + logger.info({ + message: 'Foreground location update received', + context: { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + heading: location.coords.heading, + }, + }); + useLocationStore.getState().setLocation(location); + // Foreground transmission is governed by Realtime Geolocation. The watcher itself keeps + // running either way: the map, route ETAs and the status sheet all read the location + // store, and switching transmission off must not blind the app to its own position. + sendLocationToAPI(location, this.isRealtimeGeolocationEnabled); + }, + // Without a handler expo-location drops watcher errors silently. + (reason) => { + logger.warn({ + message: 'Foreground location watcher failed', + context: { error: reason }, + }); + } + ) + .then((subscription) => { + this.locationSubscription = subscription; + }) + .finally(() => { + if (this.watcherPromise === promise) { + this.watcherPromise = null; + } + }); + this.watcherPromise = promise; + return promise; + } + + /** + * Re-establish tracking when the app returns to the foreground. + * + * Location access is usually fixed in the OS settings app while this app sits in the background + * — after the start at unit selection already failed, or ran foreground-only. Nothing else + * retries until the next cold start, so a unit that granted access would stay off dispatch's map. + * + * Never prompts: it only reads the current grants. A prompt on every resume would be a nag, and on + * Android 11+ requesting background access opens the settings app. + */ + private resumeTracking(): Promise { + if (isWeb || !this.isTrackingRequested) { + return Promise.resolve(); + } + if (this.resumePromise) { + return this.resumePromise; + } + + const promise = this.performResumeTracking().finally(() => { + if (this.resumePromise === promise) { + this.resumePromise = null; + } + }); + this.resumePromise = promise; + return promise; + } + + private async performResumeTracking(): Promise { + // A start still in flight is already doing this, with prompts where they are wanted. + if (this.startPromise) { + await this.startPromise.catch(() => {}); + } + + const { status: foregroundStatus } = await Location.getForegroundPermissionsAsync(); + // Re-checked after every await: a stop that lands mid-resume must not be undone by it. + if (foregroundStatus !== 'granted' || !this.isTrackingRequested) { + return; + } + + if (this.isBackgroundGeolocationEnabled) { + const { status: backgroundStatus } = await Location.getBackgroundPermissionsAsync(); + if (backgroundStatus === 'granted' && this.isTrackingRequested && (await registerBackgroundTask())) { + logger.info({ + message: 'Background location task registered on app resume', + }); + } + } + + if (!this.locationSubscription && this.isTrackingRequested) { + await this.startForegroundWatcher(); + logger.info({ + message: 'Foreground location updates resumed', + }); + } + + // The countdown stops while reporting is not allowed (see `sendHeartbeat`); back in the + // foreground it runs again. One that went overdue while backgrounded has already fired. + if (this.isTrackingRequested) { + ensureHeartbeatScheduled(); + } + } + async startBackgroundUpdates(): Promise { if (this.startBackgroundPromise) { return this.startBackgroundPromise; @@ -497,6 +874,12 @@ class LocationService { }); useLocationStore.getState().setLocation(location); sendLocationToAPI(location, this.isBackgroundGeolocationEnabled); // Background fixes follow the background setting + }, + (reason) => { + logger.warn({ + message: 'Background location watcher failed', + context: { error: reason }, + }); } ); @@ -525,6 +908,10 @@ class LocationService { await saveRealtimeGeolocationState(enabled); + if (!enabled) { + cancelPendingTransmission(); + } + // Flipping the flag is worthless without a watcher, and one is not guaranteed to exist: // `startLocationUpdates` runs once per unit selection and throws when the permission prompt was // declined. A unit that denied location at launch, granted it in the OS settings and then @@ -572,9 +959,7 @@ class LocationService { } // Register the task if not already registered - const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); - if (!isTaskRegistered) { - await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, getBackgroundTaskOptions()); + if (await registerBackgroundTask()) { logger.info({ message: 'Background location task registered after setting change', }); @@ -594,6 +979,7 @@ class LocationService { } } } else { + cancelPendingTransmission(); // Stop background updates and unregister task await this.stopBackgroundUpdates(); const isTaskRegistered = await TaskManager.isTaskRegisteredAsync(LOCATION_TASK_NAME); @@ -607,12 +993,24 @@ class LocationService { } async stopLocationUpdates(): Promise { + this.isTrackingRequested = false; + cancelPendingTransmission(); + cancelHeartbeat(); + // The next start may be for another unit, or long after this position was current. + transmissionGate.reset(); + // Wait out any start still in flight: it assigns this.locationSubscription // after its await, so stopping first would clear a null field and leave the // watcher that lands afterwards running with nothing tracking it. if (this.startPromise) { await this.startPromise.catch(() => {}); } + if (this.resumePromise) { + await this.resumePromise.catch(() => {}); + } + if (this.watcherPromise) { + await this.watcherPromise.catch(() => {}); + } if (this.locationSubscription) { if (isWeb) { @@ -634,6 +1032,11 @@ class LocationService { } } + // Pacing was cancelled above, but the start awaited here can still schedule the keep-alive, and + // a fix delivered before its subscription was removed can defer a transmission or restart it. + cancelPendingTransmission(); + cancelHeartbeat(); + logger.info({ message: 'All location updates stopped', }); diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index 4e9ba763..ed00448a 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -50,6 +50,41 @@ const isUndelivered = (event: QueuedEvent): boolean => const isUndeliveredUnitStatusFor = (event: QueuedEvent, unitId: string): boolean => event.type === QueuedEventType.UNIT_STATUS && String(event.data?.unitId) === String(unitId) && isUndelivered(event); +/** + * When a queued location fix was taken, or null when its timestamp is missing or unparseable. + * + * Recency is judged by this and not by `createdAt`: fixes reach the queue out of order — POSTs in + * flight together can fail in either order, and a deferred background batch can be older than the + * last foreground fix. + */ +const getLocationFixTime = (event: QueuedEvent): number | null => { + const fixTime = typeof event.data?.timestamp === 'string' ? Date.parse(event.data.timestamp) : Number.NaN; + return Number.isFinite(fixTime) ? fixTime : null; +}; + +/** + * A location replay that a position at least as recent for the same unit was queued beside while it + * was in flight: `queueLocationUpdateEvent` leaves a replay in flight alone and adds the new one next to it. + */ +const isSupersededLocationUpdate = (event: QueuedEvent): boolean => { + if (event.type !== QueuedEventType.LOCATION_UPDATE) { + return false; + } + + const fixTime = getLocationFixTime(event); + return useOfflineQueueStore + .getState() + .getEventsByType(QueuedEventType.LOCATION_UPDATE) + .some((other) => { + if (other.id === event.id || other.data?.unitId !== event.data?.unitId || !isUndelivered(other)) { + return false; + } + // A fix with no usable time cannot be shown to be the newer one. + const otherFixTime = getLocationFixTime(other); + return otherFixTime !== null && (fixTime === null || otherFixTime >= fixTime); + }); +}; + interface ProcessEventOptions { /** * The crew is waiting on this delivery (it gates a status they just submitted). A connectivity @@ -212,9 +247,36 @@ class OfflineEventManager { } /** - * Add a location update event to the queue + * Add a location update event to the queue, replacing any position still waiting for the same unit. + * + * Dispatch only needs where the unit is now. Keeping every fix from a dead zone would replay them + * all on reconnect — one request per fix, three per 10s tick, minutes of radio time spent moving + * the unit's pin back through positions it left long ago. A replay already in flight is left alone; + * if it fails, `processEvent` drops it rather than retrying it ahead of this newer one. + * + * A fix older than one the unit still owes the server, in flight included, is not queued: replayed + * after it, it would move the pin back. The id returned is then that newer event's. */ - public queueLocationUpdateEvent(unitId: string, latitude: number, longitude: number, accuracy?: number, heading?: number, speed?: number): string { + public queueLocationUpdateEvent(unitId: string, latitude: number, longitude: number, accuracy?: number, heading?: number, speed?: number, fixTimestamp?: number): string { + const store = useOfflineQueueStore.getState(); + // When the fix was taken, not when it was queued: a deferred background batch can be older. + const fixTime = fixTimestamp !== undefined && Number.isFinite(fixTimestamp) ? fixTimestamp : Date.now(); + const queuedForUnit = store.getEventsByType(QueuedEventType.LOCATION_UPDATE).filter((event) => event.data?.unitId === unitId); + + const newer = queuedForUnit.find((event) => { + const queuedFixTime = getLocationFixTime(event); + return isUndelivered(event) && queuedFixTime !== null && queuedFixTime > fixTime; + }); + if (newer) { + logger.debug({ + message: 'Location fix is older than one already queued for the unit, not queued', + context: { unitId, queuedEventId: newer.id }, + }); + return newer.id; + } + + queuedForUnit.filter((event) => event.status === QueuedEventStatus.PENDING || event.status === QueuedEventStatus.FAILED).forEach((event) => store.removeEvent(event.id)); + const data = { unitId, latitude, @@ -222,10 +284,10 @@ class OfflineEventManager { accuracy, heading, speed, - timestamp: new Date().toISOString(), + timestamp: new Date(fixTime).toISOString(), }; - return useOfflineQueueStore.getState().addEvent(QueuedEventType.LOCATION_UPDATE, data); + return store.addEvent(QueuedEventType.LOCATION_UPDATE, data); } /** @@ -490,6 +552,18 @@ class OfflineEventManager { return; } + if (isSupersededLocationUpdate(event)) { + // Retrying it would replay a position the unit has since left; the newer one queued behind it + // says where the unit is now. + store.removeEvent(event.id); + + logger.warn({ + message: 'Superseded location update could not be delivered, dropped for the newer position', + context: { eventId: event.id, error: errorMessage }, + }); + return; + } + store.updateEventStatus(event.id, QueuedEventStatus.FAILED, errorMessage); logger.error({ diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts index bb4c3590..270a643e 100644 --- a/src/stores/signalr/__tests__/signalr-store.test.ts +++ b/src/stores/signalr/__tests__/signalr-store.test.ts @@ -96,6 +96,11 @@ jest.mock('@/lib/env', () => ({ }, })); +const mockLoadRealtimeGeolocationState = jest.fn(() => Promise.resolve(true)); +jest.mock('@/lib/storage/realtime-geolocation', () => ({ + loadRealtimeGeolocationState: () => mockLoadRealtimeGeolocationState(), +})); + jest.mock('@/lib', () => ({ useAuthStore: { getState: jest.fn(() => ({ @@ -498,6 +503,21 @@ describe('useSignalRStore', () => { }); describe('connectGeolocationHub', () => { + it('should not connect while realtime geolocation is switched off', async () => { + // Every caller — init, app resume, shared-session unlock — goes through here, so the setting + // that subscribes the app to other units' positions is enforced in one place. + mockLoadRealtimeGeolocationState.mockResolvedValueOnce(false); + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); + expect(signalRService.invoke).not.toHaveBeenCalled(); + expect(result.current.isGeolocationHubConnected).toBe(false); + }); + it('should handle missing EventingUrl', async () => { // Mock core store without EventingUrl mockCoreStoreGetState.mockReturnValue({ diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 5fec29af..741907b4 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -4,6 +4,7 @@ import { useAuthStore } from '@/lib'; import { Env } from '@/lib/env'; import { type LiveLocationKind, type LiveLocations, mergeLiveLocation, parseLiveLocationPayload } from '@/lib/live-locations'; import { logger } from '@/lib/logging'; +import { loadRealtimeGeolocationState } from '@/lib/storage/realtime-geolocation'; import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; @@ -678,6 +679,14 @@ export const useSignalRStore = create((set, get) => ({ }, connectGeolocationHub: async () => { try { + // Guard here so every call path (init, app-resume reconnect, shared-session unlock) honors + // Realtime Geolocation: it is the setting that subscribes this app to other units' positions, + // and the hub pushes every position in the department — a steady stream of radio wake-ups. + if (!(await loadRealtimeGeolocationState())) { + logger.info({ message: 'Realtime geolocation disabled; skipping geolocation hub connection' }); + return; + } + // The flag alone must never block a repair: only skip when the service still holds the // connection the flag was set for. if (get().isGeolocationHubConnected && signalRService.isHubAvailable(Env.REALTIME_GEO_HUB_NAME)) { diff --git a/src/translations/ar.json b/src/translations/ar.json index 0632c4b6..8ec1bd28 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "تم تعيين المكالمة كمكالمة حالية", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", "failed_to_set_current_call": "فشل في تعيين المكالمة كمكالمة حالية", + "legend": { + "built_in_unit_statuses": "حالات الوحدات المدمجة", + "button": "دليل الخريطة", + "calls": "البلاغات", + "error": "تعذّر تحميل دليل الخريطة.", + "hydrant": "صنبور إطفاء", + "list_separator": "، ", + "loading": "جارٍ تحميل دليل الخريطة…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "بلاغات بلا نوع", + "no_role_icon": "أعضاء بلا رمز دور", + "no_unit_type": "وحدات بلا نوع", + "other": "المحطات ونقاط الاهتمام", + "other_note": "لا تتغير هذه الرموز بتغير الحالة.", + "other_with_hydrants": "المحطات وصنابير الإطفاء ونقاط الاهتمام", + "personnel": "الأفراد", + "personnel_note": "يعرض العضو رمز أول دور يشغله، بترتيب الأدوار، له رمز. ويأخذ العضو «على وحدة» لون حالة وحدته.", + "priorities": "الأولويات", + "priority_colors": "ألوان الأولويات", + "priority_unknown": "بلا معنى", + "station": "محطة", + "status_colors": "ألوان الحالات", + "statuses": "الحالات: {{name}}", + "style": { + "available": "متاح", + "delayed": "في استراحة", + "dispatched": "مُرسَل", + "emergency": "طارئة", + "high": "عالية", + "low": "منخفضة", + "medium": "متوسطة", + "onscene": "في الموقع", + "responding": "يستجيب", + "returning": "عائد", + "staging": "تجمّع واستعداد", + "transporting": "ينقل", + "unavailable": "غير متاح", + "unknown": "بلا معنى أو بلا حالة" + }, + "title": "دليل رموز الخريطة", + "units": "الوحدات" + }, "lock_map": "تثبيت الخريطة على الوحدة", "no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه", "pin_address": "Address", diff --git a/src/translations/de.json b/src/translations/de.json index 4cf96232..38aee9b1 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Anruf als aktuellen Anruf gesetzt", "failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden", "failed_to_set_current_call": "Anruf konnte nicht als aktueller Anruf gesetzt werden", + "legend": { + "built_in_unit_statuses": "Eingebaute Einheitenstatus", + "button": "Kartenlegende", + "calls": "Einsätze", + "error": "Die Kartenlegende konnte nicht geladen werden.", + "hydrant": "Hydrant", + "list_separator": ", ", + "loading": "Kartenlegende wird geladen…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Einsätze ohne Typ", + "no_role_icon": "Mitglieder ohne Rollensymbol", + "no_unit_type": "Einheiten ohne Typ", + "other": "Wachen und Points of Interest", + "other_note": "Diese Symbole ändern sich nicht mit dem Status.", + "other_with_hydrants": "Wachen, Hydranten und Points of Interest", + "personnel": "Personal", + "personnel_note": "Ein Mitglied zeigt das Symbol der ersten seiner Rollen in Rollenreihenfolge, die eines hat. Ein Mitglied „Auf Einheit“ nimmt die Farbe des Status seiner Einheit an.", + "priorities": "Prioritäten", + "priority_colors": "Prioritätsfarben", + "priority_unknown": "Keine Bedeutung", + "station": "Wache", + "status_colors": "Statusfarben", + "statuses": "Status: {{name}}", + "style": { + "available": "Verfügbar", + "delayed": "Pause", + "dispatched": "Alarmiert", + "emergency": "Notfall", + "high": "Hoch", + "low": "Niedrig", + "medium": "Mittel", + "onscene": "Vor Ort", + "responding": "Auf Anfahrt", + "returning": "Rückfahrt", + "staging": "Bereitstellung und Bereitschaft", + "transporting": "Transport", + "unavailable": "Nicht verfügbar", + "unknown": "Keine Bedeutung oder kein Status" + }, + "title": "Legende der Kartensymbole", + "units": "Einheiten" + }, "lock_map": "Karte an Einheit binden", "no_location_for_routing": "Keine Standortdaten für die Navigation verfügbar", "pin_address": "Address", diff --git a/src/translations/el.json b/src/translations/el.json index f5db07a1..aae20f81 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Η κλήση ορίστηκε ως τρέχουσα κλήση", "failed_to_open_maps": "Αποτυχία ανοίγματος εφαρμογής χαρτών", "failed_to_set_current_call": "Αποτυχία ορισμού της κλήσης ως τρέχουσας", + "legend": { + "built_in_unit_statuses": "Ενσωματωμένες καταστάσεις μονάδων", + "button": "Υπόμνημα χάρτη", + "calls": "Κλήσεις", + "error": "Δεν ήταν δυνατή η φόρτωση του υπομνήματος του χάρτη.", + "hydrant": "Κρουνός", + "list_separator": ", ", + "loading": "Φόρτωση υπομνήματος χάρτη…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Κλήσεις χωρίς τύπο", + "no_role_icon": "Μέλη χωρίς εικονίδιο ρόλου", + "no_unit_type": "Μονάδες χωρίς τύπο", + "other": "Σταθμοί και σημεία ενδιαφέροντος", + "other_note": "Αυτά τα εικονίδια δεν αλλάζουν με την κατάσταση.", + "other_with_hydrants": "Σταθμοί, κρουνοί και σημεία ενδιαφέροντος", + "personnel": "Προσωπικό", + "personnel_note": "Ένα μέλος δείχνει το εικονίδιο του πρώτου ρόλου του, κατά τη σειρά ρόλων, που έχει εικονίδιο. Ένα μέλος «Σε μονάδα» παίρνει το χρώμα της κατάστασης της μονάδας του.", + "priorities": "Προτεραιότητες", + "priority_colors": "Χρώματα προτεραιοτήτων", + "priority_unknown": "Χωρίς σημασία", + "station": "Σταθμός", + "status_colors": "Χρώματα καταστάσεων", + "statuses": "Καταστάσεις: {{name}}", + "style": { + "available": "Διαθέσιμος", + "delayed": "Σε διάλειμμα", + "dispatched": "Σε αποστολή", + "emergency": "Έκτακτη ανάγκη", + "high": "Υψηλή", + "low": "Χαμηλή", + "medium": "Μεσαία", + "onscene": "Επί τόπου", + "responding": "Σε ανταπόκριση", + "returning": "Σε επιστροφή", + "staging": "Συγκέντρωση και αναμονή", + "transporting": "Σε μεταφορά", + "unavailable": "Μη διαθέσιμος", + "unknown": "Χωρίς σημασία ή χωρίς κατάσταση" + }, + "title": "Υπόμνημα εικονιδίων χάρτη", + "units": "Μονάδες" + }, "lock_map": "Κλείδωμα χάρτη στη μονάδα", "no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση", "pin_address": "Διεύθυνση", diff --git a/src/translations/en.json b/src/translations/en.json index 28dea57d..fb7afc1b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Call set as current call", "failed_to_open_maps": "Failed to open maps application", "failed_to_set_current_call": "Failed to set call as current call", + "legend": { + "built_in_unit_statuses": "Built-in unit statuses", + "button": "Map legend", + "calls": "Calls", + "error": "Could not load the map legend.", + "hydrant": "Hydrant", + "list_separator": ", ", + "loading": "Loading map legend…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Calls with no type", + "no_role_icon": "Members with no role icon", + "no_unit_type": "Units with no type", + "other": "Stations and points of interest", + "other_note": "These icons do not change with status.", + "other_with_hydrants": "Stations, hydrants and points of interest", + "personnel": "Personnel", + "personnel_note": "A member shows the icon of the first role they hold, in role order, that has one. A member On Unit takes the color of their unit's status.", + "priorities": "Priorities", + "priority_colors": "Priority colors", + "priority_unknown": "No meaning", + "station": "Station", + "status_colors": "Status colors", + "statuses": "Statuses: {{name}}", + "style": { + "available": "Available", + "delayed": "On break", + "dispatched": "Dispatched", + "emergency": "Emergency", + "high": "High", + "low": "Low", + "medium": "Medium", + "onscene": "On scene", + "responding": "Responding", + "returning": "Returning", + "staging": "Staging and standby", + "transporting": "Transporting", + "unavailable": "Unavailable", + "unknown": "No meaning or no status" + }, + "title": "Map Icon Legend", + "units": "Units" + }, "lock_map": "Lock map to unit", "no_location_for_routing": "No location data available for routing", "pin_address": "Address", diff --git a/src/translations/es.json b/src/translations/es.json index b2e7de79..f7bcdbfc 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Llamada establecida como llamada actual", "failed_to_open_maps": "Error al abrir la aplicación de mapas", "failed_to_set_current_call": "Error al establecer la llamada como llamada actual", + "legend": { + "built_in_unit_statuses": "Estados de unidad integrados", + "button": "Leyenda del mapa", + "calls": "Llamadas", + "error": "No se pudo cargar la leyenda del mapa.", + "hydrant": "Hidrante", + "list_separator": ", ", + "loading": "Cargando la leyenda del mapa…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Llamadas sin tipo", + "no_role_icon": "Miembros sin icono de rol", + "no_unit_type": "Unidades sin tipo", + "other": "Estaciones y puntos de interés", + "other_note": "Estos iconos no cambian con el estado.", + "other_with_hydrants": "Estaciones, hidrantes y puntos de interés", + "personnel": "Personal", + "personnel_note": "Un miembro muestra el icono del primer rol que tiene, en el orden de roles, que tenga uno. Un miembro «En unidad» toma el color del estado de su unidad.", + "priorities": "Prioridades", + "priority_colors": "Colores de prioridad", + "priority_unknown": "Sin significado", + "station": "Estación", + "status_colors": "Colores de estado", + "statuses": "Estados: {{name}}", + "style": { + "available": "Disponible", + "delayed": "En descanso", + "dispatched": "Despachado", + "emergency": "Emergencia", + "high": "Alta", + "low": "Baja", + "medium": "Media", + "onscene": "En el lugar", + "responding": "En respuesta", + "returning": "Regresando", + "staging": "Concentración y espera", + "transporting": "Trasladando", + "unavailable": "No disponible", + "unknown": "Sin significado o sin estado" + }, + "title": "Leyenda de iconos del mapa", + "units": "Unidades" + }, "lock_map": "Bloquear el mapa en la unidad", "no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento", "pin_address": "Address", diff --git a/src/translations/fr.json b/src/translations/fr.json index 528e3aa2..445d123a 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Appel défini comme appel actuel", "failed_to_open_maps": "Échec de l'ouverture de l'application de cartes", "failed_to_set_current_call": "Échec de la définition de l'appel comme actuel", + "legend": { + "built_in_unit_statuses": "Statuts d'unité intégrés", + "button": "Légende de la carte", + "calls": "Appels", + "error": "Impossible de charger la légende de la carte.", + "hydrant": "Borne d'incendie", + "list_separator": ", ", + "loading": "Chargement de la légende de la carte…", + "name_list": "{{label}} : {{names}}", + "no_call_type": "Appels sans type", + "no_role_icon": "Membres sans icône de rôle", + "no_unit_type": "Unités sans type", + "other": "Casernes et points d'intérêt", + "other_note": "Ces icônes ne changent pas avec le statut.", + "other_with_hydrants": "Casernes, bornes d'incendie et points d'intérêt", + "personnel": "Personnel", + "personnel_note": "Un membre affiche l'icône du premier de ses rôles, dans l'ordre des rôles, qui en a une. Un membre « Sur une unité » prend la couleur du statut de son unité.", + "priorities": "Priorités", + "priority_colors": "Couleurs des priorités", + "priority_unknown": "Sans signification", + "station": "Caserne", + "status_colors": "Couleurs des statuts", + "statuses": "Statuts : {{name}}", + "style": { + "available": "Disponible", + "delayed": "En pause", + "dispatched": "Engagé", + "emergency": "Urgence", + "high": "Haute", + "low": "Basse", + "medium": "Moyenne", + "onscene": "Sur les lieux", + "responding": "En intervention", + "returning": "En retour", + "staging": "Regroupement et attente", + "transporting": "En transport", + "unavailable": "Indisponible", + "unknown": "Sans signification ou sans statut" + }, + "title": "Légende des icônes de la carte", + "units": "Unités" + }, "lock_map": "Verrouiller la carte sur l'unité", "no_location_for_routing": "Aucune donnée de localisation disponible pour la navigation", "pin_address": "Address", diff --git a/src/translations/it.json b/src/translations/it.json index 48612415..7d472c0f 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Chiamata impostata come chiamata corrente", "failed_to_open_maps": "Impossibile aprire l'applicazione mappe", "failed_to_set_current_call": "Impossibile impostare la chiamata come corrente", + "legend": { + "built_in_unit_statuses": "Stati unità integrati", + "button": "Legenda della mappa", + "calls": "Chiamate", + "error": "Impossibile caricare la legenda della mappa.", + "hydrant": "Idrante", + "list_separator": ", ", + "loading": "Caricamento della legenda della mappa…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Chiamate senza tipo", + "no_role_icon": "Membri senza icona di ruolo", + "no_unit_type": "Unità senza tipo", + "other": "Stazioni e punti di interesse", + "other_note": "Queste icone non cambiano con lo stato.", + "other_with_hydrants": "Stazioni, idranti e punti di interesse", + "personnel": "Personale", + "personnel_note": "Un membro mostra l'icona del primo ruolo che ricopre, nell'ordine dei ruoli, che ne ha una. Un membro «Su unità» assume il colore dello stato della sua unità.", + "priorities": "Priorità", + "priority_colors": "Colori delle priorità", + "priority_unknown": "Nessun significato", + "station": "Stazione", + "status_colors": "Colori degli stati", + "statuses": "Stati: {{name}}", + "style": { + "available": "Disponibile", + "delayed": "In pausa", + "dispatched": "Inviato", + "emergency": "Emergenza", + "high": "Alta", + "low": "Bassa", + "medium": "Media", + "onscene": "Sul posto", + "responding": "In risposta", + "returning": "In rientro", + "staging": "Ammassamento e attesa", + "transporting": "In trasporto", + "unavailable": "Non disponibile", + "unknown": "Nessun significato o nessuno stato" + }, + "title": "Legenda delle icone della mappa", + "units": "Unità" + }, "lock_map": "Blocca la mappa sull'unità", "no_location_for_routing": "Nessun dato di posizione disponibile per la navigazione", "pin_address": "Address", diff --git a/src/translations/pl.json b/src/translations/pl.json index d05c2510..e7b8b9f4 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Zgłoszenie ustawione jako bieżące", "failed_to_open_maps": "Nie udało się otworzyć aplikacji map", "failed_to_set_current_call": "Nie udało się ustawić zgłoszenia jako bieżące", + "legend": { + "built_in_unit_statuses": "Wbudowane statusy jednostek", + "button": "Legenda mapy", + "calls": "Zgłoszenia", + "error": "Nie udało się wczytać legendy mapy.", + "hydrant": "Hydrant", + "list_separator": ", ", + "loading": "Wczytywanie legendy mapy…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Zgłoszenia bez typu", + "no_role_icon": "Członkowie bez ikony roli", + "no_unit_type": "Jednostki bez typu", + "other": "Stacje i punkty zainteresowania", + "other_note": "Te ikony nie zmieniają się wraz ze statusem.", + "other_with_hydrants": "Stacje, hydranty i punkty zainteresowania", + "personnel": "Personel", + "personnel_note": "Członek pokazuje ikonę pierwszej posiadanej roli, w kolejności ról, która ją ma. Członek „W jednostce” przyjmuje kolor statusu swojej jednostki.", + "priorities": "Priorytety", + "priority_colors": "Kolory priorytetów", + "priority_unknown": "Brak znaczenia", + "station": "Stacja", + "status_colors": "Kolory statusów", + "statuses": "Statusy: {{name}}", + "style": { + "available": "Dostępny", + "delayed": "Przerwa", + "dispatched": "Zadysponowany", + "emergency": "Nagły", + "high": "Wysoki", + "low": "Niski", + "medium": "Średni", + "onscene": "Na miejscu", + "responding": "W drodze", + "returning": "Powrót", + "staging": "Strefa wyczekiwania i gotowość", + "transporting": "W transporcie", + "unavailable": "Niedostępny", + "unknown": "Brak znaczenia lub statusu" + }, + "title": "Legenda ikon mapy", + "units": "Jednostki" + }, "lock_map": "Zablokuj mapę na jednostce", "no_location_for_routing": "Brak danych lokalizacji do nawigacji", "pin_address": "Address", diff --git a/src/translations/sv.json b/src/translations/sv.json index de11d581..6f39baf3 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Samtal angett som aktuellt samtal", "failed_to_open_maps": "Det gick inte att öppna kartappen", "failed_to_set_current_call": "Det gick inte att ange samtalet som aktuellt", + "legend": { + "built_in_unit_statuses": "Inbyggda enhetsstatusar", + "button": "Kartförklaring", + "calls": "Larm", + "error": "Det gick inte att läsa in kartförklaringen.", + "hydrant": "Brandpost", + "list_separator": ", ", + "loading": "Läser in kartförklaringen…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Larm utan typ", + "no_role_icon": "Medlemmar utan rollikon", + "no_unit_type": "Enheter utan typ", + "other": "Stationer och intressepunkter", + "other_note": "Dessa ikoner ändras inte med status.", + "other_with_hydrants": "Stationer, brandposter och intressepunkter", + "personnel": "Personal", + "personnel_note": "En medlem visar ikonen för den första roll de har, i rollordning, som har en. En medlem ”På enhet” får färgen för sin enhets status.", + "priorities": "Prioriteter", + "priority_colors": "Prioritetsfärger", + "priority_unknown": "Ingen betydelse", + "station": "Station", + "status_colors": "Statusfärger", + "statuses": "Statusar: {{name}}", + "style": { + "available": "Tillgänglig", + "delayed": "Rast", + "dispatched": "Larmad", + "emergency": "Akut", + "high": "Hög", + "low": "Låg", + "medium": "Medel", + "onscene": "På plats", + "responding": "På väg", + "returning": "Återvänder", + "staging": "Uppställning och beredskap", + "transporting": "Transporterar", + "unavailable": "Inte tillgänglig", + "unknown": "Ingen betydelse eller ingen status" + }, + "title": "Förklaring till kartikoner", + "units": "Enheter" + }, "lock_map": "Lås kartan till enheten", "no_location_for_routing": "Ingen platsdata tillgänglig för navigering", "pin_address": "Address", diff --git a/src/translations/uk.json b/src/translations/uk.json index 42668cc2..e9ee2735 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -1124,6 +1124,48 @@ "call_set_as_current": "Виклик встановлено як поточний", "failed_to_open_maps": "Не вдалося відкрити додаток карт", "failed_to_set_current_call": "Не вдалося встановити виклик як поточний", + "legend": { + "built_in_unit_statuses": "Вбудовані статуси підрозділів", + "button": "Легенда карти", + "calls": "Виклики", + "error": "Не вдалося завантажити легенду карти.", + "hydrant": "Гідрант", + "list_separator": ", ", + "loading": "Завантаження легенди карти…", + "name_list": "{{label}}: {{names}}", + "no_call_type": "Виклики без типу", + "no_role_icon": "Учасники без значка ролі", + "no_unit_type": "Підрозділи без типу", + "other": "Станції та точки інтересу", + "other_note": "Ці значки не змінюються зі статусом.", + "other_with_hydrants": "Станції, гідранти та точки інтересу", + "personnel": "Персонал", + "personnel_note": "Учасник показує значок першої своєї ролі в порядку ролей, яка його має. Учасник «У підрозділі» набуває кольору статусу свого підрозділу.", + "priorities": "Пріоритети", + "priority_colors": "Кольори пріоритетів", + "priority_unknown": "Без значення", + "station": "Станція", + "status_colors": "Кольори статусів", + "statuses": "Статуси: {{name}}", + "style": { + "available": "Доступний", + "delayed": "Перерва", + "dispatched": "Направлений", + "emergency": "Екстрений", + "high": "Високий", + "low": "Низький", + "medium": "Середній", + "onscene": "На місці", + "responding": "Реагує", + "returning": "Повертається", + "staging": "Збір і очікування", + "transporting": "Транспортує", + "unavailable": "Недоступний", + "unknown": "Без значення або без статусу" + }, + "title": "Легенда значків карти", + "units": "Підрозділи" + }, "lock_map": "Закріпити карту на підрозділі", "no_location_for_routing": "Немає даних про місцезнаходження для навігації", "pin_address": "Address",