From 5c4199e2d1b181f562d693bdb390b46a62964dc7 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 29 Sep 2026 17:25:15 -0700 Subject: [PATCH 1/5] RR-T48 Expo 57 Fixes, Onboarding fixes, Records, Ops and Checklist Fixes --- .../__tests__/certification-edit.test.tsx | 16 ++ src/app/(app)/certifications/[id].tsx | 11 +- src/app/(app)/certifications/edit.tsx | 9 +- src/app/(app)/operations/[id].tsx | 10 +- src/app/__tests__/onboarding.test.tsx | 29 +-- src/app/onboarding.tsx | 244 ++---------------- .../__tests__/checklist-run-sheet.test.tsx | 98 +++++-- .../checklists/checklist-run-sheet.tsx | 8 +- .../common/__tests__/date-time-field.test.tsx | 74 ++++++ src/components/common/date-time-field.tsx | 201 +++++++++++++++ .../__tests__/onboarding-screen.test.tsx | 80 ++++++ .../onboarding/onboarding-screen.tsx | 146 +++++++++++ .../__tests__/time-report-picker.test.tsx | 56 ++++ .../operations/time-report-editor.tsx | 48 ++-- .../__tests__/record-date-picker.test.tsx | 22 ++ 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 | 14 +- src/translations/de.json | 14 +- src/translations/el.json | 14 +- src/translations/en.json | 14 +- src/translations/es.json | 14 +- src/translations/fr.json | 14 +- src/translations/it.json | 14 +- src/translations/pl.json | 14 +- src/translations/sv.json | 14 +- src/translations/uk.json | 14 +- 31 files changed, 952 insertions(+), 385 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)/__tests__/certification-edit.test.tsx b/src/app/(app)/__tests__/certification-edit.test.tsx index 9117550..cedce81 100644 --- a/src/app/(app)/__tests__/certification-edit.test.tsx +++ b/src/app/(app)/__tests__/certification-edit.test.tsx @@ -123,3 +123,19 @@ describe('EditCertificationScreen', () => { expect(getCertification).not.toHaveBeenCalled(); }); }); + +it('saves issued and expiry dates selected from pickers', async () => { + mockParams.id = '42'; + useCertificationsStore.setState({ items: [record()] }); + jest.mocked(saveCertification).mockResolvedValue(record()); + const screen = render(); + fireEvent.press(screen.getByTestId('certification-received')); + fireEvent.press(screen.getByTestId('certification-received-day-15')); + fireEvent.press(screen.getByTestId('certification-received-done')); + fireEvent.press(screen.getByTestId('certification-expires')); + fireEvent.press(screen.getByTestId('certification-expires-day-20')); + fireEvent.press(screen.getByTestId('certification-expires-done')); + fireEvent.press(screen.getByTestId('certification-save')); + await waitFor(() => expect(saveCertification).toHaveBeenCalledWith(expect.objectContaining({ ReceivedOn: '2025-01-15', ExpiresOn: '2027-01-20' }))); + screen.unmount(); +}); diff --git a/src/app/(app)/certifications/[id].tsx b/src/app/(app)/certifications/[id].tsx index f9a257f..7ae9829 100644 --- a/src/app/(app)/certifications/[id].tsx +++ b/src/app/(app)/certifications/[id].tsx @@ -4,6 +4,7 @@ import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, ScrollView } from 'react-native'; +import { DateTimeField } from '@/components/common/date-time-field'; import { Button, ButtonText } from '@/components/ui/button'; import { HStack } from '@/components/ui/hstack'; import { Input, InputField } from '@/components/ui/input'; @@ -145,9 +146,7 @@ export default function CertificationScreen() { {t('certifications.renew')} - - - + @@ -171,9 +170,9 @@ export default function CertificationScreen() { - - - + + + diff --git a/src/app/(app)/certifications/edit.tsx b/src/app/(app)/certifications/edit.tsx index 6068266..9445f40 100644 --- a/src/app/(app)/certifications/edit.tsx +++ b/src/app/(app)/certifications/edit.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; import { getCertification } from '@/api/certifications/certifications'; +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'; @@ -181,12 +182,8 @@ const CertificationForm: React.FC = ({ existing }) => { - - - - - - + + ))} - {[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 0000000..4cec15c --- /dev/null +++ b/src/components/common/__tests__/date-time-field.test.tsx @@ -0,0 +1,74 @@ +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 0000000..169b271 --- /dev/null +++ b/src/components/common/date-time-field.tsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +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 0000000..e84038e --- /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 0000000..ec3ea53 --- /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 0000000..d4936a6 --- /dev/null +++ b/src/components/operations/__tests__/time-report-picker.test.tsx @@ -0,0 +1,56 @@ +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 bed50fc..a20c0cb 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 0000000..42a6775 --- /dev/null +++ b/src/components/records/__tests__/record-date-picker.test.tsx @@ -0,0 +1,22 @@ +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 9a4436a..a2de482 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(