From 96c20900f78a29037160d0e6e6c0f3ef473656c2 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 2 Jul 2026 14:26:47 -0700 Subject: [PATCH 1/4] RU-T49 POI Sidebar fix --- src/app/(app)/_layout.tsx | 4 +- src/app/call/[id].tsx | 67 ++++++++++++- src/app/call/__tests__/[id].test.tsx | 92 +++++++++++++++++ .../sidebar/__tests__/call-sidebar.test.tsx | 98 ++++++++++++++++++- src/components/sidebar/call-sidebar.tsx | 42 +++++++- 5 files changed, 296 insertions(+), 7 deletions(-) diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 45c8671..f9c114c 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -495,9 +495,9 @@ export default function TabLayout() { ) : ( - + - + diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 86aa06b..b935d6c 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,6 +1,6 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; +import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, NavigationIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -72,6 +72,7 @@ export default function CallDetail() { const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isCloseCallModalOpen, setIsCloseCallModalOpen] = useState(false); const [isSettingActive, setIsSettingActive] = useState(false); + const [mapTarget, setMapTarget] = useState<'call' | 'destination'>('call'); const showToast = useToastStore((state) => state.showToast); const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); const startPolling = useCheckInTimerStore((state) => state.startPolling); @@ -234,6 +235,34 @@ export default function CallDetail() { } }; + /** + * Opens the device's native maps application with directions to the call's destination POI. + */ + const handleRouteToDestination = async () => { + const latitude = call?.DestinationLatitude; + const longitude = call?.DestinationLongitude; + + if (latitude === null || latitude === undefined || longitude === null || longitude === undefined) { + showToast('error', t('call_detail.no_location_for_routing')); + return; + } + + try { + const destinationName = call?.DestinationName || call?.DestinationAddress || t('call_detail.destination'); + const success = await openMapsWithDirections(latitude, longitude, destinationName, userLatitude || undefined, userLongitude || undefined); + + if (!success) { + showToast('error', t('call_detail.failed_to_open_maps')); + } + } catch (error) { + logger.error({ + message: 'Failed to open maps for destination routing', + context: { error, callId, destination: { latitude, longitude } }, + }); + showToast('error', t('call_detail.failed_to_open_maps')); + } + }; + if (isLoading) { return ( <> @@ -474,6 +503,17 @@ export default function CallDetail() { return tabs; }; + // Destination POI coordinates (if the call has a destination POI) and the + // currently displayed map target (call/dispatch location vs. destination). + const destinationLatitude = call.DestinationLatitude ?? null; + const destinationLongitude = call.DestinationLongitude ?? null; + const hasDestinationCoordinates = destinationLatitude !== null && destinationLongitude !== null && (destinationLatitude !== 0 || destinationLongitude !== 0); + const hasCallCoordinates = coordinates.latitude !== null && coordinates.longitude !== null; + const showingDestination = hasDestinationCoordinates && (mapTarget === 'destination' || !hasCallCoordinates); + const mapLatitude = showingDestination ? destinationLatitude : coordinates.latitude; + const mapLongitude = showingDestination ? destinationLongitude : coordinates.longitude; + const mapAddress = showingDestination ? call.DestinationAddress || call.DestinationName || '' : call.Address; + return ( <> {/* Map - only show when valid coordinates exist */} - {coordinates.latitude !== null && coordinates.longitude !== null ? ( + {mapLatitude !== null && mapLongitude !== null ? ( - + + {/* Toggle the map between the call (dispatch) location and the destination POI */} + {hasDestinationCoordinates ? ( + + + + + ) : null} ) : null} @@ -554,6 +607,14 @@ export default function CallDetail() { {t('common.route')} + {hasDestinationCoordinates ? ( + + + + ) : null} {/* Tabs */} diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 7875ebf..4e60efd 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -11,6 +11,7 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; import { securityStore } from '@/stores/security/store'; +import { openMapsWithDirections } from '@/lib/navigation'; import CallDetail from '../[id]'; @@ -178,12 +179,18 @@ jest.mock('lucide-react-native', () => ({ FileTextIcon: 'FileTextIcon', ImageIcon: 'ImageIcon', InfoIcon: 'InfoIcon', + MapPinIcon: 'MapPinIcon', + NavigationIcon: 'NavigationIcon', PaperclipIcon: 'PaperclipIcon', RouteIcon: 'RouteIcon', UserIcon: 'UserIcon', UsersIcon: 'UsersIcon', })); +jest.mock('@/lib/navigation', () => ({ + openMapsWithDirections: jest.fn(() => Promise.resolve(true)), +})); + // Mock react-native-svg jest.mock('react-native-svg', () => ({ Svg: 'Svg', @@ -434,6 +441,7 @@ const mockUseLocationStore = useLocationStore as jest.MockedFunction; const mockUseToastStore = useToastStore as jest.MockedFunction; const mockSecurityStore = securityStore as jest.MockedFunction; +const mockOpenMapsWithDirections = openMapsWithDirections as jest.MockedFunction; describe('CallDetail', () => { const defaultCallDetailStore = { @@ -1263,4 +1271,88 @@ describe('CallDetail', () => { }); }); }); + + describe('Destination POI', () => { + const callWithDestination = { + CallId: 'test-call-id', + Name: 'Test Call', + Number: 'C2024001', + Priority: 2, + Type: 'Emergency', + Address: '123 Main St', + Latitude: '40.7128', + Longitude: '-74.0060', + DestinationName: 'Central Hospital', + DestinationAddress: '789 Hospital Rd', + DestinationLatitude: 41.5, + DestinationLongitude: -73.5, + NotesCount: 0, + ImgagesCount: 0, + FileCount: 0, + }; + + const callWithoutDestination = { + CallId: 'test-call-id', + Name: 'Test Call', + Number: 'C2024001', + Priority: 2, + Type: 'Emergency', + Address: '123 Main St', + Latitude: '40.7128', + Longitude: '-74.0060', + NotesCount: 0, + ImgagesCount: 0, + FileCount: 0, + }; + + const mockDetailStore = (call: unknown) => { + const store = { + call, + callExtraData: null, + callPriority: null, + isLoading: false, + error: null, + fetchCallDetail: jest.fn(), + reset: jest.fn(), + }; + mockUseCallDetailStore.mockImplementation((selector: any) => (selector ? selector(store) : store)); + }; + + it('should show the map toggle and destination route button when the call has a destination POI', async () => { + mockDetailStore(callWithDestination); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('call-detail-map-toggle-call')).toBeTruthy(); + expect(getByTestId('call-detail-map-toggle-destination')).toBeTruthy(); + expect(getByTestId('call-detail-route-destination-button')).toBeTruthy(); + }); + }); + + it('should not show the map toggle or destination route button when the call has no destination POI', () => { + mockDetailStore(callWithoutDestination); + + const { queryByTestId } = render(); + + expect(queryByTestId('call-detail-map-toggle-call')).toBeNull(); + expect(queryByTestId('call-detail-map-toggle-destination')).toBeNull(); + expect(queryByTestId('call-detail-route-destination-button')).toBeNull(); + }); + + it('should route to the destination POI coordinates when the destination route button is pressed', async () => { + mockDetailStore(callWithDestination); + + const { getByTestId } = render(); + + const destinationButton = await waitFor(() => getByTestId('call-detail-route-destination-button')); + fireEvent.press(destinationButton); + + await waitFor(() => { + // Routes to the DESTINATION coordinates (41.5, -73.5), not the call's own location, + // using the current user location (40.7128, -74.0060) as the origin. + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(41.5, -73.5, 'Central Hospital', 40.7128, -74.006); + }); + }); + }); }); diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index 5af66c6..e35653f 100644 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx @@ -96,6 +96,8 @@ jest.mock('@/components/ui/button', () => ({ iconTestId = 'circle-x-icon'; } else if (Icon === require('lucide-react-native').Check) { iconTestId = 'check-icon'; + } else if (Icon === require('lucide-react-native').Navigation) { + iconTestId = 'navigation-icon'; } } return ( @@ -123,6 +125,10 @@ jest.mock('lucide-react-native', () => ({ const { View, Text } = require('react-native'); return MapPin; }, + Navigation: (props: any) => { + const { View, Text } = require('react-native'); + return Navigation; + }, })); const mockCall: CallResultData = { @@ -549,6 +555,96 @@ describe('SidebarCallCard', () => { }); }); + describe('Destination Routing Button', () => { + const mockCallWithDestination: CallResultData = { + ...mockCall, + DestinationPoiId: 42, + DestinationName: 'Central Hospital', + DestinationAddress: '789 Hospital Rd', + DestinationLatitude: 41.5, + DestinationLongitude: -73.5, + }; + + it('should show destination button when active call has a destination POI', () => { + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + expect(screen.getByTestId('call-destination-directions-button')).toBeTruthy(); + }); + + it('should not show destination button when active call has no destination', () => { + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + expect(() => screen.getByTestId('call-destination-directions-button')).toThrow(); + }); + + it('should route to destination coordinates when destination button is pressed', () => { + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + fireEvent.press(screen.getByTestId('call-destination-directions-button')); + + // Routes to the DESTINATION coordinates, not the call's own coordinates. + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(41.5, -73.5, 'Central Hospital'); + expect(mockOpenMapsWithAddress).not.toHaveBeenCalled(); + }); + + it('should route to destination address when destination has no coordinates', () => { + const destinationAddressOnly = { + ...mockCall, + DestinationName: '', + DestinationAddress: '789 Hospital Rd', + DestinationLatitude: null, + DestinationLongitude: null, + }; + + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + fireEvent.press(screen.getByTestId('call-destination-directions-button')); + + expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('789 Hospital Rd'); + expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); + }); + }); + describe('Accessibility', () => { it('should have proper testIDs for automation', () => { render(); @@ -556,4 +652,4 @@ describe('SidebarCallCard', () => { expect(screen.getByTestId('call-selection-trigger')).toBeTruthy(); }); }); -}); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx index 15fc286..355a1ca 100644 --- a/src/components/sidebar/call-sidebar.tsx +++ b/src/components/sidebar/call-sidebar.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { router } from 'expo-router'; -import { Check, CircleX, Eye, MapPin } from 'lucide-react-native'; +import { Check, CircleX, Eye, MapPin, Navigation } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; @@ -109,6 +109,40 @@ export const SidebarCallCard = () => { } }; + // Check if the call carries a routable destination POI (coordinates or address) + const hasDestinationData = (call: typeof activeCall) => { + if (!call) return false; + const hasCoordinates = call.DestinationLatitude != null && call.DestinationLongitude != null; + const hasAddress = !!call.DestinationAddress && call.DestinationAddress.trim() !== ''; + return hasCoordinates || hasAddress; + }; + + const handleDestinationDirections = async () => { + if (!activeCall) return; + + const latitude = activeCall.DestinationLatitude; + const longitude = activeCall.DestinationLongitude; + const address = activeCall.DestinationAddress; + const name = activeCall.DestinationName || activeCall.DestinationAddress; + + // Prefer the destination POI coordinates; fall back to its address. + if (latitude != null && longitude != null) { + try { + await openMapsWithDirections(latitude, longitude, name); + } catch { + showLocationAlert(); + } + } else if (address && address.trim() !== '') { + try { + await openMapsWithAddress(address); + } catch { + showLocationAlert(); + } + } else { + showLocationAlert(); + } + }; + return ( <> setIsBottomSheetOpen(true)} className="w-full" testID="call-selection-trigger"> @@ -142,6 +176,12 @@ export const SidebarCallCard = () => { )} + {hasDestinationData(activeCall) ? ( + + ) : null} + From 345d05e25e969162d63c87d036ae752a17e183e7 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 21 Jul 2026 15:35:50 -0700 Subject: [PATCH 2/4] RU-T50 Weather alert fix IC add --- src/api/calls/incidentCommand.ts | 18 + src/api/weather-alerts/weather-alerts.ts | 7 +- src/app/call/[id].tsx | 11 +- src/app/call/__tests__/[id].security.test.tsx | 5 + src/app/call/__tests__/[id].test.tsx | 5 + .../incident-command-tab-panel.test.tsx | 419 ++++++++++++++++++ .../incident-command-tab-panel.tsx | 347 +++++++++++++++ .../weather-alerts/severity-filter-tabs.tsx | 2 +- .../incidentCommand/resourceIncidentView.ts | 139 ++++++ .../resourceIncidentViewResult.ts | 7 + .../__tests__/push-notification.test.ts | 72 +++ src/services/push-notification.ts | 51 ++- .../__tests__/incident-command-store.test.ts | 219 +++++++++ src/stores/calls/incident-command-store.ts | 51 +++ src/stores/weather-alerts/store.ts | 2 +- src/translations/ar.json | 52 +++ src/translations/de.json | 52 +++ src/translations/en.json | 52 +++ src/translations/es.json | 52 +++ src/translations/fr.json | 52 +++ src/translations/it.json | 52 +++ src/translations/pl.json | 52 +++ src/translations/sv.json | 52 +++ src/translations/uk.json | 52 +++ 24 files changed, 1804 insertions(+), 19 deletions(-) create mode 100644 src/api/calls/incidentCommand.ts create mode 100644 src/components/incident-command/__tests__/incident-command-tab-panel.test.tsx create mode 100644 src/components/incident-command/incident-command-tab-panel.tsx create mode 100644 src/models/v4/incidentCommand/resourceIncidentView.ts create mode 100644 src/models/v4/incidentCommand/resourceIncidentViewResult.ts create mode 100644 src/stores/calls/__tests__/incident-command-store.test.ts create mode 100644 src/stores/calls/incident-command-store.ts diff --git a/src/api/calls/incidentCommand.ts b/src/api/calls/incidentCommand.ts new file mode 100644 index 0000000..e8a88bc --- /dev/null +++ b/src/api/calls/incidentCommand.ts @@ -0,0 +1,18 @@ +import { type ResourceIncidentViewResult } from '@/models/v4/incidentCommand/resourceIncidentViewResult'; + +import { createApiEndpoint } from '../common/client'; + +/** + * Fetches the resource-facing incident command view for a call. + * + * The route is RPC-style with the call id embedded in the path. The unit id is + * passed as a query parameter so the server can resolve the unit's lane + * assignment (MyAssignment). When no unit id is available the endpoint is + * called without it and the server resolves no assignment. + */ +export const getResourceIncidentView = async (callId: string | number, unitId?: string | number | null) => { + const getResourceIncidentViewApi = createApiEndpoint(`/IncidentCommand/GetResourceIncidentView/${encodeURIComponent(String(callId))}`); + const params = unitId !== undefined && unitId !== null && unitId !== '' ? { unitId } : undefined; + const response = await getResourceIncidentViewApi.get(params); + return response.data; +}; diff --git a/src/api/weather-alerts/weather-alerts.ts b/src/api/weather-alerts/weather-alerts.ts index f6d14fb..5cf6ad4 100644 --- a/src/api/weather-alerts/weather-alerts.ts +++ b/src/api/weather-alerts/weather-alerts.ts @@ -7,7 +7,8 @@ import { createCachedApiEndpoint } from '../common/cached-client'; import { createApiEndpoint } from '../common/client'; const getActiveAlertsApi = createCachedApiEndpoint('/WeatherAlerts/GetActiveAlerts', { ttl: 60 * 1000, enabled: true }); -const getWeatherAlertApi = createApiEndpoint('/WeatherAlerts/GetWeatherAlert'); +// GetWeatherAlert uses a path parameter, so the endpoint is created per call +const getWeatherAlertEndpoint = (alertId: string) => createApiEndpoint(`/WeatherAlerts/GetWeatherAlert/${encodeURIComponent(alertId)}`); const getAlertsNearLocationApi = createApiEndpoint('/WeatherAlerts/GetAlertsNearLocation'); const getAlertHistoryApi = createApiEndpoint('/WeatherAlerts/GetAlertHistory'); const getSettingsApi = createCachedApiEndpoint('/WeatherAlerts/GetSettings', { ttl: 5 * 60 * 1000, enabled: true }); @@ -19,9 +20,7 @@ export const getActiveAlerts = async () => { }; export const getWeatherAlert = async (alertId: string) => { - const response = await getWeatherAlertApi.get({ - alertId: encodeURIComponent(alertId), - }); + const response = await getWeatherAlertEndpoint(alertId).get(); return response.data; }; diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index b935d6c..e6c0b78 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,6 +1,6 @@ import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, NavigationIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; +import { ClipboardListIcon, ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, NavigationIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,6 +10,7 @@ import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-ta import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; +import { IncidentCommandTabPanel } from '@/components/incident-command/incident-command-tab-panel'; // Import a static map component instead of react-native-maps import StaticMap from '@/components/maps/static-map'; import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; @@ -480,6 +481,14 @@ export default function CallDetail() { }, ]; + // Incident command tab + tabs.push({ + key: 'command', + title: t('incident_command.tab_title'), + icon: , + content: , + }); + // Video feeds tab tabs.push({ key: 'video', diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index d2d9a29..2ece0ad 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -198,6 +198,10 @@ jest.mock('@/components/check-in-timers/check-in-tab-content', () => ({ CheckInTabContent: () => null, })); +jest.mock('@/components/incident-command/incident-command-tab-panel', () => ({ + IncidentCommandTabPanel: () => null, +})); + jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ VideoFeedTabContent: () => null, })); @@ -315,6 +319,7 @@ jest.mock('date-fns', () => ({ // Mock lucide-react-native icons jest.mock('lucide-react-native', () => ({ + ClipboardListIcon: ({ size, ...props }: any) =>
ClipboardList
, ClockIcon: ({ size, ...props }: any) =>
Clock
, FileTextIcon: ({ size, ...props }: any) =>
FileText
, ImageIcon: ({ size, ...props }: any) =>
Image
, diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 4e60efd..ba24394 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -175,6 +175,7 @@ jest.mock('expo-router', () => ({ // Mock Lucide React Native icons jest.mock('lucide-react-native', () => ({ + ClipboardListIcon: 'ClipboardListIcon', ClockIcon: 'ClockIcon', FileTextIcon: 'FileTextIcon', ImageIcon: 'ImageIcon', @@ -285,6 +286,10 @@ jest.mock('@/components/check-in-timers/check-in-tab-content', () => ({ CheckInTabContent: () => null, })); +jest.mock('@/components/incident-command/incident-command-tab-panel', () => ({ + IncidentCommandTabPanel: () => null, +})); + jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ VideoFeedTabContent: () => null, })); diff --git a/src/components/incident-command/__tests__/incident-command-tab-panel.test.tsx b/src/components/incident-command/__tests__/incident-command-tab-panel.test.tsx new file mode 100644 index 0000000..e9c3164 --- /dev/null +++ b/src/components/incident-command/__tests__/incident-command-tab-panel.test.tsx @@ -0,0 +1,419 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { Linking } from 'react-native'; + +import type { ResourceIncidentView } from '@/models/v4/incidentCommand/resourceIncidentView'; + +import { IncidentCommandTabPanel } from '../incident-command-tab-panel'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('lucide-react-native', () => ({ + MailIcon: () => { + const { View } = require('react-native'); + return ; + }, + PhoneIcon: () => { + const { View } = require('react-native'); + return ; + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@/components/ui/badge', () => ({ + Badge: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + BadgeText: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/box', () => ({ + Box: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/heading', () => ({ + Heading: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/pressable', () => ({ + Pressable: ({ children, onPress, ...props }: any) => { + const { TouchableOpacity } = require('react-native'); + return ( + + {children} + + ); + }, +})); + +jest.mock('@/components/ui/spinner', () => ({ + Spinner: (props: any) => { + const { View } = require('react-native'); + return ; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, ...props }: any) => { + const { Text: RNText } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +const mockFetchIncidentView = jest.fn(); + +interface MockStoreState { + view: ResourceIncidentView | null; + isLoading: boolean; + error: string | null; + fetchIncidentView: typeof mockFetchIncidentView; +} + +let mockStoreState: MockStoreState; + +jest.mock('@/stores/calls/incident-command-store', () => ({ + useIncidentCommandStore: (selector: any) => selector(mockStoreState), +})); + +const createMockView = (overrides: Partial = {}): ResourceIncidentView => + ({ + IncidentCommandId: 'ic-1', + CallId: 123, + Status: 0, + EstablishedOn: '2026-07-01T10:00:00Z', + EstimatedEndOn: '2026-07-01T18:00:00Z', + ClosedOn: null, + ImportantInformation: 'Watch for downed power lines', + IncidentActionPlan: 'Attack from the north side', + Commander: { UserId: 'user-1', Name: 'Chief Smith', Phone: '555-1234', Email: 'chief@example.com' }, + Objectives: [ + { + TacticalObjectiveId: 'obj-1', + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 123, + Name: 'Primary search', + ObjectiveType: 1, + Status: 2, + AutoPopulated: false, + CompletedByUserId: null, + CompletedOn: null, + Description: null, + ProgressPercent: 50, + Priority: 1, + TargetCompleteOn: null, + SortOrder: 0, + ModifiedOn: null, + }, + ], + Needs: [ + { + IncidentNeedId: 'need-1', + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 123, + Name: 'Water tenders', + Description: null, + Category: 0, + Status: 1, + QuantityRequested: 4, + QuantityFulfilled: 2, + Priority: 1, + CreatedByUserId: null, + CreatedOn: '2026-07-01T10:15:00Z', + MetByUserId: null, + MetOn: null, + SortOrder: 0, + ModifiedOn: null, + }, + ], + Notes: [ + { + IncidentNoteId: 'note-1', + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 123, + NoteType: 0, + Visibility: 0, + Title: 'Situation update', + Body: 'Fire is 40% contained', + ContainmentPercent: 40, + CreatedByUserId: 'user-1', + CreatedOn: '2026-07-01T11:00:00Z', + DeletedOn: null, + ModifiedOn: null, + }, + ], + Attachments: [ + { + IncidentAttachmentId: 'att-1', + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 123, + Visibility: 0, + FileName: 'preplan.pdf', + ContentType: 'application/pdf', + ContentLength: 2048, + Description: null, + UploadedByUserId: 'user-1', + UploadedOn: '2026-07-01T10:30:00Z', + DeletedOn: null, + ModifiedOn: null, + }, + ], + MyAssignment: { + ResourceAssignmentId: 'ra-1', + CommandStructureNodeId: 'node-1', + LaneName: 'Fire Attack', + NodeType: 1, + Color: '#FF0000', + AssignedOn: '2026-07-01T10:05:00Z', + PrimaryLead: { UserId: 'user-2', Name: 'Capt. Jones', Phone: '555-5678', Email: 'jones@example.com' }, + SecondaryLead: { UserId: 'user-3', Name: 'Lt. Brown', Phone: null, Email: null }, + PrimaryObjective: { + TacticalObjectiveId: 'obj-2', + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 123, + Name: 'Knock down fire', + ObjectiveType: 0, + Status: 2, + AutoPopulated: false, + CompletedByUserId: null, + CompletedOn: null, + Description: null, + ProgressPercent: 25, + Priority: 1, + TargetCompleteOn: null, + SortOrder: 0, + ModifiedOn: null, + }, + SecondaryObjective: null, + LinkedNeed: { + IncidentNeedId: 'need-2', + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 123, + Name: 'Extra hose lines', + Description: null, + Category: 3, + Status: 0, + QuantityRequested: 0, + QuantityFulfilled: 0, + Priority: 1, + CreatedByUserId: null, + CreatedOn: '2026-07-01T10:20:00Z', + MetByUserId: null, + MetOn: null, + SortOrder: 0, + ModifiedOn: null, + }, + }, + ...overrides, + }) as ResourceIncidentView; + +describe('IncidentCommandTabPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStoreState = { + view: null, + isLoading: false, + error: null, + fetchIncidentView: mockFetchIncidentView, + }; + }); + + it('should fetch the incident view on mount with the call id', () => { + const { unmount } = render(); + + expect(mockFetchIncidentView).toHaveBeenCalledWith('call123'); + + unmount(); + }); + + it('should render the loading state', () => { + mockStoreState.isLoading = true; + + const { getByTestId, unmount } = render(); + + expect(getByTestId('incident-command-loading')).toBeTruthy(); + + unmount(); + }); + + it('should render the error state', () => { + mockStoreState.error = 'Something went wrong'; + + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('incident-command-error')).toBeTruthy(); + expect(getByText('incident_command.error')).toBeTruthy(); + + unmount(); + }); + + it('should render the empty state when no incident command is active', () => { + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('incident-command-empty')).toBeTruthy(); + expect(getByText('incident_command.no_active_command')).toBeTruthy(); + + unmount(); + }); + + it('should render the unit assignment card with lane, leads, objectives and linked need', () => { + mockStoreState.view = createMockView(); + + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('incident-command-assignment-card')).toBeTruthy(); + expect(getByText('Fire Attack')).toBeTruthy(); + expect(getByText('Capt. Jones')).toBeTruthy(); + expect(getByText('Lt. Brown')).toBeTruthy(); + expect(getByText('Knock down fire')).toBeTruthy(); + expect(getByText('Extra hose lines')).toBeTruthy(); + + unmount(); + }); + + it('should not render the assignment card when there is no assignment', () => { + mockStoreState.view = createMockView({ MyAssignment: null }); + + const { queryByTestId, getByTestId, unmount } = render(); + + expect(queryByTestId('incident-command-assignment-card')).toBeNull(); + expect(getByTestId('incident-command-info-card')).toBeTruthy(); + + unmount(); + }); + + it('should render the incident info card with commander, important information and action plan', () => { + mockStoreState.view = createMockView(); + + const { getByTestId, getByText, unmount } = render(); + + expect(getByTestId('incident-command-info-card')).toBeTruthy(); + expect(getByText('Chief Smith')).toBeTruthy(); + expect(getByTestId('incident-command-important-information')).toBeTruthy(); + expect(getByText('Watch for downed power lines')).toBeTruthy(); + expect(getByText('Attack from the north side')).toBeTruthy(); + + unmount(); + }); + + it('should render objectives, needs, notes and attachments', () => { + mockStoreState.view = createMockView(); + + const { getByText, unmount } = render(); + + expect(getByText('Primary search')).toBeTruthy(); + expect(getByText('Water tenders')).toBeTruthy(); + expect(getByText('incident_command.quantity_fulfilled')).toBeTruthy(); + expect(getByText('Situation update')).toBeTruthy(); + expect(getByText('Fire is 40% contained')).toBeTruthy(); + expect(getByText('preplan.pdf')).toBeTruthy(); + expect(getByText('2.0 KB')).toBeTruthy(); + + unmount(); + }); + + it('should open the dialer when a contact phone number is pressed', () => { + const openUrlSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as never); + mockStoreState.view = createMockView(); + + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('incident-command-primary-lead-phone')); + expect(openUrlSpy).toHaveBeenCalledWith('tel:555-5678'); + + openUrlSpy.mockRestore(); + unmount(); + }); + + it('should open the mail client when a contact email is pressed', () => { + const openUrlSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as never); + mockStoreState.view = createMockView(); + + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('incident-command-commander-email')); + expect(openUrlSpy).toHaveBeenCalledWith('mailto:chief@example.com'); + + openUrlSpy.mockRestore(); + unmount(); + }); + + it('should render list empty states when the incident has no objectives, needs, notes or attachments', () => { + mockStoreState.view = createMockView({ + Objectives: [], + Needs: [], + Notes: [], + Attachments: [], + MyAssignment: null, + }); + + const { getByText, unmount } = render(); + + expect(getByText('incident_command.no_objectives')).toBeTruthy(); + expect(getByText('incident_command.no_needs')).toBeTruthy(); + expect(getByText('incident_command.no_notes')).toBeTruthy(); + expect(getByText('incident_command.no_attachments')).toBeTruthy(); + + unmount(); + }); + + it('should hide deleted notes and attachments', () => { + const view = createMockView(); + view.Notes[0].DeletedOn = '2026-07-01T12:00:00Z'; + view.Attachments[0].DeletedOn = '2026-07-01T12:00:00Z'; + mockStoreState.view = view; + + const { getByText, queryByText, unmount } = render(); + + expect(queryByText('Situation update')).toBeNull(); + expect(queryByText('preplan.pdf')).toBeNull(); + expect(getByText('incident_command.no_notes')).toBeTruthy(); + expect(getByText('incident_command.no_attachments')).toBeTruthy(); + + unmount(); + }); +}); diff --git a/src/components/incident-command/incident-command-tab-panel.tsx b/src/components/incident-command/incident-command-tab-panel.tsx new file mode 100644 index 0000000..1ff1717 --- /dev/null +++ b/src/components/incident-command/incident-command-tab-panel.tsx @@ -0,0 +1,347 @@ +import { format } from 'date-fns'; +import { MailIcon, PhoneIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking } from 'react-native'; + +import { Badge, BadgeText } from '@/components/ui/badge'; +import { Box } from '@/components/ui/box'; +import { Heading } from '@/components/ui/heading'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; +import { type IncidentContactInfo, IncidentNeedStatus, type TacticalObjective, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/resourceIncidentView'; +import { useIncidentCommandStore } from '@/stores/calls/incident-command-store'; + +interface IncidentCommandTabPanelProps { + callId: string; +} + +const OBJECTIVE_TYPE_KEYS: Record = { + 0: 'general', + 1: 'benchmark', + 2: 'safety', +}; + +const OBJECTIVE_STATUS_KEYS: Record = { + 0: 'pending', + 1: 'complete', + 2: 'in_progress', +}; + +const NEED_CATEGORY_KEYS: Record = { + 0: 'resource', + 1: 'logistics', + 2: 'medical', + 3: 'equipment', + 4: 'staffing', + 5: 'other', +}; + +const NEED_STATUS_KEYS: Record = { + 0: 'open', + 1: 'partially_met', + 2: 'met', + 3: 'cancelled', +}; + +type BadgeAction = 'error' | 'warning' | 'success' | 'info' | 'muted'; + +const getObjectiveStatusAction = (status: number): BadgeAction => { + if (status === TacticalObjectiveStatus.Complete) return 'success'; + if (status === TacticalObjectiveStatus.InProgress) return 'warning'; + return 'muted'; +}; + +const getNeedStatusAction = (status: number): BadgeAction => { + if (status === IncidentNeedStatus.Met) return 'success'; + if (status === IncidentNeedStatus.PartiallyMet) return 'warning'; + if (status === IncidentNeedStatus.Cancelled) return 'muted'; + return 'info'; +}; + +const formatDateTime = (value?: string | null): string => { + if (!value) return ''; + const date = new Date(value); + if (isNaN(date.getTime())) return ''; + return format(date, 'MMM d, h:mm a'); +}; + +const formatBytes = (bytes: number): string => { + if (!bytes || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +}; + +const openContactUrl = async (url: string) => { + try { + await Linking.openURL(url); + } catch (error) { + logger.error({ + message: 'Failed to open contact link', + context: { error, url }, + }); + } +}; + +interface ContactRowProps { + label: string; + contact: IncidentContactInfo; + testID: string; +} + +const ContactRow: React.FC = ({ label, contact, testID }) => { + return ( + + {label} + {contact.Name} + {contact.Phone ? ( + openContactUrl(`tel:${contact.Phone}`)} testID={`${testID}-phone`}> + + + {contact.Phone} + + + ) : null} + {contact.Email ? ( + openContactUrl(`mailto:${contact.Email}`)} testID={`${testID}-email`}> + + + {contact.Email} + + + ) : null} + + ); +}; + +interface LaneObjectiveRowProps { + label: string; + objective: TacticalObjective; + progressText: string; + testID: string; +} + +const LaneObjectiveRow: React.FC = ({ label, objective, progressText, testID }) => { + return ( + + {label} + {objective.Name} + {progressText} + + ); +}; + +export const IncidentCommandTabPanel: React.FC = ({ callId }) => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const view = useIncidentCommandStore((state) => state.view); + const isLoading = useIncidentCommandStore((state) => state.isLoading); + const error = useIncidentCommandStore((state) => state.error); + const fetchIncidentView = useIncidentCommandStore((state) => state.fetchIncidentView); + + useEffect(() => { + if (callId) { + fetchIncidentView(callId); + } + }, [callId, fetchIncidentView]); + + const cardClass = colorScheme === 'dark' ? 'bg-neutral-900' : 'bg-neutral-100'; + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + {t('incident_command.error')} + + ); + } + + if (!view) { + return ( + + {t('incident_command.no_active_command')} + + ); + } + + const assignment = view.MyAssignment; + const notes = view.Notes.filter((note) => !note.DeletedOn); + const attachments = view.Attachments.filter((attachment) => !attachment.DeletedOn); + + return ( + + {/* Unit assignment card */} + {assignment ? ( + + {t('incident_command.my_assignment')} + + + + {assignment.LaneName} + + + {t('incident_command.assigned_since', { time: formatDateTime(assignment.AssignedOn) })} + + {assignment.PrimaryLead ? : null} + {assignment.SecondaryLead ? : null} + {assignment.PrimaryObjective ? ( + + ) : null} + {assignment.SecondaryObjective ? ( + + ) : null} + {assignment.LinkedNeed ? ( + + {t('incident_command.linked_need')} + + {assignment.LinkedNeed.Name} + + {t(`incident_command.need_status.${NEED_STATUS_KEYS[assignment.LinkedNeed.Status] ?? 'open'}`)} + + + + ) : null} + + + ) : null} + + {/* Incident info card */} + + {t('incident_command.incident_info')} + + {view.Commander ? : null} + + {t('incident_command.established')} + {formatDateTime(view.EstablishedOn)} + + {view.EstimatedEndOn ? ( + + {t('incident_command.estimated_end')} + {formatDateTime(view.EstimatedEndOn)} + + ) : null} + {view.ImportantInformation ? ( + + {t('incident_command.important_information')} + {view.ImportantInformation} + + ) : null} + {view.IncidentActionPlan ? ( + + {t('incident_command.action_plan')} + {view.IncidentActionPlan} + + ) : null} + + + + {/* Objectives */} + + {t('incident_command.objectives')} + {view.Objectives.length > 0 ? ( + + {view.Objectives.map((objective) => ( + + + {objective.Name} + + {t(`incident_command.objective_status.${OBJECTIVE_STATUS_KEYS[objective.Status] ?? 'pending'}`)} + + + + {t(`incident_command.objective_type.${OBJECTIVE_TYPE_KEYS[objective.ObjectiveType] ?? 'general'}`)} + {t('incident_command.progress', { percent: objective.ProgressPercent })} + + + ))} + + ) : ( + {t('incident_command.no_objectives')} + )} + + + {/* Needs */} + + {t('incident_command.needs')} + {view.Needs.length > 0 ? ( + + {view.Needs.map((need) => ( + + + {need.Name} + + {t(`incident_command.need_status.${NEED_STATUS_KEYS[need.Status] ?? 'open'}`)} + + + + {t(`incident_command.need_category.${NEED_CATEGORY_KEYS[need.Category] ?? 'other'}`)} + {need.QuantityRequested > 0 ? {t('incident_command.quantity_fulfilled', { fulfilled: need.QuantityFulfilled, requested: need.QuantityRequested })} : null} + + + ))} + + ) : ( + {t('incident_command.no_needs')} + )} + + + {/* Notes */} + + {t('incident_command.notes')} + {notes.length > 0 ? ( + + {notes.map((note) => ( + + {note.Title ? {note.Title} : null} + {note.Body} + {formatDateTime(note.CreatedOn)} + + ))} + + ) : ( + {t('incident_command.no_notes')} + )} + + + {/* Attachments */} + + {t('incident_command.attachments')} + {attachments.length > 0 ? ( + + {attachments.map((attachment) => ( + + {attachment.FileName} + {formatBytes(attachment.ContentLength)} + + ))} + + ) : ( + {t('incident_command.no_attachments')} + )} + + + ); +}; diff --git a/src/components/weather-alerts/severity-filter-tabs.tsx b/src/components/weather-alerts/severity-filter-tabs.tsx index c3d79dc..736b5d8 100644 --- a/src/components/weather-alerts/severity-filter-tabs.tsx +++ b/src/components/weather-alerts/severity-filter-tabs.tsx @@ -31,7 +31,7 @@ export const SeverityFilterTabs: React.FC = ({ selected }; return ( - + {FILTERS.map((filter) => { const isActive = selectedFilter === filter.severity; const count = getCount(filter.severity); diff --git a/src/models/v4/incidentCommand/resourceIncidentView.ts b/src/models/v4/incidentCommand/resourceIncidentView.ts new file mode 100644 index 0000000..fc69f2b --- /dev/null +++ b/src/models/v4/incidentCommand/resourceIncidentView.ts @@ -0,0 +1,139 @@ +// Enum values mirror the server's IncidentCommand enums. Numeric values MUST stay in sync +// with the backend (they are persisted and sent over the wire). + +export enum TacticalObjectiveType { + General = 0, + Benchmark = 1, + Safety = 2, +} + +export enum TacticalObjectiveStatus { + Pending = 0, + Complete = 1, + InProgress = 2, +} + +export enum IncidentNeedCategory { + Resource = 0, + Logistics = 1, + Medical = 2, + Equipment = 3, + Staffing = 4, + Other = 5, +} + +export enum IncidentNeedStatus { + Open = 0, + PartiallyMet = 1, + Met = 2, + Cancelled = 3, +} + +export class IncidentContactInfo { + public UserId?: string | null = null; + public Name: string = ''; + public Phone?: string | null = null; + public Email?: string | null = null; +} + +export class TacticalObjective { + public TacticalObjectiveId: string = ''; + public IncidentCommandId: string = ''; + public DepartmentId: number = 0; + public CallId: number = 0; + public Name: string = ''; + public ObjectiveType: number = 0; + public Status: number = 0; + public AutoPopulated: boolean = false; + public CompletedByUserId?: string | null = null; + public CompletedOn?: string | null = null; + public Description?: string | null = null; + public ProgressPercent: number = 0; + public Priority: number = 0; + public TargetCompleteOn?: string | null = null; + public SortOrder: number = 0; + public ModifiedOn?: string | null = null; +} + +export class IncidentNeed { + public IncidentNeedId: string = ''; + public IncidentCommandId: string = ''; + public DepartmentId: number = 0; + public CallId: number = 0; + public Name: string = ''; + public Description?: string | null = null; + public Category: number = 0; + public Status: number = 0; + public QuantityRequested: number = 0; + public QuantityFulfilled: number = 0; + public Priority: number = 0; + public CreatedByUserId?: string | null = null; + public CreatedOn: string = ''; + public MetByUserId?: string | null = null; + public MetOn?: string | null = null; + public SortOrder: number = 0; + public ModifiedOn?: string | null = null; +} + +export class IncidentNote { + public IncidentNoteId: string = ''; + public IncidentCommandId: string = ''; + public DepartmentId: number = 0; + public CallId: number = 0; + public NoteType: number = 0; + public Visibility: number = 0; + public Title?: string | null = null; + public Body: string = ''; + public ContainmentPercent?: number | null = null; + public CreatedByUserId: string = ''; + public CreatedOn: string = ''; + public DeletedOn?: string | null = null; + public ModifiedOn?: string | null = null; +} + +export class IncidentAttachment { + public IncidentAttachmentId: string = ''; + public IncidentCommandId: string = ''; + public DepartmentId: number = 0; + public CallId: number = 0; + public Visibility: number = 0; + public FileName: string = ''; + public ContentType: string = ''; + public ContentLength: number = 0; + public Description?: string | null = null; + public UploadedByUserId: string = ''; + public UploadedOn: string = ''; + public DeletedOn?: string | null = null; + public ModifiedOn?: string | null = null; +} + +export class ResourceLaneAssignmentView { + public ResourceAssignmentId: string = ''; + public CommandStructureNodeId: string = ''; + public LaneName: string = ''; + public NodeType: number = 0; + public Color?: string | null = null; + public AssignedOn: string = ''; + public PrimaryLead?: IncidentContactInfo | null = null; + public SecondaryLead?: IncidentContactInfo | null = null; + public PrimaryObjective?: TacticalObjective | null = null; + public SecondaryObjective?: TacticalObjective | null = null; + public LinkedNeed?: IncidentNeed | null = null; +} + +export class ResourceIncidentView { + public IncidentCommandId: string = ''; + public CallId: number = 0; + public Status: number = 0; + public EstablishedOn: string = ''; + public EstimatedEndOn?: string | null = null; + public ClosedOn?: string | null = null; + public ImportantInformation?: string | null = null; + public IncidentActionPlan?: string | null = null; + public Commander?: IncidentContactInfo | null = null; + public Objectives: TacticalObjective[] = []; + public Needs: IncidentNeed[] = []; + public Notes: IncidentNote[] = []; + public Attachments: IncidentAttachment[] = []; + public MyAssignment?: ResourceLaneAssignmentView | null = null; +} diff --git a/src/models/v4/incidentCommand/resourceIncidentViewResult.ts b/src/models/v4/incidentCommand/resourceIncidentViewResult.ts new file mode 100644 index 0000000..31fb37e --- /dev/null +++ b/src/models/v4/incidentCommand/resourceIncidentViewResult.ts @@ -0,0 +1,7 @@ +import { BaseV4Request } from '../baseV4Request'; +import { type ResourceIncidentView } from './resourceIncidentView'; + +export class ResourceIncidentViewResult extends BaseV4Request { + // Data is null when the call has no incident command (Status === 'NotFound'). + public Data: ResourceIncidentView | null = null; +} diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index e141a6f..a404a3b 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -426,6 +426,78 @@ describe('Push Notification Service Integration', () => { }); }); + describe('cold-start tap dedupe', () => { + let pushNotificationService: any; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + + jest.unmock('../push-notification'); + const module = require('../push-notification'); + pushNotificationService = module.pushNotificationService; + + // resetModules gave the re-required service a FRESH store mock instance — the outer + // describe's mockGetState points at the old one, so prime the fresh instance directly. + const freshStore = require('@/stores/push-notification/store').usePushNotificationModalStore; + (freshStore.getState as jest.Mock).mockReturnValue({ + showNotificationModal: mockShowNotificationModal, + }); + }); + + afterEach(() => { + pushNotificationService.cleanup(); + jest.useRealTimers(); + }); + + it('handles the same cold-start tap only once across the listener and killed-state replay', async () => { + jest.useFakeTimers(); + const launchResponse = { + actionIdentifier: 'default', + notification: { + request: { + identifier: 'launch-tap', + content: { title: 'Call', body: 'Dispatch', data: { eventCode: 'C:77' } }, + }, + }, + }; + mockGetLastNotificationResponseAsync.mockResolvedValueOnce(launchResponse as never); + + await pushNotificationService.initialize(); + + const responseHandler = (mockAddNotificationResponseReceivedListener.mock.calls as unknown[][])[0]?.[0] as (r: unknown) => void; + responseHandler(launchResponse); + + // Let the killed-state initial delay elapse and its promise resolve + jest.advanceTimersByTime(1100); + await Promise.resolve(); + await Promise.resolve(); + jest.advanceTimersByTime(1000); + + expect(mockShowNotificationModal).toHaveBeenCalledTimes(1); + expect(mockShowNotificationModal).toHaveBeenCalledWith(expect.objectContaining({ eventCode: 'C:77' })); + }); + + it('still handles distinct notification taps separately', async () => { + jest.useFakeTimers(); + await pushNotificationService.initialize(); + + const responseHandler = (mockAddNotificationResponseReceivedListener.mock.calls as unknown[][])[0]?.[0] as (r: unknown) => void; + const makeResponse = (id: string, eventCode: string) => ({ + actionIdentifier: 'default', + notification: { + request: { identifier: id, content: { title: 'T', body: 'B', data: { eventCode } } }, + }, + }); + + responseHandler(makeResponse('tap-a', 'C:1')); + responseHandler(makeResponse('tap-b', 'C:2')); + jest.advanceTimersByTime(400); + + expect(mockShowNotificationModal).toHaveBeenCalledTimes(2); + }); + }); + describe('listener lifecycle', () => { let pushNotificationService: any; diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 4efacfa..7f9cf5a 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -74,6 +74,13 @@ class PushNotificationService { private notificationListener: { remove: () => void } | null = null; private responseListener: { remove: () => void } | null = null; private notifeeForegroundUnsubscribe: (() => void) | null = null; + /** + * Request identifiers of taps already routed to the modal. On a cold start the SAME + * launch tap can arrive through both addNotificationResponseReceivedListener and + * getLastNotificationResponseAsync — dedupe by identifier so it shows once while + * distinct notifications still each get handled. + */ + private handledResponseIds = new Set(); public static getInstance(): PushNotificationService { if (!PushNotificationService.instance) { @@ -215,20 +222,42 @@ class PushNotificationService { this.showModalForData(data, notification.request.content.title, notification.request.content.body); }; - // Notification tap (background → foreground) via expo-notifications. - private handleNotificationResponse = (response: Notifications.NotificationResponse): void => { - const content = response.notification.request.content; + /** + * Single path for tap responses from expo-notifications (live listener AND the + * cold-start replay). Skips responses whose request identifier was already routed. + */ + private handleResponseOnce(response: Notifications.NotificationResponse, delayMs: number, source: string): void { + const request = response.notification.request; + const identifier = request.identifier; + + if (identifier && this.handledResponseIds.has(identifier)) { + logger.debug({ + message: 'Skipping already-handled notification response', + context: { identifier, source }, + }); + return; + } + if (identifier) { + this.handledResponseIds.add(identifier); + } + + const content = request.content; const data = content.data as Record | undefined; logger.info({ message: 'Notification response received (tap)', - context: { data, actionIdentifier: response.actionIdentifier }, + context: { data, actionIdentifier: response.actionIdentifier, source }, }); // Delay so the React tree is mounted and the modal store is ready. setTimeout(() => { this.showModalForData(data, content.title, content.body); - }, TAP_BACKGROUND_DELAY_MS); + }, delayMs); + } + + // Notification tap (background → foreground) via expo-notifications. + private handleNotificationResponse = (response: Notifications.NotificationResponse): void => { + this.handleResponseOnce(response, TAP_BACKGROUND_DELAY_MS, 'listener'); }; // Notifee events handle taps/actions on notifee-displayed notifications, @@ -316,24 +345,22 @@ class PushNotificationService { this.setupNotifeeEvents(); // Handle the notification that launched the app from a killed state. - // expo-notifications surfaces this via getLastNotificationResponseAsync(). + // expo-notifications surfaces this via getLastNotificationResponseAsync(); the same + // tap may also have reached the response listener above, so both routes flow through + // handleResponseOnce which dedupes by request identifier. setTimeout(() => { Notifications.getLastNotificationResponseAsync() .then((response) => { if (!response) { return; } - const content = response.notification.request.content; - const data = content.data as Record | undefined; logger.info({ message: 'App opened from notification (killed state)', - context: { data }, + context: { data: response.notification.request.content.data }, }); - setTimeout(() => { - this.showModalForData(data, content.title, content.body); - }, TAP_KILLED_MODAL_DELAY_MS); + this.handleResponseOnce(response, TAP_KILLED_MODAL_DELAY_MS, 'killed-state'); }) .catch((error) => { logger.error({ diff --git a/src/stores/calls/__tests__/incident-command-store.test.ts b/src/stores/calls/__tests__/incident-command-store.test.ts new file mode 100644 index 0000000..198542e --- /dev/null +++ b/src/stores/calls/__tests__/incident-command-store.test.ts @@ -0,0 +1,219 @@ +// Mock Platform first before any imports +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: jest.fn((specifics: any) => specifics.ios || specifics.default), + Version: 17, + }, +})); + +// Mock MMKV storage +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + set: jest.fn(), + getString: jest.fn(), + delete: jest.fn(), + })), + useMMKVBoolean: jest.fn(() => [false, jest.fn()]), +})); + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { getResourceIncidentView } from '@/api/calls/incidentCommand'; +import { useCoreStore } from '@/stores/app/core-store'; + +import { useIncidentCommandStore } from '../incident-command-store'; + +// Mock the API calls +jest.mock('@/api/calls/incidentCommand'); + +// Mock the core store so no real storage/config dependencies load +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: { + getState: jest.fn(), + }, +})); + +const mockGetResourceIncidentView = getResourceIncidentView as jest.MockedFunction; +const mockGetCoreState = useCoreStore.getState as jest.MockedFunction; + +const createMockView = () => ({ + IncidentCommandId: 'ic-1', + CallId: 123, + Status: 0, + EstablishedOn: '2026-07-01T10:00:00Z', + EstimatedEndOn: null, + ClosedOn: null, + ImportantInformation: 'Watch for downed lines', + IncidentActionPlan: 'Attack from the north side', + Commander: { UserId: 'user-1', Name: 'Chief Smith', Phone: '555-1234', Email: 'chief@example.com' }, + Objectives: [], + Needs: [], + Notes: [], + Attachments: [], + MyAssignment: null, +}); + +describe('useIncidentCommandStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset store state + useIncidentCommandStore.setState({ + view: null, + isLoading: false, + error: null, + }); + mockGetCoreState.mockReturnValue({ activeUnitId: '42' } as any); + }); + + describe('fetchIncidentView', () => { + it('should fetch the incident view successfully and pass the active unit id', async () => { + const mockView = createMockView(); + mockGetResourceIncidentView.mockResolvedValue({ + Data: mockView, + Status: 'Ok', + } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + // Verify initial state + expect(result.current.view).toBeNull(); + expect(result.current.isLoading).toBe(false); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + await waitFor(() => { + expect(result.current.view).toEqual(mockView); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + expect(mockGetResourceIncidentView).toHaveBeenCalledWith('call123', '42'); + + unmount(); + }); + + it('should call the endpoint without a unit id when there is no active unit', async () => { + mockGetCoreState.mockReturnValue({ activeUnitId: null } as any); + mockGetResourceIncidentView.mockResolvedValue({ + Data: createMockView(), + Status: 'Ok', + } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + expect(mockGetResourceIncidentView).toHaveBeenCalledWith('call123', undefined); + + unmount(); + }); + + it('should handle loading state correctly', async () => { + mockGetResourceIncidentView.mockImplementation(() => new Promise(() => {})); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + act(() => { + result.current.fetchIncidentView('call123'); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.view).toBeNull(); + + unmount(); + }); + + it('should set an empty view when the call has no incident command (NotFound)', async () => { + mockGetResourceIncidentView.mockResolvedValue({ + Data: null, + Status: 'NotFound', + } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + await waitFor(() => { + expect(result.current.view).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + unmount(); + }); + + it('should handle fetch errors', async () => { + const errorMessage = 'Network error'; + mockGetResourceIncidentView.mockRejectedValue(new Error(errorMessage)); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + await waitFor(() => { + expect(result.current.error).toBe(errorMessage); + expect(result.current.isLoading).toBe(false); + expect(result.current.view).toBeNull(); + }); + + expect(mockGetResourceIncidentView).toHaveBeenCalledWith('call123', '42'); + + unmount(); + }); + + it('should clear a previous error when refetching', async () => { + useIncidentCommandStore.setState({ error: 'Previous error' }); + + const mockView = createMockView(); + mockGetResourceIncidentView.mockResolvedValue({ + Data: mockView, + Status: 'Ok', + } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + await act(async () => { + await result.current.fetchIncidentView('call123'); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.view).toEqual(mockView); + }); + + unmount(); + }); + }); + + describe('reset', () => { + it('should reset the store to its initial state', async () => { + useIncidentCommandStore.setState({ + view: createMockView() as any, + isLoading: true, + error: 'Some error', + }); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + act(() => { + result.current.reset(); + }); + + expect(result.current.view).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + + unmount(); + }); + }); +}); diff --git a/src/stores/calls/incident-command-store.ts b/src/stores/calls/incident-command-store.ts new file mode 100644 index 0000000..8f08ebd --- /dev/null +++ b/src/stores/calls/incident-command-store.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; + +import { getResourceIncidentView } from '@/api/calls/incidentCommand'; +import { type ResourceIncidentView } from '@/models/v4/incidentCommand/resourceIncidentView'; +import { useCoreStore } from '@/stores/app/core-store'; + +interface IncidentCommandState { + view: ResourceIncidentView | null; + isLoading: boolean; + error: string | null; + fetchIncidentView: (callId: string) => Promise; + reset: () => void; +} + +export const useIncidentCommandStore = create((set) => ({ + view: null, + isLoading: false, + error: null, + reset: () => + set({ + view: null, + isLoading: false, + error: null, + }), + fetchIncidentView: async (callId: string) => { + set({ isLoading: true, error: null }); + try { + const activeUnitId = useCoreStore.getState().activeUnitId; + const result = await getResourceIncidentView(callId, activeUnitId ?? undefined); + + if (result && result.Data && result.Status !== 'NotFound') { + set({ + view: result.Data, + isLoading: false, + }); + } else { + // The call has no incident command established (Status === 'NotFound'). + set({ + view: null, + isLoading: false, + }); + } + } catch (error) { + set({ + view: null, + error: error instanceof Error ? error.message : 'An unknown error occurred', + isLoading: false, + }); + } + }, +})); diff --git a/src/stores/weather-alerts/store.ts b/src/stores/weather-alerts/store.ts index 8c5f59a..548d4c7 100644 --- a/src/stores/weather-alerts/store.ts +++ b/src/stores/weather-alerts/store.ts @@ -72,7 +72,7 @@ export const useWeatherAlertsStore = create((set, get) => ({ } }, fetchAlertDetail: async (alertId: string) => { - set({ isLoadingDetail: true }); + set({ isLoadingDetail: true, selectedAlert: null }); try { const response = await getWeatherAlert(alertId); set({ selectedAlert: response.Data, isLoadingDetail: false }); diff --git a/src/translations/ar.json b/src/translations/ar.json index 359d439..10dabd9 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -479,6 +479,58 @@ "invalid_url": "يرجى إدخال عنوان URL صالح يبدأ بـ http:// أو https://", "required": "هذا الحقل مطلوب" }, + "incident_command": { + "action_plan": "خطة عمل الحادث", + "assigned_since": "تم التعيين {{time}}", + "attachments": "المرفقات", + "commander": "القائد", + "error": "فشل في تحميل معلومات قيادة الحادث", + "established": "تم التأسيس", + "estimated_end": "النهاية المتوقعة", + "important_information": "معلومات مهمة", + "incident_info": "معلومات الحادث", + "linked_need": "احتياج مرتبط", + "my_assignment": "تعيين الوحدة", + "need_category": { + "equipment": "معدات", + "logistics": "لوجستيات", + "medical": "طبي", + "other": "أخرى", + "resource": "موارد", + "staffing": "طاقم عمل" + }, + "need_status": { + "cancelled": "ملغي", + "met": "ملبى", + "open": "مفتوح", + "partially_met": "ملبى جزئيًا" + }, + "needs": "الاحتياجات", + "no_active_command": "لا توجد قيادة حادث نشطة لهذه المكالمة", + "no_attachments": "لا توجد مرفقات", + "no_needs": "لا توجد احتياجات", + "no_notes": "لا توجد ملاحظات", + "no_objectives": "لا توجد أهداف", + "notes": "ملاحظات", + "objective_status": { + "complete": "مكتمل", + "in_progress": "قيد التنفيذ", + "pending": "قيد الانتظار" + }, + "objective_type": { + "benchmark": "نقطة مرجعية", + "general": "عام", + "safety": "السلامة" + }, + "objectives": "الأهداف", + "primary_lead": "المسؤول الرئيسي", + "primary_objective": "الهدف الرئيسي", + "progress": "اكتمل {{percent}}%", + "quantity_fulfilled": "تم تلبية {{fulfilled}} من {{requested}}", + "secondary_lead": "المسؤول الثانوي", + "secondary_objective": "الهدف الثانوي", + "tab_title": "القيادة" + }, "livekit": { "audio_devices": "أجهزة الصوت", "audio_settings": "إعدادات الصوت", diff --git a/src/translations/de.json b/src/translations/de.json index ee4dc19..f9dffec 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -479,6 +479,58 @@ "invalid_url": "Bitte eine gültige URL eingeben, die mit http:// oder https:// beginnt", "required": "Dieses Feld ist erforderlich" }, + "incident_command": { + "action_plan": "Einsatzplan", + "assigned_since": "Zugewiesen {{time}}", + "attachments": "Anhänge", + "commander": "Einsatzleiter", + "error": "Informationen zur Einsatzleitung konnten nicht geladen werden", + "established": "Eingerichtet", + "estimated_end": "Voraussichtliches Ende", + "important_information": "Wichtige Informationen", + "incident_info": "Vorfallinformationen", + "linked_need": "Verknüpfter Bedarf", + "my_assignment": "Einheitszuweisung", + "need_category": { + "equipment": "Ausrüstung", + "logistics": "Logistik", + "medical": "Medizinisch", + "other": "Sonstiges", + "resource": "Ressource", + "staffing": "Personal" + }, + "need_status": { + "cancelled": "Abgebrochen", + "met": "Erfüllt", + "open": "Offen", + "partially_met": "Teilweise erfüllt" + }, + "needs": "Bedarfe", + "no_active_command": "Für diesen Anruf ist keine Einsatzleitung aktiv", + "no_attachments": "Keine Anhänge", + "no_needs": "Keine Bedarfe", + "no_notes": "Keine Notizen", + "no_objectives": "Keine Ziele", + "notes": "Notizen", + "objective_status": { + "complete": "Abgeschlossen", + "in_progress": "In Bearbeitung", + "pending": "Ausstehend" + }, + "objective_type": { + "benchmark": "Meilenstein", + "general": "Allgemein", + "safety": "Sicherheit" + }, + "objectives": "Ziele", + "primary_lead": "Primäre Leitung", + "primary_objective": "Primäres Ziel", + "progress": "{{percent}} % abgeschlossen", + "quantity_fulfilled": "{{fulfilled}} von {{requested}} erfüllt", + "secondary_lead": "Sekundäre Leitung", + "secondary_objective": "Sekundäres Ziel", + "tab_title": "Führung" + }, "livekit": { "audio_devices": "Audiogeräte", "audio_settings": "Audioeinstellungen", diff --git a/src/translations/en.json b/src/translations/en.json index b0918e9..d8dbf5b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -479,6 +479,58 @@ "invalid_url": "Please enter a valid URL starting with http:// or https://", "required": "This field is required" }, + "incident_command": { + "action_plan": "Incident Action Plan", + "assigned_since": "Assigned {{time}}", + "attachments": "Attachments", + "commander": "Commander", + "error": "Failed to load incident command information", + "established": "Established", + "estimated_end": "Estimated End", + "important_information": "Important Information", + "incident_info": "Incident Information", + "linked_need": "Linked Need", + "my_assignment": "Unit Assignment", + "need_category": { + "equipment": "Equipment", + "logistics": "Logistics", + "medical": "Medical", + "other": "Other", + "resource": "Resource", + "staffing": "Staffing" + }, + "need_status": { + "cancelled": "Cancelled", + "met": "Met", + "open": "Open", + "partially_met": "Partially Met" + }, + "needs": "Needs", + "no_active_command": "No incident command is active for this call", + "no_attachments": "No attachments", + "no_needs": "No needs", + "no_notes": "No notes", + "no_objectives": "No objectives", + "notes": "Notes", + "objective_status": { + "complete": "Complete", + "in_progress": "In Progress", + "pending": "Pending" + }, + "objective_type": { + "benchmark": "Benchmark", + "general": "General", + "safety": "Safety" + }, + "objectives": "Objectives", + "primary_lead": "Primary Lead", + "primary_objective": "Primary Objective", + "progress": "{{percent}}% complete", + "quantity_fulfilled": "{{fulfilled}} of {{requested}} fulfilled", + "secondary_lead": "Secondary Lead", + "secondary_objective": "Secondary Objective", + "tab_title": "Command" + }, "livekit": { "audio_devices": "Audio Devices", "audio_settings": "Audio Settings", diff --git a/src/translations/es.json b/src/translations/es.json index 210e1f1..ae7614d 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -479,6 +479,58 @@ "invalid_url": "Por favor, introduce una URL válida que comience con http:// o https://", "required": "Este campo es obligatorio" }, + "incident_command": { + "action_plan": "Plan de acción del incidente", + "assigned_since": "Asignado {{time}}", + "attachments": "Adjuntos", + "commander": "Comandante", + "error": "Error al cargar la información del comando del incidente", + "established": "Establecido", + "estimated_end": "Fin estimado", + "important_information": "Información importante", + "incident_info": "Información del incidente", + "linked_need": "Necesidad vinculada", + "my_assignment": "Asignación de la unidad", + "need_category": { + "equipment": "Equipamiento", + "logistics": "Logística", + "medical": "Médico", + "other": "Otro", + "resource": "Recurso", + "staffing": "Personal" + }, + "need_status": { + "cancelled": "Cancelada", + "met": "Satisfecha", + "open": "Abierta", + "partially_met": "Parcialmente satisfecha" + }, + "needs": "Necesidades", + "no_active_command": "No hay un comando de incidente activo para esta llamada", + "no_attachments": "No hay adjuntos", + "no_needs": "No hay necesidades", + "no_notes": "No hay notas", + "no_objectives": "No hay objetivos", + "notes": "Notas", + "objective_status": { + "complete": "Completado", + "in_progress": "En progreso", + "pending": "Pendiente" + }, + "objective_type": { + "benchmark": "Hito", + "general": "General", + "safety": "Seguridad" + }, + "objectives": "Objetivos", + "primary_lead": "Líder principal", + "primary_objective": "Objetivo principal", + "progress": "{{percent}}% completado", + "quantity_fulfilled": "{{fulfilled}} de {{requested}} completados", + "secondary_lead": "Líder secundario", + "secondary_objective": "Objetivo secundario", + "tab_title": "Comando" + }, "livekit": { "audio_devices": "Dispositivos de audio", "audio_settings": "Configuración de audio", diff --git a/src/translations/fr.json b/src/translations/fr.json index efa5286..21f4566 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -479,6 +479,58 @@ "invalid_url": "Veuillez saisir une URL valide commençant par http:// ou https://", "required": "Ce champ est obligatoire" }, + "incident_command": { + "action_plan": "Plan d'action de l'incident", + "assigned_since": "Affecté {{time}}", + "attachments": "Pièces jointes", + "commander": "Commandant", + "error": "Échec du chargement des informations du commandement de l'incident", + "established": "Établi", + "estimated_end": "Fin estimée", + "important_information": "Informations importantes", + "incident_info": "Informations sur l'incident", + "linked_need": "Besoin lié", + "my_assignment": "Affectation de l'unité", + "need_category": { + "equipment": "Équipement", + "logistics": "Logistique", + "medical": "Médical", + "other": "Autre", + "resource": "Ressource", + "staffing": "Personnel" + }, + "need_status": { + "cancelled": "Annulé", + "met": "Satisfait", + "open": "Ouvert", + "partially_met": "Partiellement satisfait" + }, + "needs": "Besoins", + "no_active_command": "Aucun commandement d'incident n'est actif pour cet appel", + "no_attachments": "Aucune pièce jointe", + "no_needs": "Aucun besoin", + "no_notes": "Aucune note", + "no_objectives": "Aucun objectif", + "notes": "Notes", + "objective_status": { + "complete": "Terminé", + "in_progress": "En cours", + "pending": "En attente" + }, + "objective_type": { + "benchmark": "Jalon", + "general": "Général", + "safety": "Sécurité" + }, + "objectives": "Objectifs", + "primary_lead": "Responsable principal", + "primary_objective": "Objectif principal", + "progress": "{{percent}} % terminé", + "quantity_fulfilled": "{{fulfilled}} sur {{requested}} satisfaits", + "secondary_lead": "Responsable secondaire", + "secondary_objective": "Objectif secondaire", + "tab_title": "Commandement" + }, "livekit": { "audio_devices": "Appareils audio", "audio_settings": "Paramètres audio", diff --git a/src/translations/it.json b/src/translations/it.json index a3a31d5..3a61453 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -479,6 +479,58 @@ "invalid_url": "Inserisci un URL valido che inizia con http:// o https://", "required": "Questo campo è obbligatorio" }, + "incident_command": { + "action_plan": "Piano d'azione dell'incidente", + "assigned_since": "Assegnata {{time}}", + "attachments": "Allegati", + "commander": "Comandante", + "error": "Impossibile caricare le informazioni del comando incidente", + "established": "Istituito", + "estimated_end": "Fine stimata", + "important_information": "Informazioni importanti", + "incident_info": "Informazioni sull'incidente", + "linked_need": "Necessità collegata", + "my_assignment": "Assegnazione unità", + "need_category": { + "equipment": "Attrezzatura", + "logistics": "Logistica", + "medical": "Medico", + "other": "Altro", + "resource": "Risorsa", + "staffing": "Personale" + }, + "need_status": { + "cancelled": "Annullata", + "met": "Soddisfatta", + "open": "Aperta", + "partially_met": "Parzialmente soddisfatta" + }, + "needs": "Necessità", + "no_active_command": "Nessun comando incidente attivo per questa chiamata", + "no_attachments": "Nessun allegato", + "no_needs": "Nessuna necessità", + "no_notes": "Nessuna nota", + "no_objectives": "Nessun obiettivo", + "notes": "Note", + "objective_status": { + "complete": "Completato", + "in_progress": "In corso", + "pending": "In attesa" + }, + "objective_type": { + "benchmark": "Traguardo", + "general": "Generale", + "safety": "Sicurezza" + }, + "objectives": "Obiettivi", + "primary_lead": "Responsabile principale", + "primary_objective": "Obiettivo principale", + "progress": "{{percent}}% completato", + "quantity_fulfilled": "{{fulfilled}} di {{requested}} soddisfatti", + "secondary_lead": "Responsabile secondario", + "secondary_objective": "Obiettivo secondario", + "tab_title": "Comando" + }, "livekit": { "audio_devices": "Dispositivi audio", "audio_settings": "Impostazioni audio", diff --git a/src/translations/pl.json b/src/translations/pl.json index ad32975..677d617 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -479,6 +479,58 @@ "invalid_url": "Wpisz prawidłowy URL zaczynający się od http:// lub https://", "required": "To pole jest wymagane" }, + "incident_command": { + "action_plan": "Plan działań incydentu", + "assigned_since": "Przydzielono {{time}}", + "attachments": "Załączniki", + "commander": "Dowódca", + "error": "Nie udało się załadować informacji o dowodzeniu incydentem", + "established": "Ustanowiono", + "estimated_end": "Szacowane zakończenie", + "important_information": "Ważne informacje", + "incident_info": "Informacje o incydencie", + "linked_need": "Powiązana potrzeba", + "my_assignment": "Przydział jednostki", + "need_category": { + "equipment": "Sprzęt", + "logistics": "Logistyka", + "medical": "Medyczne", + "other": "Inne", + "resource": "Zasób", + "staffing": "Personel" + }, + "need_status": { + "cancelled": "Anulowana", + "met": "Zaspokojona", + "open": "Otwarta", + "partially_met": "Częściowo zaspokojona" + }, + "needs": "Potrzeby", + "no_active_command": "Brak aktywnego dowodzenia incydentem dla tego zgłoszenia", + "no_attachments": "Brak załączników", + "no_needs": "Brak potrzeb", + "no_notes": "Brak notatek", + "no_objectives": "Brak celów", + "notes": "Notatki", + "objective_status": { + "complete": "Zakończony", + "in_progress": "W trakcie", + "pending": "Oczekujący" + }, + "objective_type": { + "benchmark": "Kamień milowy", + "general": "Ogólny", + "safety": "Bezpieczeństwo" + }, + "objectives": "Cele", + "primary_lead": "Główny kierujący", + "primary_objective": "Cel główny", + "progress": "Ukończono {{percent}}%", + "quantity_fulfilled": "Zaspokojono {{fulfilled}} z {{requested}}", + "secondary_lead": "Pomocniczy kierujący", + "secondary_objective": "Cel drugorzędny", + "tab_title": "Dowodzenie" + }, "livekit": { "audio_devices": "Urządzenia audio", "audio_settings": "Ustawienia audio", diff --git a/src/translations/sv.json b/src/translations/sv.json index 326bdde..c83663b 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -479,6 +479,58 @@ "invalid_url": "Ange en giltig URL som börjar med http:// eller https://", "required": "Detta fält är obligatoriskt" }, + "incident_command": { + "action_plan": "Insatsplan", + "assigned_since": "Tilldelad {{time}}", + "attachments": "Bilagor", + "commander": "Insatsledare", + "error": "Det gick inte att ladda information om insatsledningen", + "established": "Upprättad", + "estimated_end": "Beräknat slut", + "important_information": "Viktig information", + "incident_info": "Incidentinformation", + "linked_need": "Kopplat behov", + "my_assignment": "Enhetstilldelning", + "need_category": { + "equipment": "Utrustning", + "logistics": "Logistik", + "medical": "Medicinskt", + "other": "Övrigt", + "resource": "Resurs", + "staffing": "Bemanning" + }, + "need_status": { + "cancelled": "Avbrutet", + "met": "Uppfyllt", + "open": "Öppet", + "partially_met": "Delvis uppfyllt" + }, + "needs": "Behov", + "no_active_command": "Ingen insatsledning är aktiv för detta samtal", + "no_attachments": "Inga bilagor", + "no_needs": "Inga behov", + "no_notes": "Inga anteckningar", + "no_objectives": "Inga mål", + "notes": "Anteckningar", + "objective_status": { + "complete": "Slutfört", + "in_progress": "Pågår", + "pending": "Väntande" + }, + "objective_type": { + "benchmark": "Milstolpe", + "general": "Allmänt", + "safety": "Säkerhet" + }, + "objectives": "Mål", + "primary_lead": "Huvudansvarig", + "primary_objective": "Primärt mål", + "progress": "{{percent}} % slutfört", + "quantity_fulfilled": "{{fulfilled}} av {{requested}} uppfyllda", + "secondary_lead": "Biträdande ansvarig", + "secondary_objective": "Sekundärt mål", + "tab_title": "Ledning" + }, "livekit": { "audio_devices": "Ljudenheter", "audio_settings": "Ljudinställningar", diff --git a/src/translations/uk.json b/src/translations/uk.json index 3e1e2ad..fa4b4b7 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -479,6 +479,58 @@ "invalid_url": "Введіть правильний URL, що починається з http:// або https://", "required": "Це поле обов'язкове" }, + "incident_command": { + "action_plan": "План дій щодо інциденту", + "assigned_since": "Призначено {{time}}", + "attachments": "Вкладення", + "commander": "Керівник", + "error": "Не вдалося завантажити інформацію про керування інцидентом", + "established": "Встановлено", + "estimated_end": "Орієнтовне завершення", + "important_information": "Важлива інформація", + "incident_info": "Інформація про інцидент", + "linked_need": "Пов'язана потреба", + "my_assignment": "Призначення підрозділу", + "need_category": { + "equipment": "Обладнання", + "logistics": "Логістика", + "medical": "Медичне", + "other": "Інше", + "resource": "Ресурс", + "staffing": "Персонал" + }, + "need_status": { + "cancelled": "Скасовано", + "met": "Задоволено", + "open": "Відкрито", + "partially_met": "Частково задоволено" + }, + "needs": "Потреби", + "no_active_command": "Для цього виклику немає активного керування інцидентом", + "no_attachments": "Немає вкладень", + "no_needs": "Немає потреб", + "no_notes": "Немає приміток", + "no_objectives": "Немає цілей", + "notes": "Примітки", + "objective_status": { + "complete": "Завершено", + "in_progress": "У процесі", + "pending": "Очікує" + }, + "objective_type": { + "benchmark": "Контрольна точка", + "general": "Загальна", + "safety": "Безпека" + }, + "objectives": "Цілі", + "primary_lead": "Основний відповідальний", + "primary_objective": "Основна ціль", + "progress": "Виконано {{percent}}%", + "quantity_fulfilled": "Виконано {{fulfilled}} з {{requested}}", + "secondary_lead": "Додатковий відповідальний", + "secondary_objective": "Другорядна ціль", + "tab_title": "Керування" + }, "livekit": { "audio_devices": "Аудіопристрої", "audio_settings": "Налаштування звуку", From fc6a91a350ce64b67062e1ff3c5ec65053c0acf2 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 21 Jul 2026 16:14:01 -0700 Subject: [PATCH 3/4] RU-T50 PR#253 fixes --- .../sidebar/__tests__/call-sidebar.test.tsx | 113 ++++++++++++++++++ src/components/sidebar/call-sidebar.tsx | 20 +++- .../__tests__/push-notification.test.ts | 8 +- .../__tests__/incident-command-store.test.ts | 94 +++++++++++++++ src/stores/calls/incident-command-store.ts | 13 +- 5 files changed, 238 insertions(+), 10 deletions(-) diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index e35653f..8ece529 100644 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx @@ -553,6 +553,57 @@ describe('SidebarCallCard', () => { [{ text: 'common.ok' }] ); }); + + it('should show error alert when openMapsWithDirections resolves false', async () => { + mockOpenMapsWithDirections.mockResolvedValue(false); + + render(); + + fireEvent.press(screen.getByTestId('map-pin-icon')); + + // Wait for the async operation to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAlert.alert).toHaveBeenCalledWith( + 'calls.no_location_title', + 'calls.no_location_message', + [{ text: 'common.ok' }] + ); + }); + + it('should show error alert when openMapsWithAddress resolves false', async () => { + const callWithAddressOnly = { + ...mockCall, + Latitude: '', + Longitude: '', + Address: '123 Test Street', + }; + + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + mockOpenMapsWithAddress.mockResolvedValue(false); + + render(); + + fireEvent.press(screen.getByTestId('map-pin-icon')); + + // Wait for the async operation to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAlert.alert).toHaveBeenCalledWith( + 'calls.no_location_title', + 'calls.no_location_message', + [{ text: 'common.ok' }] + ); + }); }); describe('Destination Routing Button', () => { @@ -643,6 +694,68 @@ describe('SidebarCallCard', () => { expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('789 Hospital Rd'); expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); }); + + it('should show error alert when destination openMapsWithDirections resolves false', async () => { + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + mockOpenMapsWithDirections.mockResolvedValue(false); + + render(); + + fireEvent.press(screen.getByTestId('call-destination-directions-button')); + + // Wait for the async operation to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAlert.alert).toHaveBeenCalledWith( + 'calls.no_location_title', + 'calls.no_location_message', + [{ text: 'common.ok' }] + ); + }); + + it('should show error alert when destination openMapsWithAddress resolves false', async () => { + const destinationAddressOnly = { + ...mockCall, + DestinationName: '', + DestinationAddress: '789 Hospital Rd', + DestinationLatitude: null, + DestinationLongitude: null, + }; + + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + mockOpenMapsWithAddress.mockResolvedValue(false); + + render(); + + fireEvent.press(screen.getByTestId('call-destination-directions-button')); + + // Wait for the async operation to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockAlert.alert).toHaveBeenCalledWith( + 'calls.no_location_title', + 'calls.no_location_message', + [{ text: 'common.ok' }] + ); + }); }); describe('Accessibility', () => { diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx index 355a1ca..db370ce 100644 --- a/src/components/sidebar/call-sidebar.tsx +++ b/src/components/sidebar/call-sidebar.tsx @@ -92,14 +92,20 @@ export const SidebarCallCard = () => { // Check if we have coordinates if (latitude && longitude) { try { - await openMapsWithDirections(latitude, longitude, address); + const opened = await openMapsWithDirections(latitude, longitude, address); + if (!opened) { + showLocationAlert(); + } } catch { showLocationAlert(); } } else if (address && address.trim() !== '') { // Fall back to address if no coordinates try { - await openMapsWithAddress(address); + const opened = await openMapsWithAddress(address); + if (!opened) { + showLocationAlert(); + } } catch { showLocationAlert(); } @@ -128,13 +134,19 @@ export const SidebarCallCard = () => { // Prefer the destination POI coordinates; fall back to its address. if (latitude != null && longitude != null) { try { - await openMapsWithDirections(latitude, longitude, name); + const opened = await openMapsWithDirections(latitude, longitude, name); + if (!opened) { + showLocationAlert(); + } } catch { showLocationAlert(); } } else if (address && address.trim() !== '') { try { - await openMapsWithAddress(address); + const opened = await openMapsWithAddress(address); + if (!opened) { + showLocationAlert(); + } } catch { showLocationAlert(); } diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index a404a3b..0f8e43a 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -427,7 +427,7 @@ describe('Push Notification Service Integration', () => { }); describe('cold-start tap dedupe', () => { - let pushNotificationService: any; + let pushNotificationService: typeof import('../push-notification').pushNotificationService; beforeEach(() => { jest.clearAllMocks(); @@ -499,7 +499,7 @@ describe('Push Notification Service Integration', () => { }); describe('listener lifecycle', () => { - let pushNotificationService: any; + let pushNotificationService: typeof import('../push-notification').pushNotificationService; beforeEach(() => { jest.clearAllMocks(); @@ -556,7 +556,7 @@ describe('Push Notification Service Integration', () => { }); describe('registration', () => { - let pushNotificationService: any; + let pushNotificationService: typeof import('../push-notification').pushNotificationService; beforeEach(() => { jest.clearAllMocks(); @@ -611,7 +611,7 @@ describe('Push Notification Service Integration', () => { }); describe('Android notification channels', () => { - let pushNotificationService: any; + let pushNotificationService: typeof import('../push-notification').pushNotificationService; beforeEach(() => { jest.clearAllMocks(); diff --git a/src/stores/calls/__tests__/incident-command-store.test.ts b/src/stores/calls/__tests__/incident-command-store.test.ts index 198542e..f2372ab 100644 --- a/src/stores/calls/__tests__/incident-command-store.test.ts +++ b/src/stores/calls/__tests__/incident-command-store.test.ts @@ -195,6 +195,100 @@ describe('useIncidentCommandStore', () => { }); }); + describe('stale request handling', () => { + it('should ignore a superseded fetch result', async () => { + const staleView = { ...createMockView(), ImportantInformation: 'stale' }; + const freshView = { ...createMockView(), ImportantInformation: 'fresh' }; + let resolveFirst: (value: unknown) => void = () => {}; + mockGetResourceIncidentView + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) as any + ) + .mockResolvedValueOnce({ Data: freshView, Status: 'Ok' } as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + let firstFetch: Promise = Promise.resolve(); + act(() => { + firstFetch = result.current.fetchIncidentView('call-old'); + }); + await act(async () => { + await result.current.fetchIncidentView('call-new'); + }); + await act(async () => { + resolveFirst({ Data: staleView, Status: 'Ok' }); + await firstFetch; + }); + + expect(result.current.view).toEqual(freshView); + expect(result.current.isLoading).toBe(false); + + unmount(); + }); + + it('should ignore a fetch result arriving after reset', async () => { + let resolveFetch: (value: unknown) => void = () => {}; + mockGetResourceIncidentView.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) as any + ); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.fetchIncidentView('call123'); + }); + act(() => { + result.current.reset(); + }); + await act(async () => { + resolveFetch({ Data: createMockView(), Status: 'Ok' }); + await pending; + }); + + expect(result.current.view).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + + unmount(); + }); + + it('should ignore a fetch error arriving after reset', async () => { + let rejectFetch: (error: Error) => void = () => {}; + mockGetResourceIncidentView.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFetch = reject; + }) as any + ); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.fetchIncidentView('call123'); + }); + act(() => { + result.current.reset(); + }); + await act(async () => { + rejectFetch(new Error('late failure')); + await pending; + }); + + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(false); + + unmount(); + }); + }); + describe('reset', () => { it('should reset the store to its initial state', async () => { useIncidentCommandStore.setState({ diff --git a/src/stores/calls/incident-command-store.ts b/src/stores/calls/incident-command-store.ts index 8f08ebd..bad35fe 100644 --- a/src/stores/calls/incident-command-store.ts +++ b/src/stores/calls/incident-command-store.ts @@ -12,21 +12,29 @@ interface IncidentCommandState { reset: () => void; } +// Monotonic token guarding against stale async results: reset() and every new +// fetch bump it, so an in-flight request that has been superseded is discarded. +let requestSeq = 0; + export const useIncidentCommandStore = create((set) => ({ view: null, isLoading: false, error: null, - reset: () => + reset: () => { + requestSeq++; set({ view: null, isLoading: false, error: null, - }), + }); + }, fetchIncidentView: async (callId: string) => { + const seq = ++requestSeq; set({ isLoading: true, error: null }); try { const activeUnitId = useCoreStore.getState().activeUnitId; const result = await getResourceIncidentView(callId, activeUnitId ?? undefined); + if (seq !== requestSeq) return; if (result && result.Data && result.Status !== 'NotFound') { set({ @@ -41,6 +49,7 @@ export const useIncidentCommandStore = create((set) => ({ }); } } catch (error) { + if (seq !== requestSeq) return; set({ view: null, error: error instanceof Error ? error.message : 'An unknown error occurred', From b85022748a2e125152c65e0d5668ff481e342fdc Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 21 Jul 2026 17:17:28 -0700 Subject: [PATCH 4/4] RU-T50 PR#253 fixes --- src/app/call/[id].tsx | 12 ++++- .../incident-command-tab-panel.tsx | 8 ++- .../sidebar/__tests__/call-sidebar.test.tsx | 51 +++++++++++++++++++ src/components/sidebar/call-sidebar.tsx | 6 ++- .../incidentCommand/resourceIncidentView.ts | 8 +-- .../resourceIncidentViewResult.ts | 3 ++ .../__tests__/incident-command-store.test.ts | 16 ++++++ src/stores/calls/incident-command-store.ts | 6 ++- 8 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index e6c0b78..35f90b6 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -112,6 +112,14 @@ export default function CallDetail() { setIsCloseCallModalOpen(true); }, []); + const handleShowCallOnMap = useCallback(() => { + setMapTarget('call'); + }, []); + + const handleShowDestinationOnMap = useCallback(() => { + setMapTarget('destination'); + }, []); + const handleSetActive = async () => { if (!call) return; @@ -562,11 +570,11 @@ export default function CallDetail() { {/* Toggle the map between the call (dispatch) location and the destination POI */} {hasDestinationCoordinates ? ( - - diff --git a/src/components/incident-command/incident-command-tab-panel.tsx b/src/components/incident-command/incident-command-tab-panel.tsx index 1ff1717..545fd2e 100644 --- a/src/components/incident-command/incident-command-tab-panel.tsx +++ b/src/components/incident-command/incident-command-tab-panel.tsx @@ -82,9 +82,15 @@ const openContactUrl = async (url: string) => { try { await Linking.openURL(url); } catch (error) { + // The URL embeds a phone number or email address (PII) — log only the scheme, + // and strip the URL from the platform error message, which repeats it. + const scheme = url.split(':')[0]; logger.error({ message: 'Failed to open contact link', - context: { error, url }, + context: { + scheme, + error: error instanceof Error ? error.message.replaceAll(url, `${scheme}:`) : String(error), + }, }); } }; diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index 8ece529..35ddc62 100644 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx @@ -695,6 +695,57 @@ describe('SidebarCallCard', () => { expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); }); + it('should treat (0,0) destination coordinates as no-data and route by address instead', async () => { + const destinationZeroCoords = { + ...mockCall, + DestinationName: 'Central Hospital', + DestinationAddress: '789 Hospital Rd', + DestinationLatitude: 0, + DestinationLongitude: 0, + }; + + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: destinationZeroCoords, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: destinationZeroCoords, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + fireEvent.press(screen.getByTestId('call-destination-directions-button')); + + expect(mockOpenMapsWithAddress).toHaveBeenCalledWith('789 Hospital Rd'); + expect(mockOpenMapsWithDirections).not.toHaveBeenCalled(); + }); + + it('should hide destination button when destination has only (0,0) coordinates and no address', () => { + const destinationZeroNoAddress = { + ...mockCall, + DestinationName: '', + DestinationAddress: '', + DestinationLatitude: 0, + DestinationLongitude: 0, + }; + + mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ + activeCall: destinationZeroNoAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) : { + activeCall: destinationZeroNoAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }); + + render(); + + expect(() => screen.getByTestId('call-destination-directions-button')).toThrow(); + }); + it('should show error alert when destination openMapsWithDirections resolves false', async () => { mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ activeCall: mockCallWithDestination, diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx index db370ce..5c2ac3e 100644 --- a/src/components/sidebar/call-sidebar.tsx +++ b/src/components/sidebar/call-sidebar.tsx @@ -118,7 +118,8 @@ export const SidebarCallCard = () => { // Check if the call carries a routable destination POI (coordinates or address) const hasDestinationData = (call: typeof activeCall) => { if (!call) return false; - const hasCoordinates = call.DestinationLatitude != null && call.DestinationLongitude != null; + // (0,0) is the server's no-data sentinel, not a real destination + const hasCoordinates = call.DestinationLatitude != null && call.DestinationLongitude != null && (call.DestinationLatitude !== 0 || call.DestinationLongitude !== 0); const hasAddress = !!call.DestinationAddress && call.DestinationAddress.trim() !== ''; return hasCoordinates || hasAddress; }; @@ -132,7 +133,8 @@ export const SidebarCallCard = () => { const name = activeCall.DestinationName || activeCall.DestinationAddress; // Prefer the destination POI coordinates; fall back to its address. - if (latitude != null && longitude != null) { + // (0,0) is the server's no-data sentinel, not a real destination. + if (latitude != null && longitude != null && (latitude !== 0 || longitude !== 0)) { try { const opened = await openMapsWithDirections(latitude, longitude, name); if (!opened) { diff --git a/src/models/v4/incidentCommand/resourceIncidentView.ts b/src/models/v4/incidentCommand/resourceIncidentView.ts index fc69f2b..5ea46fe 100644 --- a/src/models/v4/incidentCommand/resourceIncidentView.ts +++ b/src/models/v4/incidentCommand/resourceIncidentView.ts @@ -42,8 +42,8 @@ export class TacticalObjective { public DepartmentId: number = 0; public CallId: number = 0; public Name: string = ''; - public ObjectiveType: number = 0; - public Status: number = 0; + public ObjectiveType: TacticalObjectiveType = TacticalObjectiveType.General; + public Status: TacticalObjectiveStatus = TacticalObjectiveStatus.Pending; public AutoPopulated: boolean = false; public CompletedByUserId?: string | null = null; public CompletedOn?: string | null = null; @@ -62,8 +62,8 @@ export class IncidentNeed { public CallId: number = 0; public Name: string = ''; public Description?: string | null = null; - public Category: number = 0; - public Status: number = 0; + public Category: IncidentNeedCategory = IncidentNeedCategory.Resource; + public Status: IncidentNeedStatus = IncidentNeedStatus.Open; public QuantityRequested: number = 0; public QuantityFulfilled: number = 0; public Priority: number = 0; diff --git a/src/models/v4/incidentCommand/resourceIncidentViewResult.ts b/src/models/v4/incidentCommand/resourceIncidentViewResult.ts index 31fb37e..e0f7da8 100644 --- a/src/models/v4/incidentCommand/resourceIncidentViewResult.ts +++ b/src/models/v4/incidentCommand/resourceIncidentViewResult.ts @@ -1,6 +1,9 @@ import { BaseV4Request } from '../baseV4Request'; import { type ResourceIncidentView } from './resourceIncidentView'; +// Envelope Status value the server sends when the call has no incident command. +export const INCIDENT_VIEW_STATUS_NOT_FOUND = 'NotFound'; + export class ResourceIncidentViewResult extends BaseV4Request { // Data is null when the call has no incident command (Status === 'NotFound'). public Data: ResourceIncidentView | null = null; diff --git a/src/stores/calls/__tests__/incident-command-store.test.ts b/src/stores/calls/__tests__/incident-command-store.test.ts index f2372ab..35e24ed 100644 --- a/src/stores/calls/__tests__/incident-command-store.test.ts +++ b/src/stores/calls/__tests__/incident-command-store.test.ts @@ -196,6 +196,22 @@ describe('useIncidentCommandStore', () => { }); describe('stale request handling', () => { + it('should clear the previous view synchronously when a new fetch starts', () => { + useIncidentCommandStore.setState({ view: createMockView() as any }); + mockGetResourceIncidentView.mockImplementation(() => new Promise(() => {}) as any); + + const { result, unmount } = renderHook(() => useIncidentCommandStore()); + + act(() => { + result.current.fetchIncidentView('call-next'); + }); + + expect(result.current.view).toBeNull(); + expect(result.current.isLoading).toBe(true); + + unmount(); + }); + it('should ignore a superseded fetch result', async () => { const staleView = { ...createMockView(), ImportantInformation: 'stale' }; const freshView = { ...createMockView(), ImportantInformation: 'fresh' }; diff --git a/src/stores/calls/incident-command-store.ts b/src/stores/calls/incident-command-store.ts index bad35fe..7a3a8ad 100644 --- a/src/stores/calls/incident-command-store.ts +++ b/src/stores/calls/incident-command-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { getResourceIncidentView } from '@/api/calls/incidentCommand'; import { type ResourceIncidentView } from '@/models/v4/incidentCommand/resourceIncidentView'; +import { INCIDENT_VIEW_STATUS_NOT_FOUND } from '@/models/v4/incidentCommand/resourceIncidentViewResult'; import { useCoreStore } from '@/stores/app/core-store'; interface IncidentCommandState { @@ -30,13 +31,14 @@ export const useIncidentCommandStore = create((set) => ({ }, fetchIncidentView: async (callId: string) => { const seq = ++requestSeq; - set({ isLoading: true, error: null }); + // Clear the previous call's view so it can never render for the new call. + set({ view: null, isLoading: true, error: null }); try { const activeUnitId = useCoreStore.getState().activeUnitId; const result = await getResourceIncidentView(callId, activeUnitId ?? undefined); if (seq !== requestSeq) return; - if (result && result.Data && result.Status !== 'NotFound') { + if (result && result.Data && result.Status !== INCIDENT_VIEW_STATUS_NOT_FOUND) { set({ view: result.Data, isLoading: false,