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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
microphonePermission: 'Allow Resgrid Responder to access the microphone for audio input used in PTT and calls.',
},
],
[
'expo-image-picker',
{
cameraPermission: 'Allow Resgrid Responder to access the camera to take photos for call documentation.',
photosPermission: 'Allow Resgrid Responder to access your photos library to attach images to calls.',
},
],
'react-native-ble-manager',
'expo-secure-store',
'@livekit/react-native-expo-plugin',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ jest.mock('react-i18next', () => ({
jest.mock('@/lib/utils', () => ({
formatDateForDisplay: (date: any) => 'Formatted Date',
parseDateISOString: (date: string) => new Date(date),
safeFormatTimestamp: (timestamp: string, format: string) => 'Formatted Date',
getAvatarUrl: (userId: string) => `https://example.com/avatar/${userId}`,
getInitials: (first: string, last: string) => 'JD',
getColorFromString: (str: string) => '#000000',
}));

describe('PersonnelDetailsSheet', () => {
Expand Down
155 changes: 90 additions & 65 deletions src/components/personnel/personnel-card.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Mail, Phone, Users } from 'lucide-react-native';
import * as React from 'react';
import { Pressable } from 'react-native';

import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
import { formatDateForDisplay, getAvatarUrl, getColorFromString, getInitials, parseDateISOString, safeFormatTimestamp } from '@/lib/utils';
import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
import { useSecurityStore } from '@/stores/security/store';

import { Avatar, AvatarFallbackText, AvatarImage } from '../ui/avatar';
import { Badge } from '../ui/badge';
import { Box } from '../ui/box';
import { HStack } from '../ui/hstack';
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';


Check warning on line 16 in src/components/personnel/personnel-card.tsx

View workflow job for this annotation

GitHub Actions / test

Delete `⏎⏎`

interface PersonnelCardProps {
personnel: PersonnelInfoResultData;
onPress: (id: string) => void;
Expand All @@ -19,90 +23,111 @@
export const PersonnelCard: React.FC<PersonnelCardProps> = ({ personnel, onPress }) => {
const fullName = `${personnel.FirstName} ${personnel.LastName}`.trim();
const { canUserViewPII } = useSecurityStore();
const [imageError, setImageError] = React.useState(false);

React.useEffect(() => {
setImageError(false);
}, [personnel.UserId]);

const avatarUrl = getAvatarUrl(personnel.UserId);
const initials = getInitials(personnel.FirstName, personnel.LastName);
const fallbackColor = getColorFromString(personnel.UserId || fullName);

return (
<Pressable onPress={() => onPress(personnel.UserId)} testID={`personnel-card-${personnel.UserId}`}>
<Box className="mb-3 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
<VStack space="xs">
<Text className="text-lg font-semibold text-gray-800 dark:text-gray-100">{fullName}</Text>
<HStack space="md" className="items-start">
{/* Profile Avatar */}
<Avatar size="md" style={imageError ? { backgroundColor: fallbackColor } : undefined}>
{!imageError && (

Check warning on line 42 in src/components/personnel/personnel-card.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `(⏎··············<AvatarImage⏎················source={{·uri:·avatarUrl·}}⏎················onError={()·=>·setImageError(true)}⏎··············/>⏎············)` with `<AvatarImage·source={{·uri:·avatarUrl·}}·onError={()·=>·setImageError(true)}·/>`
<AvatarImage
source={{ uri: avatarUrl }}
onError={() => setImageError(true)}
/>
)}
{imageError && (

Check warning on line 48 in src/components/personnel/personnel-card.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `(⏎··············<AvatarFallbackText·className="text-white">{initials}</AvatarFallbackText>⏎············)` with `<AvatarFallbackText·className="text-white">{initials}</AvatarFallbackText>`
<AvatarFallbackText className="text-white">{initials}</AvatarFallbackText>
)}
</Avatar>

{/* Contact Information */}
{canUserViewPII ? (
<VStack space="xs">
{personnel.EmailAddress ? (
<HStack space="xs" className="items-center">
<Mail size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.EmailAddress}
</Text>
</HStack>
) : null}
<VStack space="xs" className="flex-1">
<Text className="text-lg font-semibold text-gray-800 dark:text-gray-100">{fullName}</Text>
{/* Contact Information */}
{canUserViewPII ? (
<VStack space="xs">
{personnel.EmailAddress ? (
<HStack space="xs" className="items-center">
<Mail size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.EmailAddress}
</Text>
</HStack>
) : null}

{personnel.MobilePhone ? (
<HStack space="xs" className="items-center">
<Phone size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.MobilePhone}
</Text>
</HStack>
) : null}
{personnel.MobilePhone ? (
<HStack space="xs" className="items-center">
<Phone size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.MobilePhone}
</Text>
</HStack>
) : null}

{personnel.GroupName ? (
{personnel.GroupName ? (
<HStack space="xs" className="items-center">
<Users size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.GroupName}
</Text>
</HStack>
) : null}
</VStack>
) : personnel.GroupName ? (
<VStack space="xs">
<HStack space="xs" className="items-center">
<Users size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.GroupName}
</Text>
</HStack>
) : null}
</VStack>
) : personnel.GroupName ? (
<VStack space="xs">
{personnel.GroupName ? (
<HStack space="xs" className="items-center">
<Users size={16} className="text-gray-600 dark:text-gray-400" />
<Text className="text-sm text-gray-600 dark:text-gray-300" numberOfLines={1}>
{personnel.GroupName}
</Text>
</HStack>
) : null}
</VStack>
) : null}

{/* Status and Staffing Badges */}
<HStack className="mt-2 flex-wrap">
{personnel.Status ? (
<Badge className="mb-1 mr-1" style={{ backgroundColor: personnel.StatusColor || '#3B82F6' }}>
<Text className="text-xs text-white">{personnel.Status}</Text>
</Badge>
</VStack>
) : null}

{personnel.Staffing ? (
<Badge className="mb-1 mr-1" style={{ backgroundColor: personnel.StaffingColor || '#10B981' }}>
<Text className="text-xs text-white">{personnel.Staffing}</Text>
</Badge>
) : null}
</HStack>

{/* Roles */}
{personnel.Roles && personnel.Roles.length > 0 ? (
<HStack className="mt-1 flex-wrap">
{personnel.Roles.slice(0, 3).map((role, index) => (
<Badge key={index} className="mb-1 mr-1 bg-gray-100 dark:bg-gray-700">
<Text className="text-xs text-gray-800 dark:text-gray-100">{role}</Text>
{/* Status and Staffing Badges */}
<HStack className="mt-2 flex-wrap">
{personnel.Status ? (
<Badge className="mb-1 mr-1" style={{ backgroundColor: personnel.StatusColor || '#3B82F6' }}>
<Text className="text-xs text-white">{personnel.Status}</Text>
</Badge>
))}
{personnel.Roles.length > 3 ? (
<Badge className="mb-1 mr-1 bg-gray-100 dark:bg-gray-700">
<Text className="text-xs text-gray-800 dark:text-gray-100">+{personnel.Roles.length - 3}</Text>
) : null}

{personnel.Staffing ? (
<Badge className="mb-1 mr-1" style={{ backgroundColor: personnel.StaffingColor || '#10B981' }}>
<Text className="text-xs text-white">{personnel.Staffing}</Text>
</Badge>
) : null}
</HStack>
) : null}

{/* Last Status Update */}
{personnel.StatusTimestamp ? <Text className="mt-2 text-xs text-gray-500 dark:text-gray-400">Status: {formatDateForDisplay(parseDateISOString(personnel.StatusTimestamp), 'yyyy-MM-dd HH:mm Z')}</Text> : null}
</VStack>
{/* Roles */}
{personnel.Roles && personnel.Roles.length > 0 ? (
<HStack className="mt-1 flex-wrap">
{personnel.Roles.slice(0, 3).map((role, index) => (
<Badge key={index} className="mb-1 mr-1 bg-gray-100 dark:bg-gray-700">
<Text className="text-xs text-gray-800 dark:text-gray-100">{role}</Text>
</Badge>
))}
{personnel.Roles.length > 3 ? (
<Badge className="mb-1 mr-1 bg-gray-100 dark:bg-gray-700">
<Text className="text-xs text-gray-800 dark:text-gray-100">+{personnel.Roles.length - 3}</Text>
</Badge>
) : null}
</HStack>
) : null}

{/* Last Status Update */}
{personnel.StatusTimestamp ? <Text className="mt-2 text-xs text-gray-500 dark:text-gray-400">Status: {safeFormatTimestamp(personnel.StatusTimestamp, 'yyyy-MM-dd HH:mm Z')}</Text> : null}
</VStack>
</HStack>
</Box>
</Pressable>
);
Expand Down
52 changes: 32 additions & 20 deletions src/components/personnel/personnel-details-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Calendar, IdCard, Mail, Phone, Tag, Users, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';

import { useAnalytics } from '@/hooks/use-analytics';
import { formatDateForDisplay, parseDateISOString } from '@/lib/utils';
import { formatDateForDisplay, getAvatarUrl, getColorFromString, getInitials, parseDateISOString, safeFormatTimestamp } from '@/lib/utils';
import { usePersonnelStore } from '@/stores/personnel/store';
import { useSecurityStore } from '@/stores/security/store';

import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet';
import { Avatar, AvatarFallbackText, AvatarImage } from '../ui/avatar';
import { Badge } from '../ui/badge';
import { Box } from '../ui/box';
import { Button } from '../ui/button';
Expand All @@ -18,30 +19,24 @@
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';

/**
* Safely formats a timestamp string with error handling.
* Returns formatted date string on success, empty string on failure.
*/
const safeFormatTimestamp = (timestamp: string | undefined | null, format: string): string => {
if (!timestamp) return '';

try {
const parsed = parseDateISOString(timestamp);
return formatDateForDisplay(parsed, format);
} catch (error) {
console.warn('Failed to parse timestamp:', timestamp, error);
return '';
}
};

Check warning on line 22 in src/components/personnel/personnel-details-sheet.tsx

View workflow job for this annotation

GitHub Actions / test

Delete `⏎⏎⏎⏎`



export const PersonnelDetailsSheet: React.FC = () => {
const { t } = useTranslation();
const { personnel, selectedPersonnelId, isDetailsOpen, closeDetails } = usePersonnelStore();
const { canUserViewPII } = useSecurityStore();
const { trackEvent } = useAnalytics();
const [imageError, setImageError] = useState(false);

const selectedPersonnel = personnel?.find((person) => person.UserId === selectedPersonnelId);

// Reset image error state when selected personnel changes
useEffect(() => {
setImageError(false);
}, [selectedPersonnelId]);

// Cache formatted timestamps to avoid double parsing
const formattedStatusTimestamp = useMemo(() => safeFormatTimestamp(selectedPersonnel?.StatusTimestamp, 'yyyy-MM-dd HH:mm Z'), [selectedPersonnel?.StatusTimestamp]);

Expand Down Expand Up @@ -86,6 +81,9 @@
if (!selectedPersonnel || !isDetailsOpen) return null;

const fullName = `${selectedPersonnel.FirstName} ${selectedPersonnel.LastName}`.trim();
const avatarUrl = getAvatarUrl(selectedPersonnel.UserId);
const initials = getInitials(selectedPersonnel.FirstName, selectedPersonnel.LastName);
const fallbackColor = getColorFromString(selectedPersonnel.UserId || fullName);

return (
<Actionsheet isOpen={isDetailsOpen} onClose={closeDetails} snapPoints={[67]}>
Expand All @@ -97,9 +95,23 @@

<Box className="w-full flex-1 p-4">
<HStack className="mb-4 items-center justify-between">
<Heading size="lg" className="text-gray-800 dark:text-gray-100">
{fullName}
</Heading>
<HStack space="md" className="flex-1 items-center">
{/* Profile Avatar */}
<Avatar size="lg" style={imageError ? { backgroundColor: fallbackColor } : undefined}>
{!imageError && (

Check warning on line 101 in src/components/personnel/personnel-details-sheet.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `(⏎··················<AvatarImage⏎····················source={{·uri:·avatarUrl·}}⏎····················onError={()·=>·setImageError(true)}⏎··················/>⏎················)` with `<AvatarImage·source={{·uri:·avatarUrl·}}·onError={()·=>·setImageError(true)}·/>`
<AvatarImage
source={{ uri: avatarUrl }}
onError={() => setImageError(true)}
/>
)}
{imageError && (

Check warning on line 107 in src/components/personnel/personnel-details-sheet.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `(⏎··················<AvatarFallbackText·className="text-white">{initials}</AvatarFallbackText>⏎················)` with `<AvatarFallbackText·className="text-white">{initials}</AvatarFallbackText>`
<AvatarFallbackText className="text-white">{initials}</AvatarFallbackText>
)}
</Avatar>
<Heading size="lg" className="flex-1 text-gray-800 dark:text-gray-100">
{fullName}
</Heading>
</HStack>
<Button variant="link" onPress={closeDetails} className="p-1" testID="close-button">
<X size={24} className="text-gray-600 dark:text-gray-400" />
</Button>
Expand Down
2 changes: 1 addition & 1 deletion src/components/shifts/shift-details-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ const ShiftDetailsSheetComponent: React.FC<ShiftDetailsSheetProps> = ({ isOpen,
{/* Type Badges */}
<VStack space="md">
<Text size="lg" className="font-semibold text-gray-900 dark:text-white">
{t('shifts.shift_type')}
{t('shifts.shift_type.label')}
</Text>
<HStack space="sm">
<Badge action="info" size="md">
Expand Down
43 changes: 42 additions & 1 deletion src/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatLocalDateString, isSameDate, isToday, getTodayLocalString } from '../utils';
import { formatLocalDateString, isSameDate, isToday, getTodayLocalString, safeFormatTimestamp, getColorFromString, getInitials } from '../utils';
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for safeFormatTimestamp tests in the test file
rg -n "safeFormatTimestamp" src/lib/__tests__/utils.test.ts -C 5

Repository: Resgrid/Responder

Length of output: 440


🏁 Script executed:

#!/bin/bash
# Also check the entire test file to see all test cases
wc -l src/lib/__tests__/utils.test.ts && echo "---" && cat -n src/lib/__tests__/utils.test.ts | tail -100

Repository: Resgrid/Responder

Length of output: 4075


🏁 Script executed:

#!/bin/bash
# Search for the utils implementation to understand what safeFormatTimestamp does
cat -n src/lib/utils.ts | grep -A 20 "safeFormatTimestamp"

Repository: Resgrid/Responder

Length of output: 877


Add test cases for safeFormatTimestamp.

The function is imported but has no test coverage. Add tests for various scenarios: valid timestamps with different formats, null/undefined inputs, and error handling for invalid timestamps.

🤖 Prompt for AI Agents
In `@src/lib/__tests__/utils.test.ts` at line 1, Add unit tests covering
safeFormatTimestamp in src/lib/__tests__/utils.test.ts: create cases for (1)
valid timestamp inputs with different format strings to assert expected outputs,
(2) null and undefined inputs to assert they return empty string or expected
fallback, and (3) invalid/non-parsable timestamp inputs to assert the function
handles errors gracefully (e.g., returns empty string or a specific error
value). Use the existing test harness and imports (safeFormatTimestamp) and
mirror patterns used for other helpers (formatLocalDateString,
getTodayLocalString) to keep tests consistent and isolated.


describe('Date Utility Functions', () => {
describe('formatLocalDateString', () => {
Expand Down Expand Up @@ -205,4 +205,45 @@ describe('Date Utility Functions', () => {
expect(() => formatLocalDateString(validDate)).not.toThrow();
});
});

describe('getColorFromString', () => {
it('generates deterministic color from string', () => {
const color1 = getColorFromString('test-string');
const color2 = getColorFromString('test-string');
expect(color1).toBe(color2);
expect(color1).toMatch(/^hsl\(\d+, 65%, 45%\)$/);
});

it('generates different colors for different strings', () => {
const color1 = getColorFromString('string-1');
const color2 = getColorFromString('string-2');
expect(color1).not.toBe(color2);
});
});

describe('getInitials', () => {
it('returns initials from first and last name', () => {
expect(getInitials('John', 'Doe')).toBe('JD');
});

it('handles missing first name', () => {
expect(getInitials(undefined, 'Doe')).toBe('D');
});

it('handles missing last name', () => {
expect(getInitials('John')).toBe('J');
});

it('handles empty strings', () => {
expect(getInitials('', '')).toBe('?');
});

it('handles extra whitespace', () => {
expect(getInitials(' John ', ' Doe ')).toBe('JD');
});

it('returns ? for no input', () => {
expect(getInitials()).toBe('?');
});
});
});
Loading