From 491400256c25c36478958cefca3572e8f679eacc Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 29 Sep 2026 17:26:16 -0700 Subject: [PATCH 1/3] RU-T53 Expo 57 Fixes, Onboarding fixes, Records, Ops and Checklist Fixes --- src/app/(app)/operations/[id].tsx | 10 +- src/app/onboarding.tsx | 193 +++-------------- .../__tests__/checklist-run-sheet.test.tsx | 101 ++++++--- .../checklists/checklist-run-sheet.tsx | 8 +- .../common/__tests__/date-time-field.test.tsx | 75 +++++++ src/components/common/date-time-field.tsx | 202 ++++++++++++++++++ .../__tests__/onboarding-screen.test.tsx | 80 +++++++ .../onboarding/onboarding-screen.tsx | 146 +++++++++++++ .../__tests__/time-report-picker.test.tsx | 57 +++++ .../operations/time-report-editor.tsx | 48 ++--- .../__tests__/record-date-picker.test.tsx | 23 ++ src/components/records/record-field.tsx | 5 + src/lib/checklists/__tests__/vault.test.ts | 90 +++++--- src/lib/checklists/vault.ts | 6 +- src/lib/date-time-picker.ts | 31 +++ src/lib/records/__tests__/uploads.test.ts | 9 + src/lib/records/uploads.ts | 4 +- src/translations/ar.json | 6 + src/translations/de.json | 6 + src/translations/el.json | 6 + src/translations/en.json | 6 + src/translations/es.json | 6 + src/translations/fr.json | 6 + src/translations/it.json | 6 + src/translations/pl.json | 6 + src/translations/sv.json | 6 + src/translations/uk.json | 6 + 27 files changed, 894 insertions(+), 254 deletions(-) create mode 100644 src/components/common/__tests__/date-time-field.test.tsx create mode 100644 src/components/common/date-time-field.tsx create mode 100644 src/components/onboarding/__tests__/onboarding-screen.test.tsx create mode 100644 src/components/onboarding/onboarding-screen.tsx create mode 100644 src/components/operations/__tests__/time-report-picker.test.tsx create mode 100644 src/components/records/__tests__/record-date-picker.test.tsx create mode 100644 src/lib/date-time-picker.ts diff --git a/src/app/(app)/operations/[id].tsx b/src/app/(app)/operations/[id].tsx index 2cc98d9c..aa4f586a 100644 --- a/src/app/(app)/operations/[id].tsx +++ b/src/app/(app)/operations/[id].tsx @@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; +import { DateTimeField } from '@/components/common/date-time-field'; import { ExpensesPanel } from '@/components/operations/expenses-panel'; import { MarsPanel } from '@/components/operations/mars-panel'; import { AwaitingApproval, DayReports, ScopePicker, useScopeLabel } from '@/components/operations/scope-picker'; @@ -103,8 +104,7 @@ export default function OperationsDeploymentScreen() { return mine.length > 0 ? mine : crewed; }, [deployment, access, activeUnitId]); - const changeDay = (days: number) => { - const next = shiftDateKey(dateKey, days); + const selectDay = (next: string) => { dateRef.current = next; setDateKey(next); useOperationsStore.getState().setScope(useOperationsStore.getState().scope, next); @@ -182,11 +182,11 @@ export default function OperationsDeploymentScreen() { ) : null} {deployment && section !== 'mars' ? ( - changeDay(-1)} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> + selectDay(shiftDateKey(dateKey, -1))} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}> - {dateKey} - changeDay(1)} testID="operations-day-next" accessibilityRole="button" accessibilityLabel={t('operations.nextDay')}> + + selectDay(shiftDateKey(dateKey, 1))} testID="operations-day-next" accessibilityRole="button" accessibilityLabel={t('operations.nextDay')}> diff --git a/src/app/onboarding.tsx b/src/app/onboarding.tsx index 8796fbb4..357cb3a0 100644 --- a/src/app/onboarding.tsx +++ b/src/app/onboarding.tsx @@ -1,202 +1,67 @@ import { useRouter } from 'expo-router'; -import { Bell, ChevronRight, MapPin, Users } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; +import { Bell, MapPin, Users } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; -import { Image, Pressable, View } from 'react-native'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; +import { useTranslation } from 'react-i18next'; -import { FocusAwareStatusBar, SafeAreaView } from '@/components/ui'; -import { Text } from '@/components/ui/text'; +import { OnboardingScreen } from '@/components/onboarding/onboarding-screen'; import { useAuthStore } from '@/lib/auth'; import { useIsFirstTime } from '@/lib/storage'; -type OnboardingItemProps = { - title: string; - description: string; - icon: React.ReactNode; -}; - const SLIDES = [ { + titleKey: 'onboarding.unitTitle', title: 'Resgrid Unit', + descriptionKey: 'onboarding.unitDescription', description: "Track your unit's location and status in real-time with our advanced mapping and AVL system", - icon: , + icon: , }, { + titleKey: 'onboarding.notificationsTitle', title: 'Instant Notifications', + descriptionKey: 'onboarding.notificationsDescription', description: 'Receive immediate alerts for emergencies and important updates from your department', - icon: , + icon: , }, { + titleKey: 'onboarding.callsTitle', title: 'Interact with Calls', + descriptionKey: 'onboarding.callsDescription', description: 'Seamlessly view call information and interact with your team members for efficient emergency response', - icon: , + icon: , }, ]; -const SLIDE_COUNT = SLIDES.length; -const SPRING_CONFIG = { damping: 25, stiffness: 200 }; - -const OnboardingSlide: React.FC = ({ title, description, icon, slideWidth }) => ( - - {icon} - {title} - - {description} - - -); - -const Pagination: React.FC<{ currentIndex: number; length: number }> = ({ currentIndex, length }) => ( - - {Array.from({ length }).map((_, index) => ( - - ))} - -); - export default function Onboarding() { - const [_, setIsFirstTime] = useIsFirstTime(); + const { t } = useTranslation(); + const [, setIsFirstTime] = useIsFirstTime(); const setIsOnboarding = useAuthStore((state) => state.setIsOnboarding); const router = useRouter(); const [currentIndex, setCurrentIndex] = useState(0); - const [containerWidthState, setContainerWidthState] = useState(0); - const containerWidth = useSharedValue(0); - const { colorScheme } = useColorScheme(); - - // Shared value tracks slide position as an index offset (e.g. 0 = first slide, 1 = second) - const slideOffset = useSharedValue(0); - const buttonOpacity = useSharedValue(0); - const startOffset = useSharedValue(0); useEffect(() => { setIsOnboarding(); }, [setIsOnboarding]); - const updateIndex = useCallback( - (index: number) => { - setCurrentIndex(index); - buttonOpacity.value = index === SLIDE_COUNT - 1 ? withTiming(1, { duration: 500 }) : withTiming(0, { duration: 300 }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - // Sync shared value → JS state whenever the slide settles - useAnimatedReaction( - () => Math.round(slideOffset.value), - (result, previous) => { - if (result !== previous && result >= 0 && result < SLIDE_COUNT) { - runOnJS(updateIndex)(result); - } - } - ); - - // Swipe gesture: pan horizontally, snap to nearest slide on release - const panGesture = Gesture.Pan() - .activeOffsetX([-15, 15]) - .onStart(() => { - 'worklet'; - startOffset.value = slideOffset.value; - }) - .onUpdate((event) => { - 'worklet'; - const dragIndex = -event.translationX / containerWidth.value; - const target = startOffset.value + dragIndex; - slideOffset.value = Math.max(-0.3, Math.min(SLIDE_COUNT - 1 + 0.3, target)); - }) - .onEnd(() => { - 'worklet'; - const snapped = Math.round(slideOffset.value); - const clamped = Math.max(0, Math.min(SLIDE_COUNT - 1, snapped)); - slideOffset.value = withSpring(clamped, SPRING_CONFIG); - }); - - const nextSlide = useCallback(() => { - const next = Math.min(currentIndex + 1, SLIDE_COUNT - 1); - slideOffset.value = withSpring(next, SPRING_CONFIG); - }, [currentIndex, slideOffset]); - - const skip = useCallback(() => { - setIsFirstTime(false); - router.replace('/login'); - }, [setIsFirstTime, router]); - const finish = useCallback(() => { setIsFirstTime(false); router.replace('/login'); }, [setIsFirstTime, router]); - const handleContainerLayout = useCallback( - (event: { nativeEvent: { layout: { width: number } } }) => { - const w = event.nativeEvent.layout.width; - containerWidth.value = w; - setContainerWidthState(w); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - // The entire carousel translates on X - const carouselStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: -slideOffset.value * containerWidth.value }], - })); - - const buttonAnimatedStyle = useAnimatedStyle(() => ({ - opacity: buttonOpacity.value, - transform: [{ translateY: (1 - buttonOpacity.value) * 20 }], - })); - - if (containerWidthState === 0) { - // Render only the outer container to measure width before laying out slides - return ( - - - ); - } - + const slide = SLIDES[currentIndex]; return ( - - + setCurrentIndex((index) => Math.min(index + 1, SLIDES.length - 1))} + onSlideChange={setCurrentIndex} + /> ); } diff --git a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx index f60a8668..d7919ed5 100644 --- a/src/components/checklists/__tests__/checklist-run-sheet.test.tsx +++ b/src/components/checklists/__tests__/checklist-run-sheet.test.tsx @@ -1,3 +1,4 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); jest.mock('@/stores/data-protection/store', () => ({ dataProtectionStore: { getState: () => ({ isStepUpActive: () => false }) } })); jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); jest.mock('expo-crypto', () => ({ randomUUID: () => 'image-id' })); @@ -7,13 +8,22 @@ jest.mock('expo-location', () => ({ requestForegroundPermissionsAsync: jest.fn() jest.mock('expo-file-system/legacy', () => ({ cacheDirectory: 'file:///cache/', deleteAsync: jest.fn() })); jest.mock('@/api/checklists/checklists', () => ({ attestChecklistRun: jest.fn() })); jest.mock('@/components/checklists/signature-pad', () => ({ ChecklistSignaturePad: () => null })); -jest.mock('@/components/ui/button', () => { const { Pressable, Text } = require('react-native'); return { Button: ({ isDisabled, ...props }: { isDisabled?: boolean }) => , ButtonText: Text }; }); +jest.mock('@/components/ui/button', () => { + const { Pressable, Text } = require('react-native'); + return { Button: ({ isDisabled, ...props }: { isDisabled?: boolean }) => , ButtonText: Text }; +}); jest.mock('@/components/ui/text', () => ({ Text: require('react-native').Text })); jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); -// NativeModal carries a toast host; toast layering is covered by the toast-container suite. -jest.mock('@/components/toast/toast-container', () => ({ ModalToastHost: () => null })); -jest.mock('@/stores/auth/store', () => { const { create } = jest.requireActual('zustand'); return { __esModule: true, default: create(() => ({ userId: 'author' })) }; }); -jest.mock('@/stores/checklists/store', () => { const { create } = jest.requireActual('zustand'); return { useChecklistsStore: create(() => ({ active: null, update: jest.fn().mockResolvedValue(undefined), queue: jest.fn().mockResolvedValue(undefined), close: jest.fn(), addImage: jest.fn().mockResolvedValue(undefined) })) }; }); +jest.mock('@/stores/auth/store', () => { + const { create } = jest.requireActual('zustand'); + return { __esModule: true, default: create(() => ({ userId: 'author' })) }; +}); +jest.mock('@/stores/checklists/store', () => { + const { create } = jest.requireActual('zustand'); + return { + useChecklistsStore: create(() => ({ active: null, update: jest.fn().mockResolvedValue(undefined), queue: jest.fn().mockResolvedValue(undefined), close: jest.fn(), addImage: jest.fn().mockResolvedValue(undefined) })), + }; +}); import React from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import * as ImagePicker from 'expo-image-picker'; @@ -23,32 +33,73 @@ import { ChecklistRunSheet } from '@/components/checklists/checklist-run-sheet'; import { draft } from '@/lib/checklists/__tests__/fixtures'; import { useChecklistsStore } from '@/stores/checklists/store'; -beforeEach(() => { jest.clearAllMocks(); const local = draft(); local.queued = false; useChecklistsStore.setState({ active: local }); }); -it.each([0,1,2,3,4,5,6,7,8,9])('renders question type %i with localized controls', type => { - const local = draft(); local.queued = false; local.run.Form.Sections[0].Items[0].Type = type; local.run.Form.Sections[0].Items[0].Options = ['Synthetic option']; - useChecklistsStore.setState({ active: local }); const screen = render(); +beforeEach(() => { + jest.clearAllMocks(); + const local = draft(); + local.queued = false; + useChecklistsStore.setState({ active: local }); +}); +it.each([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])('renders question type %i with localized controls', (type) => { + const local = draft(); + local.queued = false; + local.run.Form.Sections[0].Items[0].Type = type; + local.run.Form.Sections[0].Items[0].Options = ['Synthetic option']; + useChecklistsStore.setState({ active: local }); + const screen = render(); expect(screen.getByText('Ready (checklists.labels.Required)')).toBeTruthy(); - if ([3,4,5,7].includes(type)) { fireEvent.changeText(screen.getByLabelText('checklists.labels.Answer'), '12'); expect(useChecklistsStore.getState().update).toHaveBeenCalledWith(expect.objectContaining({ Answers: [expect.objectContaining({ Value: '12' })] })); } - if ([0,1,2,6].includes(type)) { fireEvent.press(screen.getByText(type===0 ? 'checklists.labels.Pass' : type===1 ? 'checklists.labels.Yes' : type===2 ? 'checklists.labels.Checked' : 'Synthetic option')); expect(useChecklistsStore.getState().update).toHaveBeenCalled(); } - if (type===8) expect(screen.getByText('checklists.labels.Photo')).toBeTruthy(); - if (type===9) expect(screen.getByText('checklists.labels.Signature')).toBeTruthy(); + if ([3, 4, 5].includes(type)) { + fireEvent.changeText(screen.getByLabelText('checklists.labels.Answer'), '12'); + expect(useChecklistsStore.getState().update).toHaveBeenCalledWith(expect.objectContaining({ Answers: [expect.objectContaining({ Value: '12' })] })); + } + if ([0, 1, 2, 6].includes(type)) { + fireEvent.press(screen.getByText(type === 0 ? 'checklists.labels.Pass' : type === 1 ? 'checklists.labels.Yes' : type === 2 ? 'checklists.labels.Checked' : 'Synthetic option')); + expect(useChecklistsStore.getState().update).toHaveBeenCalled(); + } + if (type === 8) expect(screen.getByText('checklists.labels.Photo')).toBeTruthy(); + if (type === 9) expect(screen.getByText('checklists.labels.Signature')).toBeTruthy(); screen.unmount(); }); it('queues progress and submission through the durable store', async () => { - const screen=render(); fireEvent.press(screen.getByText('checklists.labels.SaveProgress')); - await waitFor(()=>expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(false)); - fireEvent.press(screen.getByText('checklists.labels.SubmitRun')); await waitFor(()=>expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(true)); screen.unmount(); + const screen = render(); + fireEvent.press(screen.getByText('checklists.labels.SaveProgress')); + await waitFor(() => expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(false)); + fireEvent.press(screen.getByText('checklists.labels.SubmitRun')); + await waitFor(() => expect(useChecklistsStore.getState().queue).toHaveBeenCalledWith(true)); + screen.unmount(); }); it('normalizes photo bytes and deletes only app cache copies after encrypted persistence', async () => { - const local=draft(); local.queued=false; local.run.Form.Sections[0].Items[0].Type=8; useChecklistsStore.setState({active:local}); - jest.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({granted:true} as never); - jest.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({canceled:false,assets:[{uri:'file:///gallery/original.heic',width:4000,height:3000}]} as never); - jest.mocked(manipulateAsync).mockResolvedValue({uri:'file:///cache/converted.jpg',width:2048,height:1536,base64:'synthetic-image'}); - const screen=render(); fireEvent.press(screen.getByText('checklists.labels.Photo')); - await waitFor(()=>expect(useChecklistsStore.getState().addImage).toHaveBeenCalledWith(expect.objectContaining({base64:'synthetic-image',contentType:'image/jpeg'}))); - expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/converted.jpg',{idempotent:true}); expect(FileSystem.deleteAsync).not.toHaveBeenCalledWith('file:///gallery/original.heic',expect.anything()); screen.unmount(); + const local = draft(); + local.queued = false; + local.run.Form.Sections[0].Items[0].Type = 8; + useChecklistsStore.setState({ active: local }); + jest.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({ granted: true } as never); + jest.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///gallery/original.heic', width: 4000, height: 3000 }] } as never); + jest.mocked(manipulateAsync).mockResolvedValue({ uri: 'file:///cache/converted.jpg', width: 2048, height: 1536, base64: 'synthetic-image' }); + const screen = render(); + fireEvent.press(screen.getByText('checklists.labels.Photo')); + await waitFor(() => expect(useChecklistsStore.getState().addImage).toHaveBeenCalledWith(expect.objectContaining({ base64: 'synthetic-image', contentType: 'image/jpeg' }))); + expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/converted.jpg', { idempotent: true }); + expect(FileSystem.deleteAsync).not.toHaveBeenCalledWith('file:///gallery/original.heic', expect.anything()); + screen.unmount(); }); it('removes entered values from the screen as soon as the store conceals the draft', () => { - const screen=render(); act(() => useChecklistsStore.setState({active:null})); screen.rerender(); - expect(screen.queryByText('Synthetic readiness check')).toBeNull(); screen.unmount(); + const screen = render(); + act(() => useChecklistsStore.setState({ active: null })); + screen.rerender(); + expect(screen.queryByText('Synthetic readiness check')).toBeNull(); + screen.unmount(); +}); + +it('saves a date answer selected from the calendar', () => { + const local = draft(); + local.queued = false; + local.run.Form.Sections[0].Items[0].Type = 7; + local.input.Answers = [{ ItemId: local.run.Form.Sections[0].Items[0].Id, Status: 1, Value: '2024-02-01' }]; + useChecklistsStore.setState({ active: local }); + const screen = render(); + fireEvent.press(screen.getByTestId('checklist-answer-date')); + fireEvent.press(screen.getByTestId('checklist-answer-date-day-29')); + fireEvent.press(screen.getByTestId('checklist-answer-date-done')); + expect(useChecklistsStore.getState().update).toHaveBeenCalledWith(expect.objectContaining({ Answers: [expect.objectContaining({ Value: '2024-02-29', Status: 1 })] })); + screen.unmount(); }); diff --git a/src/components/checklists/checklist-run-sheet.tsx b/src/components/checklists/checklist-run-sheet.tsx index 60555c1f..c6a1e320 100644 --- a/src/components/checklists/checklist-run-sheet.tsx +++ b/src/components/checklists/checklist-run-sheet.tsx @@ -10,6 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { attestChecklistRun, getChecklistImage } from '@/api/checklists/checklists'; import { ChecklistSignaturePad } from '@/components/checklists/signature-pad'; +import { DateTimeField } from '@/components/common/date-time-field'; import { NativeModal } from '@/components/common/native-modal'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; @@ -133,7 +134,10 @@ export const ChecklistRunSheet: React.FC = () => { {item.Type === 6 ? label : t(`checklists.labels.${label}`)} ))} - {[3, 4, 5, 7].includes(item.Type) ? ( + {item.Type === 7 ? ( + change({ Status: Value ? 1 : 0, Value })} label={item.Name} disabled={!editable || busy} testID="checklist-answer-date" /> + ) : null} + {[3, 4, 5].includes(item.Type) ? ( { multiline={item.Type === 5} keyboardType={[3, 4].includes(item.Type) ? 'decimal-pad' : 'default'} value={answer?.Value ?? ''} - placeholder={item.Type === 7 ? 'YYYY-MM-DD' : (item.Units ?? '')} + placeholder={item.Units ?? ''} onChangeText={(Value) => change({ Status: Value ? 1 : 0, Value })} /> ) : null} diff --git a/src/components/common/__tests__/date-time-field.test.tsx b/src/components/common/__tests__/date-time-field.test.tsx new file mode 100644 index 00000000..dba34c68 --- /dev/null +++ b/src/components/common/__tests__/date-time-field.test.tsx @@ -0,0 +1,75 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { TextInput } from 'react-native'; + +import { DateTimeField } from '@/components/common/date-time-field'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) })); +jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); + +it('selects a leap day using the calendar and commits only on Done', () => { + const onChange = jest.fn(); + const screen = render(); + expect(screen.UNSAFE_queryAllByType(TextInput)).toHaveLength(0); + fireEvent.press(screen.getByTestId('date')); + expect(screen.queryByTestId('date-day-30')).toBeNull(); + fireEvent.press(screen.getByTestId('date-day-29')); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenCalledWith('2024-02-29'); + screen.unmount(); +}); + +it('changes month and year without rolling an end-of-month value into another month', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-year')); + fireEvent.press(screen.getByTestId('date-year-2025')); + fireEvent.press(screen.getByTestId('date-month')); + fireEvent.press(screen.getByTestId('date-month-1')); + expect(screen.queryByTestId('date-day-29')).toBeNull(); + fireEvent.press(screen.getByTestId('date-day-28')); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenCalledWith('2025-02-28'); + screen.unmount(); +}); + +it('cancels without changing the value, supports clearing, and prevents disabled edits', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-day-15')); + fireEvent.press(screen.getByTestId('date-cancel')); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-clear')); + expect(onChange).toHaveBeenCalledWith(''); + screen.rerender(); + fireEvent.press(screen.getByTestId('date')); + expect(screen.queryByTestId('date-done')).toBeNull(); + screen.unmount(); +}); + +it('picks clock hours and minutes without a keyboard or clearing a required time', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('time')); + expect(screen.UNSAFE_queryAllByType(TextInput)).toHaveLength(0); + expect(screen.queryByTestId('time-clear')).toBeNull(); + fireEvent.press(screen.getByTestId('time-hour-23')); + fireEvent.press(screen.getByTestId('time-minute-59')); + fireEvent.press(screen.getByTestId('time-done')); + expect(onChange).toHaveBeenCalledWith('23:59'); + screen.unmount(); +}); + +it('preserves a record timestamp when confirming its date and time without changes', () => { + const onChange = jest.fn(); + const screen = render(); + fireEvent.press(screen.getByTestId('date')); + fireEvent.press(screen.getByTestId('date-done')); + expect(onChange).toHaveBeenCalledWith('2026-09-26T21:15:00.000Z'); + screen.unmount(); +}); diff --git a/src/components/common/date-time-field.tsx b/src/components/common/date-time-field.tsx new file mode 100644 index 00000000..d82e3b10 --- /dev/null +++ b/src/components/common/date-time-field.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { NativeModal } from '@/components/common/native-modal'; +import { Text } from '@/components/ui/text'; +import { parsePickerValue, pickerDate, type PickerMode, pickerTime, serializePickerValue } from '@/lib/date-time-picker'; + +interface DateTimeFieldProps { + value: string; + onChange: (value: string) => void; + label: string; + mode?: PickerMode; + disabled?: boolean; + clearable?: boolean; + testID?: string; +} + +// One picker for native and web. No keyboard entry; cancel never commits the draft. +// Date-only and clock-only values keep their calendar day/clock across time zones. +export const DateTimeField = ({ value, onChange, label, mode = 'date', disabled = false, clearable = true, testID = 'date-time-field' }: DateTimeFieldProps) => { + const { t, i18n } = useTranslation(); + const locale = i18n?.language ?? 'en'; + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(() => parsePickerValue(value, mode)); + const [month, setMonth] = useState(draft.getMonth()); + const [year, setYear] = useState(draft.getFullYear()); + const [view, setView] = useState<'days' | 'months' | 'years'>('days'); + const [yearPage, setYearPage] = useState(year - 11); + const selected = parsePickerValue(value, mode); + const display = !value ? label : mode === 'time' ? pickerTime(selected) : mode === 'date' ? selected.toLocaleDateString(locale) : selected.toLocaleString(locale); + const show = () => { + if (disabled) return; + const next = parsePickerValue(value, mode); + setDraft(next); + setMonth(next.getMonth()); + setYear(next.getFullYear()); + setYearPage(Math.max(1, Math.min(9976, next.getFullYear() - 11))); + setView('days'); + setOpen(true); + }; + const commit = (next: string) => { + if (!disabled) onChange(next); + setOpen(false); + }; + const calendarDate = (day: number) => { + const date = new Date(draft); + date.setDate(1); + date.setFullYear(year, month, day); + return date; + }; + const monthEnd = calendarDate(1); + monthEnd.setMonth(month + 1, 0); + const move = (direction: number) => { + if (view === 'years') { + setYearPage((page) => Math.max(1, Math.min(9976, page + direction * 24))); + return; + } + const next = calendarDate(1); + next.setMonth(month + direction); + if (next.getFullYear() < 1 || next.getFullYear() > 9999) return; + setMonth(next.getMonth()); + setYear(next.getFullYear()); + }; + const choice = (text: string, id: string, onPress: () => void, chosen = false, accessibilityLabel = text) => ( + + {text} + + ); + const clockColumn = (part: 'hour' | 'minute', count: number, current: number) => ( + + {t(`dateTimePicker.${part}`)} + + {Array.from({ length: count }, (_, number) => + choice( + String(number).padStart(2, '0'), + `${part}-${number}`, + () => { + const next = new Date(draft); + if (part === 'hour') next.setHours(number, next.getMinutes(), 0, 0); + else next.setMinutes(number, 0, 0); + setDraft(next); + }, + current === number + ) + )} + + + ); + return ( + + + {value ? {label} : null} + {display} + + {open && !disabled ? ( + setOpen(false)}> + + + + {label} + + + {mode !== 'time' ? ( + + + {choice('‹', 'previous', () => move(-1), false, t('common.previous'))} + {choice(calendarDate(1).toLocaleDateString(locale, { month: 'long' }), 'month', () => setView(view === 'months' ? 'days' : 'months'))} + {choice(String(year), 'year', () => setView(view === 'years' ? 'days' : 'years'))} + {choice('›', 'next', () => move(1), false, t('common.next'))} + + {view === 'years' ? ( + + {Array.from({ length: 24 }, (_, index) => yearPage + index).map((number) => ( + + {choice( + String(number), + `year-${number}`, + () => { + setYear(number); + setView('days'); + }, + year === number + )} + + ))} + + ) : view === 'months' ? ( + + {Array.from({ length: 12 }, (_, number) => ( + + {choice( + new Date(2024, number, 1).toLocaleDateString(locale, { month: 'short' }), + `month-${number}`, + () => { + setMonth(number); + setView('days'); + }, + month === number + )} + + ))} + + ) : ( + + {Array.from({ length: 7 }, (_, day) => ( + + {new Date(2024, 0, 7 + day).toLocaleDateString(locale, { weekday: 'short' })} + + ))} + {Array.from({ length: calendarDate(1).getDay() }, (_, index) => ( + + ))} + {Array.from({ length: monthEnd.getDate() }, (_, index) => index + 1).map((day) => { + const date = calendarDate(day); + return ( + + {choice(String(day), `day-${day}`, () => setDraft(date), pickerDate(date) === pickerDate(draft), date.toLocaleDateString(locale, { dateStyle: 'full' }))} + + ); + })} + + )} + + ) : null} + {mode !== 'date' ? ( + + {clockColumn('hour', 24, draft.getHours())} + {clockColumn('minute', 60, draft.getMinutes())} + + ) : null} + + + {clearable ? choice(t('dateTimePicker.clear'), 'clear', () => commit('')) : } + {choice(t('common.cancel'), 'cancel', () => setOpen(false))} + {choice(t('common.done'), 'done', () => commit(serializePickerValue(draft, mode)), true)} + + + + + ) : null} + + ); +}; diff --git a/src/components/onboarding/__tests__/onboarding-screen.test.tsx b/src/components/onboarding/__tests__/onboarding-screen.test.tsx new file mode 100644 index 00000000..e84038ee --- /dev/null +++ b/src/components/onboarding/__tests__/onboarding-screen.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { ScrollView } from 'react-native'; + +import { OnboardingScreen } from '../onboarding-screen'; + +let mockWindow = { width: 320, height: 568, fontScale: 1, scale: 1 }; +jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }), cssInterop: jest.fn() })); +jest.mock('@/components/ui', () => { + const { View } = require('react-native'); + return { View, SafeAreaView: View, FocusAwareStatusBar: () => null }; +}); +jest.mock('@/components/ui/text', () => ({ Text: require('react-native').Text })); +jest.mock('@/components/ui/pressable', () => ({ Pressable: require('react-native').Pressable })); +jest.mock('lucide-react-native', () => ({ ChevronRight: () => null })); + +const props = { + title: 'Resgrid Responder', + description: 'Stay connected to your department.', + icon: null, + currentIndex: 0, + total: 3, + skipLabel: 'Skip', + nextLabel: 'Next', + finishLabel: 'Get started', + onSkip: jest.fn(), + onNext: jest.fn(), + onFinish: jest.fn(), + onSlideChange: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockWindow = { width: 320, height: 568, fontScale: 1, scale: 1 }; + jest.spyOn(require('react-native'), 'useWindowDimensions').mockImplementation(() => mockWindow); +}); + +it.each([ + [320, 568, 1, 'column'], + [844, 390, 1, 'row'], + [768, 1024, 1, 'row'], + [1024, 768, 2, 'column'], +] as const)('supports %s × %s with font scale %s', (width, height, fontScale, direction) => { + mockWindow = { width, height, fontScale, scale: 1 }; + const screen = render(); + expect(Object.assign({}, ...screen.getByTestId('onboarding-content').props.style.flat(Infinity)).flexDirection).toBe(direction); + const title = screen.UNSAFE_getAllByType(require('react-native').Text).find((node) => node.props.children === props.title)!; + expect(title.props.className).toContain(width < 360 ? 'leading-[36px]' : 'leading-[44px]'); + expect(title.props.numberOfLines).toBeUndefined(); + expect(screen.UNSAFE_getByType(ScrollView).props.contentContainerStyle.flexGrow).toBe(1); + fireEvent.press(screen.getByTestId('next-button')); + expect(props.onNext).toHaveBeenCalledTimes(1); + screen.unmount(); +}); + +it('reflows after rotation without resetting the selected step', () => { + const screen = render(); + mockWindow = { width: 844, height: 390, fontScale: 1, scale: 1 }; + screen.rerender(); + const safeArea = screen.UNSAFE_getAllByType(require('react-native').View).find((view) => view.props.onLayout); + fireEvent(safeArea!, 'layout', { nativeEvent: { layout: { width: 844 } } }); + expect(Object.assign({}, ...screen.getByTestId('onboarding-content').props.style.flat(Infinity)).flexDirection).toBe('row'); + expect(screen.getByRole('progressbar').props.accessibilityValue.now).toBe(2); + screen.unmount(); +}); + +it('ignores vertical scrolling and clamps swipes at the last step without completing', () => { + const screen = render(); + const content = screen.getByTestId('onboarding-flatlist'); + fireEvent(content, 'touchStart', { nativeEvent: { pageX: 280, pageY: 100 } }); + fireEvent(content, 'touchEnd', { nativeEvent: { pageX: 180, pageY: 400 } }); + expect(props.onSlideChange).not.toHaveBeenCalled(); + fireEvent(content, 'touchStart', { nativeEvent: { pageX: 280, pageY: 100 } }); + fireEvent(content, 'touchEnd', { nativeEvent: { pageX: 80, pageY: 110 } }); + expect(props.onSlideChange).toHaveBeenCalledWith(2); + expect(props.onFinish).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId('get-started-button')); + expect(props.onFinish).toHaveBeenCalledTimes(1); + screen.unmount(); +}); diff --git a/src/components/onboarding/onboarding-screen.tsx b/src/components/onboarding/onboarding-screen.tsx new file mode 100644 index 00000000..ec3ea536 --- /dev/null +++ b/src/components/onboarding/onboarding-screen.tsx @@ -0,0 +1,146 @@ +import { ChevronRight } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React, { useRef, useState } from 'react'; +import type { GestureResponderEvent } from 'react-native'; +import { Image, ScrollView, StyleSheet, useWindowDimensions } from 'react-native'; + +import { FocusAwareStatusBar, SafeAreaView, View } from '@/components/ui'; +import { Pressable } from '@/components/ui/pressable'; +import { Text } from '@/components/ui/text'; + +interface OnboardingScreenProps { + title: string; + description: string; + icon: React.ReactNode; + currentIndex: number; + total: number; + skipLabel: string; + nextLabel: string; + finishLabel: string; + onSkip: () => void; + onNext: () => void; + onFinish: () => void; + onSlideChange: (index: number) => void; +} + +/** Content grows with text size; the whole page remains scrollable on short screens. */ +export const OnboardingScreen: React.FC = ({ title, description, icon, currentIndex, total, skipLabel, nextLabel, finishLabel, onSkip, onNext, onFinish, onSlideChange }) => { + const { width, height, fontScale } = useWindowDimensions(); + const [containerWidth, setContainerWidth] = useState(width); + const { colorScheme } = useColorScheme(); + const start = useRef<{ x: number; y: number } | null>(null); + const availableWidth = Math.min(width, containerWidth); + const horizontal = availableWidth >= 680 && fontScale < 1.5; + const compact = height < 500; + const last = currentIndex === total - 1; + const actionColor = colorScheme === 'dark' ? 'black' : 'white'; + + const handleTouchStart = (event: GestureResponderEvent) => { + start.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY ?? 0 }; + }; + const handleTouchEnd = (event: GestureResponderEvent) => { + const origin = start.current; + start.current = null; + if (!origin) return; + const dx = event.nativeEvent.pageX - origin.x; + const dy = (event.nativeEvent.pageY ?? 0) - origin.y; + // Vertical scrolling must not accidentally advance or complete onboarding. + if (Math.abs(dx) < 60 || Math.abs(dx) <= Math.abs(dy) * 1.5) return; + onSlideChange(Math.max(0, Math.min(total - 1, currentIndex + (dx < 0 ? 1 : -1)))); + }; + + return ( + setContainerWidth(event.nativeEvent.layout.width)}> + + ); +}; + +const styles = StyleSheet.create({ + scroll: { flexGrow: 1 }, + page: { flexGrow: 1, width: '100%', maxWidth: 960, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 16 }, + logo: { width: 136, height: 36, flexShrink: 1 }, + skip: { minHeight: 48, justifyContent: 'center', paddingHorizontal: 12, flexShrink: 1 }, + main: { flexGrow: 1, justifyContent: 'center' }, + card: { borderRadius: 28, padding: 24, gap: 28, alignItems: 'center', width: '100%' }, + horizontal: { flexDirection: 'row', padding: 32, gap: 40 }, + vertical: { flexDirection: 'column' }, + artwork: { borderRadius: 999, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, + icon: { width: 104, height: 104, borderRadius: 32, justifyContent: 'center', alignItems: 'center' }, + copy: { flexShrink: 1, minWidth: 0, width: '100%', gap: 16 }, + // Explicit line heights override the Text component's default body line height. + title: { alignSelf: 'stretch', paddingVertical: 4 }, + description: { alignSelf: 'stretch' }, + footer: { width: '100%', maxWidth: 480, alignSelf: 'center', gap: 24 }, + progress: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 8, minHeight: 16 }, + dot: { height: 8, borderRadius: 4 }, + action: { minHeight: 56, borderRadius: 16, paddingVertical: 16, paddingHorizontal: 24, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 8 }, +}); diff --git a/src/components/operations/__tests__/time-report-picker.test.tsx b/src/components/operations/__tests__/time-report-picker.test.tsx new file mode 100644 index 00000000..ebfb2f40 --- /dev/null +++ b/src/components/operations/__tests__/time-report-picker.test.tsx @@ -0,0 +1,57 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; + +import { TimeReportEditor } from '@/components/operations/time-report-editor'; +import { newEntry } from '@/lib/operations/time'; +import { type TimeReport, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components/operations/option-select', () => ({ OptionSelect: () => null })); +jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); + +const subject = { type: TimeSubjectType.Personnel, id: 'person-1', label: 'Crew member' }; +const props = () => ({ + dateKey: '2026-09-26', + report: { CanAct: true, Status: TimeReportStatus.Draft } as TimeReport, + entries: [newEntry(subject, '2026-09-26', 0, { StartLocal: '2026-09-26T22:00:00', EndLocal: '2026-09-26T23:00:00' })], + subjects: [subject], + names: { 'person-1': 'Crew member' }, + scopeLabel: 'Crew member', + isCrew: false, + coveredBy: null, + issues: [], + warnings: [], + canCreate: true, + canApprove: false, + canWrite: () => true, + dirty: false, + busy: false, + onStart: jest.fn(), + onChange: jest.fn(), + onSave: jest.fn(), + onSubmit: jest.fn(), + onSign: jest.fn(), + onApprove: jest.fn(), +}); + +it('keeps overnight end times on the next day after picking a clock time', () => { + const options = props(); + const screen = render(); + fireEvent.press(screen.getByTestId('operations-entry-end-0')); + fireEvent.press(screen.getByTestId('operations-entry-end-0-hour-2')); + fireEvent.press(screen.getByTestId('operations-entry-end-0-minute-30')); + fireEvent.press(screen.getByTestId('operations-entry-end-0-done')); + expect(options.onChange).toHaveBeenCalledWith([expect.objectContaining({ StartLocal: '2026-09-26T22:00:00', EndLocal: '2026-09-27T02:30' })]); + screen.unmount(); +}); + +it('does not allow time edits on a submitted report', () => { + const options = props(); + options.report.Status = TimeReportStatus.Submitted; + const screen = render(); + fireEvent.press(screen.getByTestId('operations-entry-start-0')); + expect(screen.queryByTestId('operations-entry-start-0-done')).toBeNull(); + expect(options.onChange).not.toHaveBeenCalled(); + screen.unmount(); +}); diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx index bed50fc4..a20c0cbd 100644 --- a/src/components/operations/time-report-editor.tsx +++ b/src/components/operations/time-report-editor.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { DateTimeField } from '@/components/common/date-time-field'; import { OptionSelect } from '@/components/operations/option-select'; import { Button, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; @@ -9,7 +10,7 @@ import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import type { EntrySubject } from '@/lib/operations/time'; -import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isClockTime, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; +import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time'; import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations'; import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations'; @@ -41,31 +42,6 @@ interface TimeReportEditorProps { onApprove: () => void; } -// Keeps the keystrokes local and only commits a complete HH:mm, so a half-typed time never lands -// in the entry as an unparseable value. -const ClockField = ({ value, onCommit, isDisabled, testID, label }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string; label: string }) => { - const [text, setText] = useState(value); - useEffect(() => { - setText(value); - }, [value]); - return ( - - { - setText(next); - if (isClockTime(next)) onCommit(next); - }} - /> - - ); -}; - const typeRank = (entry: TimeEntry) => (entry.SubjectType === TimeSubjectType.Unit ? 0 : entry.SubjectType === TimeSubjectType.Personnel ? 1 : 2); // A crew or individual time report for one day (the paper CTR / OF-288 analog). On a crew report the unit's @@ -185,13 +161,23 @@ export const TimeReportEditor = ({ /> {t('operations.time.from')} - setStart(index, clock)} isDisabled={!rowEditable} testID={`operations-entry-start-${index}`} /> + setStart(index, clock)} + disabled={!rowEditable} + testID={`operations-entry-start-${index}`} + /> {t('operations.time.to')} - update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} - isDisabled={!rowEditable} + onChange={(clock) => update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })} + disabled={!rowEditable} testID={`operations-entry-end-${index}`} /> diff --git a/src/components/records/__tests__/record-date-picker.test.tsx b/src/components/records/__tests__/record-date-picker.test.tsx new file mode 100644 index 00000000..3bfef7c1 --- /dev/null +++ b/src/components/records/__tests__/record-date-picker.test.tsx @@ -0,0 +1,23 @@ +jest.mock('@/components/common/native-modal', () => ({ NativeModal: require('react-native').Modal })); +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { TextInput } from 'react-native'; + +import { RecordField } from '@/components/records/record-field'; +import { RmsFieldClassification, RmsFieldType } from '@/models/v4/records'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('react-native-safe-area-context', () => ({ SafeAreaView: require('react-native').View })); + +it.each([RmsFieldType.Date, RmsFieldType.DateTime])('renders record field type %i as a picker and preserves its value', (type) => { + const onChange = jest.fn(); + const value = type === RmsFieldType.Date ? '2024-02-29' : '2024-02-29T21:30:00.000Z'; + const screen = render( + + ); + expect(screen.UNSAFE_queryAllByType(TextInput)).toHaveLength(0); + fireEvent.press(screen.getByTestId('record-date-picker')); + fireEvent.press(screen.getByTestId('record-date-picker-done')); + expect(onChange).toHaveBeenCalledWith({ Value: value }); + screen.unmount(); +}); diff --git a/src/components/records/record-field.tsx b/src/components/records/record-field.tsx index 9a4436af..a2de482d 100644 --- a/src/components/records/record-field.tsx +++ b/src/components/records/record-field.tsx @@ -2,6 +2,7 @@ import { Lock } from 'lucide-react-native'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { DateTimeField } from '@/components/common/date-time-field'; import { Badge, BadgeText } from '@/components/ui/badge'; import { Box } from '@/components/ui/box'; import { Checkbox, CheckboxIcon, CheckboxIndicator, CheckboxLabel } from '@/components/ui/checkbox'; @@ -77,6 +78,10 @@ export const RecordField: React.FC = ({ field, value, required } switch (field.Type) { + case RmsFieldType.Date: + case RmsFieldType.DateTime: + return wrap(); + case RmsFieldType.LongText: return wrap(