Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/api/calls/incidentCommand.ts
Original file line number Diff line number Diff line change
@@ -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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Missing JSDoc Promise and error annotations on getResourceIncidentView — Rule 23 requires async functions to document the resolve value and rejection conditions. Add @returns {Promise<ResourceIncidentViewResult>} and @throws {Error} when the API request fails or the response is invalid. to the existing JSDoc block.

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/api/calls/incidentCommand.ts:

Line 13:

Missing JSDoc Promise and error annotations on `getResourceIncidentView` — Rule 23 requires async functions to document the resolve value and rejection conditions. Add `@returns {Promise<ResourceIncidentViewResult>}` and `@throws {Error} when the API request fails or the response is invalid.` to the existing JSDoc block.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const getResourceIncidentViewApi = createApiEndpoint(`/IncidentCommand/GetResourceIncidentView/${encodeURIComponent(String(callId))}`);
const params = unitId !== undefined && unitId !== null && unitId !== '' ? { unitId } : undefined;
const response = await getResourceIncidentViewApi.get<ResourceIncidentViewResult>(params);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled rejection in getResourceIncidentView — the awaited getResourceIncidentViewApi.get call has no try/catch, violating Rule 1 which requires every awaited async operation to be guarded. Wrap the await in a try/catch, log the failure with context (callId, err), and rethrow.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/api/calls/incidentCommand.ts:

Line 16:

Unhandled rejection in `getResourceIncidentView` — the awaited `getResourceIncidentViewApi.get` call has no try/catch, violating Rule 1 which requires every awaited async operation to be guarded. Wrap the await in a try/catch, log the failure with context (`callId`, `err`), and rethrow.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Missing error handling around the network call — Rule 30 requires external/network calls to be wrapped in try/catch with context added and mapped to application-level errors. Wrap the GET in try/catch, log the operation name and identifiers (callId, unitId), and rethrow or map to a domain error such as ApiError.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/calls/incidentCommand.ts:

Line 16:

Missing error handling around the network call — Rule 30 requires external/network calls to be wrapped in try/catch with context added and mapped to application-level errors. Wrap the GET in try/catch, log the operation name and identifiers (`callId`, `unitId`), and rethrow or map to a domain error such as `ApiError`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return response.data;
};
7 changes: 3 additions & 4 deletions src/api/weather-alerts/weather-alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -19,9 +20,7 @@ export const getActiveAlerts = async () => {
};

export const getWeatherAlert = async (alertId: string) => {
const response = await getWeatherAlertApi.get<WeatherAlertResult>({
alertId: encodeURIComponent(alertId),
});
const response = await getWeatherAlertEndpoint(alertId).get<WeatherAlertResult>();
return response.data;
};

Expand Down
4 changes: 2 additions & 2 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,9 @@ export default function TabLayout() {
<Sidebar />
</View>
) : (
<Drawer isOpen={isOpen} onClose={handleCloseDrawer} {...({} as any)}>
<Drawer isOpen={isOpen} onClose={handleCloseDrawer} size="lg" anchor="left">
<DrawerBackdrop onPress={handleCloseDrawer} />
<DrawerContent className="w-4/5 bg-white p-1 dark:bg-gray-900">
<DrawerContent className="w-3/4 bg-white p-1 dark:bg-gray-900">
<DrawerBody>
<Sidebar onClose={handleCloseDrawer} />
</DrawerBody>
Expand Down
84 changes: 81 additions & 3 deletions src/app/call/[id].tsx
Original file line number Diff line number Diff line change
@@ -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 { 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';
Expand All @@ -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';
Expand Down Expand Up @@ -72,6 +73,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);
Expand Down Expand Up @@ -110,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;

Expand Down Expand Up @@ -234,6 +244,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 (
<>
Expand Down Expand Up @@ -451,6 +489,14 @@ export default function CallDetail() {
},
];

// Incident command tab
tabs.push({
key: 'command',
title: t('incident_command.tab_title'),
icon: <ClipboardListIcon size={16} />,
content: <IncidentCommandTabPanel callId={call.CallId} />,
});

// Video feeds tab
tabs.push({
key: 'video',
Expand All @@ -474,6 +520,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 (
<>
<Stack.Screen
Expand Down Expand Up @@ -507,9 +564,22 @@ export default function CallDetail() {
</Box>

{/* Map - only show when valid coordinates exist */}
{coordinates.latitude !== null && coordinates.longitude !== null ? (
{mapLatitude !== null && mapLongitude !== null ? (
<Box className="w-full">
<StaticMap latitude={coordinates.latitude} longitude={coordinates.longitude} address={call.Address} zoom={15} height={200} showUserLocation={true} />
<StaticMap latitude={mapLatitude} longitude={mapLongitude} address={mapAddress} zoom={15} height={200} showUserLocation={true} />
{/* Toggle the map between the call (dispatch) location and the destination POI */}
{hasDestinationCoordinates ? (
<HStack className="w-full">
<Button onPress={handleShowCallOnMap} variant={showingDestination ? 'outline' : 'solid'} size="sm" className="flex-1 rounded-none" testID="call-detail-map-toggle-call">
<ButtonIcon as={MapPinIcon} />
<ButtonText className="text-xs">{t('call_detail.call_location')}</ButtonText>
</Button>
<Button onPress={handleShowDestinationOnMap} variant={showingDestination ? 'solid' : 'outline'} size="sm" className="flex-1 rounded-none" testID="call-detail-map-toggle-destination">
<ButtonIcon as={NavigationIcon} />
<ButtonText className="text-xs">{t('call_detail.destination')}</ButtonText>
</Button>
</HStack>
) : null}
</Box>
) : null}

Expand Down Expand Up @@ -554,6 +624,14 @@ export default function CallDetail() {
<ButtonText className={isLandscape ? '' : 'text-xs'}>{t('common.route')}</ButtonText>
</Button>
</Box>
{hasDestinationCoordinates ? (
<Box className="relative mx-1 flex-1">
<Button onPress={handleRouteToDestination} variant="outline" className="w-full" size={isLandscape ? 'md' : 'sm'} testID="call-detail-route-destination-button">
<ButtonIcon as={NavigationIcon} />
<ButtonText className={isLandscape ? '' : 'text-xs'}>{t('call_detail.destination')}</ButtonText>
</Button>
</Box>
) : null}
</HStack>

{/* Tabs */}
Expand Down
5 changes: 5 additions & 0 deletions src/app/call/__tests__/[id].security.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
Expand Down Expand Up @@ -315,6 +319,7 @@ jest.mock('date-fns', () => ({

// Mock lucide-react-native icons
jest.mock('lucide-react-native', () => ({
ClipboardListIcon: ({ size, ...props }: any) => <div {...props} data-testid="clipboard-list-icon" style={{ width: size, height: size }}>ClipboardList</div>,
ClockIcon: ({ size, ...props }: any) => <div {...props} data-testid="clock-icon" style={{ width: size, height: size }}>Clock</div>,
FileTextIcon: ({ size, ...props }: any) => <div {...props} data-testid="file-text-icon" style={{ width: size, height: size }}>FileText</div>,
ImageIcon: ({ size, ...props }: any) => <div {...props} data-testid="image-icon" style={{ width: size, height: size }}>Image</div>,
Expand Down
97 changes: 97 additions & 0 deletions src/app/call/__tests__/[id].test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]';

Expand Down Expand Up @@ -174,16 +175,23 @@ jest.mock('expo-router', () => ({

// Mock Lucide React Native icons
jest.mock('lucide-react-native', () => ({
ClipboardListIcon: 'ClipboardListIcon',
ClockIcon: 'ClockIcon',
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',
Expand Down Expand Up @@ -278,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,
}));
Expand Down Expand Up @@ -434,6 +446,7 @@ const mockUseLocationStore = useLocationStore as jest.MockedFunction<typeof useL
const mockUseStatusBottomSheetStore = useStatusBottomSheetStore as jest.MockedFunction<typeof useStatusBottomSheetStore>;
const mockUseToastStore = useToastStore as jest.MockedFunction<typeof useToastStore>;
const mockSecurityStore = securityStore as jest.MockedFunction<typeof securityStore>;
const mockOpenMapsWithDirections = openMapsWithDirections as jest.MockedFunction<typeof openMapsWithDirections>;

describe('CallDetail', () => {
const defaultCallDetailStore = {
Expand Down Expand Up @@ -1263,4 +1276,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(<CallDetail />);

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(<CallDetail />);

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(<CallDetail />);

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);
});
});
});
});
Loading
Loading