Skip to content

RD-T42 Adding POIs#119

Open
ucswift wants to merge 2 commits intomasterfrom
develop
Open

RD-T42 Adding POIs#119
ucswift wants to merge 2 commits intomasterfrom
develop

Conversation

@ucswift
Copy link
Copy Markdown
Member

@ucswift ucswift commented Apr 27, 2026

Summary by CodeRabbit

  • New Features

    • Added Points of Interest (POIs) browsing and search screen with filtering and sorting.
    • Added POI detail screens with location information and directions routing.
    • Added scheduled calls list screen with search functionality.
    • Added POI destination selection to call creation and editing flows.
    • Added POI layers to map with visibility controls and filtering.
    • Added destination information display in call details and cards.
    • Added POI destination support to personnel and unit status assignment.
  • Chores

    • Updated Expo dependency versions.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@ucswift has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 58 minutes and 24 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa19774b-fd32-4acc-9411-ae6a4272e2f2

📥 Commits

Reviewing files that changed from the base of the PR and between b92c992 and 1fd3f69.

📒 Files selected for processing (1)
  • .github/workflows/react-native-cicd.yml
📝 Walkthrough

Walkthrough

This PR introduces comprehensive Points of Interest (POI) support across the application, including new API endpoints for fetching POI data and scheduled calls, new mobile screens for POI browsing and scheduled call management, integration of POI layers on maps with visibility controls, POI destination selection in call creation/editing flows, and POI-based destination assignment for personnel and unit status in the dispatch console.

Changes

Cohort / File(s) Summary
Dependency & Package Updates
package.json
Updated Expo and related dependencies from ^54.0.33 to ~54.0.34 and patch/minor versions for expo-auth-session, expo-crypto, expo-dev-client, and other modules using ~ constraints.
API Layer – POIs
src/api/mapping/mapping.ts
Added three new exported functions: getPoiTypes() to fetch available POI types, getPois(options, signal) to fetch POIs with optional poiTypeId and destinationOnly filters, and getPoi(poiId, signal) to fetch a single POI by ID. Exports new GetPoisOptions interface.
API Layer – Calls & Dispatch
src/api/calls/calls.ts, src/api/dispatch/dispatch.ts
Added getPendingScheduledCalls() API wrapper; extended CreateCallRequest and UpdateCallRequest with optional destinationPoiId field. Added getNewCallData() and getSetUnitStatusData() endpoints to retrieve form data including destination POIs and POI types.
API Layer – Personnel Status
src/api/personnel/personnelStatuses.ts
Added getCurrentStatus() and savePersonStatus() API wrappers for retrieving and persisting individual personnel status with updated model imports.
Data Models – POI Entities
src/models/v4/mapping/poiResultData.ts, src/models/v4/mapping/poiTypeResultData.ts, src/models/v4/mapping/poiResult.ts, src/models/v4/mapping/poiTypesResult.ts, src/models/v4/mapping/poisResult.ts, src/models/v4/mapping/poiLayerData.ts
Added new data models: PoiResultData, PoiTypeResultData, PoiResult, PoiTypesResult, PoisResult, and PoiLayerData to represent POI and POI-type result structures with metadata (ID, name, coordinates, color, marker, destination flag).
Data Models – Extensions
src/models/v4/calls/callResultData.ts, src/models/v4/mapping/getMapDataAndMarkersData.ts, src/models/v4/dispatch/getSetUnitStateResultData.ts, src/models/v4/dispatch/newCallFormResultData.ts
Extended existing models with POI-related fields: CallResultData gains destination POI metadata and scheduled timestamps; map data includes PoiLayers array and marker properties; dispatch/form result data include DestinationPois and PoiTypes arrays.
Data Models – Status & Unit
src/models/v4/personnelStatuses/getCurrentStatusResultData.ts, src/models/v4/personnelStatuses/savePersonStatusInput.ts, src/models/v4/personnelStatuses/savePersonsStatusesInput.ts, src/models/v4/unitStatus/saveUnitStatusInput.ts, src/models/v4/unitStatus/unitStatusResultData.ts
Extended personnel/unit status models with RespondingToType field (numeric destination type) and refactored destination tracking from string-based IDs to typed numeric identifiers with address/name metadata.
Utility & Helper Libraries
src/lib/destination-helpers.ts, src/lib/poi-display.ts, src/lib/poi-map-layers.ts, src/lib/map-markers.ts
Added new utility modules: destination-helpers provides enums/types for destination entities, selection logic, and capability checks; poi-display computes primary/secondary text and search values; poi-map-layers manages POI layer IDs and visibility filtering; map-markers resolves marker colors, icons, coordinates validation, and pin summaries.
Store Management – POIs
src/stores/pois/store.ts
Added usePoisStore Zustand store to manage POI data (all POIs, destination POIs, POI types) with async fetch actions, per-request loading/error state, detail caching, and upsert helpers.
Store Management – Scheduled Calls
src/stores/calls/scheduled-store.ts
Added useScheduledCallsStore to fetch and cache pending scheduled calls with loading/error handling and priority lookup utilities.
Store Management – Dispatch & Status
src/stores/dispatch/personnel-actions-store.ts, src/stores/dispatch/unit-actions-store.ts, src/stores/status/store.ts
Extended stores to support POI destinations alongside calls and stations: added statusSelectedPoi, availablePois state; updated destination-type switching and submission logic to handle POI selection and populate RespondingToType; unified DestinationType to use shared DestinationSelectionType.
Map – Core & Web
src/app/(app)/map.tsx, src/app/(app)/map.web.tsx
Integrated POI layers into map rendering: added poiLayers and visiblePoiLayerIds state, computed combinedLayers merging custom and POI layers, filtered visibleMapPins by POI layer visibility, updated analytics and effects to include POI layer counts/visibility, added POI-layer toggle/show-all/hide-all controls to layers panel.
Map Components – Rendering & Details
src/components/maps/map-pins.tsx, src/components/maps/pin-marker.tsx, src/components/maps/pin-detail-modal.tsx, src/components/maps/unified-map-view.web.tsx
Updated marker rendering to filter invalid coordinates, validate via hasValidMapCoordinates, and render POI pins as custom circular markers using getMapMarkerColor; non-POI pins use resolveMapMarkerIconKey for icon selection; pin-detail modal uses getMapPinSummary and adds POI detail navigation; popup content switched to getMapPinSummary instead of InfoWindowContent.
New Screens – POI Browsing
src/app/(app)/pois.tsx
Added new Pois screen component supporting POI type selection, keyword search, sorting (by name/type/address), pull-to-refresh, and error/loading/empty states; renders PoiCard items navigating to /poi/{PoiId}.
New Screens – Scheduled Calls
src/app/(app)/scheduled-calls.tsx
Added new ScheduledCalls screen fetching pending scheduled calls, asynchronously loading per-call dispatch data, supporting search/filter, pull-to-refresh, and rendering ScheduledCallCard items with error/loading/empty states.
New Screens – POI Detail
src/app/poi/[id].tsx, src/app/poi/[id].web.tsx
Added POI detail route files (native and web) that import and export PoiDetailScreen component, wiring the /poi/[id] pages.
POI Components – Cards & Detail
src/components/pois/poi-card.tsx, src/components/pois/poi-detail-screen.tsx
Added PoiCard component rendering POI metadata (name, type, address, destination badge, note) with press navigation; added PoiDetailScreen component displaying POI details, address/map card, static map with route button, and error/loading/empty states.
Call Components – Cards & Details
src/components/calls/call-card.tsx, src/components/calls/call-card.web.tsx, src/components/calls/scheduled-call-card.tsx
Updated CallCard to display destination name/type with pin icon; added new ScheduledCallCard component rendering scheduled call metadata, dispatch badges, and color-coded priority; conditionally show scheduled timestamp and destination fields.
Call Creation & Editing – Native
src/app/call/[id]/edit.tsx, src/app/call/new/index.tsx
Added destinationPoiId form field to call creation/editing with DestinationPois dropdown; fetches destination POIs on mount via getNewCallData(); converts selected value to number for submission; includes loading/empty-state feedback.
Call Creation & Editing – Web
src/app/call/[id]/edit.web.tsx, src/app/call/new/index.web.tsx
Updated web forms to include destinationPoiId select control with sentinel __none__ option; enhanced WebSelect with useIdValue mode to emit POI IDs; loads destination POIs on mount; includes type.Id payload change for call type submission.
Call Details – Display
src/app/call/[id].tsx, src/app/call/[id].web.tsx
Added conditional UI to display scheduled timestamp and destination metadata (name, type, address) in call info tabs when available.
Dispatch Console – Personnel
src/components/dispatch-console/personnel-actions-panel.tsx, src/components/dispatch-console/personnel-panel.tsx
Added POI destination support: PersonnelActionsPanel loads destination POIs, adds POI tab/selection to destination sheet; PersonnelItem simplified to always use MapPin icon for destinations; updated destination initialization to match against calls, stations, then POIs.
Dispatch Console – Units
src/components/dispatch-console/unit-actions-panel.tsx, src/components/dispatch-console/units-panel.tsx
Replaced parallel getAllUnitStatuses() + getAllGroups() with single getSetUnitStatusData() call; added POI destination state and selection; uses getDestinationCapabilities() for dynamic tab visibility; added POI tab to destination sheet; UnitItem simplified to use MapPin for all destinations.
Status Management
src/components/status/status-bottom-sheet.tsx
Extended StatusBottomSheet to support POI destinations: added selectedPoi/availablePois state, updated destination-tab initialization via getDefaultDestinationTab(), uses getDestinationCapabilities() and getEnabledDestinationTabs() for dynamic rendering, added POI tab/content with loading and empty-state messaging.
Navigation & Sidebar
src/components/sidebar/side-menu.tsx, src/components/sidebar/__tests__/side-menu.test.tsx
Added CalendarClock and MapPinned icon imports; extended menu with scheduled-calls sub-item under calls group and new top-level pois entry; updated tests to verify POIs label and navigation.
Internationalization
src/translations/en.json, src/translations/es.json, src/translations/ar.json
Added comprehensive translation keys for POI browsing/detail (pois.*), scheduled calls (scheduled_calls.*), destination POI selection, loading/empty states, map actions, status tabs, and navigation menu items across English, Spanish, and Arabic locales.
Tests
src/lib/__tests__/destination-helpers.test.ts, src/lib/__tests__/poi-display.test.ts
Added test suites verifying destination-helpers (capabilities, entity type conversion, marker detection) and poi-display (primary/secondary text, selection labels, search values).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant PoisScreen as Pois Screen
    participant PoisStore as Pois Store
    participant API as POI API
    participant Map as Map Component
    
    User->>PoisScreen: Open POIs Screen
    PoisScreen->>PoisStore: fetchPoiTypes()
    PoisStore->>API: GET /Mapping/GetPoiTypes
    API-->>PoisStore: PoiTypeResultData[]
    PoisStore-->>PoisScreen: Update poiTypes
    
    PoisScreen->>PoisStore: fetchPois()
    PoisStore->>API: GET /Mapping/GetPois
    API-->>PoisStore: PoiResultData[]
    PoisStore-->>PoisScreen: Update pois list
    
    User->>PoisScreen: Select POI Type / Search
    PoisScreen->>PoisScreen: Filter & Sort POIs
    PoisScreen->>User: Display filtered list
    
    User->>PoisScreen: Tap POI Card
    PoisScreen->>PoisStore: fetchPoi(poiId)
    PoisStore->>API: GET /Mapping/GetPoi/{id}
    API-->>PoisStore: PoiResultData detail
    PoisStore-->>PoisScreen: Update selectedPoi
    PoisScreen->>User: Show POI Detail Screen
Loading
sequenceDiagram
    actor User
    participant CallEditScreen as Call Edit Screen
    participant DispatchAPI as Dispatch API
    participant CallStore as Calls Store
    participant CallAPI as Calls API
    
    User->>CallEditScreen: Open Edit Call
    CallEditScreen->>DispatchAPI: getNewCallData()
    DispatchAPI-->>CallEditScreen: NewCallFormResult (DestinationPois[])
    CallEditScreen->>CallEditScreen: Populate POI dropdown
    
    User->>CallEditScreen: Select Destination POI
    CallEditScreen->>CallEditScreen: Set destinationPoiId in form
    
    User->>CallEditScreen: Submit Form
    CallEditScreen->>CallAPI: updateCall(createCallRequest)
    Note over CallAPI: Includes DestinationPoiId
    CallAPI-->>CallStore: Call updated
    CallStore-->>User: Navigate back
Loading
sequenceDiagram
    actor User
    participant Map as Map Screen
    participant MapStore as Map Data Store
    participant MapAPI as Mapping API
    participant MapComponent as Map Component
    
    User->>Map: Navigate to Map
    Map->>MapAPI: getMapDataAndMarkers()
    MapAPI-->>Map: MapDataAndMarkersData (includes PoiLayers[])
    Map->>Map: Extract PoiLayers
    Map->>Map: Initialize visiblePoiLayerIds (all enabled)
    Map->>Map: Combine customLayers + PoiLayers
    
    Map->>MapComponent: Render with combinedLayers
    MapComponent->>MapComponent: Filter pins by POI visibility
    MapComponent->>User: Display map with POI markers
    
    User->>Map: Toggle POI Layer Visibility
    Map->>Map: Update visiblePoiLayerIds
    Map->>MapComponent: Re-filter visibleMapPins
    MapComponent->>User: Update rendered pins
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • PR #80: Modifies side-menu.tsx and side-menu.test.tsx to add menu items and navigation, directly related to the menu additions in this PR.
  • PR #100: Updates src/api/calls/calls.ts with CreateCallRequest/UpdateCallRequest extensions and call payload changes, overlapping with destination field additions in this PR.
  • PR #113: Modifies overlapping files (callResultData.ts, call-card.tsx) for destination/check-in features, sharing similar domain patterns with this PR's destination enhancements.

Poem

🐰 Hops of Joy for POI Days!

Pins upon the map do dance,
POI layers get their chance!
Destinations now align,
With scheduled calls, all systems shine.
From screens to stores, the features spread,
A rabbit's map now wisely fed! 🗺️✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'RD-T42 Adding POIs' directly and clearly describes the main objective of this comprehensive changeset, which adds Points of Interest (POI) functionality throughout the application.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/app/call/[id].web.tsx (1)

443-446: ⚠️ Potential issue | 🟡 Minor

Stale keyboard shortcut hint.

The handler at lines 143-147 supports keys 17 (and tabs include up to checkin when enabled), but the displayed tip still says 1-5. Update the translated hint and default to match the actual range.

-              {t('call_detail.keyboard_shortcuts', 'Tip: Press 1-5 to switch tabs, Ctrl+E to edit, Escape to go back')}
+              {t('call_detail.keyboard_shortcuts', 'Tip: Press 1-7 to switch tabs, Ctrl+E to edit, Escape to go back')}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/call/`[id].web.tsx around lines 443 - 446, The keyboard shortcut hint
string passed to t('call_detail.keyboard_shortcuts', ...) is stale (shows "1-5")
while the key handler supports 1–7 and tabs can include 'checkin'; update the
default translation to "Tip: Press 1-7 to switch tabs, Ctrl+E to edit, Escape to
go back" or, better, compute the upper bound dynamically from the actual tabs
array (the same source used by the key handler) and inject that number into the
translated string so the displayed range matches the handler; modify the call to
t(...) and the shortcut hint Text usage (referencing styles.shortcutHint,
styles.shortcutText, isDark) accordingly.
src/app/call/[id]/edit.tsx (1)

272-280: ⚠️ Potential issue | 🟠 Major

Send the call type ID here, not the display name.

This screen still posts type?.Name, even though the form is hydrated from call.Type by matching on Id and the web editor in this PR now submits type?.Id. Native edits can silently change or clear the type on save.

Proposed fix
       await useCallDetailStore.getState().updateCall({
         callId: callId!,
         name: data.name,
         nature: data.nature,
         priority: priority?.Id || 0,
-        type: type?.Name || '',
+        type: type?.Id || '',
         note: data.note,
         destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/call/`[id]/edit.tsx around lines 272 - 280, The form currently sends
type?: Name to updateCall which loses the actual type identifier; change the
payload sent by useCallDetailStore.getState().updateCall so it includes the call
type Id (e.g., type?.Id or null/0 as appropriate) instead of type?.Name — locate
the updateCall invocation in edit.tsx that sets properties (callId, name,
nature, priority, type, note, destinationPoiId, address) and replace the type
field with a typeId (or the proper parameter name expected by updateCall)
populated from type?.Id to ensure native edits persist the type correctly.
src/translations/ar.json (1)

649-656: ⚠️ Potential issue | 🟡 Minor

Remove the duplicate dispatched_resources key in this object.

JSON keeps only the last duplicate key, so one value here is silently discarded. That makes this translation block brittle and easy to mis-edit later.

Proposed fix
     "show_all": "إظهار الكل",
     "view_poi_details": "عرض تفاصيل نقطة الاهتمام",
-    "dispatched_resources": "المرسلون",
     "hide_all": "إخفاء الكل"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/translations/ar.json` around lines 649 - 656, There are two identical
keys "dispatched_resources" in the translations object which causes one value to
be silently dropped; remove the duplicate so only a single
"dispatched_resources" entry remains, keep the correct Arabic value you intend
to use, and verify the surrounding keys ("view_call_details", "layers",
"no_layers", "show_all", "view_poi_details", "hide_all") remain unchanged to
ensure no accidental reordering or typos.
src/app/(app)/map.web.tsx (1)

292-300: ⚠️ Potential issue | 🟠 Major

Cache getMapPinSummary(pin) result and sanitize HTML content.

getMapPinSummary(pin) runs twice per marker (line 295). Additionally, pin.Title, Address, Note, PoiTypeName, and InfoWindowContent are interpolated directly into setHTML() without escaping—if these fields contain untrusted data from the API, this creates an HTML injection risk.

Suggested fix
+      const summary = getMapPinSummary(pin);
+      const escapeHtml = (text: string) => {
+        const div = document.createElement('div');
+        div.textContent = text;
+        return div.innerHTML;
+      };
       const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(
         `<div style="padding: 8px;">
-          <h3 style="margin: 0 0 8px 0; font-weight: 600;">${pin.Title}</h3>
-          ${getMapPinSummary(pin) ? `<p style="margin: 0 0 8px 0; font-size: 12px;">${getMapPinSummary(pin)}</p>` : ''}
+          <h3 style="margin: 0 0 8px 0; font-weight: 600;">${escapeHtml(pin.Title)}</h3>
+          ${summary ? `<p style="margin: 0 0 8px 0; font-size: 12px;">${escapeHtml(summary)}</p>` : ''}

Or use a library like DOMPurify if HTML tags must be preserved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/map.web.tsx around lines 292 - 300, Cache the result of
getMapPinSummary(pin) into a local variable so it’s only computed once and avoid
calling it twice when building the popup; also never interpolate raw fields
(pin.Title, pin.Address, pin.Note, pin.PoiTypeName, pin.InfoWindowContent)
directly into mapboxgl.Popup().setHTML — escape/encode these values for HTML or
sanitize the assembled HTML using a library like DOMPurify before calling
setHTML to prevent injection. Ensure you reference getMapPinSummary(pin) for
caching and sanitize output used in setHTML.
🧹 Nitpick comments (24)
src/models/v4/mapping/poiTypesResult.ts (1)

1-3: Run ESLint autofix to remove the blank line between the two imports.

ESLint's simple-import-sort/imports rule treats BaseV4Request and PoiTypeResultData as part of the same relative-import group, so the blank line on line 2 is flagged. Same nit applies to poisResult.ts and poiResult.ts.

Proposed fix
 import { BaseV4Request } from '../baseV4Request';
-
 import { PoiTypeResultData } from './poiTypeResultData';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/v4/mapping/poiTypesResult.ts` around lines 1 - 3, Remove the extra
blank line between the two relative imports so ESLint's
simple-import-sort/imports groups them together; update the import block
containing BaseV4Request and PoiTypeResultData (and apply the same change to
similar files poisResult.ts and poiResult.ts) by running ESLint autofix or
manually deleting the empty line so the imports are contiguous.
src/models/v4/dispatch/newCallFormResultData.ts (1)

23-24: Consider co-locating destination POI fields with related call/dispatch state.

Minor consistency nit: in GetSetUnitStateResultData the equivalent fields are declared as DestinationPois then PoiTypes, while here they're declared as PoiTypes then DestinationPois. Keeping the ordering identical across both result models makes the parallel between the two payloads easier to scan and reduces the chance of accidental field-mapping bugs in code that handles both.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/v4/dispatch/newCallFormResultData.ts` around lines 23 - 24, The
two public fields in NewCallFormResultData are declared as PoiTypes then
DestinationPois which is the opposite order of the equivalent fields in
GetSetUnitStateResultData; reorder the declarations in the NewCallFormResultData
class so DestinationPois appears before PoiTypes (i.e., declare public
DestinationPois: PoiResultData[] = []; then public PoiTypes: PoiTypeResultData[]
= [];) to match GetSetUnitStateResultData and keep field ordering consistent.
src/components/pois/poi-card.tsx (1)

22-22: Avoid anonymous handler in Pressable.onPress.

() => onPress(poi) allocates a new function on every render and defeats memoization (especially relevant since this card is rendered inside the FlatList on the POIs screen). Use useCallback to bind poi.

As per coding guidelines: "Avoid anonymous functions in renderItem or event handlers to prevent re-renders".

Proposed change
-export const PoiCard: React.FC<PoiCardProps> = ({ poi, onPress }) => {
+export const PoiCard: React.FC<PoiCardProps> = React.memo(({ poi, onPress }) => {
   const { t } = useTranslation();
+  const handlePress = React.useCallback(() => onPress(poi), [onPress, poi]);
   const primaryText = getPoiPrimaryDisplayText(poi);
   const secondaryText = getPoiSecondaryDisplayText(poi);
   const accentStyle = StyleSheet.flatten([styles.accent, { backgroundColor: poi.Color || '#2563eb' }]);
 
   return (
-    <Pressable style={styles.card} onPress={() => onPress(poi)}>
+    <Pressable style={styles.card} onPress={handlePress}>
@@
-};
+});
+PoiCard.displayName = 'PoiCard';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/pois/poi-card.tsx` at line 22, Replace the inline anonymous
handler in the Pressable (onPress={() => onPress(poi)}) with a stable callback
created using useCallback: add a local handler (e.g., handlePress) defined with
useCallback that calls the passed prop onPress(poi) and use that handler in the
Pressable's onPress; this ensures the component (poi-card / Pressable) doesn't
create a new function each render and preserves memoization when used inside
FlatList.
src/models/v4/calls/callResultData.ts (1)

39-40: Optional: tighten Protocols/UdfValues typing.

unknown[] is fine as a placeholder, but if these arrays carry structured payloads from the API they'll be awkward to consume without narrowing. Consider introducing dedicated interfaces (or Record<string, unknown>[]) once the schema is known.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/v4/calls/callResultData.ts` around lines 39 - 40, Protocols and
UdfValues are typed as unknown[] which makes downstream usage painful; replace
unknown[] with a more specific type (e.g., create interfaces like Protocol and
UdfValue or use Record<string, unknown>[]) and update their declarations in
CallResultData (properties Protocols and UdfValues in callResultData.ts) so
callers can access expected fields without repetitive narrowing; if the exact
schema is unknown, prefer Record<string, unknown>[] as an interim type and add
TODO comments to refine once API schema is available.
src/lib/__tests__/poi-display.test.ts (1)

5-25: Add coverage for trimming, null/undefined, and getPoiSecondaryDisplayText.

The current suite covers happy paths but not:

  • Whitespace-only fields being treated as empty (the trim() behavior is the whole point of getTrimmedValue).
  • Name/Address/etc. being null/undefined (the helper signature allows string | null).
  • getPoiSecondaryDisplayText is not tested at all although it is exported.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/poi-display.test.ts` around lines 5 - 25, Add unit tests
that cover trimming and null/undefined inputs and include tests for
getPoiSecondaryDisplayText: specifically, call getPoiPrimaryDisplayText,
getPoiSelectionLabel, getPoiDestinationOptionLabel, and getPoiSearchValue with
fields that are whitespace-only (to assert trim behavior via getTrimmedValue)
and with Name/Address/Note set to null or undefined to assert they are treated
as empty strings, and add tests exercising getPoiSecondaryDisplayText for
combinations (e.g., Name present with Address secondary, Name absent with
Address primary, and fallback to Note/PoiTypeName) to ensure expected
secondary-label behavior.
src/components/calls/call-card.web.tsx (1)

33-33: Consider extracting destinationText into a shared helper.

This same DestinationTypeName: DestinationName formatting is duplicated in src/components/calls/call-card.tsx (lines 39–45). A small shared formatter (e.g., formatCallDestination(call) in @/lib/utils or alongside the call models) would prevent the two variants from drifting if the format ever needs to change.

♻️ Sketch
// src/lib/calls.ts
import type { CallResultData } from '@/models/v4/calls/callResultData';

export const formatCallDestination = (call: CallResultData): string => {
  if (!call.DestinationName) return '';
  return call.DestinationTypeName ? `${call.DestinationTypeName}: ${call.DestinationName}` : call.DestinationName;
};

Then in both call-card.tsx and call-card.web.tsx:

-  const destinationText = call.DestinationName ? (call.DestinationTypeName ? `${call.DestinationTypeName}: ${call.DestinationName}` : call.DestinationName) : '';
+  const destinationText = formatCallDestination(call);

Also applies to: 92-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/calls/call-card.web.tsx` at line 33, Duplicate destination
formatting logic (constructing "DestinationTypeName: DestinationName") appears
in call-card.web.tsx and call-card.tsx; extract it into a shared helper (e.g.,
formatCallDestination) and replace the inline expression in both files with a
call to that helper. Create the helper (suggested name formatCallDestination)
that accepts the call object (or CallResultData) and returns the formatted
string using call.DestinationName and call.DestinationTypeName, export it from a
shared module (e.g., src/lib/calls or utils), and update both components to
import and use formatCallDestination wherever destinationText is computed.
src/components/maps/unified-map-view.web.tsx (1)

297-305: Avoid double-invoking getMapPinSummary(pin).

getMapPinSummary(pin) is computed twice for every pin's popup HTML. Hoist it once before building the template literal.

♻️ Proposed fix
-      // Create popup
-      const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(
-        `<div style="padding: 8px;">
-          <h3 style="margin: 0 0 8px 0; font-weight: 600;">${pin.Title}</h3>
-          ${getMapPinSummary(pin) ? `<p style="margin: 0 0 8px 0; font-size: 12px;">${getMapPinSummary(pin)}</p>` : ''}
+      // Create popup
+      const summary = getMapPinSummary(pin);
+      const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(
+        `<div style="padding: 8px;">
+          <h3 style="margin: 0 0 8px 0; font-weight: 600;">${pin.Title}</h3>
+          ${summary ? `<p style="margin: 0 0 8px 0; font-size: 12px;">${summary}</p>` : ''}
           <p style="margin: 0; font-size: 11px; color: `#666`;">
             ${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}
           </p>
         </div>`
       );

Also consider HTML-escaping pin.Title and summary if they may contain markup, since they're injected via setHTML.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/maps/unified-map-view.web.tsx` around lines 297 - 305, Hoist
the result of getMapPinSummary(pin) into a local variable instead of calling it
twice when building the popup HTML for new mapboxgl.Popup(...).setHTML(...):
compute const summary = getMapPinSummary(pin) once, use summary in the template
conditional and interpolation (alongside pin.Title, pin.Latitude,
pin.Longitude), and replace the duplicate calls to getMapPinSummary(pin) with
that variable; also consider HTML-escaping pin.Title and summary before
injecting into setHTML to avoid markup injection.
src/app/call/new/index.tsx (1)

236-238: Prefer the project logger over console.error.

The codebase already imports @/lib/logging in similar files (e.g. src/app/call/[id].web.tsx) and uses structured logger.error({ message, context }). Worth migrating this site as well — though not new in this PR, the freshly-added handler is a good place to start.

-      .catch((error) => {
-        console.error('Failed to load destination POIs:', error);
-      })
+      .catch((error) => {
+        logger.error({ message: 'Failed to load destination POIs', context: { error } });
+      })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/call/new/index.tsx` around lines 236 - 238, Replace the raw
console.error call in the promise .catch handler with the project's structured
logger: import logger from '@/lib/logging' at the top of this module and call
logger.error with an object payload (e.g., { message: 'Failed to load
destination POIs', error, context: { /* any relevant ids or params available in
this scope */ } }) instead of console.error; keep the same error variable name
and include any local context you can (route params or handler identifiers) to
aid debugging.
src/app/(app)/pois.tsx (2)

123-137: Avoid the anonymous renderItem and onPress closures.

renderItem and the onPress={(poi) => router.push(...)} arrow are recreated on every render of Pois, defeating row memoization in FlatList. Hoist them with useCallback so PoiCard instances can memoize meaningfully.

♻️ Suggested refactor
+  const handlePoiPress = useCallback((poi: PoiResultData) => {
+    router.push(`/poi/${poi.PoiId}` as Href);
+  }, []);
+
+  const renderPoi = useCallback(
+    ({ item }: { item: PoiResultData }) => <PoiCard poi={item} onPress={handlePoiPress} />,
+    [handlePoiPress]
+  );
   ...
-          <FlatList<PoiResultData>
-            testID="pois-list"
-            data={filteredPois}
-            keyExtractor={(item) => item.PoiId.toString()}
-            renderItem={({ item }) => <PoiCard poi={item} onPress={(poi) => router.push(`/poi/${poi.PoiId}` as Href)} />}
+          <FlatList<PoiResultData>
+            testID="pois-list"
+            data={filteredPois}
+            keyExtractor={keyExtractor}
+            renderItem={renderPoi}

As per coding guidelines, "Avoid anonymous functions in renderItem or event handlers to prevent re-renders".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/pois.tsx around lines 123 - 137, The anonymous renderItem and
inline onPress arrow in the FlatList are recreated every render which prevents
row memoization; hoist them into stable callbacks using React.useCallback:
create a memoized renderItem callback (e.g., renderPoiItem) that returns
<PoiCard poi={item} onPress={handlePoiPress} /> and a separate memoized handler
(e.g., handlePoiPress) that calls router.push(`/poi/${poi.PoiId}` as Href);
update the FlatList to use renderItem={renderPoiItem} and remove the inline
onPress so PoiCard instances can memoize properly.

8-15: Minor: ESLint import-sort failure and barrel inconsistency.

CI flagged simple-import-sort/imports for this file (lines 1-18). Also, FocusAwareStatusBar is imported via the @/components/ui barrel here while other screens (e.g. scheduled-calls.tsx) import it from @/components/ui/focus-aware-status-bar — pick one for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/pois.tsx around lines 8 - 15, The imports in pois.tsx are
mis-ordered (triggering simple-import-sort) and the FocusAwareStatusBar is
imported from the ui barrel inconsistent with other screens; reorder and group
imports to satisfy the linter (external packages, absolute aliases, then local
UI components) and replace the barrel import of FocusAwareStatusBar with its
direct module import (the focus-aware-status-bar export) to match other files;
update the import statement for FocusAwareStatusBar and adjust import ordering
so ESLint simple-import-sort/imports passes.
src/app/(app)/scheduled-calls.tsx (1)

109-114: Avoid anonymous functions in renderItem.

The inline arrow defines a new renderItem and a new onPress closure on every render, defeating FlatList memoization for cards. Per the coding guidelines, prefer a stable, memoized callback (and a stable onPress per row, e.g., a useCallback-bound handler that takes the call as an argument).

♻️ Suggested refactor
+  const handleCallPress = useCallback((callId: string) => {
+    router.push(`/call/${callId}` as Href);
+  }, []);
+
+  const renderCallItem = useCallback(
+    ({ item }: { item: CallResultData }) => (
+      <ScheduledCallCard
+        call={item}
+        priority={callPriorities.find((p) => p.Id === item.Priority)}
+        dispatches={callDispatchesMap[item.CallId]}
+        isLoadingDispatches={loadingDispatchIds.has(item.CallId)}
+        onPress={handleCallPress}
+      />
+    ),
+    [callPriorities, callDispatchesMap, loadingDispatchIds, handleCallPress]
+  );
   ...
-      <FlatList<CallResultData>
-        ...
-        renderItem={({ item }: { item: CallResultData }) => (
-          <Pressable onPress={() => router.push(`/call/${item.CallId}` as Href)}>
-            <ScheduledCallCard ... />
-          </Pressable>
-        )}
+      <FlatList<CallResultData>
+        ...
+        renderItem={renderCallItem}

(Pressable can move into ScheduledCallCard so onPress carries the call id without a closure.)

As per coding guidelines, "Avoid anonymous functions in renderItem or event handlers to prevent re-renders".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/scheduled-calls.tsx around lines 109 - 114, The inline
renderItem and Pressable onPress create new closures each render; refactor by
extracting a stable memoized render handler and a stable onPress callback:
create a useCallback-based onPress function (e.g. handlePress(callId) using
router.push) and a memoized renderItem function (e.g. renderScheduledCall) that
reads callPriorities, callDispatchesMap and loadingDispatchIds and returns
<ScheduledCallCard ... /> (move Pressable into ScheduledCallCard or have
renderScheduledCall wrap ScheduledCallCard with a non-allocating onPress
reference that calls handlePress), ensuring renderItem and the per-row onPress
are stable and the component is memoized.
src/stores/dispatch/personnel-actions-store.ts (1)

203-221: Optional: collapse the two destination branches into a single resolution.

respondingTo and respondingToType are computed via two parallel if/else-if chains over the same (statusDestinationType, selected*) state. Folding them into one block reduces duplication and keeps the two values impossible to drift apart.

♻️ Sketch
-      let respondingTo = '';
-      if (statusDestinationType === 'call' && statusSelectedCall) { respondingTo = statusSelectedCall.CallId; }
-      else if (statusDestinationType === 'station' && statusSelectedStation) { respondingTo = statusSelectedStation.GroupId; }
-      else if (statusDestinationType === 'poi' && statusSelectedPoi) { respondingTo = statusSelectedPoi.PoiId.toString(); }
-
-      let respondingToType: number | null = null;
-      if (statusDestinationType === 'call' && statusSelectedCall) { respondingToType = DestinationEntityType.Call; }
-      else if (statusDestinationType === 'station' && statusSelectedStation) { respondingToType = DestinationEntityType.Station; }
-      else if (statusDestinationType === 'poi' && statusSelectedPoi) { respondingToType = DestinationEntityType.Poi; }
+      let respondingTo = '';
+      let respondingToType: number | null = null;
+      if (statusDestinationType === 'call' && statusSelectedCall) {
+        respondingTo = statusSelectedCall.CallId;
+        respondingToType = DestinationEntityType.Call;
+      } else if (statusDestinationType === 'station' && statusSelectedStation) {
+        respondingTo = statusSelectedStation.GroupId;
+        respondingToType = DestinationEntityType.Station;
+      } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
+        respondingTo = statusSelectedPoi.PoiId.toString();
+        respondingToType = DestinationEntityType.Poi;
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/dispatch/personnel-actions-store.ts` around lines 203 - 221,
Collapse the two parallel branches that set respondingTo and respondingToType
into one unified block so they cannot diverge: inspect statusDestinationType and
the corresponding selected entity (statusSelectedCall, statusSelectedStation,
statusSelectedPoi) in a single if/else-if or switch, and inside each branch
assign both respondingTo (string) and respondingToType (number | null) together
(use CallId/GroupId/PoiId.toString() and
DestinationEntityType.Call/Station/Poi), otherwise set respondingTo = '' and
respondingToType = null; update the existing variables respondingTo and
respondingToType in that combined block to preserve current types and behavior.
src/components/status/status-bottom-sheet.tsx (1)

359-378: Replace numeric magic values with CustomStateDetailType enum.

getStatusDetailDescription switches on raw numbers 1–7, but CustomStateDetailType is already imported elsewhere in the POI flow (e.g., destination-helpers.test.ts) and used to express the same concepts. Using the enum would improve readability and prevent drift if values change.

♻️ Suggested refactor
+  // Add CustomStateDetailType to imports from '@/lib/destination-helpers'
   const getStatusDetailDescription = (detail: number) => {
     switch (detail) {
-      case 1:
+      case CustomStateDetailType.Stations:
         return t('status.station_destination_enabled');
-      case 2:
+      case CustomStateDetailType.Calls:
         return t('status.call_destination_enabled');
-      case 3:
+      case CustomStateDetailType.CallsAndStations:
         return t('status.both_destinations_enabled');
-      case 4:
+      case CustomStateDetailType.Pois:
         return t('status.poi_destination_enabled');
-      case 5:
+      case CustomStateDetailType.CallsAndPois:
         return t('status.calls_and_pois_destination_enabled');
-      case 6:
+      case CustomStateDetailType.StationsAndPois:
         return t('status.stations_and_pois_destination_enabled');
-      case 7:
+      case CustomStateDetailType.CallsStationsAndPois:
         return t('status.all_destinations_enabled');
       default:
         return '';
     }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/status/status-bottom-sheet.tsx` around lines 359 - 378,
getStatusDetailDescription currently switches on raw numbers (1–7); replace
those numeric case values with the corresponding CustomStateDetailType enum
members (e.g., CustomStateDetailType.StationDestination, .CallDestination, etc.)
so the switch uses enum symbols instead of magic numbers. Update the import
usage if needed to ensure CustomStateDetailType is referenced in this file, keep
the same return strings for each case, and leave the default branch returning an
empty string unchanged.
src/stores/calls/scheduled-store.ts (1)

28-68: LGTM on the new scheduled-calls store.

Loading/error/lastFetched lifecycle is handled cleanly, errors are logged with context, and getPriorityForCall correctly delegates to the calls store. One optional consideration: if fetchScheduledCalls can be triggered concurrently (e.g., screen focus + pull-to-refresh), you may want to short-circuit when isLoading is already true to avoid redundant API calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/calls/scheduled-store.ts` around lines 28 - 68, The
fetchScheduledCalls implementation should short-circuit when a fetch is already
in progress: at the start of useScheduledCallsStore's fetchScheduledCalls, check
the current store state (useScheduledCallsStore.getState().isLoading) and
immediately return if true to prevent concurrent API calls; otherwise proceed to
set isLoading and continue as before. Ensure you reference and update the same
state keys (isLoading, error, scheduledCalls, lastFetched) and keep existing
try/catch logging behavior intact.
src/lib/__tests__/destination-helpers.test.ts (2)

45-49: Avoid as any in tests.

Per coding guidelines, prefer precise types over any. Cast to PoiResultData (or Partial<PoiResultData> as PoiResultData) so the test fixture stays type-checked if the model changes.

As per coding guidelines: "Avoid using any; strive for precise types".

♻️ Suggested change
+import { type PoiResultData } from '../../models/v4/mapping/poiResultData';
@@
         selectedPoi: {
           PoiId: 42,
-        } as any,
+        } as PoiResultData,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/destination-helpers.test.ts` around lines 45 - 49, Replace
the use of the unsafe cast "as any" in the test fixture with a precise type:
cast the selectedPoi object to PoiResultData (or use Partial<PoiResultData> as
PoiResultData) so the test stays type-checked if the model changes; update the
selectedPoi assignment in the test (the object with PoiId: 42) to use one of
these precise casts instead of "as any".

17-56: Consider broadening test coverage for negative cases.

The current suite covers happy paths but doesn't assert behavior for non-POI inputs (e.g., isPoiDestinationType(DestinationEntityType.Call) should be false, getDefaultDestinationTab(CustomStateDetailType.Stations) should be 'stations', isCallMarker(MapMarkerEntityType.Poi) should be false). Adding a few negative assertions would harden against regressions in the bidirectional mappings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/destination-helpers.test.ts` around lines 17 - 56, Add
negative test assertions to harden the mappings and tab defaults: extend the
test file to assert that isPoiDestinationType(DestinationEntityType.Call)
returns false, that getDefaultDestinationTab(CustomStateDetailType.Stations)
returns 'stations' (and optionally that getDefaultDestinationTab(null/undefined)
still returns 'calls' to cover fallback), and that
isCallMarker(MapMarkerEntityType.Poi) returns false; also consider adding the
inverse mapping check like getDestinationSelectionTypeValue('call') ===
DestinationEntityType.Call to cover both directions. These assertions should
reference the existing helpers getDefaultDestinationTab, isPoiDestinationType,
isCallMarker, and getDestinationSelectionTypeValue to locate the code to modify.
src/stores/status/store.ts (1)

4-14: Imports could be tidier.

getSetUnitStatusData (line 4) and getAllGroups (line 5) are placed before DestinationSelectionType (line 6), but saveUnitStatus (line 7) sits after them. Other files in this PR have already been flagged for simple-import-sort violations — running the autofix here would prevent the same CI failures from creeping in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/status/store.ts` around lines 4 - 14, Imports in this file are not
sorted by the project's simple-import-sort rules: reorder the imports so
external and internal groups are alphabetically sorted and related
types/functions are grouped (ensure getSetUnitStatusData, getAllGroups,
DestinationSelectionType, saveUnitStatus and the other type imports follow the
same ordering used across the repo); best fix is to run the project's autofix
(eslint --fix or simple-import-sort --write) or manually reorder the imports to
match the established import grouping and alphabetical order.
src/components/dispatch-console/personnel-actions-panel.tsx (1)

656-662: Inconsistent i18n namespaces for POI strings.

The POI tab label uses t('menu.pois') and the empty state uses t('status.no_pois_available'), while sibling Calls/Stations tabs use t('dispatch.calls') / t('dispatch.stations') and the stations empty state uses t('dispatch.personnel_actions.no_stations_available'). Consider relocating the POI keys under dispatch.* (e.g., dispatch.pois and dispatch.personnel_actions.no_pois_available) for consistency and easier translation maintenance.

As per coding guidelines: "Ensure all text is wrapped in t() from react-i18next for translations with the dictionary files stored in src/translations".

Also applies to: 718-718

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dispatch-console/personnel-actions-panel.tsx` around lines 656
- 662, The POI labels use inconsistent i18n keys; replace usages of
t('menu.pois') and t('status.no_pois_available') with namespaced keys under
dispatch (e.g., t('dispatch.pois') for the tab label and
t('dispatch.personnel_actions.no_pois_available') for the empty state) so they
match the Calls/Stations pattern; update the translation JSON/YAML in
src/translations accordingly and ensure the components referencing
destinationConfig.showPois, setDestinationTab, destinationTab and availablePois
use the new keys.
src/components/maps/pin-marker.tsx (1)

26-28: Avoid computing POI marker styles for non-POI pins.

poiMarkerStyle, poiMarkerDotStyle, and getMapMarkerColor(pin) are evaluated on every render even when isPoiMapPin is false. Consider gating these behind the isPoiMapPin check (e.g., compute them inside the JSX ternary or via a useMemo keyed on isPoiMapPin/pin/size) to avoid unnecessary work for the common non-POI path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/maps/pin-marker.tsx` around lines 26 - 28, poiMarkerStyle,
poiMarkerDotStyle, poiMarkerSize and getMapMarkerColor(pin) are being computed
on every render even when isPoiMapPin is false; move these computations behind
the isPoiMapPin check to avoid work on the common non-POI path — either compute
them inline in the JSX ternary that renders the POI marker or wrap the
calculations in a useMemo that depends on [isPoiMapPin, pin, size] (referencing
poiMarkerSize, poiMarkerStyle, poiMarkerDotStyle, and getMapMarkerColor) so they
only run when needed.
src/stores/pois/store.ts (1)

105-114: Redundant guard after the cache assignment.

After line 109 sets selectedPoi to cachedPoi, the get().selectedPoi?.PoiId === poiId check on line 112 is always true whenever cachedPoi is truthy, so the third clause adds no extra protection. The expression simplifies to if (!forceRefresh && cachedPoi) return;. Not a bug, just dead logic that could mislead future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/pois/store.ts` around lines 105 - 114, In fetchPoi, the guard that
checks get().selectedPoi?.PoiId === poiId after you already set selectedPoi to
cachedPoi is redundant; simplify the condition to return early when a cached POI
exists and forceRefresh is false (i.e., replace the current if (!forceRefresh &&
get().selectedPoi?.PoiId === poiId && cachedPoi) return; with a check that uses
cachedPoi directly). Update the logic in fetchPoi (and related uses of
getPoiById/selectedPoi/PoiId) so the early-return is based on cachedPoi and
forceRefresh only.
src/components/dispatch-console/unit-actions-panel.tsx (2)

176-195: Effect re-fetches when selectedUnit reference changes even for the same unit.

The effect's dependency on the whole selectedUnit object will re-run whenever the upstream store/prop produces a new reference (e.g., on unrelated unit metadata updates), causing redundant getSetUnitStatusData API calls. Depending on selectedUnit?.UnitId instead pins the fetch to identity that actually matters.

♻️ Suggested change
-    if (selectedUnit) {
-      loadOptions();
-    }
-  }, [selectedUnit, setAvailableStatuses, setAvailableCalls, setAvailableStations, setAvailablePois, setIsLoadingOptions]);
+    if (selectedUnit) {
+      loadOptions();
+    }
+  }, [selectedUnit?.UnitId, setAvailableStatuses, setAvailableCalls, setAvailableStations, setAvailablePois, setIsLoadingOptions]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dispatch-console/unit-actions-panel.tsx` around lines 176 -
195, The effect currently depends on the entire selectedUnit object causing
ref-change re-fetches; change the dependency array to depend only on
selectedUnit?.UnitId and keep the existing early-return logic so loadOptions
runs only when UnitId is present; retain calls to getSetUnitStatusData and the
setters (setAvailableStatuses, setAvailableCalls, setAvailableStations,
setAvailablePois, setIsLoadingOptions) inside the effect but remove them from
the dependency array so the fetch is triggered only when the unit identity
(selectedUnit?.UnitId) changes.

99-101: Avoid poi! non-null assertion; render label only when present.

getPoiSelectionLabel(poi!) will pass null at runtime if DestinationSheetOption is ever invoked with type="poi" and no item. Although current call sites always pass an item, this assertion silently disables TypeScript's null check and is fragile. Prefer explicit narrowing:

♻️ Suggested change
-          {isNone ? label : isCall ? `#${call?.Number} - ${call?.Name}` : isPoi ? getPoiSelectionLabel(poi!) : station?.Name}
+          {isNone ? label : isCall ? `#${call?.Number} - ${call?.Name}` : isPoi ? (poi ? getPoiSelectionLabel(poi) : '') : station?.Name}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dispatch-console/unit-actions-panel.tsx` around lines 99 -
101, The expression uses a non-null assertion on poi (poi!) inside the JSX
conditional; replace that with an explicit null check and only call
getPoiSelectionLabel when poi is defined—i.e., in the ternary branch for isPoi,
return getPoiSelectionLabel(poi) only if poi is truthy, otherwise fall back to
label (or another safe value); update the conditional around isNone/isCall/isPoi
to reference getPoiSelectionLabel and poi together to avoid silencing TypeScript
and prevent runtime null usage in getPoiSelectionLabel.
src/lib/destination-helpers.ts (1)

88-99: getDestinationTypeLabel is currently the identity function for valid inputs.

Each case returns the exact same string literal as the input DestinationSelectionType, so this helper is effectively (type) => type plus a 'none' fallback. If the intent is to provide localized/human-readable labels, route these through t() (e.g., t('destinations.types.call')); otherwise the helper can be removed and call sites can use the type directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/destination-helpers.ts` around lines 88 - 99, The
getDestinationTypeLabel function is effectively an identity mapping; either
convert it to return localized strings or remove it and use the type directly.
If you want localization, replace the literal returns in getDestinationTypeLabel
with calls to the translation helper (e.g., t('destinations.types.call'),
t('destinations.types.station'), t('destinations.types.poi')) and import/receive
the i18n t function as needed (or accept t as a parameter) so callers get
human-readable labels; otherwise delete getDestinationTypeLabel and update all
call sites to use the raw DestinationSelectionType value directly and remove the
helper import.
src/components/pois/poi-detail-screen.tsx (1)

27-31: Avoid full-store destructuring and inline object selectors to prevent extra re-renders.

Two related issues:

  • Line 27 destructures from the un-selected usePoisStore() call, which subscribes this screen to every change in the POI store (e.g., pois, destinationPois, poiTypes, all isLoading* flags). Any unrelated update will re-render the entire detail screen.
  • Line 28–31's useLocationStore selector returns a fresh { latitude, longitude } object on every invocation, so without a custom equality function it will re-render on every useLocationStore set call regardless of whether lat/lng changed.
♻️ Suggested refactor
-  const { selectedPoi, isLoadingDetail, detailError, fetchPoi, resetSelectedPoi } = usePoisStore();
-  const userLocation = useLocationStore((state) => ({
-    latitude: state.latitude,
-    longitude: state.longitude,
-  }));
+  const selectedPoi = usePoisStore((s) => s.selectedPoi);
+  const isLoadingDetail = usePoisStore((s) => s.isLoadingDetail);
+  const detailError = usePoisStore((s) => s.detailError);
+  const fetchPoi = usePoisStore((s) => s.fetchPoi);
+  const resetSelectedPoi = usePoisStore((s) => s.resetSelectedPoi);
+  const userLatitude = useLocationStore((s) => s.latitude);
+  const userLongitude = useLocationStore((s) => s.longitude);

Then update the handleRoute call site to pass userLatitude/userLongitude directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/pois/poi-detail-screen.tsx` around lines 27 - 31, Right now
the component subscribes to the entire POI store and creates a fresh object for
location on every render; fix by replacing the full destructure of
usePoisStore() with specific selectors (e.g., call usePoisStore(state =>
state.selectedPoi), usePoisStore(state => state.isLoadingDetail),
usePoisStore(state => state.detailError), usePoisStore(state => state.fetchPoi),
usePoisStore(state => state.resetSelectedPoi)) so the component only subscribes
to the exact fields it needs, and replace the inline object selector for
useLocationStore with two stable selectors (e.g., userLatitude =
useLocationStore(s => s.latitude) and userLongitude = useLocationStore(s =>
s.longitude)) and update the handleRoute call site to pass
userLatitude/userLongitude directly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 53b402ca-ca7b-4ad3-8bfe-72375f8ed1a8

📥 Commits

Reviewing files that changed from the base of the PR and between d78dbe7 and b92c992.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (62)
  • package.json
  • src/api/calls/calls.ts
  • src/api/dispatch/dispatch.ts
  • src/api/mapping/mapping.ts
  • src/api/personnel/personnelStatuses.ts
  • src/app/(app)/map.tsx
  • src/app/(app)/map.web.tsx
  • src/app/(app)/pois.tsx
  • src/app/(app)/scheduled-calls.tsx
  • src/app/call/[id].tsx
  • src/app/call/[id].web.tsx
  • src/app/call/[id]/edit.tsx
  • src/app/call/[id]/edit.web.tsx
  • src/app/call/new/index.tsx
  • src/app/call/new/index.web.tsx
  • src/app/poi/[id].tsx
  • src/app/poi/[id].web.tsx
  • src/components/calls/call-card.tsx
  • src/components/calls/call-card.web.tsx
  • src/components/calls/scheduled-call-card.tsx
  • src/components/dispatch-console/personnel-actions-panel.tsx
  • src/components/dispatch-console/personnel-panel.tsx
  • src/components/dispatch-console/unit-actions-panel.tsx
  • src/components/dispatch-console/units-panel.tsx
  • src/components/maps/map-pins.tsx
  • src/components/maps/pin-detail-modal.tsx
  • src/components/maps/pin-marker.tsx
  • src/components/maps/unified-map-view.web.tsx
  • src/components/pois/poi-card.tsx
  • src/components/pois/poi-detail-screen.tsx
  • src/components/sidebar/__tests__/side-menu.test.tsx
  • src/components/sidebar/side-menu.tsx
  • src/components/status/status-bottom-sheet.tsx
  • src/lib/__tests__/destination-helpers.test.ts
  • src/lib/__tests__/poi-display.test.ts
  • src/lib/destination-helpers.ts
  • src/lib/map-markers.ts
  • src/lib/poi-display.ts
  • src/lib/poi-map-layers.ts
  • src/models/v4/calls/callResultData.ts
  • src/models/v4/dispatch/getSetUnitStateResultData.ts
  • src/models/v4/dispatch/newCallFormResultData.ts
  • src/models/v4/mapping/getMapDataAndMarkersData.ts
  • src/models/v4/mapping/poiLayerData.ts
  • src/models/v4/mapping/poiResult.ts
  • src/models/v4/mapping/poiResultData.ts
  • src/models/v4/mapping/poiTypeResultData.ts
  • src/models/v4/mapping/poiTypesResult.ts
  • src/models/v4/mapping/poisResult.ts
  • src/models/v4/personnelStatuses/getCurrentStatusResultData.ts
  • src/models/v4/personnelStatuses/savePersonStatusInput.ts
  • src/models/v4/personnelStatuses/savePersonsStatusesInput.ts
  • src/models/v4/unitStatus/saveUnitStatusInput.ts
  • src/models/v4/unitStatus/unitStatusResultData.ts
  • src/stores/calls/scheduled-store.ts
  • src/stores/dispatch/personnel-actions-store.ts
  • src/stores/dispatch/unit-actions-store.ts
  • src/stores/pois/store.ts
  • src/stores/status/store.ts
  • src/translations/ar.json
  • src/translations/en.json
  • src/translations/es.json

Comment thread src/app/(app)/map.tsx
Comment on lines 75 to +114
useMapSignalRUpdates(setMapPins);

const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => {
setPoiLayers(nextPoiLayers);
setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers));
}, []);

const togglePoiLayer = useCallback((layerId: string) => {
setVisiblePoiLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);

if (nextLayerIds.has(layerId)) {
nextLayerIds.delete(layerId);
} else {
nextLayerIds.add(layerId);
}

return nextLayerIds;
});
}, []);

const showAllMapLayers = useCallback(() => {
showAllLayers();
setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(poiLayers));
}, [poiLayers, showAllLayers]);

const hideAllMapLayers = useCallback(() => {
hideAllLayers();
setVisiblePoiLayerIds(new Set());
}, [hideAllLayers]);

const combinedLayers = useMemo(
() => [
...layers.map((layer) => ({ Id: layer.Id, Name: layer.Name, Color: layer.Color, kind: 'custom' as const })),
...poiLayers.map((layer) => ({ Id: getPoiMapLayerId(layer.PoiTypeId), Name: layer.Name, Color: layer.Color, kind: 'poi' as const })),
],
[layers, poiLayers]
);

const visibleMapPins = useMemo(() => filterMapPinsByPoiLayers(mapPins, visiblePoiLayerIds), [mapPins, visiblePoiLayerIds]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm filterMapPinsByPoiLayers behavior for unknown POI ids and empty sets
fd -e ts poi-map-layers
rg -nP -C5 'export function (filterMapPinsByPoiLayers|createDefaultVisiblePoiLayerIds|getPoiMapLayerId)' --type=ts

Repository: Resgrid/Dispatch

Length of output: 86


🏁 Script executed:

cat -n src/lib/poi-map-layers.ts

Repository: Resgrid/Dispatch

Length of output: 970


🏁 Script executed:

rg -n 'useMapSignalRUpdates' --type ts -A 10

Repository: Resgrid/Dispatch

Length of output: 20938


🏁 Script executed:

cat -n src/hooks/use-map-signalr-updates.ts | head -60

Repository: Resgrid/Dispatch

Length of output: 2821


🏁 Script executed:

rg -n 'getMapDataAndMarkers' --type ts -B 2 -A 5 src/api | head -40

Repository: Resgrid/Dispatch

Length of output: 1629


🏁 Script executed:

rg -n 'GetMapDataAndMarkersResult' --type ts -A 10 src/models | head -50

Repository: Resgrid/Dispatch

Length of output: 356


🏁 Script executed:

cat -n src/app/\(app\)/map.tsx | sed -n '225,240p'

Repository: Resgrid/Dispatch

Length of output: 795


🏁 Script executed:

cat -n src/hooks/use-map-signalr-updates.ts | sed -n '49,95p'

Repository: Resgrid/Dispatch

Length of output: 2060


🏁 Script executed:

cat -n src/app/\(app\)/map.tsx | sed -n '195,235p'

Repository: Resgrid/Dispatch

Length of output: 1877


🏁 Script executed:

rg -n 'class MapDataAndMarkersData' --type ts -A 15 src/models

Repository: Resgrid/Dispatch

Length of output: 1383


🏁 Script executed:

cat -n src/app/\(app\)/map.tsx | sed -n '73,78p'

Repository: Resgrid/Dispatch

Length of output: 305


🏁 Script executed:

cat -n src/hooks/use-map-signalr-updates.ts | sed -n '60,75p'

Repository: Resgrid/Dispatch

Length of output: 772


SignalR updates can introduce hidden POI markers.

useMapSignalRUpdates fetches the full map data (including PoiLayers) but only passes marker updates to its callback (setMapPins on line 75). If a SignalR update introduces a POI marker with a new type ID, it will be filtered out indefinitely by filterMapPinsByPoiLayers because:

  1. The new POI layer is never synced to poiLayers state
  2. The new type ID never appears in visiblePoiLayerIds
  3. The marker is silently hidden with no UI control to display it

Consider updating useMapSignalRUpdates to accept a callback for POI layer updates as well, then invoke syncPoiLayers with the latest data when available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/map.tsx around lines 75 - 114, The SignalR hook
useMapSignalRUpdates currently only calls setMapPins so new POI types never
reach poiLayers or visiblePoiLayerIds; update useMapSignalRUpdates to accept a
second callback (e.g., onPoiLayersUpdate) and, where the hook receives full map
data including PoiLayers, call that callback with the fresh PoiLayers so the
component can call syncPoiLayers (or
setPoiLayers/createDefaultVisiblePoiLayerIds) to merge new layer definitions and
update visiblePoiLayerIds; ensure the component invocation of
useMapSignalRUpdates passes both setMapPins and syncPoiLayers so
filterMapPinsByPoiLayers can see newly introduced POI types.

Comment thread src/app/(app)/map.tsx
Comment on lines +274 to +282
trackEvent('map_view_rendered', {
hasMapPins: mapPins.length > 0,
mapPinsCount: mapPins.length,
isMapLocked: location.isMapLocked,
theme: colorScheme || 'light',
layersCount: layers.length,
visibleLayersCount: visibleLayers.size,
layersCount: combinedLayers.length,
visibleLayersCount: visibleLayers.size + visiblePoiLayerIds.size,
});
}, [trackEvent, mapPins.length, location.isMapLocked, colorScheme, layers.length, visibleLayers.size]);
}, [trackEvent, mapPins.length, location.isMapLocked, colorScheme, combinedLayers.length, visibleLayers.size, visiblePoiLayerIds.size]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Run formatter to clear ESLint whitespace warnings.

The CI test check is flagging stray whitespace on line 274 and around lines 495–504 (Delete \·``). Run the formatter/autofix to clear these before merge.

🧰 Tools
🪛 GitHub Check: test

[warning] 274-274:
Delete ··

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/map.tsx around lines 274 - 282, There are stray whitespace
characters around the useEffect block that logs trackEvent (the call referencing
trackEvent, mapPins, combinedLayers, visibleLayers, visiblePoiLayerIds, and
location.isMapLocked); run the project's formatter or ESLint autofix (e.g.,
prettier or npm run format / eslint --fix) on src/app/(app)/map.tsx to remove
the extra spaces (delete the stray whitespace characters near the trackEvent
call and the surrounding lines) so the file passes CI formatting checks.

Comment on lines +39 to +85
useEffect(() => {
trackEvent('scheduled_calls_view_rendered', {
callsCount: scheduledCalls.length,
});
}, [trackEvent, scheduledCalls.length]);

useEffect(() => {
const toFetch = scheduledCalls.filter((c) => !fetchedIdsRef.current.has(c.CallId)).map((c) => c.CallId);

if (toFetch.length === 0) return;

toFetch.forEach((id) => fetchedIdsRef.current.add(id));

setLoadingDispatchIds((prev) => {
const next = new Set(prev);
toFetch.forEach((id) => next.add(id));
return next;
});

Promise.all(
toFetch.map((callId) =>
getCallExtraData(callId)
.then((res) => ({ callId, dispatches: res?.Data?.Dispatches ?? ([] as DispatchedEventResultData[]) }))
.catch(() => ({ callId, dispatches: [] as DispatchedEventResultData[] }))
)
).then((results) => {
setCallDispatchesMap((prev) => {
const next = { ...prev };
results.forEach(({ callId, dispatches }) => {
next[callId] = dispatches;
});
return next;
});
setLoadingDispatchIds((prev) => {
const next = new Set(prev);
toFetch.forEach((id) => next.delete(id));
return next;
});
});
}, [scheduledCalls]);

const handleRefresh = useCallback(() => {
fetchedIdsRef.current = new Set();
setCallDispatchesMap({});
setLoadingDispatchIds(new Set());
fetchScheduledCalls();
}, [fetchScheduledCalls]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Race condition and missing unmount guard in dispatch fetch effect.

Two related concerns:

  1. There is no abort/cleanup in the effect. If the screen unmounts while Promise.all is in flight, setCallDispatchesMap/setLoadingDispatchIds will fire on an unmounted component.
  2. handleRefresh resets fetchedIdsRef.current and clears callDispatchesMap, but any in‑flight Promise.all from a prior render will still resolve and overwrite the freshly fetched data, producing stale results that match no current intent.

Consider an AbortController per effect run (passed into getCallExtraData if it supports signal) and an isCancelled flag checked before each setState. Alternatively, track an incrementing fetch token and ignore results whose token no longer matches.

♻️ Sketch
   useEffect(() => {
     const toFetch = scheduledCalls.filter((c) => !fetchedIdsRef.current.has(c.CallId)).map((c) => c.CallId);
     if (toFetch.length === 0) return;
+    let cancelled = false;
     toFetch.forEach((id) => fetchedIdsRef.current.add(id));
     ...
     Promise.all(...).then((results) => {
+      if (cancelled) return;
       setCallDispatchesMap((prev) => { ... });
       setLoadingDispatchIds((prev) => { ... });
     });
+    return () => { cancelled = true; };
   }, [scheduledCalls]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
trackEvent('scheduled_calls_view_rendered', {
callsCount: scheduledCalls.length,
});
}, [trackEvent, scheduledCalls.length]);
useEffect(() => {
const toFetch = scheduledCalls.filter((c) => !fetchedIdsRef.current.has(c.CallId)).map((c) => c.CallId);
if (toFetch.length === 0) return;
toFetch.forEach((id) => fetchedIdsRef.current.add(id));
setLoadingDispatchIds((prev) => {
const next = new Set(prev);
toFetch.forEach((id) => next.add(id));
return next;
});
Promise.all(
toFetch.map((callId) =>
getCallExtraData(callId)
.then((res) => ({ callId, dispatches: res?.Data?.Dispatches ?? ([] as DispatchedEventResultData[]) }))
.catch(() => ({ callId, dispatches: [] as DispatchedEventResultData[] }))
)
).then((results) => {
setCallDispatchesMap((prev) => {
const next = { ...prev };
results.forEach(({ callId, dispatches }) => {
next[callId] = dispatches;
});
return next;
});
setLoadingDispatchIds((prev) => {
const next = new Set(prev);
toFetch.forEach((id) => next.delete(id));
return next;
});
});
}, [scheduledCalls]);
const handleRefresh = useCallback(() => {
fetchedIdsRef.current = new Set();
setCallDispatchesMap({});
setLoadingDispatchIds(new Set());
fetchScheduledCalls();
}, [fetchScheduledCalls]);
useEffect(() => {
const toFetch = scheduledCalls.filter((c) => !fetchedIdsRef.current.has(c.CallId)).map((c) => c.CallId);
if (toFetch.length === 0) return;
let cancelled = false;
toFetch.forEach((id) => fetchedIdsRef.current.add(id));
setLoadingDispatchIds((prev) => {
const next = new Set(prev);
toFetch.forEach((id) => next.add(id));
return next;
});
Promise.all(
toFetch.map((callId) =>
getCallExtraData(callId)
.then((res) => ({ callId, dispatches: res?.Data?.Dispatches ?? ([] as DispatchedEventResultData[]) }))
.catch(() => ({ callId, dispatches: [] as DispatchedEventResultData[] }))
)
).then((results) => {
if (cancelled) return;
setCallDispatchesMap((prev) => {
const next = { ...prev };
results.forEach(({ callId, dispatches }) => {
next[callId] = dispatches;
});
return next;
});
setLoadingDispatchIds((prev) => {
const next = new Set(prev);
toFetch.forEach((id) => next.delete(id));
return next;
});
});
return () => { cancelled = true; };
}, [scheduledCalls]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/scheduled-calls.tsx around lines 39 - 85, The fetch effect for
call dispatches can update state after unmount or after a refresh because
in‑flight Promise.all results are not cancelled or correlated to the current
run; update the effect to create a per-run identifier (e.g. fetchTokenRef) that
you increment at the start of the effect and also increment inside
handleRefresh, capture that token in the Promise.all then handler and only call
setCallDispatchesMap / setLoadingDispatchIds when the captured token still
equals fetchTokenRef.current; additionally create an AbortController for the run
(if getCallExtraData accepts a signal) and pass controller.signal into each
getCallExtraData call, and call controller.abort() in the effect cleanup to
cancel network requests on unmount/refresh—these changes involve the useEffect
that computes toFetch, fetchedIdsRef, getCallExtraData, setCallDispatchesMap,
setLoadingDispatchIds, and the handleRefresh callback.

</Pressable>
)}
keyExtractor={(item: CallResultData) => item.CallId}
refreshControl={<RefreshControl refreshing={false} onRefresh={handleRefresh} />}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

refreshing is hardcoded to false — pull-to-refresh spinner never appears.

The user can trigger handleRefresh, but the RefreshControl will never reflect the in‑progress state, so the gesture feels broken and a second pull can be initiated mid‑refetch.

🐛 Suggested fix
-        refreshControl={<RefreshControl refreshing={false} onRefresh={handleRefresh} />}
+        refreshControl={<RefreshControl refreshing={isLoading} onRefresh={handleRefresh} />}

If isLoading doesn't capture only the pull-to-refresh path, consider tracking a local isRefreshing state (as is done in pois.tsx) for an accurate indicator.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(app)/scheduled-calls.tsx at line 115, The RefreshControl is
hardcoded with refreshing={false} so the pull-to-refresh spinner never shows;
update the component to pass a real boolean tied to the refresh action (either
use existing isLoading if it only represents pull-to-refresh, or add a local
state like isRefreshing and set it true/false inside handleRefresh) so
RefreshControl's refreshing prop reflects the actual refresh lifecycle; locate
the RefreshControl usage and the handleRefresh function in scheduled-calls.tsx
and wire refreshing to isRefreshing (or isLoading) and ensure handleRefresh
toggles that state around the async fetch.

Comment thread src/app/call/[id].tsx
Comment on lines +289 to +296
{(call.ScheduledOn || call.ScheduledOnUtc) ? (
<Box className="border-b border-outline-100 pb-2">
<Text className="text-sm text-gray-500">{t('call_detail.scheduled_on')}</Text>
<Text className="font-medium text-amber-600 dark:text-amber-400">
{format(new Date(call.ScheduledOn || call.ScheduledOnUtc), 'MMM d, yyyy h:mm a')}
</Text>
</Box>
) : null}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the typing and any other consumers of ScheduledOn/ScheduledOnUtc to confirm
# whether these are guaranteed ISO strings or could be empty/non-ISO.
fd -t f 'callResultData.ts' | xargs -I{} cat {}
echo '---'
rg -nP -C2 '\bScheduledOn(Utc)?\b' --type=ts --type=tsx

Repository: Resgrid/Dispatch

Length of output: 1687


🏁 Script executed:

# Read the file around lines 287-296 to see the full context
sed -n '280,305p' src/app/call/[id].tsx | cat -n

Repository: Resgrid/Dispatch

Length of output: 1791


🏁 Script executed:

# Check for other date formatting patterns in the same file
rg 'format\(new Date' src/app/call/[id].tsx -A 2 -B 2

Repository: Resgrid/Dispatch

Length of output: 738


🏁 Script executed:

# Check if there are any date validation utilities in the codebase
fd -t f '\.(ts|tsx)$' | xargs rg 'Number\.isNaN.*getTime|isValidDate|parseDate' | head -20

Repository: Resgrid/Dispatch

Length of output: 2486


🏁 Script executed:

# Check the parseDateISOString implementation to understand its behavior
sed -n '1,100p' src/lib/utils.ts | grep -A 15 'parseDateISOString'

Repository: Resgrid/Dispatch

Length of output: 42


🏁 Script executed:

# Also check formatDateForDisplay to see the complete pattern
sed -n '1,200p' src/lib/utils.ts | grep -A 10 'formatDateForDisplay'

Repository: Resgrid/Dispatch

Length of output: 42


🏁 Script executed:

# Read the entire utils.ts file to see the date utility implementations
cat src/lib/utils.ts

Repository: Resgrid/Dispatch

Length of output: 17257


Guard against invalid/empty date strings in both LoggedOn and ScheduledOn fields.

The format() calls at lines 288 and 289 lack validation and will fail if date strings are empty (the default) or malformed:

  1. Line 288: format(new Date(call.LoggedOn), ...) is unguarded; LoggedOn defaults to '' in CallResultData
  2. Line 289: format(new Date(call.ScheduledOn || call.ScheduledOnUtc), ...) is unguarded; both fields default to ''

Calling format() on an invalid Date object will error. The codebase has an established pattern for this elsewhere: use parseDateISOString() + formatDateForDisplay() (seen in protocol-card, notes, dispatch-console). Consider applying that pattern here, or add validity checks before formatting.

The timezone concern from the original comment (local vs UTC semantics for ScheduledOn vs ScheduledOnUtc) remains valid—confirm with your backend that both fields use consistent ISO-8601 formatting with appropriate zone designators.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/call/`[id].tsx around lines 289 - 296, Guard the date formatting
calls for call.LoggedOn and call.ScheduledOn/ call.ScheduledOnUtc by converting
and validating the ISO strings before calling format: replace direct format(new
Date(...)) usages with the established helpers parseDateISOString(...) to obtain
a valid Date (or null) and then call formatDateForDisplay(...) (or only call
format if the Date is valid); update the JSX that renders LoggedOn and the
ScheduledOn block to render only when parseDateISOString(call.LoggedOn) or
parseDateISOString(call.ScheduledOn || call.ScheduledOnUtc) returns a valid date
(and keep using CallResultData defaults), and confirm you use the same helper
functions seen in protocol-card/notes/dispatch-console to preserve timezone/ISO
handling.

Comment on lines +7 to +23
expect(getPoiPrimaryDisplayText({ Name: 'Mercy Hospital', Address: '123 Main St', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('Mercy Hospital');
expect(getPoiPrimaryDisplayText({ Name: '', Address: '123 Main St', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('123 Main St');
expect(getPoiPrimaryDisplayText({ Name: '', Address: '', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('ER entrance');
expect(getPoiPrimaryDisplayText({ Name: '', Address: '', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('Hospital');
});

it('builds selection labels from name and address when available', () => {
expect(getPoiSelectionLabel({ Name: 'Mercy Hospital', Address: '123 Main St', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('Mercy Hospital - 123 Main St');
expect(getPoiSelectionLabel({ Name: '', Address: '123 Main St', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('123 Main St');
});

it('builds destination option labels with the type name prefix', () => {
expect(getPoiDestinationOptionLabel({ Name: 'Mercy Hospital', Address: '123 Main St', Note: '', PoiTypeName: 'Hospital' } as any)).toBe('Hospital: Mercy Hospital - 123 Main St');
});

it('builds a searchable lower-case value from all visible fields', () => {
expect(getPoiSearchValue({ Name: 'Mercy Hospital', Address: '123 Main St', Note: 'ER Entrance', PoiTypeName: 'Hospital' } as any)).toBe('mercy hospital 123 main st er entrance hospital');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace as any with Partial<PoiResultData> (or the helper's Pick arg type).

The casts hide type errors and contradict project guidance to avoid any. Since each helper accepts a Pick<PoiResultData, …>, the literals will type-check directly without the cast.

As per coding guidelines: "Avoid using any; strive for precise types".

Suggested adjustment
-import { getPoiDestinationOptionLabel, getPoiPrimaryDisplayText, getPoiSearchValue, getPoiSelectionLabel } from '../poi-display';
+import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
+import { getPoiDestinationOptionLabel, getPoiPrimaryDisplayText, getPoiSearchValue, getPoiSelectionLabel } from '../poi-display';
+
+type PoiDisplayInput = Pick<PoiResultData, 'Name' | 'Address' | 'Note' | 'PoiTypeName'>;
@@
-    expect(getPoiPrimaryDisplayText({ Name: 'Mercy Hospital', Address: '123 Main St', Note: 'ER entrance', PoiTypeName: 'Hospital' } as any)).toBe('Mercy Hospital');
+    expect(getPoiPrimaryDisplayText({ Name: 'Mercy Hospital', Address: '123 Main St', Note: 'ER entrance', PoiTypeName: 'Hospital' } satisfies PoiDisplayInput)).toBe('Mercy Hospital');

(Apply the same pattern to the other cases.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/__tests__/poi-display.test.ts` around lines 7 - 23, Tests use unsafe
casts "as any" which hide type errors; update the test literals passed to
getPoiPrimaryDisplayText, getPoiSelectionLabel, getPoiDestinationOptionLabel,
and getPoiSearchValue to use the proper typed shape instead of any (e.g.,
Partial<PoiResultData> or the specific Pick<> type the helper expects) so the
objects type-check without casting—replace each "as any" with the appropriate
Partial<PoiResultData> or Pick<PoiResultData, 'Name' | 'Address' | 'Note' |
'PoiTypeName'> to follow the project's no-any guideline.

Comment thread src/lib/poi-map-layers.ts
Comment on lines +12 to +18
export const filterMapPinsByPoiLayers = (pins: MapMakerInfoData[], visiblePoiLayerIds: Set<string>) => {
return pins.filter((pin) => {
if (pin.Type !== MapMarkerEntityType.Poi || !pin.PoiTypeId) {
return true;
}

return visiblePoiLayerIds.has(getPoiMapLayerId(pin.PoiTypeId));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Don't treat PoiTypeId = 0 as “missing”.

!pin.PoiTypeId bypasses layer filtering for any falsy numeric ID. If the backend ever emits 0, that POI can never be hidden with its layer. Check for null/undefined explicitly instead.

Proposed fix
 export const filterMapPinsByPoiLayers = (pins: MapMakerInfoData[], visiblePoiLayerIds: Set<string>) => {
   return pins.filter((pin) => {
-    if (pin.Type !== MapMarkerEntityType.Poi || !pin.PoiTypeId) {
+    if (pin.Type !== MapMarkerEntityType.Poi || pin.PoiTypeId == null) {
       return true;
     }
 
     return visiblePoiLayerIds.has(getPoiMapLayerId(pin.PoiTypeId));
   });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const filterMapPinsByPoiLayers = (pins: MapMakerInfoData[], visiblePoiLayerIds: Set<string>) => {
return pins.filter((pin) => {
if (pin.Type !== MapMarkerEntityType.Poi || !pin.PoiTypeId) {
return true;
}
return visiblePoiLayerIds.has(getPoiMapLayerId(pin.PoiTypeId));
export const filterMapPinsByPoiLayers = (pins: MapMakerInfoData[], visiblePoiLayerIds: Set<string>) => {
return pins.filter((pin) => {
if (pin.Type !== MapMarkerEntityType.Poi || pin.PoiTypeId == null) {
return true;
}
return visiblePoiLayerIds.has(getPoiMapLayerId(pin.PoiTypeId));
});
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/poi-map-layers.ts` around lines 12 - 18, The current filter in
filterMapPinsByPoiLayers treats pin.PoiTypeId with falsy check (!pin.PoiTypeId)
which incorrectly treats 0 as missing; change the check to explicitly test for
null/undefined (e.g., pin.PoiTypeId == null) so only absent IDs bypass layer
filtering; update the conditional that uses MapMarkerEntityType,
MapMakerInfoData, visiblePoiLayerIds and getPoiMapLayerId accordingly so POIs
with PoiTypeId === 0 are still filtered by visiblePoiLayerIds.

Comment on lines +9 to +18
public DestinationId: number | null = null;
public DestinationType: number | null = null;
public DestinationName: string = '';
public DestinationAddress: string = '';
public DestinationTypeName: string = '';
public Note: string = '';
public Latitude: string = '';
public Longitude: string = '';
public Latitude: number | null = null;
public Longitude: number | null = null;
public GroupName: string = '';
public GroupId: string = '';
public GroupId: number = 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find consumers of fields with potentially incompatible patterns.
rg -nP --type=ts -C2 '\.DestinationId\b'
rg -nP --type=ts -C2 '\.GroupId\b' -g '!**/*.test.ts'
rg -nP --type=ts -C2 '\bUnitStatusResultData\b'
# String-ish operations that would break against numeric fields:
rg -nP --type=ts -C1 'DestinationId\s*\.(startsWith|includes|toLowerCase|split)\b'
rg -nP --type=ts -C1 '(Latitude|Longitude)\s*\.(toString|startsWith|includes|split)\b'

Repository: Resgrid/Dispatch

Length of output: 12331


Breaking type changes — multiple callsites have incompatible type mismatches.

Several public field types changed shape and not all consumers were updated:

  • DestinationId: stringnumber | null
  • GroupId: stringnumber
  • Latitude / Longitude: stringnumber | null

Specific issues found:

  1. Type mismatch in station selection (src/components/status/status-bottom-sheet.tsx:657): handleStationSelect(station.GroupId) passes a number to a function that declares stationId: string parameter. Line 95 then compares GroupId (number) === stationId (string), which will never match.

  2. Incorrect fallback (src/lib/destination-helpers.ts:152): selectedStation?.GroupId ?? '' returns either a number or empty string, breaking the return type contract.

  3. Similar mismatches in src/components/dispatch-console/unit-actions-panel.tsx:237 and personnel-actions-panel.tsx:269 with the same pattern.

These are not just type violations—they cause runtime logic failures (e.g., station selection will silently fail due to number ≠ string comparison). All callsites require migration to numeric operations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/v4/unitStatus/unitStatusResultData.ts` around lines 9 - 18, The
model changed several fields from string to numeric (DestinationId, GroupId,
Latitude, Longitude), so update all consumers to use numeric operations: change
the parameter type of handleStationSelect from stationId: string to stationId:
number and update its callers (e.g., the station selection at
src/components/status/status-bottom-sheet.tsx) to pass numbers; replace string
fallbacks like selectedStation?.GroupId ?? '' with a numeric-safe fallback
(null, 0, or undefined) in src/lib/destination-helpers.ts and ensure return
types reflect numbers; similarly update unit-actions-panel.tsx and
personnel-actions-panel.tsx callsites to accept/compare numbers (or explicitly
coerce via Number(...) at call boundary) so comparisons like GroupId ===
stationId are numeric-to-numeric and not number-to-string. Ensure any UI
bindings that expect strings serialize numbers with .toString() only where a
string is required.

Comment thread src/stores/pois/store.ts
Comment on lines +124 to +129
set({
selectedPoi: nextPoi,
pois: nextPoi ? upsertPoi(currentPois, nextPoi) : currentPois,
destinationPois: nextPoi?.IsDestination ? upsertPoi(currentDestinationPois, nextPoi) : currentDestinationPois,
isLoadingDetail: false,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stale entries possible in destinationPois when IsDestination flips to false.

The upsert only adds/updates destinationPois when nextPoi.IsDestination is truthy, but it never removes a previously-cached POI whose IsDestination has since become false server-side. After such a transition, destinationPois will keep the stale POI until a full fetchDestinationPois(true) refresh runs.

♻️ Suggested change
-      set({
-        selectedPoi: nextPoi,
-        pois: nextPoi ? upsertPoi(currentPois, nextPoi) : currentPois,
-        destinationPois: nextPoi?.IsDestination ? upsertPoi(currentDestinationPois, nextPoi) : currentDestinationPois,
-        isLoadingDetail: false,
-      });
+      set({
+        selectedPoi: nextPoi,
+        pois: nextPoi ? upsertPoi(currentPois, nextPoi) : currentPois,
+        destinationPois: nextPoi
+          ? nextPoi.IsDestination
+            ? upsertPoi(currentDestinationPois, nextPoi)
+            : currentDestinationPois.filter((p) => p.PoiId !== nextPoi.PoiId)
+          : currentDestinationPois,
+        isLoadingDetail: false,
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/pois/store.ts` around lines 124 - 129, The current set call only
upserts into destinationPois when nextPoi.IsDestination is true, leaving stale
entries when a POI's IsDestination flips false; update the logic in the set
block that updates destinationPois (around selectedPoi/pois/destinationPois
usage) so that when nextPoi exists and nextPoi.IsDestination is false you remove
that POI from currentDestinationPois (e.g., filter out by POI id) instead of
returning currentDestinationPois unchanged; keep using upsertPoi when
IsDestination is true and ensure isLoadingDetail is still set to false.

Comment on lines 84 to 113
fetchDestinationData: async (unitId: string) => {
set({ isLoading: true, error: null });
try {
// Fetch calls and groups (stations) in parallel
const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);
const response = await getSetUnitStatusData(unitId);
const destinationData = response?.Data;

set({
availableCalls: callsResponse.Data || [],
availableStations: groupsResponse.Data || [],
availableCalls: destinationData?.Calls || [],
availableStations: destinationData?.Stations || [],
availablePois: destinationData?.DestinationPois || [],
isLoading: false,
});
} catch (error) {
set({
error: 'Failed to fetch destination data',
isLoading: false,
});
try {
const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);

set({
availableCalls: callsResponse.Data || [],
availableStations: groupsResponse.Data || [],
availablePois: [],
isLoading: false,
});
} catch {
set({
error: 'Failed to fetch destination data',
isLoading: false,
});
}
}
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add logging and consider the impact of silently swallowing the original error.

The new primary→fallback flow swallows both error paths without logging. Compare with scheduled-store.ts, which logs failed fetches with context. This file already imports logger (line 8) — using it here would significantly aid debugging when the unit-scoped endpoint fails. Also, when both calls fail, the user-facing error: 'Failed to fetch destination data' masks both root causes.

🛡️ Suggested fix
     } catch (error) {
+      logger.warn({
+        message: 'getSetUnitStatusData failed; falling back to getCalls/getAllGroups',
+        context: { unitId, error },
+      });
       try {
         const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);

         set({
           availableCalls: callsResponse.Data || [],
           availableStations: groupsResponse.Data || [],
           availablePois: [],
           isLoading: false,
         });
-      } catch {
+      } catch (fallbackError) {
+        logger.error({
+          message: 'Failed to fetch destination data (primary and fallback)',
+          context: { unitId, error, fallbackError },
+        });
         set({
           error: 'Failed to fetch destination data',
           isLoading: false,
         });
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/status/store.ts` around lines 84 - 113, In fetchDestinationData,
don’t silently swallow errors: when getSetUnitStatusData(unitId) fails, log the
original error and context using the existing logger (reference
fetchDestinationData and getSetUnitStatusData) before proceeding to the
fallback; likewise, if the Promise.all fallback (getCalls(), getAllGroups())
fails, log that error and include both error details in the final set({ error:
... }) so the user-facing error carries useful context (mirror the logging
pattern used in scheduled-store.ts), e.g., logger.error with descriptive
messages and the caught exceptions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant