diff --git a/SparkyFitnessMobile/AGENTS.md b/SparkyFitnessMobile/AGENTS.md index d9bf082328..8bb09700ea 100644 --- a/SparkyFitnessMobile/AGENTS.md +++ b/SparkyFitnessMobile/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -*Last updated: 2026-08-24* +*Last updated: 2026-08-26* SparkyFitness Mobile is a React Native 0.85 + Expo SDK 56 app for syncing Apple Health / Health Connect data with the SparkyFitness backend, tracking nutrition, hydration, fasting, measurements, exercise, saved foods, meal templates, custom exercises, workout presets, iOS / Android widgets, the active workout HUD, and the Sparky AI chat. @@ -78,7 +78,7 @@ npx expo prebuild --clean - Screens intentionally off the hook (e.g. `FoodSearchScreen`'s bespoke anchored-menu bar) must mirror custom actions with `unstable_header{Left,Right}Items` themselves, hide the screen-owned React header behind `useNativeIOSHeadersActive()` with a guard such as `{!usesNativeHeader &&
}`, and gate the `useLayoutEffect` that sets native header items on the same flag; otherwise iOS renders both headers. - When adding a tab, update `TabParamList`, `NativeTab.Screen`, and `FallbackTab.Screen`; for content tabs also add a tab-local native stack screen using `createIOSNativeHeaderOptions(...)`. - `__tests__/navigation/nativeHeaderContract.test.ts` enforces this native-header wiring. If it fails, fix the route/type/navigator alignment instead of weakening the test. -- Current stack screens include onboarding/tabs, library/detail/form flows for foods/meals/exercises/presets, food entry view/edit, meal type detail and copy, `EditBarcode`, food search/entry/scan/photo flow, workout/activity add/detail, exercise/preset search, settings subscreens, logs, sync, measurements, fasting, and `WhatsNew`. +- Current stack screens include onboarding/tabs, library/detail/form flows for foods/meals/exercises/presets, food entry view/edit, meal type detail and copy, the family diary flows (`FamilyMembers`, `FamilyDiary`, `FamilyMealDetail`, and `FamilyCopyReview`), `EditBarcode`, food search/entry/scan/photo flow, workout/activity add/detail, exercise/preset search, settings subscreens, logs, sync, measurements, fasting, and `WhatsNew`. - `AddSheet` offers Food, Workout, Activity, Preset, Measurements, Scan Food, Ask Sparky, and Sync Health Data. Keep its present/dismiss refs intact to avoid Android re-present loops. - `useNavigationActionGuard` locks navigation-triggering actions while a native-stack transition is running (idle-callback unlock on re-focus, 5s safety release) so double-taps cannot queue duplicate screens; Library create actions use it. - `ActiveWorkoutBar` is mounted outside normal screen trees, uses the root navigation ref, and hides itself on modal/editor routes such as food search/forms/scan/photo, exercise search, workout/activity add, measurements, and barcode edit. @@ -88,7 +88,7 @@ npx expo prebuild --clean - `src/components/` - reusable UI, charts, settings rows, custom tab bar, add sheet, workout HUD, form chrome, library rows, diary rows, serving sheets, food/workout editors, fasting UI, writeback UI, and `ui/` primitives. - `src/components/auth/` - MFA UI shared by onboarding, setup, and reauth. -- `src/screens/` - top-level route destinations: dashboard, diary, settings, sync, logs, Whats New, fasting, food search/scan/photo, library CRUD flows, workout/activity flows, and measurement entry. +- `src/screens/` - top-level route destinations: dashboard, diary, family member/diary/meal/copy-review flows, settings, sync, logs, Whats New, fasting, food search/scan/photo, library CRUD flows, workout/activity flows, and measurement entry. - `src/navigation/` - navigation-level modules such as `safeScreens.tsx`, the error-boundary-wrapped screen components registered in `App.tsx`. (`FoodPhotoFlow` lives in `src/components/`.) - `src/hooks/` - TanStack Query hooks, auth/connection hooks, library/search/mutation hooks, measurement/water/check-in hooks, fasting hooks, workout form hooks, widget sync, query client, query keys, and cache helpers. - `src/services/api/` - backend clients. `apiClient.ts` handles normal API auth/proxy headers; `healthDataApi.ts`, `aiSettingsApi.ts`, food-photo estimate, and other raw fetch paths must keep auth, proxy, timeout, and session-expiry behavior aligned. @@ -312,6 +312,7 @@ const androidService = require('../../src/services/healthConnectService.ts'); - Health writeback bug: inspect `HealthDataWriteback`, `services/writeback.ts` / `.ios.ts`, platform writeback modules, mapper files, tracking storage, app permissions, and inbound source filters. - Food library/edit bug: inspect `LibraryScreen`, food library/detail/form/barcode screens, `FoodForm`, unit selector, food hooks, `foodsApi`, food unit types, and `foodDetails.ts`. - Meal bug: inspect meals library/detail/add/edit screens, `MealTypeDetailScreen`, food picker routes, meal hooks/API, selection service, logged-meal API, and meal nutrition utils. +- Family diary bug: inspect `FamilyMembersScreen`, `FamilyDiaryScreen`, `FamilyMealDetailScreen`, `FamilyCopyReviewScreen`, family hooks/API, navigation params, and the server family/food-entry routes and models. - Exercise/preset bug: inspect library/detail/form/search screens, related hooks/API, selected-exercise handoff, rest-period controls, and workout session helpers. - Workout/activity/HUD bug: inspect `AddSheet`, workout/activity screens, workout form hooks, `workoutDraftService`, `activeWorkoutStore`, `ActiveWorkoutBar`, rest notifications, and detail screen set interactions. - Fasting bug: inspect `FastingDetailScreen`, `FastingCard`, `FastingGoalReconciler`, `useFasting`, `useFastingTimer`, `fastingApi`, `notifications`, and card visibility preferences. diff --git a/SparkyFitnessMobile/App.tsx b/SparkyFitnessMobile/App.tsx index 800658ee7d..fdc8ea6f9b 100644 --- a/SparkyFitnessMobile/App.tsx +++ b/SparkyFitnessMobile/App.tsx @@ -78,6 +78,10 @@ import { SafeWhatsNew, SafeDailyNutritionDetails, SafeNutrientTrends, + SafeFamilyMembers, + SafeFamilyDiary, + SafeFamilyMealDetail, + SafeFamilyCopyReview, SafeCycleSettings, SafeCycleOnboarding, SafeCycleHub, @@ -336,6 +340,35 @@ function AppContent() { /> )} + + createStackScreenOptions( + route.params.familyUser.displayName.trim() || t('familyDiary.unnamedMember', { defaultValue: 'Family member' }), + { headerBackButtonDisplayMode: 'minimal' }, + )} + /> + createStackScreenOptions(route.params.mealTypeName, { + headerBackButtonDisplayMode: 'minimal', + })} + /> + { + beforeAll(async () => { + await initializeI18n('en'); + }); + + afterAll(async () => { + await i18n.changeLanguage('en'); + }); + + test('renders an accessible 44 by 44 header action', () => { + const onPress = jest.fn(); + const { getByRole } = render( + + + , + ); + + const action = getByRole('button', { name: 'Open family diaries' }); + expect(action.props.style).toEqual( + expect.objectContaining({ width: 44, height: 44 }), + ); + fireEvent.press(action); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + test('exposes every date control as a named 44 point button', () => { + const onPreviousDay = jest.fn(); + const onNextDay = jest.fn(); + const onDatePress = jest.fn(); + const { getByRole } = render( + + + , + ); + + const previous = getByRole('button', { name: 'Previous day' }); + const picker = getByRole('button', { name: 'Choose date' }); + const next = getByRole('button', { name: 'Next day' }); + + for (const control of [previous, picker, next]) { + expect(control.props.style).toEqual( + expect.objectContaining({ minHeight: 44, minWidth: 44 }), + ); + } + expect(previous.props.accessibilityHint).toBe('Shows the previous day'); + expect(picker.props.accessibilityHint).toBe('Opens the date picker'); + expect(next.props.accessibilityHint).toBe('Shows the next day'); + + fireEvent.press(previous); + fireEvent.press(picker); + fireEvent.press(next); + expect(onPreviousDay).toHaveBeenCalledTimes(1); + expect(onDatePress).toHaveBeenCalledTimes(1); + expect(onNextDay).toHaveBeenCalledTimes(1); + }); + + test('renders the global relative date and translated accessible controls', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2025, 0, 15, 12)); + const screen = render( + + + , + ); + + expect(screen.getByText('Today')).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Poprzedni dzień' }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Następny dzień' })).toBeTruthy(); + jest.useRealTimers(); + }); + + test('localizes default accessible controls for existing callers', async () => { + await i18n.changeLanguage('pl'); + + const screen = render( + + + , + ); + + expect( + screen.getByRole('button', { name: 'Poprzedni dzień' }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Wybierz datę' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Następny dzień' })).toBeTruthy(); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts b/SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts new file mode 100644 index 0000000000..351bd869af --- /dev/null +++ b/SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts @@ -0,0 +1,214 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import Toast from 'react-native-toast-message'; +import { + copyReviewedFoodEntriesFromUser, + copySelectedFoodEntriesFromUser, +} from '../../src/services/api/foodEntriesApi'; +import { + useCopyFamilyFoodEntries, + type FamilyCopyRequest, +} from '../../src/hooks/useCopyFamilyFoodEntries'; +import { + createQueryWrapper, + createTestQueryClient, + type QueryClient, +} from './queryTestUtils'; +import { ApiError } from '../../src/services/api/errors'; + +jest.mock('../../src/services/api/foodEntriesApi', () => ({ + copyReviewedFoodEntriesFromUser: jest.fn(), + copySelectedFoodEntriesFromUser: jest.fn(), +})); + +jest.mock('react-native-toast-message', () => ({ show: jest.fn() })); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (_key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? _key, + }), +})); + +const wholeRequest: FamilyCopyRequest = { + kind: 'whole', + payload: { + familyUserId: 'family-user', + sourceDate: '2026-08-23', + sourceMealType: 'breakfast', + targetDate: '2026-08-24', + targetMealType: 'breakfast', + entries: [{ entryId: 'entry-1', sourceFingerprint: 'snapshot' }], + }, +}; + +const selectedRequest: FamilyCopyRequest = { + kind: 'selected', + payload: { + familyUserId: 'family-user', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: 'lunch', + entries: [ + { entryId: 'entry-1', quantity: 1.5, sourceFingerprint: 'snapshot' }, + ], + }, +}; + +describe('useCopyFamilyFoodEntries', () => { + let queryClient: QueryClient; + + beforeEach(() => { + jest.clearAllMocks(); + queryClient = createTestQueryClient(); + }); + + afterEach(() => queryClient.clear()); + + test.each([ + ['whole', wholeRequest, copyReviewedFoodEntriesFromUser], + ['selected', selectedRequest, copySelectedFoodEntriesFromUser], + ] as const)( + 'routes %s requests to the correct operation without reshaping payload', + async (_kind, request, expectedFn) => { + (expectedFn as jest.Mock).mockResolvedValue(undefined); + const { result } = renderHook(() => useCopyFamilyFoodEntries(), { + wrapper: createQueryWrapper(queryClient), + }); + + await act(async () => { + await result.current.copyFromFamilyAsync(request); + }); + + expect(expectedFn).toHaveBeenCalledWith(request.payload); + }, + ); + + test('invalidates only the signed-in target day after success', async () => { + (copySelectedFoodEntriesFromUser as jest.Mock).mockResolvedValue(undefined); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook(() => useCopyFamilyFoodEntries(), { + wrapper: createQueryWrapper(queryClient), + }); + + await act(async () => { + await result.current.copyFromFamilyAsync(selectedRequest); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['dailySummary', '2026-08-24'], + }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: expect.arrayContaining(['familyDailySummary']), + }); + }); + + test('calls onSuccess with the discriminated request', async () => { + (copyReviewedFoodEntriesFromUser as jest.Mock).mockResolvedValue(undefined); + const onSuccess = jest.fn(); + const { result } = renderHook( + () => useCopyFamilyFoodEntries({ onSuccess }), + { + wrapper: createQueryWrapper(queryClient), + }, + ); + + await act(async () => { + await result.current.copyFromFamilyAsync(wholeRequest); + }); + + expect(onSuccess).toHaveBeenCalledWith(wholeRequest); + }); + + test('keeps review state usable and shows a stable error when the copy fails', async () => { + (copySelectedFoodEntriesFromUser as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const onSuccess = jest.fn(); + const { result } = renderHook( + () => useCopyFamilyFoodEntries({ onSuccess }), + { + wrapper: createQueryWrapper(queryClient), + }, + ); + + await expect( + act(async () => { + await result.current.copyFromFamilyAsync(selectedRequest); + }), + ).rejects.toThrow('boom'); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(Toast.show).toHaveBeenCalledWith({ + type: 'error', + text1: 'Could not copy foods', + text2: 'Your review is still here. Please try again.', + }); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + test('refreshes family capabilities and explains permission revocation after a 403', async () => { + (copySelectedFoodEntriesFromUser as jest.Mock).mockRejectedValue( + new ApiError('Forbidden', 403), + ); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const refetchSpy = jest.spyOn(queryClient, 'refetchQueries'); + const { result } = renderHook(() => useCopyFamilyFoodEntries(), { + wrapper: createQueryWrapper(queryClient), + }); + + await expect( + act(async () => { + await result.current.copyFromFamilyAsync(selectedRequest); + }), + ).rejects.toThrow('Forbidden'); + + await waitFor(() => + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['familyDiaryUsers'], + }), + ); + expect(refetchSpy).toHaveBeenCalledWith({ queryKey: ['familyDiaryUsers'] }); + expect(Toast.show).toHaveBeenCalledWith({ + type: 'error', + text1: 'Copy permission was removed', + text2: 'Refresh family diaries to see your current access.', + }); + }); + + test('refreshes and reopens the source diary after a stale 409 source', async () => { + (copySelectedFoodEntriesFromUser as jest.Mock).mockRejectedValue( + new ApiError('Conflict', 409), + ); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const refetchSpy = jest.spyOn(queryClient, 'refetchQueries'); + const onStale = jest.fn(); + const { result } = renderHook(() => useCopyFamilyFoodEntries({ onStale }), { + wrapper: createQueryWrapper(queryClient), + }); + + await expect( + act(async () => { + await result.current.copyFromFamilyAsync(selectedRequest); + }), + ).rejects.toThrow('Conflict'); + + await waitFor(() => + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['familyDailySummary', 'family-user', '2026-08-23'], + }), + ); + expect(refetchSpy).toHaveBeenCalledWith({ + queryKey: ['familyDailySummary', 'family-user', '2026-08-23'], + }); + expect(onStale).toHaveBeenCalledWith(selectedRequest); + await waitFor(() => + expect(Toast.show).toHaveBeenCalledWith({ + type: 'error', + text1: 'Family diary changed', + text2: 'The latest family diary is opening for review.', + }), + ); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts b/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts new file mode 100644 index 0000000000..7313f09e16 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts @@ -0,0 +1,116 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { + useFamilyDailySummary, + useFamilyUsers, +} from '../../src/hooks/useFamilyDiary'; +import { fetchFamilyDiaryUsers } from '../../src/services/api/familyApi'; +import { fetchDailySummary } from '../../src/services/api/dailySummaryApi'; +import { resolveCollapsedFoodEntries } from '../../src/utils/loggedMealCollapse'; +import { + createQueryWrapper, + createTestQueryClient, + type QueryClient, +} from './queryTestUtils'; + +jest.mock('../../src/services/api/familyApi', () => ({ + fetchFamilyDiaryUsers: jest.fn(), +})); + +jest.mock('../../src/services/api/dailySummaryApi', () => ({ + fetchDailySummary: jest.fn(), +})); + +jest.mock('../../src/utils/loggedMealCollapse', () => ({ + resolveCollapsedFoodEntries: jest.fn(), +})); + +const mockFetchFamilyDiaryUsers = fetchFamilyDiaryUsers as jest.MockedFunction< + typeof fetchFamilyDiaryUsers +>; +const mockFetchDailySummary = fetchDailySummary as jest.MockedFunction< + typeof fetchDailySummary +>; + +describe('useFamilyDiary', () => { + let queryClient: QueryClient; + + beforeEach(() => { + jest.clearAllMocks(); + queryClient = createTestQueryClient(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + test('uses an isolated key for accessible family users', async () => { + mockFetchFamilyDiaryUsers.mockResolvedValue([ + { + userId: 'member-b', + displayName: 'Member B', + email: 'b@example.test', + canCopy: true, + accessEndDate: null, + }, + ]); + + const { result } = renderHook(() => useFamilyUsers(), { + wrapper: createQueryWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.data).toHaveLength(1)); + expect(queryClient.getQueryData(['familyDiaryUsers'])).toEqual( + result.current.data, + ); + }); + + test('does not fetch accessible family users while the server is disconnected', async () => { + renderHook(() => useFamilyUsers({ enabled: false }), { + wrapper: createQueryWrapper(queryClient), + }); + + await waitFor(() => expect(mockFetchFamilyDiaryUsers).not.toHaveBeenCalled()); + }); + + test('isolates family summaries by family user and date without collapsing entries', async () => { + mockFetchDailySummary.mockResolvedValue({ + goals: {}, + foodEntries: [{ id: 'component-1' }, { id: 'component-2' }], + exerciseSessions: [], + waterIntake: 0, + } as Awaited>); + + const { result } = renderHook( + () => + useFamilyDailySummary({ familyUserId: 'member-b', date: '2026-08-23' }), + { wrapper: createQueryWrapper(queryClient) }, + ); + + await waitFor(() => + expect(result.current.data?.foodEntries).toHaveLength(2), + ); + expect( + queryClient.getQueryData([ + 'familyDailySummary', + 'member-b', + '2026-08-23', + ]), + ).toBeDefined(); + expect(mockFetchDailySummary).toHaveBeenCalledWith( + '2026-08-23', + 'member-b', + ); + expect(resolveCollapsedFoodEntries).not.toHaveBeenCalled(); + }); + + test('does not fetch a family summary when no family user is selected', async () => { + renderHook( + () => useFamilyDailySummary({ familyUserId: '', date: '2026-08-23' }), + { + wrapper: createQueryWrapper(queryClient), + }, + ); + + await waitFor(() => expect(mockFetchDailySummary).not.toHaveBeenCalled()); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts b/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts index c8532af601..1b5c458fb2 100644 --- a/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts +++ b/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts @@ -339,6 +339,62 @@ describe('native header navigation contract', () => { } }); + it('registers the family diary flow through safe root-stack screens', () => { + const familyRoutes = [ + { + routeName: 'FamilyMembers', + component: 'SafeFamilyMembers', + optionSnippet: "t('familyDiary.title', { defaultValue: 'Family Diaries' })", + backOption: "headerBackButtonDisplayMode: 'minimal'", + }, + { + routeName: 'FamilyDiary', + component: 'SafeFamilyDiary', + optionSnippet: 'route.params.familyUser.displayName.trim()', + backOption: "headerBackButtonDisplayMode: 'minimal'", + }, + { + routeName: 'FamilyMealDetail', + component: 'SafeFamilyMealDetail', + optionSnippet: 'route.params.mealTypeName', + backOption: "headerBackButtonDisplayMode: 'minimal'", + }, + { + routeName: 'FamilyCopyReview', + component: 'SafeFamilyCopyReview', + optionSnippet: "t('familyDiary.copyReview', { defaultValue: 'Review copy' })", + backOption: "headerBackButtonDisplayMode: 'minimal'", + }, + ] as const; + const rootStackScreenNames = extractScreenNames(appSource, 'Stack'); + const stackComponentsByRoute = extractStackComponentsByRoute(appSource); + + for (const { routeName, component, optionSnippet, backOption } of familyRoutes) { + expect(rootStackScreenNames.filter((name) => name === routeName)).toHaveLength(1); + expect(stackComponentsByRoute.get(routeName)).toBe(component); + + const screenBlock = getStackScreenBlock(appSource, routeName); + expect(screenBlock).toBeDefined(); + expect(screenBlock).toContain(`component={${component}}`); + expect(screenBlock).toContain(optionSnippet); + expect(screenBlock).toContain(backOption); + expect(screenBlock).not.toMatch(/\bpresentation\s*:/); + } + + expect(safeScreensSource).toContain( + "withErrorBoundary(FamilyMembersScreen, 'FamilyMembers', { canGoBack: true })", + ); + expect(safeScreensSource).toContain( + "withErrorBoundary(FamilyDiaryScreen, 'FamilyDiary', { canGoBack: true })", + ); + expect(safeScreensSource).toContain( + "withErrorBoundary(FamilyMealDetailScreen, 'FamilyMealDetail', { canGoBack: true })", + ); + expect(safeScreensSource).toContain( + "withErrorBoundary(FamilyCopyReviewScreen, 'FamilyCopyReview', { canGoBack: true })", + ); + }); + it('requires every root-stack screen to have native-tabs coverage or an explicit exclusion reason', () => { const rootStackRoutes = extractTypeKeys(navigationSource, 'RootStackParamList'); const appScreens = extractScreenNames(appSource, 'Stack'); diff --git a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx index 42f230db2b..fcbb4760db 100644 --- a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx @@ -3,9 +3,11 @@ import { act, fireEvent, render } from '@testing-library/react-native'; import { RefreshControl } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import DiaryScreen from '../../src/screens/DiaryScreen'; +import type DateNavigatorComponent from '../../src/components/DateNavigator'; import { useDailySummary, useCustomNutrients, + useFamilyUsers, useNutrientDisplayPreferences, useServerConnection, } from '../../src/hooks'; @@ -13,6 +15,11 @@ import { useMeasurements } from '../../src/hooks/useMeasurements'; import { useCustomMeasurementsByDate } from '../../src/hooks/useCustomMeasurements'; import { useDiaryDateStore } from '../../src/stores/diaryDateStore'; import { getTodayDate } from '../../src/utils/dateUtils'; +import { useNativeIOSTabsActive } from '../../src/services/nativeTabBarPreference'; +import { setNativeHeaderDatePickerOptions } from '../../src/utils/nativeHeaderDatePicker'; + +type DiaryScreenProps = React.ComponentProps; +type DateNavigatorProps = React.ComponentProps; const mockNavigation = { setOptions: jest.fn(), @@ -21,7 +28,13 @@ const mockNavigation = { setParams: jest.fn(), addListener: jest.fn(() => jest.fn()), isFocused: jest.fn(() => true), -} as any; +} as unknown as DiaryScreenProps['navigation']; + +const diaryRoute = { + key: 'Diary-1', + name: 'Diary', + params: undefined, +} as unknown as DiaryScreenProps['route']; jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); @@ -38,6 +51,7 @@ jest.mock('../../src/hooks', () => ({ useServerConnection: jest.fn(), useDailySummary: jest.fn(), useCustomNutrients: jest.fn(), + useFamilyUsers: jest.fn(), useNutrientDisplayPreferences: jest.fn(), useMealTypes: jest.fn(() => ({ mealTypes: [], isLoading: false, isError: false })), })); @@ -69,6 +83,13 @@ jest.mock('../../src/hooks/useHeaderActionColors', () => ({ useHeaderActionColors: jest.fn(() => ({ defaultColor: '#000000' })), })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key === 'familyDiary.openFamilyDiaries' ? 'Open family diaries' : key, + i18n: { language: 'en-US' }, + }), +})); + jest.mock('../../src/services/nativeTabBarPreference', () => ({ useNativeIOSTabsActive: jest.fn(() => false), useNativeIOSHeadersActive: jest.fn(() => false), @@ -101,12 +122,17 @@ jest.mock('../../src/components/ServingAdjustSheet', () => { }); jest.mock('../../src/components/DateNavigator', () => { - const { Text, View } = require('react-native'); + const { Pressable, Text, View } = require('react-native'); return { __esModule: true, - default: ({ title }: any) => ( + default: ({ title, action }: DateNavigatorProps) => ( {title} + {action ? ( + + {action.accessibilityLabel} + + ) : null} ), }; @@ -167,13 +193,14 @@ jest.mock('../../src/components/Icon', () => { const { View } = require('react-native'); return { __esModule: true, - default: ({ name }: any) => , + default: ({ name }: { name: string }) => , }; }); const mockUseServerConnection = useServerConnection as jest.MockedFunction; const mockUseDailySummary = useDailySummary as jest.MockedFunction; const mockUseCustomNutrients = useCustomNutrients as jest.MockedFunction; +const mockUseFamilyUsers = useFamilyUsers as jest.MockedFunction; const mockUseNutrientDisplayPreferences = useNutrientDisplayPreferences as jest.MockedFunction< typeof useNutrientDisplayPreferences >; @@ -181,6 +208,10 @@ const mockUseMeasurements = useMeasurements as jest.MockedFunction; +const mockUseNativeIOSTabsActive = useNativeIOSTabsActive as jest.MockedFunction; +const mockSetNativeHeaderDatePickerOptions = setNativeHeaderDatePickerOptions as jest.MockedFunction< + typeof setNativeHeaderDatePickerOptions +>; const baseSummary = { foodEntries: [], @@ -200,7 +231,19 @@ const configureConnection = (isConnected: boolean, isLoading = false) => { isLoading, isError: false, refetch: jest.fn(), - } as any); + } as ReturnType); +}; + +const configureFamilyUsers = (users: { + userId: string; + displayName: string; + email: string | null; + canCopy: boolean; + accessEndDate: string | null; +}[]) => { + mockUseFamilyUsers.mockReturnValue({ + data: users, + } as ReturnType); }; const configureOnlineData = (overrides: { @@ -219,25 +262,25 @@ const configureOnlineData = (overrides: { isLoading: false, isError: false, refetch: refetchSummary, - } as any); + } as ReturnType); mockUseMeasurements.mockReturnValue({ measurements: null, isLoading: false, isError: false, refetch: refetchMeasurements, - } as any); + } as ReturnType); mockUseCustomMeasurementsByDate.mockReturnValue({ data: [], refetch: refetchCustomMeasurements, - } as any); + } as ReturnType); mockUseCustomNutrients.mockReturnValue({ customNutrients: [], refetch: refetchCustomNutrients, - } as any); + } as ReturnType); mockUseNutrientDisplayPreferences.mockReturnValue({ preferences: [], refetch: refetchNutrientPrefs, - } as any); + } as ReturnType); }; const insets = { top: 0, bottom: 0, left: 0, right: 0 }; @@ -246,7 +289,7 @@ const frame = { x: 0, y: 0, width: 390, height: 844 }; const renderScreen = () => render( - + , ); @@ -258,6 +301,16 @@ describe('DiaryScreen custom queries', () => { lastKnownToday: getTodayDate(), }); configureConnection(true); + mockUseNativeIOSTabsActive.mockReturnValue(false); + configureFamilyUsers([ + { + userId: 'member-b', + displayName: 'Member B', + email: 'b@example.test', + canCopy: true, + accessEndDate: null, + }, + ]); configureOnlineData(); }); @@ -376,15 +429,15 @@ describe('DiaryScreen custom queries', () => { isLoading: false, isError: false, refetch: refetchReject, - } as any); + } as ReturnType); const { UNSAFE_getByType, UNSAFE_queryByType } = renderScreen(); const refreshControl = UNSAFE_queryByType(RefreshControl); const onRefresh = refreshControl?.props.onRefresh as () => Promise; - await expect(async () => { - await onRefresh(); - }).not.toThrow(); + await act(async () => { + await expect(onRefresh()).resolves.toBeUndefined(); + }); // Every other query still ran. expect(refetchSummary).toHaveBeenCalled(); @@ -395,4 +448,61 @@ describe('DiaryScreen custom queries', () => { expect(UNSAFE_getByType(RefreshControl).props.refreshing).toBe(false); }); + test('opens family diaries from the custom date header', () => { + const { getByLabelText } = renderScreen(); + + fireEvent.press(getByLabelText('Open family diaries')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('FamilyMembers'); + }); + + test('opens family diaries from the native leading header action', () => { + mockUseNativeIOSTabsActive.mockReturnValue(true); + + renderScreen(); + + const options = mockSetNativeHeaderDatePickerOptions.mock.calls[ + mockSetNativeHeaderDatePickerOptions.mock.calls.length - 1 + ]?.[1]; + expect(options?.leadingAction).toEqual(expect.objectContaining({ + sfSymbol: 'person.2.fill', + accessibilityLabel: 'Open family diaries', + })); + options?.leadingAction?.onPress(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith('FamilyMembers'); + }); + + test('hides the native family diaries action while disconnected', () => { + configureConnection(false); + mockUseNativeIOSTabsActive.mockReturnValue(true); + + renderScreen(); + + const options = mockSetNativeHeaderDatePickerOptions.mock.calls[ + mockSetNativeHeaderDatePickerOptions.mock.calls.length - 1 + ]?.[1]; + expect(options?.leadingAction).toBeUndefined(); + }); + + test('hides the custom family diaries action when no diary is shared', () => { + configureFamilyUsers([]); + + const { queryByLabelText } = renderScreen(); + + expect(queryByLabelText('Open family diaries')).toBeNull(); + }); + + test('hides the native family diaries action when no diary is shared', () => { + configureFamilyUsers([]); + mockUseNativeIOSTabsActive.mockReturnValue(true); + + renderScreen(); + + const options = mockSetNativeHeaderDatePickerOptions.mock.calls[ + mockSetNativeHeaderDatePickerOptions.mock.calls.length - 1 + ]?.[1]; + expect(options?.leadingAction).toBeUndefined(); + }); + }); diff --git a/SparkyFitnessMobile/__tests__/screens/DiaryScreenSupplementOnlyDay.test.tsx b/SparkyFitnessMobile/__tests__/screens/DiaryScreenSupplementOnlyDay.test.tsx index 354e590fc4..e82573bdf1 100644 --- a/SparkyFitnessMobile/__tests__/screens/DiaryScreenSupplementOnlyDay.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/DiaryScreenSupplementOnlyDay.test.tsx @@ -11,6 +11,7 @@ jest.mock('../../src/hooks', () => ({ useCustomNutrients: () => ({ customNutrients: [] }), useNutrientDisplayPreferences: () => ({ preferences: [] }), useMealTypes: () => ({ mealTypes: [], isLoading: false, isError: false }), + useFamilyUsers: () => ({ data: [] }), })); jest.mock('../../src/hooks/useMeasurements', () => ({ diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx new file mode 100644 index 0000000000..f84da0d5d9 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx @@ -0,0 +1,458 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import FamilyCopyReviewScreen from '../../src/screens/FamilyCopyReviewScreen'; +import { useCopyFamilyFoodEntries } from '../../src/hooks/useCopyFamilyFoodEntries'; +import { useMealTypes } from '../../src/hooks/useMealTypes'; +import { useDiaryDateStore } from '../../src/stores/diaryDateStore'; +import type { FoodEntry } from '../../src/types/foodEntries'; +import type { FamilyDiaryUser } from '../../src/types/familyDiary'; +import { foodEntryCopyFingerprint } from '@workspace/shared'; + +const familyUser: FamilyDiaryUser = { + userId: 'member-b', + displayName: 'Alex Family', + email: 'alex@example.test', + canCopy: true, + accessEndDate: null, +}; + +const pasta: FoodEntry = { + id: 'pasta-id', + food_id: 'pasta-food-id', + meal_type: 'Dinner', + meal_type_id: 'dinner-id', + quantity: 150, + unit: 'g', + serving_size: 100, + entry_date: '2026-08-23', + food_name: 'Family Pasta', + calories: 180, + protein: 6, + carbs: 32, + fat: 3, +}; + +const sauce: FoodEntry = { + ...pasta, + id: 'sauce-id', + food_id: 'sauce-food-id', + food_name: 'Tomato Sauce', + quantity: 50, + serving_size: 50, + calories: 30, + protein: 2, + carbs: 6, + fat: 1, +}; + +const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), + popTo: jest.fn(), + setOptions: jest.fn(), +}; +const copyFromFamilyAsync = jest.fn(); +let onCopySuccess: ((request: unknown) => void) | undefined; +let onCopyStale: ((request: unknown) => void) | undefined; + +jest.mock('../../src/hooks/useScreenHeader', () => ({ + useScreenHeader: () => null, +})); + +jest.mock('../../src/services/nativeTabBarPreference', () => ({ + useNativeIOSHeadersActive: () => false, +})); + +jest.mock('../../src/components/ActiveWorkoutBar', () => ({ + useActiveWorkoutBarPadding: () => 0, +})); + +jest.mock('../../src/hooks/useMealTypes', () => ({ + useMealTypes: jest.fn(), +})); + +jest.mock('../../src/hooks/useCopyFamilyFoodEntries', () => ({ + useCopyFamilyFoodEntries: jest.fn(), +})); + +jest.mock('../../src/components/CalendarSheet', () => { + const React = require('react'); + const { Pressable } = require('react-native'); + return React.forwardRef( + ({ onSelectDate }: { onSelectDate: (date: string) => void }, ref) => { + React.useImperativeHandle(ref, () => ({ + present: jest.fn(), + dismiss: jest.fn(), + })); + return React.createElement(Pressable, { + accessibilityRole: 'button', + accessibilityLabel: 'Choose August 25', + onPress: () => onSelectDate('2026-08-25'), + }); + }, + ); +}); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { defaultValue?: string; [name: string]: unknown }, + ) => + (options?.defaultValue ?? key).replace( + /\{\{(\w+)\}\}/g, + (match, name: string) => String(options?.[name] ?? match), + ), + }), +})); + +const mockMealTypes = useMealTypes as jest.MockedFunction; +const mockCopyMutation = useCopyFamilyFoodEntries as jest.MockedFunction< + typeof useCopyFamilyFoodEntries +>; + +function renderReview({ + selectedEntryIds = [pasta.id, sauce.id], + mealTypes = [ + { id: 'breakfast-id', name: 'Breakfast', is_visible: true, sort_order: 1 }, + { id: 'dinner-id', name: 'Dinner', is_visible: true, sort_order: 2 }, + ], + defaultMealTypeId = 'breakfast-id', +}: { + selectedEntryIds?: string[]; + mealTypes?: { + id: string; + name: string; + is_visible: boolean; + sort_order: number; + }[]; + defaultMealTypeId?: string | null; +} = {}) { + mockMealTypes.mockReturnValue({ + mealTypes: mealTypes as never, + defaultMealTypeId, + isLoading: false, + isError: false, + }); + + return render( + + + , + ); +} + +describe('FamilyCopyReviewScreen', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-24T10:00:00')); + jest.clearAllMocks(); + onCopySuccess = undefined; + onCopyStale = undefined; + copyFromFamilyAsync.mockResolvedValue(undefined); + useDiaryDateStore.setState({ + selectedDate: '2026-08-23', + lastKnownToday: '2026-08-24', + }); + mockCopyMutation.mockImplementation(options => { + onCopySuccess = options?.onSuccess as typeof onCopySuccess; + onCopyStale = options?.onStale as typeof onCopyStale; + return { + copyFromFamily: jest.fn(), + copyFromFamilyAsync, + isPending: false, + }; + }); + }); + + afterEach(() => jest.useRealTimers()); + + test('defaults selected quantities from the source and the target date to today', () => { + const screen = renderReview(); + + expect(screen.getByDisplayValue('150')).toBeTruthy(); + expect(screen.getByDisplayValue('50')).toBeTruthy(); + expect(screen.getByText('Mon, Aug 24')).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Copy date: Mon, Aug 24' }), + ).toBeTruthy(); + expect( + screen.getByLabelText('Quantity for Family Pasta').props.style, + ).toEqual({ minHeight: 44 }); + expect(screen.getByRole('button', { name: 'Dinner' }).props.style).toEqual({ + minHeight: 44, + minWidth: 44, + }); + }); + + test('blocks zero quantities with an inline message while retaining the typed value', () => { + const screen = renderReview(); + fireEvent.changeText( + screen.getByLabelText('Quantity for Family Pasta'), + '0', + ); + + expect(screen.getByDisplayValue('0')).toBeTruthy(); + expect( + screen.getByText('Enter a quantity greater than zero.'), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Copy to my diary' }).props + .accessibilityState, + ).toEqual({ disabled: true }); + expect( + screen.getByLabelText('Quantity for Family Pasta').props['aria-invalid'], + ).toBe(true); + expect( + screen.getByLabelText('Quantity for Family Pasta').props[ + 'aria-describedby' + ], + ).toBe('family-copy-quantity-error-pasta-id'); + const error = screen.getByText('Enter a quantity greater than zero.'); + expect(error.props.nativeID).toBe('family-copy-quantity-error-pasta-id'); + expect(error.props.accessibilityRole).toBe('alert'); + expect(error.props.accessibilityLiveRegion).toBe('assertive'); + }); + + test('recalculates nutrients once from the source serving basis', () => { + const screen = renderReview({ selectedEntryIds: [pasta.id] }); + fireEvent.changeText( + screen.getByLabelText('Quantity for Family Pasta'), + '200', + ); + + expect(screen.getByText('360 kcal')).toBeTruthy(); + expect(screen.getByText('12 g protein')).toBeTruthy(); + expect(screen.getByText('64 g carbs')).toBeTruthy(); + expect(screen.getByText('6 g fat')).toBeTruthy(); + }); + + test('accepts a comma decimal quantity, scales the preview once, and submits its numeric value', () => { + const screen = renderReview({ selectedEntryIds: [pasta.id] }); + + fireEvent.changeText( + screen.getByLabelText('Quantity for Family Pasta'), + '150,5', + ); + + expect(screen.getByText('270.9 kcal')).toBeTruthy(); + fireEvent.press(screen.getByText('Copy to my diary')); + expect(copyFromFamilyAsync).toHaveBeenCalledWith({ + kind: 'selected', + payload: { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: 'dinner-id', + entries: [ + { + entryId: 'pasta-id', + quantity: 150.5, + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + ], + }, + }); + }); + + test('uses the source canonical meal only when it exists in the signed-in meal types', () => { + const screen = renderReview({ + mealTypes: [ + { + id: 'breakfast-id', + name: 'Breakfast', + is_visible: true, + sort_order: 1, + }, + ], + }); + + expect( + screen.getByRole('button', { name: 'Breakfast' }).props + .accessibilityState, + ).toEqual({ selected: true }); + }); + + test('uses the reviewed whole-meal operation with an exact source snapshot', () => { + const screen = renderReview(); + fireEvent.press(screen.getByText('Copy to my diary')); + + expect(copyFromFamilyAsync).toHaveBeenCalledWith({ + kind: 'whole', + payload: { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + sourceMealType: 'dinner-id', + targetDate: '2026-08-24', + targetMealType: 'dinner-id', + entries: [ + { + entryId: 'pasta-id', + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + { + entryId: 'sauce-id', + sourceFingerprint: foodEntryCopyFingerprint(sauce), + }, + ], + }, + }); + }); + + test('uses selected-copy for a partial selection with the exact payload', () => { + const screen = renderReview({ selectedEntryIds: [pasta.id] }); + fireEvent.press(screen.getByText('Copy to my diary')); + + expect(copyFromFamilyAsync).toHaveBeenCalledWith({ + kind: 'selected', + payload: { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: 'dinner-id', + entries: [ + { + entryId: 'pasta-id', + quantity: 150, + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + ], + }, + }); + }); + + test('uses route selection order for a multi-row adjusted selected-copy payload', () => { + const screen = renderReview({ selectedEntryIds: [sauce.id, pasta.id] }); + fireEvent.changeText( + screen.getByLabelText('Quantity for Family Pasta'), + '200', + ); + fireEvent.press(screen.getByText('Copy to my diary')); + + expect(copyFromFamilyAsync).toHaveBeenCalledWith({ + kind: 'selected', + payload: { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: 'dinner-id', + entries: [ + { + entryId: 'sauce-id', + quantity: 50, + sourceFingerprint: foodEntryCopyFingerprint(sauce), + }, + { + entryId: 'pasta-id', + quantity: 200, + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + ], + }, + }); + }); + + test('disables submission when a route selection is duplicated or missing', () => { + const duplicateSelection = renderReview({ + selectedEntryIds: [pasta.id, pasta.id], + }); + expect( + duplicateSelection.getByRole('button', { name: 'Copy to my diary' }).props + .accessibilityState, + ).toEqual({ disabled: true }); + + const missingSelection = renderReview({ + selectedEntryIds: [pasta.id, 'missing-entry-id'], + }); + expect( + missingSelection.getByRole('button', { name: 'Copy to my diary' }).props + .accessibilityState, + ).toEqual({ disabled: true }); + }); + + test('prevents duplicate submissions and opens the own diary only on success', () => { + const screen = renderReview({ selectedEntryIds: [pasta.id] }); + fireEvent.changeText( + screen.getByLabelText('Quantity for Family Pasta'), + '200', + ); + fireEvent.press(screen.getByText('Copy to my diary')); + fireEvent.press(screen.getByText('Copy to my diary')); + + expect(copyFromFamilyAsync).toHaveBeenCalledTimes(1); + expect(screen.getByDisplayValue('200')).toBeTruthy(); + expect(navigation.navigate).not.toHaveBeenCalled(); + + onCopySuccess?.(copyFromFamilyAsync.mock.calls[0][0]); + expect(useDiaryDateStore.getState().selectedDate).toBe('2026-08-24'); + expect(navigation.navigate).toHaveBeenCalledWith('Tabs', { + screen: 'Diary', + params: { selectedDate: '2026-08-24' }, + }); + }); + + test('retains edited quantities and allows a retry after a copy failure', async () => { + copyFromFamilyAsync + .mockRejectedValueOnce(new Error('copy failed')) + .mockResolvedValue(undefined); + const screen = renderReview({ selectedEntryIds: [pasta.id] }); + fireEvent.changeText( + screen.getByLabelText('Quantity for Family Pasta'), + '200', + ); + + fireEvent.press(screen.getByText('Copy to my diary')); + await act(async () => { + await Promise.resolve(); + }); + expect(copyFromFamilyAsync).toHaveBeenCalledTimes(1); + expect(screen.getByDisplayValue('200')).toBeTruthy(); + + fireEvent.press(screen.getByText('Copy to my diary')); + expect(copyFromFamilyAsync).toHaveBeenCalledTimes(2); + }); + + test('navigates to the submitted target date when the visible date changes while pending', () => { + const screen = renderReview(); + fireEvent.press(screen.getByText('Copy to my diary')); + const submittedRequest = copyFromFamilyAsync.mock.calls[0][0]; + + fireEvent.press(screen.getByRole('button', { name: 'Choose August 25' })); + expect(screen.getByText('Tue, Aug 25')).toBeTruthy(); + + onCopySuccess?.(submittedRequest); + expect(navigation.navigate).toHaveBeenCalledWith('Tabs', { + screen: 'Diary', + params: { selectedDate: '2026-08-24' }, + }); + }); + + test('returns to the source diary when a stale review is rejected', () => { + renderReview(); + + onCopyStale?.({}); + + expect(navigation.popTo).toHaveBeenCalledWith('FamilyDiary', { + familyUser, + }); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx new file mode 100644 index 0000000000..7e059ad952 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { useFamilyDailySummary } from '../../src/hooks'; +import FamilyDiaryScreen from '../../src/screens/FamilyDiaryScreen'; +import { ApiError } from '../../src/services/api/errors'; +import type { FoodEntry } from '../../src/types/foodEntries'; +import type { FamilyDiaryUser } from '../../src/types/familyDiary'; + +const familyUser: FamilyDiaryUser = { + userId: 'family-user-id', + displayName: 'Alex Family', + email: 'alex@example.test', + canCopy: true, + accessEndDate: null, +}; + +const pasta: FoodEntry = { + id: 'pasta-id', + food_id: 'pasta-food-id', + meal_type: 'Dinner', + meal_type_id: 'dinner-id', + quantity: 150, + unit: 'g', + serving_size: 100, + entry_date: '2026-08-23', + food_name: 'Family Pasta', + calories: 180, + protein: 6, + carbs: 32, + fat: 3, +}; + +const sauce: FoodEntry = { + ...pasta, + id: 'sauce-id', + food_id: 'sauce-food-id', + food_name: 'Tomato Sauce', + quantity: 50, + calories: 40, +}; + +const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), + setOptions: jest.fn(), +}; + +const mockInvalidateQueries = jest.fn(); + +jest.mock('../../src/hooks', () => ({ + useFamilyDailySummary: jest.fn(), +})); + +jest.mock('../../src/hooks/useScreenHeader', () => ({ + useScreenHeader: () => null, +})); + +jest.mock('../../src/services/nativeTabBarPreference', () => ({ + useNativeIOSHeadersActive: () => false, +})); + +jest.mock('../../src/components/ActiveWorkoutBar', () => ({ + useActiveWorkoutBarPadding: () => 0, +})); + +jest.mock('../../src/components/DateNavigator', () => { + const { Pressable, Text, View } = require('react-native'); + return ({ + title, + selectedDate, + onPreviousDay, + onNextDay, + onToday, + onDatePress, + dateControls, + }: { + title: string; + selectedDate: string; + onPreviousDay: () => void; + onNextDay: () => void; + onToday: () => void; + onDatePress: () => void; + dateControls: { previousDayLabel: string; nextDayLabel: string }; + }) => ( + + {title} + {selectedDate} + + + + + + ); +}); + +jest.mock('../../src/components/CalendarSheet', () => { + const React = require('react'); + const { Pressable, Text, View } = require('react-native'); + return React.forwardRef( + ( + { + selectedDate, + onSelectDate, + }: { + selectedDate: string; + onSelectDate: (date: string) => void; + }, + ref: unknown, + ) => { + React.useImperativeHandle(ref, () => ({ + present: () => undefined, + dismiss: () => undefined, + })); + return ( + + {`Calendar: ${selectedDate}`} + onSelectDate('2026-08-24')} + /> + + ); + }, + ); +}); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { defaultValue?: string; [name: string]: unknown }, + ) => + (options?.defaultValue ?? key).replace( + /\{\{(\w+)\}\}/g, + (match, name: string) => String(options?.[name] ?? match), + ), + i18n: { language: 'en-US' }, + }), +})); + +const mockUseFamilyDailySummary = useFamilyDailySummary as jest.MockedFunction< + typeof useFamilyDailySummary +>; + +const renderFamilyDiary = () => + render( + + + , + ); + +describe('FamilyDiaryScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 7, 23)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('shows every raw family component and opens its meal', () => { + mockUseFamilyDailySummary.mockReturnValue({ + data: { foodEntries: [pasta, sauce] }, + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + const screen = renderFamilyDiary(); + + expect(screen.getByText('Family Pasta')).toBeTruthy(); + expect(screen.getByText('Tomato Sauce')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('Open Dinner meal')); + + expect(navigation.navigate).toHaveBeenCalledWith('FamilyMealDetail', { + familyUser, + sourceDate: '2026-08-23', + mealTypeId: 'dinner-id', + mealTypeName: 'Dinner', + entries: [pasta, sauce], + }); + expect(mockUseFamilyDailySummary).toHaveBeenLastCalledWith({ + familyUserId: 'family-user-id', + date: '2026-08-23', + }); + }); + + test('updates the source-specific query when browsing days or selecting a calendar date', () => { + mockUseFamilyDailySummary.mockReturnValue({ + data: { foodEntries: [] }, + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + const screen = renderFamilyDiary(); + + fireEvent.press(screen.getByLabelText('Previous day')); + expect(mockUseFamilyDailySummary).toHaveBeenLastCalledWith({ + familyUserId: 'family-user-id', + date: '2026-08-22', + }); + + fireEvent.press(screen.getByLabelText('Next day')); + expect(mockUseFamilyDailySummary).toHaveBeenLastCalledWith({ + familyUserId: 'family-user-id', + date: '2026-08-23', + }); + + fireEvent.press(screen.getByLabelText('Open calendar')); + fireEvent.press(screen.getByLabelText('Choose August 24')); + expect(mockUseFamilyDailySummary).toHaveBeenLastCalledWith({ + familyUserId: 'family-user-id', + date: '2026-08-24', + }); + }); + + test('shows an explicit loading state', () => { + mockUseFamilyDailySummary.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + expect(renderFamilyDiary().getByText('Loading family diary…')).toBeTruthy(); + }); + + test('uses an explicit date-specific empty state', () => { + mockUseFamilyDailySummary.mockReturnValue({ + data: { foodEntries: [] }, + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + expect( + renderFamilyDiary().getByText('No food entries for this date'), + ).toBeTruthy(); + }); + + test('invalidates family users when access has been revoked', () => { + const refetch = jest.fn(); + mockUseFamilyDailySummary.mockReturnValue({ + data: undefined, + error: new ApiError('Forbidden', 403), + isLoading: false, + isError: true, + refetch, + } as ReturnType); + + const screen = renderFamilyDiary(); + + expect(screen.getByText('Family diary access unavailable')).toBeTruthy(); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['familyDiaryUsers'], + }); + fireEvent.press(screen.getByText('Retry')); + fireEvent.press(screen.getByText('Back')); + expect(refetch).toHaveBeenCalledTimes(1); + expect(navigation.goBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyMealDetailScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyMealDetailScreen.test.tsx new file mode 100644 index 0000000000..8776e3c0d8 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyMealDetailScreen.test.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import FamilyMealDetailScreen from '../../src/screens/FamilyMealDetailScreen'; +import type { FoodEntry } from '../../src/types/foodEntries'; +import type { FamilyDiaryUser } from '../../src/types/familyDiary'; + +const familyUser: FamilyDiaryUser = { + userId: 'family-user-id', + displayName: 'Alex Family', + email: 'alex@example.test', + canCopy: true, + accessEndDate: null, +}; + +const pasta: FoodEntry = { + id: 'pasta-id', + food_id: 'pasta-food-id', + meal_type: 'Dinner', + meal_type_id: 'dinner-id', + quantity: 150, + unit: 'g', + serving_size: 100, + entry_date: '2026-08-23', + food_name: 'Family Pasta', + calories: 180, + protein: 6, + carbs: 32, + fat: 3, +}; + +const sauce: FoodEntry = { + ...pasta, + id: 'sauce-id', + food_id: 'sauce-food-id', + food_name: 'Tomato Sauce', + quantity: 50, + serving_size: 50, + calories: 30, + protein: 2, + carbs: 6, + fat: 1, +}; + +const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), + setOptions: jest.fn(), +}; + +jest.mock('../../src/hooks/useScreenHeader', () => ({ + useScreenHeader: () => null, +})); + +jest.mock('../../src/services/nativeTabBarPreference', () => ({ + useNativeIOSHeadersActive: () => false, +})); + +jest.mock('../../src/components/ActiveWorkoutBar', () => ({ + useActiveWorkoutBarPadding: () => 0, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { defaultValue?: string; [name: string]: unknown }, + ) => + (options?.defaultValue ?? key).replace( + /\{\{(\w+)\}\}/g, + (match, name: string) => String(options?.[name] ?? match), + ), + }), +})); + +const renderMealDetail = ({ + canCopy = true, + entries = [pasta, sauce], +}: { + canCopy?: boolean; + entries?: FoodEntry[]; +} = {}) => + render( + + + , + ); + +describe('FamilyMealDetailScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('defaults to the whole meal and can continue with all IDs', () => { + const screen = renderMealDetail(); + + fireEvent.press(screen.getByText('Continue')); + + expect(navigation.navigate).toHaveBeenCalledWith('FamilyCopyReview', { + familyUser, + sourceDate: '2026-08-23', + mealTypeId: 'dinner-id', + mealTypeName: 'Dinner', + sourceEntries: [pasta, sauce], + selectedEntryIds: ['pasta-id', 'sauce-id'], + }); + }); + + test('shows each raw food nutrition and updates only the aggregate for a partial selection', () => { + const screen = renderMealDetail(); + + expect( + screen.getByText('270 kcal · P 9 g · C 48 g · F 4.5 g'), + ).toBeTruthy(); + expect(screen.getByText('30 kcal · P 2 g · C 6 g · F 1 g')).toBeTruthy(); + expect( + screen.getByText('Selected: 300 kcal · P 11 g · C 54 g · F 5.5 g'), + ).toBeTruthy(); + + const selectedSauce = screen.getByLabelText('Deselect Tomato Sauce'); + expect(selectedSauce.props.accessibilityState).toEqual({ selected: true }); + fireEvent.press(selectedSauce); + + const deselectedSauce = screen.getByLabelText('Select Tomato Sauce'); + expect(deselectedSauce.props.accessibilityState).toEqual({ + selected: false, + }); + expect( + screen.getByText('Selected: 270 kcal · P 9 g · C 48 g · F 4.5 g'), + ).toBeTruthy(); + expect(screen.getByText('30 kcal · P 2 g · C 6 g · F 1 g')).toBeTruthy(); + + fireEvent.press(screen.getByText('Continue')); + + expect(navigation.navigate).toHaveBeenCalledWith('FamilyCopyReview', { + familyUser, + sourceDate: '2026-08-23', + mealTypeId: 'dinner-id', + mealTypeName: 'Dinner', + sourceEntries: [pasta, sauce], + selectedEntryIds: ['pasta-id'], + }); + }); + + test('deselects all, disables continue, and reselects source IDs in order', () => { + const screen = renderMealDetail(); + + const deselectAll = screen.getByLabelText('Deselect all'); + expect(deselectAll.props.accessibilityState).toMatchObject({ + selected: true, + }); + fireEvent.press(deselectAll); + + const selectAll = screen.getByLabelText('Select all'); + expect(selectAll.props.accessibilityState).toMatchObject({ + selected: false, + }); + expect( + screen.getByRole('button', { name: 'Continue' }).props.accessibilityState, + ).toMatchObject({ disabled: true }); + + fireEvent.press(selectAll); + fireEvent.press(screen.getByText('Continue')); + expect(navigation.navigate).toHaveBeenCalledWith('FamilyCopyReview', { + familyUser, + sourceDate: '2026-08-23', + mealTypeId: 'dinner-id', + mealTypeName: 'Dinner', + sourceEntries: [pasta, sauce], + selectedEntryIds: ['pasta-id', 'sauce-id'], + }); + }); + + test('keeps diary-only access read only', () => { + const screen = renderMealDetail({ canCopy: false }); + + expect(screen.getByText('Viewing only')).toBeTruthy(); + expect(screen.queryByText('Continue')).toBeNull(); + expect(screen.queryByLabelText('Deselect Tomato Sauce')).toBeNull(); + }); + + test('keeps selection controls safe for an empty meal', () => { + const screen = renderMealDetail({ entries: [] }); + + expect( + screen.getByLabelText('Select all').props.accessibilityState, + ).toMatchObject({ + selected: false, + }); + expect( + screen.getByRole('button', { name: 'Continue' }).props.accessibilityState, + ).toMatchObject({ disabled: true }); + expect( + screen.getByText('Selected: 0 kcal · P 0 g · C 0 g · F 0 g'), + ).toBeTruthy(); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx new file mode 100644 index 0000000000..5d2e1d3cba --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import FamilyMembersScreen from '../../src/screens/FamilyMembersScreen'; +import { useFamilyUsers } from '../../src/hooks'; +import type { RootStackScreenProps } from '../../src/types/navigation'; + +type ScreenProps = RootStackScreenProps<'FamilyMembers'>; + +const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), + setOptions: jest.fn(), +} as unknown as ScreenProps['navigation']; + +const route = { + key: 'FamilyMembers-1', + name: 'FamilyMembers', + params: undefined, +} as unknown as ScreenProps['route']; + +jest.mock('../../src/hooks', () => ({ + useFamilyUsers: jest.fn(), +})); + +jest.mock('../../src/hooks/useScreenHeader', () => ({ + useScreenHeader: () => null, +})); + +jest.mock('../../src/services/nativeTabBarPreference', () => ({ + useNativeIOSHeadersActive: () => false, +})); + +jest.mock('../../src/components/ActiveWorkoutBar', () => ({ + useActiveWorkoutBarPadding: jest.fn(() => 12), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'familyDiary.title': 'Family Diaries', + 'familyDiary.loadingMembers': 'Loading family members…', + 'familyDiary.loadMembersFailed': "Couldn't load family members", + 'familyDiary.noMembers': 'No family members', + 'familyDiary.manageOnWeb': + 'Family diary access is managed in the web app.', + 'familyDiary.canCopy': 'Can copy', + 'familyDiary.viewOnly': 'View only', + 'familyDiary.unnamedMember': 'Family member', + 'common.retry': 'Retry', + }[key] ?? key), + }), +})); + +const mockUseFamilyUsers = useFamilyUsers as jest.MockedFunction< + typeof useFamilyUsers +>; + +const renderScreen = (bottomInset = 0) => + render( + + + , + ); + +describe('FamilyMembersScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('opens a copy-enabled family diary', () => { + mockUseFamilyUsers.mockReturnValue({ + data: [ + { + userId: 'member-b', + displayName: 'Member B', + email: 'b@example.test', + canCopy: true, + accessEndDate: null, + }, + ], + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + const { getByLabelText } = renderScreen(); + fireEvent.press(getByLabelText('Member B. Can copy')); + + expect(navigation.navigate).toHaveBeenCalledWith('FamilyDiary', { + familyUser: expect.objectContaining({ + userId: 'member-b', + canCopy: true, + }), + }); + }); + + test('labels a diary-only connection as read only', () => { + mockUseFamilyUsers.mockReturnValue({ + data: [ + { + userId: 'member-a', + displayName: 'Member A', + email: null, + canCopy: false, + accessEndDate: null, + }, + ], + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + expect(renderScreen().getByLabelText('Member A. View only')).toBeTruthy(); + }); + + test('uses a localized fallback for a member without a name or email', () => { + mockUseFamilyUsers.mockReturnValue({ + data: [ + { + userId: 'member-unnamed', + displayName: '', + email: null, + canCopy: false, + accessEndDate: null, + }, + ], + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + expect( + renderScreen().getByLabelText('Family member. View only'), + ).toBeTruthy(); + }); + + test('explains that empty family access is managed on the web', () => { + mockUseFamilyUsers.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + expect( + renderScreen().getByText( + 'Family diary access is managed in the web app.', + ), + ).toBeTruthy(); + }); + + test('leaves safe-area and active-workout space below the member list', () => { + mockUseFamilyUsers.mockReturnValue({ + data: [ + { + userId: 'member-a', + displayName: 'Member A', + email: null, + canCopy: false, + accessEndDate: null, + }, + ], + isLoading: false, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + const { getByTestId } = renderScreen(20); + + expect( + getByTestId('family-members-list').props.contentContainerStyle, + ).toEqual({ + padding: 16, + paddingBottom: 48, + }); + }); + + test('shows a loading state while family members are loading', () => { + mockUseFamilyUsers.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: jest.fn(), + } as ReturnType); + + expect(renderScreen().getByText('Loading family members…')).toBeTruthy(); + }); + + test('retries after the family member request fails', () => { + const refetch = jest.fn(); + mockUseFamilyUsers.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch, + } as ReturnType); + + const { getByText } = renderScreen(); + fireEvent.press(getByText('Retry')); + + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx new file mode 100644 index 0000000000..d5ef91595c --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import SettingsScreen from '../../src/screens/SettingsScreen'; +import { + usePreferences, + useServerConfigs, + useServerConnection, +} from '../../src/hooks'; + +type ScreenProps = React.ComponentProps; + +const navigation = { + navigate: jest.fn(), +} as unknown as ScreenProps['navigation']; +const route = { + key: 'Settings-1', + name: 'Settings', + params: undefined, +} as unknown as ScreenProps['route']; + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { ...actual, useFocusEffect: (callback: () => void) => callback() }; +}); + +jest.mock('../../src/hooks', () => ({ + useServerConnection: jest.fn(), + useServerConfigs: jest.fn(), + usePreferences: jest.fn(), + queryClient: { getQueryCache: () => ({ getAll: () => [] }) }, +})); + +jest.mock('../../src/hooks/useDiscreetMode', () => ({ + useDiscreetMode: () => ({ discreetMode: false }), +})); + +jest.mock('../../src/components/ActiveWorkoutBar', () => ({ + useActiveWorkoutBarPadding: () => 0, +})); + +jest.mock('../../src/services/nativeTabBarPreference', () => ({ + useNativeIOSTabsActive: () => false, +})); + +jest.mock('../../src/services/storage', () => ({ + loadLastSyncedTime: jest.fn().mockResolvedValue(null), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => (key === 'familyDiary.title' ? 'Family Diaries' : key), + i18n: { language: 'en-US' }, + }), +})); + +const mockUseServerConnection = useServerConnection as jest.MockedFunction< + typeof useServerConnection +>; +const mockUseServerConfigs = useServerConfigs as jest.MockedFunction< + typeof useServerConfigs +>; +const mockUsePreferences = usePreferences as jest.MockedFunction< + typeof usePreferences +>; + +describe('SettingsScreen family diary entry', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseServerConnection.mockReturnValue({ isConnected: true } as ReturnType< + typeof useServerConnection + >); + mockUseServerConfigs.mockReturnValue({ activeConfig: null } as ReturnType< + typeof useServerConfigs + >); + mockUsePreferences.mockReturnValue({ preferences: null } as ReturnType< + typeof usePreferences + >); + }); + + test('opens family diaries when connected', () => { + const { getByText } = render( + + + , + ); + + fireEvent.press(getByText('Family Diaries')); + + expect(navigation.navigate).toHaveBeenCalledWith('FamilyMembers'); + }); + + test('hides family diaries while disconnected', () => { + mockUseServerConnection.mockReturnValue({ + isConnected: false, + } as ReturnType); + + const { queryByText } = render( + + + , + ); + + expect(queryByText('Family Diaries')).toBeNull(); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/services/familyApi.test.ts b/SparkyFitnessMobile/__tests__/services/familyApi.test.ts new file mode 100644 index 0000000000..09942a71cd --- /dev/null +++ b/SparkyFitnessMobile/__tests__/services/familyApi.test.ts @@ -0,0 +1,231 @@ +import { fetchFamilyDiaryUsers } from '../../src/services/api/familyApi'; +import { fetchDailySummary } from '../../src/services/api/dailySummaryApi'; +import { + getActiveServerConfig, + type ServerConfig, +} from '../../src/services/storage'; + +jest.mock('../../src/services/storage', () => ({ + getActiveServerConfig: jest.fn(), + proxyHeadersToRecord: jest.requireActual('../../src/services/storage') + .proxyHeadersToRecord, +})); + +jest.mock('../../src/services/LogService', () => ({ + addLog: jest.fn(), +})); + +const mockGetActiveServerConfig = getActiveServerConfig as jest.MockedFunction< + typeof getActiveServerConfig +>; + +describe('familyApi', () => { + const mockFetch = jest.fn(); + const testConfig: ServerConfig = { + id: 'test-id', + url: 'https://example.com', + apiKey: 'test-api-key-12345', + }; + + beforeEach(() => { + jest.resetAllMocks(); + global.fetch = mockFetch; + mockGetActiveServerConfig.mockResolvedValue(testConfig); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test('leaves an unnamed member blank for the presentation layer to localize', async () => { + mockGetActiveServerConfig.mockResolvedValue(testConfig); + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + user_id: 'member-unnamed', + full_name: null, + email: null, + permissions: { diary: true }, + access_end_date: null, + }, + ]), + }); + + await expect(fetchFamilyDiaryUsers()).resolves.toEqual([ + expect.objectContaining({ displayName: '' }), + ]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('fetches and normalizes only diary-authorized family users', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + user_id: 'member-b', + full_name: 'Member B', + email: 'b@example.test', + permissions: { + can_manage_diary: true, + can_view_food_library: false, + }, + access_end_date: null, + }, + { + user_id: 'member-c', + full_name: 'Member C', + email: 'c@example.test', + permissions: { can_manage_checkin: true }, + access_end_date: null, + }, + ]), + }); + + await expect(fetchFamilyDiaryUsers()).resolves.toEqual([ + { + userId: 'member-b', + displayName: 'Member B', + email: 'b@example.test', + canCopy: false, + accessEndDate: null, + }, + ]); + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api/identity/users/accessible-users', + expect.anything(), + ); + }); + + it('supports legacy diary and food-library permission aliases', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + user_id: 'member-b', + full_name: 'Member B', + email: null, + permissions: { diary: true, food_list: true }, + access_end_date: '2026-12-31', + }, + { + user_id: 'member-c', + full_name: 'Member C', + email: 'c@example.test', + permissions: { calorie: true }, + access_end_date: null, + }, + ]), + }); + + await expect(fetchFamilyDiaryUsers()).resolves.toEqual([ + { + userId: 'member-b', + displayName: 'Member B', + email: null, + canCopy: false, + accessEndDate: '2026-12-31', + }, + { + userId: 'member-c', + displayName: 'Member C', + email: 'c@example.test', + canCopy: false, + accessEndDate: null, + }, + ]); + }); + + it('matches diary-read and copy permissions enforced by the server', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + user_id: 'reports-only', + full_name: 'Reports Only', + email: null, + permissions: { can_view_reports: true }, + access_end_date: null, + }, + { + user_id: 'food-library-only', + full_name: 'Food Library Only', + email: null, + permissions: { can_view_food_library: true }, + access_end_date: null, + }, + { + user_id: 'diary-and-food-library', + full_name: 'Diary Manager', + email: null, + permissions: { + can_manage_diary: true, + can_view_food_library: true, + }, + access_end_date: null, + }, + { + user_id: 'legacy-diary-read', + full_name: 'Legacy Diary Read', + email: null, + permissions: { + can_manage_checkin: true, + calorie: true, + food_list: true, + }, + access_end_date: null, + }, + { + user_id: 'legacy-food-library-manager', + full_name: 'Legacy Food Library Manager', + email: null, + permissions: { + can_manage_diary: true, + food_list: true, + }, + access_end_date: null, + }, + { + user_id: 'checkin-only', + full_name: 'Check-in Only', + email: null, + permissions: { can_manage_checkin: true }, + access_end_date: null, + }, + ]), + }); + + await expect(fetchFamilyDiaryUsers()).resolves.toEqual([ + expect.objectContaining({ userId: 'reports-only', canCopy: false }), + expect.objectContaining({ userId: 'food-library-only', canCopy: false }), + expect.objectContaining({ + userId: 'diary-and-food-library', + canCopy: true, + }), + expect.objectContaining({ userId: 'legacy-diary-read', canCopy: false }), + expect.objectContaining({ + userId: 'legacy-food-library-manager', + canCopy: true, + }), + ]); + }); + + it('adds the explicit family user to daily-summary requests', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + await fetchDailySummary('2026-08-23', 'member-b'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api/daily-summary?date=2026-08-23&userId=member-b', + expect.anything(), + ); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts b/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts index 49b8897898..0589ab1b65 100644 --- a/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts +++ b/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts @@ -4,6 +4,8 @@ import { updateFoodEntry, deleteFoodEntry, copyFoodEntries, + copyReviewedFoodEntriesFromUser, + copySelectedFoodEntriesFromUser, calculateCaloriesConsumed, calculateProtein, calculateCarbs, @@ -507,4 +509,53 @@ describe('foodEntriesApi', () => { ); }); }); + + describe('family copies', () => { + const testConfig: ServerConfig = { + id: 'test-id', + url: 'https://example.com', + apiKey: 'test-api-key-12345', + }; + + const wholeMealPayload = { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + sourceMealType: 'breakfast', + targetDate: '2026-08-24', + targetMealType: 'lunch', + entries: [{ entryId: 'entry-1', sourceFingerprint: 'snapshot' }], + }; + + const selectedPayload = { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: 'lunch', + entries: [{ entryId: 'entry-1', quantity: 150, sourceFingerprint: 'snapshot' }], + }; + + test('posts a reviewed whole family meal with its exact snapshot', async () => { + mockGetActiveServerConfig.mockResolvedValue(testConfig); + mockFetch.mockResolvedValue({ ok: true, status: 204, headers: { get: () => null } }); + + await copyReviewedFoodEntriesFromUser(wholeMealPayload); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api/food-entries/copy-reviewed-from-user', + expect.objectContaining({ method: 'POST', body: JSON.stringify(wholeMealPayload) }), + ); + }); + + test('posts selected family entry IDs and quantities', async () => { + mockGetActiveServerConfig.mockResolvedValue(testConfig); + mockFetch.mockResolvedValue({ ok: true, status: 204, headers: { get: () => null } }); + + await copySelectedFoodEntriesFromUser(selectedPayload); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/api/food-entries/copy-selected-from-user', + expect.objectContaining({ method: 'POST', body: JSON.stringify(selectedPayload) }), + ); + }); + }); }); diff --git a/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts b/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts index 19794a04fc..0aef84e82f 100644 --- a/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts +++ b/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts @@ -4,6 +4,7 @@ import { addDays, normalizeDate, formatDateLabel, + formatDate, formatRelativeTime, } from '../../src/utils/dateUtils'; import i18n, { initializeI18n } from '../../src/localization/i18n'; @@ -111,6 +112,18 @@ describe('with a pinned clock', () => { expect(other).not.toBe('Today'); expect(other).not.toBe('Yesterday'); }); + + test('accepts localized relative labels and date formatting', async () => { + await initializeI18n('pl'); + await i18n.changeLanguage('pl'); + const polishTranslator = i18n.getFixedT('pl'); + expect(formatDateLabel('2024-06-15', polishTranslator, 'pl')).toBe('Dzisiaj'); + expect(formatDateLabel('2024-06-14', polishTranslator, 'pl')).toBe('Wczoraj'); + expect(formatDate('2024-06-13', 'pl')).not.toBe( + formatDate('2024-06-13', 'en-US'), + ); + await i18n.changeLanguage('en'); + }); }); describe('formatRelativeTime', () => { diff --git a/SparkyFitnessMobile/__tests__/utils/familyDiary.test.ts b/SparkyFitnessMobile/__tests__/utils/familyDiary.test.ts new file mode 100644 index 0000000000..1271e17a25 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/utils/familyDiary.test.ts @@ -0,0 +1,142 @@ +import type { FoodEntry } from '../../src/types/foodEntries'; +import { + calculateFamilyCopyTotals, + groupFamilyFoodEntries, + isUnchangedWholeMeal, +} from '../../src/utils/familyDiary'; + +const entry = (overrides: Partial = {}): FoodEntry => + ({ + id: 'entry-id', + meal_type: 'Dinner', + meal_type_id: 'dinner-id', + quantity: 100, + unit: 'g', + serving_size: 100, + entry_date: '2026-08-23', + calories: 100, + protein: 10, + carbs: 20, + fat: 5, + ...overrides, + } as FoodEntry); + +describe('family diary helpers', () => { + it('groups raw components by canonical meal identity and preserves every row', () => { + const groups = groupFamilyFoodEntries([ + entry({ id: 'a', meal_type_id: 'dinner-id', meal_type: 'Dinner' }), + entry({ id: 'b', meal_type_id: 'dinner-id', meal_type: 'Dinner' }), + entry({ id: 'c', meal_type_id: 'snack-id', meal_type: 'Snacks' }), + ]); + + expect( + groups.map(group => [group.key, group.entries.map(item => item.id)]), + ).toEqual([ + ['dinner-id', ['a', 'b']], + ['snack-id', ['c']], + ]); + }); + + it('uses quantity and serving basis once for the review totals', () => { + expect( + calculateFamilyCopyTotals([ + { + entry: entry({ + calories: 180, + protein: 6, + carbs: 32, + fat: 3, + serving_size: 100, + }), + quantity: 150, + }, + ]), + ).toEqual({ calories: 270, protein: 9, carbs: 48, fat: 4.5 }); + }); + + it('classifies only all-selected unchanged quantities as a whole meal', () => { + const source = [ + entry({ id: 'a', quantity: 100 }), + entry({ id: 'b', quantity: 1 }), + ]; + + expect( + isUnchangedWholeMeal(source, new Set(['a', 'b']), { a: 100, b: 1 }), + ).toBe(true); + expect(isUnchangedWholeMeal(source, new Set(['a']), { a: 100 })).toBe( + false, + ); + expect( + isUnchangedWholeMeal(source, new Set(['a', 'b']), { a: 150, b: 1 }), + ).toBe(false); + }); + + it('uses a stable legacy key when a meal type id is unavailable', () => { + const groups = groupFamilyFoodEntries([ + entry({ id: 'a', meal_type_id: undefined, meal_type: 'Custom' }), + entry({ id: 'b', meal_type_id: undefined, meal_type: 'custom' }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ key: 'legacy:custom', mealTypeId: null }); + expect(groups[0].entries.map(item => item.id)).toEqual(['a', 'b']); + }); + + it('returns zero for nutrients whose serving basis is not positive', () => { + expect( + calculateFamilyCopyTotals([ + { entry: entry({ serving_size: 0 }), quantity: 150 }, + ]), + ).toEqual({ + calories: 0, + protein: 0, + carbs: 0, + fat: 0, + }); + }); + + it('rejects zero, negative, and non-finite source or requested quantities', () => { + expect( + isUnchangedWholeMeal( + [entry({ id: 'zero', quantity: 0 })], + new Set(['zero']), + { zero: 0 }, + ), + ).toBe(false); + expect( + isUnchangedWholeMeal( + [entry({ id: 'negative', quantity: -1 })], + new Set(['negative']), + { negative: -1 }, + ), + ).toBe(false); + expect( + isUnchangedWholeMeal( + [entry({ id: 'source-nan', quantity: Number.NaN })], + new Set(['source-nan']), + { 'source-nan': Number.NaN }, + ), + ).toBe(false); + expect( + isUnchangedWholeMeal( + [entry({ id: 'requested-zero', quantity: 1 })], + new Set(['requested-zero']), + { 'requested-zero': 0 }, + ), + ).toBe(false); + expect( + isUnchangedWholeMeal( + [entry({ id: 'requested-negative', quantity: 1 })], + new Set(['requested-negative']), + { 'requested-negative': -1 }, + ), + ).toBe(false); + expect( + isUnchangedWholeMeal( + [entry({ id: 'requested-inf', quantity: 1 })], + new Set(['requested-inf']), + { 'requested-inf': Number.POSITIVE_INFINITY }, + ), + ).toBe(false); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts b/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts index af2839aaf0..ede4157afe 100644 --- a/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts +++ b/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts @@ -2,6 +2,7 @@ import { createNativeHeaderDatePickerItems, setNativeHeaderDatePickerOptions, } from '../../src/utils/nativeHeaderDatePicker'; +import type { TFunction } from 'i18next'; describe('nativeHeaderDatePicker', () => { const onPreviousDate = jest.fn(); @@ -14,6 +15,9 @@ describe('nativeHeaderDatePicker', () => { onNextDate, tintColor: '#0A84FF', accessibilityLabel: 'Choose diary date', + t: ((key: string, values?: { defaultValue?: string }) => + values?.defaultValue ?? key) as TFunction, + locale: 'en-US', }; beforeEach(() => { @@ -24,12 +28,12 @@ describe('nativeHeaderDatePicker', () => { const items = createNativeHeaderDatePickerItems(options); expect(items).toHaveLength(3); - expect(items.map((item) => item.identifier)).toEqual([ + expect(items.map(item => item.identifier)).toEqual([ 'date-picker-previous', 'date-picker', 'date-picker-next', ]); - expect(items.every((item) => item.tintColor === '#0A84FF')).toBe(true); + expect(items.every(item => item.tintColor === '#0A84FF')).toBe(true); expect(items[1]?.label).toContain('Jan 15'); items[0]?.onPress(); @@ -50,7 +54,65 @@ describe('nativeHeaderDatePicker', () => { const configuredOptions = setOptions.mock.calls[0]?.[0]; expect(configuredOptions).toEqual({ unstable_headerRightItems: expect.any(Function), + unstable_headerLeftItems: undefined, }); expect(configuredOptions.unstable_headerRightItems()).toHaveLength(3); }); + + it('adds a leading family diary action when one is supplied', () => { + const onPress = jest.fn(); + const setOptions = jest.fn(); + + setNativeHeaderDatePickerOptions( + { setOptions }, + { + ...options, + leadingAction: { + sfSymbol: 'person.2.fill', + onPress, + accessibilityLabel: 'Open family diaries', + identifier: 'family-diaries', + }, + }, + ); + + const configuredOptions = setOptions.mock.calls[0]?.[0]; + const leadingItems = configuredOptions.unstable_headerLeftItems(); + expect(leadingItems).toEqual([ + expect.objectContaining({ + icon: { type: 'sfSymbol', name: 'person.2.fill' }, + accessibilityLabel: 'Open family diaries', + identifier: 'family-diaries', + }), + ]); + leadingItems[0]?.onPress(); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('clears a previously configured leading action when access disappears', () => { + let configuredOptions: Record = {}; + const setOptions = jest.fn((nextOptions: Record) => { + configuredOptions = { ...configuredOptions, ...nextOptions }; + }); + + setNativeHeaderDatePickerOptions( + { setOptions }, + { + ...options, + leadingAction: { + sfSymbol: 'person.2.fill', + onPress: jest.fn(), + accessibilityLabel: 'Open family diaries', + identifier: 'family-diaries', + }, + }, + ); + expect(configuredOptions.unstable_headerLeftItems).toEqual( + expect.any(Function), + ); + + setNativeHeaderDatePickerOptions({ setOptions }, options); + + expect(configuredOptions.unstable_headerLeftItems).toBeUndefined(); + }); }); diff --git a/SparkyFitnessMobile/src/components/DateNavigator.tsx b/SparkyFitnessMobile/src/components/DateNavigator.tsx index 3a8ab0dcae..10a8cc93cd 100644 --- a/SparkyFitnessMobile/src/components/DateNavigator.tsx +++ b/SparkyFitnessMobile/src/components/DateNavigator.tsx @@ -4,6 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; import { useCSSVariable } from 'uniwind'; import Icon from './Icon'; +import type { IconName } from './Icon'; import { formatDateLabel, formatDate } from '../utils/dateUtils'; interface DateNavigatorProps { @@ -18,6 +19,21 @@ interface DateNavigatorProps { skipTopInset?: boolean; skipHorizontalPadding?: boolean; compact?: boolean; + action?: { + icon: IconName; + accessibilityLabel: string; + onPress: () => void; + }; + dateControls?: { + previousDayLabel: string; + previousDayHint: string; + nextDayLabel: string; + nextDayHint: string; + chooseDateLabel: string; + chooseDateHint: string; + goToTodayLabel: string; + goToTodayHint: string; + }; } const DateNavigator: React.FC = ({ @@ -32,11 +48,37 @@ const DateNavigator: React.FC = ({ skipTopInset, skipHorizontalPadding, compact, + action, + dateControls, }) => { // Subscribe to the reactive app language so the date label re-localizes // immediately on a runtime PL <-> EN switch without an app restart. const { t, i18n } = useTranslation(); const locale = i18n.language.startsWith('pl') ? 'pl-PL' : 'en-US'; + const resolvedDateControls = dateControls ?? { + previousDayLabel: t('familyDiary.previousDay', { + defaultValue: 'Previous day', + }), + previousDayHint: t('familyDiary.previousDayHint', { + defaultValue: 'Shows the previous day', + }), + nextDayLabel: t('familyDiary.nextDay', { defaultValue: 'Next day' }), + nextDayHint: t('familyDiary.nextDayHint', { + defaultValue: 'Shows the next day', + }), + chooseDateLabel: t('familyDiary.chooseDate', { + defaultValue: 'Choose date', + }), + chooseDateHint: t('familyDiary.chooseDateHint', { + defaultValue: 'Opens the date picker', + }), + goToTodayLabel: t('familyDiary.goToToday', { + defaultValue: 'Go to today', + }), + goToTodayHint: t('familyDiary.goToTodayHint', { + defaultValue: 'Returns to today', + }), + }; const insets = useSafeAreaInsets(); const secondaryTextColor = useCSSVariable('--color-text-secondary') as string; const primaryTextColor = useCSSVariable('--color-text-primary') as string; @@ -48,25 +90,76 @@ const DateNavigator: React.FC = ({ const paddingTop = compact ? 0 : skipTopInset ? 16 : insets.top + 16; return ( - - {title} + + + {title} + {action ? ( + + + + ) : null} + {!hideChevrons && ( - + )} - + {dateLabel} {onDatePress && ( - + )} {!hideChevrons && ( - + )} diff --git a/SparkyFitnessMobile/src/hooks/index.ts b/SparkyFitnessMobile/src/hooks/index.ts index d821d09f28..a8631a0579 100644 --- a/SparkyFitnessMobile/src/hooks/index.ts +++ b/SparkyFitnessMobile/src/hooks/index.ts @@ -3,6 +3,8 @@ export { serverConnectionQueryKey, serverConfigsQueryKey, dailySummaryQueryKey, + familyUsersQueryKey, + familyDailySummaryQueryKey, measurementsQueryKey, preferencesQueryKey, waterContainersQueryKey, @@ -61,6 +63,9 @@ export { useServerConnection } from './useServerConnection'; export { useServerConfigs } from './useServerConfigs'; export { useSyncHealthData } from './useSyncHealthData'; export { useDailySummary } from './useDailySummary'; +export { useFamilyUsers, useFamilyDailySummary } from './useFamilyDiary'; +export { useCopyFamilyFoodEntries } from './useCopyFamilyFoodEntries'; +export type { FamilyCopyRequest } from './useCopyFamilyFoodEntries'; export { useMeasurements } from './useMeasurements'; export { useUpsertCheckIn } from './useUpsertCheckIn'; export { usePreferences } from './usePreferences'; diff --git a/SparkyFitnessMobile/src/hooks/queryKeys.ts b/SparkyFitnessMobile/src/hooks/queryKeys.ts index 9873da1b43..f3c5868d8f 100644 --- a/SparkyFitnessMobile/src/hooks/queryKeys.ts +++ b/SparkyFitnessMobile/src/hooks/queryKeys.ts @@ -6,6 +6,10 @@ export const dailySummaryQueryKey = (date: string) => ['dailySummary', date] as /** Prefix for every date, so a mutation that moves a day's totals can invalidate without knowing which day. */ export const dailySummaryRootQueryKey = ['dailySummary'] as const; +export const familyUsersQueryKey = ['familyDiaryUsers'] as const; +export const familyDailySummaryQueryKey = (familyUserId: string, date: string) => + ['familyDailySummary', familyUserId, date] as const; + export const measurementsQueryKey = (date: string) => ['measurements', date] as const; export const preferencesQueryKey = ['userPreferences'] as const; diff --git a/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts new file mode 100644 index 0000000000..5979ed20a2 --- /dev/null +++ b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts @@ -0,0 +1,104 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import Toast from 'react-native-toast-message'; +import { + copyReviewedFoodEntriesFromUser, + copySelectedFoodEntriesFromUser, +} from '../services/api/foodEntriesApi'; +import type { + CopyReviewedFoodEntriesFromUserPayload, + CopySelectedFoodEntriesFromUserPayload, +} from '@workspace/shared'; +import { ApiError } from '../services/api/errors'; +import { + dailySummaryQueryKey, + familyDailySummaryQueryKey, + familyUsersQueryKey, +} from './queryKeys'; + +export type FamilyCopyRequest = + | { kind: 'whole'; payload: CopyReviewedFoodEntriesFromUserPayload } + | { kind: 'selected'; payload: CopySelectedFoodEntriesFromUserPayload }; + +interface UseCopyFamilyFoodEntriesOptions { + onSuccess?: (request: FamilyCopyRequest) => void; + onStale?: (request: FamilyCopyRequest) => void; +} + +export function useCopyFamilyFoodEntries( + options?: UseCopyFamilyFoodEntriesOptions, +) { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const mutation = useMutation({ + mutationFn: (request: FamilyCopyRequest) => + request.kind === 'whole' + ? copyReviewedFoodEntriesFromUser(request.payload) + : copySelectedFoodEntriesFromUser(request.payload), + onSuccess: (_data, request) => { + queryClient.invalidateQueries({ + queryKey: dailySummaryQueryKey(request.payload.targetDate), + }); + Toast.show({ + type: 'success', + text1: t('familyDiary.copySuccess', { + defaultValue: 'Copied to your diary', + }), + }); + options?.onSuccess?.(request); + }, + onError: (error, request) => { + if (error instanceof ApiError && error.statusCode === 403) { + void queryClient.invalidateQueries({ queryKey: familyUsersQueryKey }); + void queryClient.refetchQueries({ queryKey: familyUsersQueryKey }); + Toast.show({ + type: 'error', + text1: t('familyDiary.copyPermissionRevoked', { + defaultValue: 'Copy permission was removed', + }), + text2: t('familyDiary.copyPermissionRevokedGuidance', { + defaultValue: 'Refresh family diaries to see your current access.', + }), + }); + return; + } + + if (error instanceof ApiError && error.statusCode === 409) { + const sourceQueryKey = familyDailySummaryQueryKey( + request.payload.familyUserId, + request.payload.sourceDate, + ); + void queryClient.invalidateQueries({ queryKey: sourceQueryKey }); + void queryClient.refetchQueries({ queryKey: sourceQueryKey }); + Toast.show({ + type: 'error', + text1: t('familyDiary.copyStale', { + defaultValue: 'Family diary changed', + }), + text2: t('familyDiary.copyStaleGuidance', { + defaultValue: 'The latest family diary is opening for review.', + }), + }); + options?.onStale?.(request); + return; + } + + Toast.show({ + type: 'error', + text1: t('familyDiary.copyFailed', { + defaultValue: 'Could not copy foods', + }), + text2: t('familyDiary.copyFailedGuidance', { + defaultValue: 'Your review is still here. Please try again.', + }), + }); + }, + }); + + return { + copyFromFamily: mutation.mutate, + copyFromFamilyAsync: mutation.mutateAsync, + isPending: mutation.isPending, + }; +} diff --git a/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts b/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts new file mode 100644 index 0000000000..7406d129d3 --- /dev/null +++ b/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchFamilyDiaryUsers } from '../services/api/familyApi'; +import { fetchDailySummary } from '../services/api/dailySummaryApi'; +import { familyDailySummaryQueryKey, familyUsersQueryKey } from './queryKeys'; + +interface UseFamilyDailySummaryOptions { + familyUserId: string; + date: string; + enabled?: boolean; +} + +interface UseFamilyUsersOptions { + enabled?: boolean; +} + +export function useFamilyUsers({ enabled = true }: UseFamilyUsersOptions = {}) { + return useQuery({ + queryKey: familyUsersQueryKey, + queryFn: fetchFamilyDiaryUsers, + enabled, + }); +} + +export function useFamilyDailySummary({ + familyUserId, + date, + enabled = true, +}: UseFamilyDailySummaryOptions) { + return useQuery({ + queryKey: familyDailySummaryQueryKey(familyUserId, date), + queryFn: () => fetchDailySummary(date, familyUserId), + enabled: enabled && familyUserId.length > 0, + }); +} diff --git a/SparkyFitnessMobile/src/localization/locales/en/translation.json b/SparkyFitnessMobile/src/localization/locales/en/translation.json index 80d9df76b7..13a99530a6 100644 --- a/SparkyFitnessMobile/src/localization/locales/en/translation.json +++ b/SparkyFitnessMobile/src/localization/locales/en/translation.json @@ -61,7 +61,8 @@ "selectOption": "Select an option", "requestTimedOut": "Request timed out. Check your server connection.", "nextStep": "Next step", - "notNow": "Not now" + "notNow": "Not now", + "retry": "Retry" }, "settings": { "app": "App Settings", @@ -141,6 +142,66 @@ "add": "Add", "mealTypes": "Meal Types" }, + "familyDiary": { + "title": "Family Diaries", + "openFamilyDiaries": "Open family diaries", + "loadingMembers": "Loading family members…", + "loadMembersFailed": "Couldn't load family members", + "noMembers": "No family members", + "manageOnWeb": "Family diary access is managed in the web app.", + "canCopy": "Can copy", + "viewOnly": "View only", + "diary": "Family diary", + "loadingDiary": "Loading family diary…", + "loadDiaryFailed": "Family diary access unavailable", + "back": "Back", + "emptyForDate": "No food entries for this date", + "diaryForDate": "Family diary · {{date}}", + "openMeal": "Open {{meal}} meal", + "mealCalories": "{{calories}} kcal", + "unnamedFood": "Unnamed food", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "viewingOnly": "Viewing only", + "selectFood": "Select {{food}}", + "deselectFood": "Deselect {{food}}", + "continue": "Continue", + "nutritionSummary": "{{calories}} kcal · P {{protein}} g · C {{carbs}} g · F {{fat}} g", + "selectedNutrition": "Selected: {{calories}} kcal · P {{protein}} g · C {{carbs}} g · F {{fat}} g", + "copyReview": "Review copy", + "copyFrom": "Copying from {{name}}", + "copyMealTitle": "{{meal}} meal", + "quantityForFood": "Quantity for {{food}}", + "quantityMustBePositive": "Enter a quantity greater than zero.", + "copyNutrition": "Copy nutrition", + "copyCalories": "{{calories}} kcal", + "copyProtein": "{{protein}} g protein", + "copyCarbs": "{{carbs}} g carbs", + "copyFat": "{{fat}} g fat", + "copyTargetDate": "Copy date", + "copyTargetDateHint": "Opens the date picker", + "copyTargetMeal": "Copy to meal", + "copyTargetMealRequired": "Choose a meal before copying.", + "copyToMyDiary": "Copy to my diary", + "copyInvalidSelection": "Selected foods are no longer available.", + "copyTargetDateLabel": "Copy date: {{date}}", + "copySuccess": "Copied to your diary", + "copyFailed": "Could not copy foods", + "copyFailedGuidance": "Your review is still here. Please try again.", + "copyPermissionRevoked": "Copy permission was removed", + "copyPermissionRevokedGuidance": "Refresh family diaries to see your current access.", + "copyStale": "Family diary changed", + "copyStaleGuidance": "The latest family diary is opening for review.", + "unnamedMember": "Family member", + "previousDay": "Previous day", + "previousDayHint": "Shows the previous day", + "nextDay": "Next day", + "nextDayHint": "Shows the next day", + "chooseDate": "Choose date", + "chooseDateHint": "Opens the date picker", + "goToToday": "Go to today", + "goToTodayHint": "Returns to today" + }, "notifications": { "title": "Notifications", "channels": { diff --git a/SparkyFitnessMobile/src/localization/locales/pl/translation.json b/SparkyFitnessMobile/src/localization/locales/pl/translation.json index 11ec5760b5..5f3d9a0c96 100644 --- a/SparkyFitnessMobile/src/localization/locales/pl/translation.json +++ b/SparkyFitnessMobile/src/localization/locales/pl/translation.json @@ -63,7 +63,8 @@ "selectOption": "Wybierz opcję", "requestTimedOut": "Przekroczono limit czasu żądania. Sprawdź połączenie z serwerem.", "nextStep": "Następny krok", - "notNow": "Później" + "notNow": "Później", + "retry": "Spróbuj ponownie" }, "settings": { "app": "Ustawienia aplikacji", @@ -143,6 +144,66 @@ "add": "Dodaj", "mealTypes": "Typy posiłków" }, + "familyDiary": { + "title": "Dzienniki rodzinne", + "openFamilyDiaries": "Otwórz dzienniki rodzinne", + "loadingMembers": "Wczytywanie członków rodziny…", + "loadMembersFailed": "Nie udało się wczytać członków rodziny", + "noMembers": "Brak członków rodziny", + "manageOnWeb": "Dostęp do dziennika rodzinnego jest zarządzany w aplikacji internetowej.", + "canCopy": "Można kopiować", + "viewOnly": "Tylko do odczytu", + "diary": "Dziennik rodzinny", + "loadingDiary": "Wczytywanie dziennika rodzinnego…", + "loadDiaryFailed": "Dostęp do dziennika rodzinnego jest niedostępny", + "back": "Wstecz", + "emptyForDate": "Brak wpisów żywieniowych dla tej daty", + "diaryForDate": "Dziennik rodzinny · {{date}}", + "openMeal": "Otwórz posiłek {{meal}}", + "mealCalories": "{{calories}} kcal", + "unnamedFood": "Nienazwany produkt", + "selectAll": "Zaznacz wszystko", + "deselectAll": "Odznacz wszystko", + "viewingOnly": "Tylko do odczytu", + "selectFood": "Zaznacz {{food}}", + "deselectFood": "Odznacz {{food}}", + "continue": "Kontynuuj", + "nutritionSummary": "{{calories}} kcal · B {{protein}} g · W {{carbs}} g · T {{fat}} g", + "selectedNutrition": "Wybrano: {{calories}} kcal · B {{protein}} g · W {{carbs}} g · T {{fat}} g", + "copyReview": "Sprawdź kopiowanie", + "copyFrom": "Kopiowanie od {{name}}", + "copyMealTitle": "Posiłek: {{meal}}", + "quantityForFood": "Ilość produktu {{food}}", + "quantityMustBePositive": "Wprowadź ilość większą od zera.", + "copyNutrition": "Wartości odżywcze do skopiowania", + "copyCalories": "{{calories}} kcal", + "copyProtein": "{{protein}} g białka", + "copyCarbs": "{{carbs}} g węglowodanów", + "copyFat": "{{fat}} g tłuszczu", + "copyTargetDate": "Data kopiowania", + "copyTargetDateHint": "Otwiera wybór daty", + "copyTargetMeal": "Kopiuj do posiłku", + "copyTargetMealRequired": "Wybierz posiłek przed skopiowaniem.", + "copyToMyDiary": "Kopiuj do mojego dziennika", + "copyInvalidSelection": "Wybrane produkty nie są już dostępne.", + "copyTargetDateLabel": "Data kopiowania: {{date}}", + "copySuccess": "Skopiowano do Twojego dziennika", + "copyFailed": "Nie udało się skopiować produktów", + "copyFailedGuidance": "Twoje zmiany pozostają tutaj. Spróbuj ponownie.", + "copyPermissionRevoked": "Uprawnienie do kopiowania zostało odebrane", + "copyPermissionRevokedGuidance": "Odśwież dzienniki rodzinne, aby zobaczyć bieżący dostęp.", + "copyStale": "Dziennik rodzinny został zmieniony", + "copyStaleGuidance": "Otwieramy najnowszy dziennik rodzinny do ponownego sprawdzenia.", + "unnamedMember": "Członek rodziny", + "previousDay": "Poprzedni dzień", + "previousDayHint": "Pokazuje poprzedni dzień", + "nextDay": "Następny dzień", + "nextDayHint": "Pokazuje następny dzień", + "chooseDate": "Wybierz datę", + "chooseDateHint": "Otwiera wybór daty", + "goToToday": "Przejdź do dzisiaj", + "goToTodayHint": "Wraca do dzisiaj" + }, "notifications": { "title": "Powiadomienia", "channels": { diff --git a/SparkyFitnessMobile/src/navigation/safeScreens.tsx b/SparkyFitnessMobile/src/navigation/safeScreens.tsx index 2332741b09..78ad60c34b 100644 --- a/SparkyFitnessMobile/src/navigation/safeScreens.tsx +++ b/SparkyFitnessMobile/src/navigation/safeScreens.tsx @@ -56,6 +56,10 @@ import MedicationFormScreen from '../screens/MedicationFormScreen'; import MedicationScheduleFormScreen from '../screens/MedicationScheduleFormScreen'; import DailyNutritionDetailsScreen from '../screens/DailyNutritionDetailsScreen'; import NutrientTrendsScreen from '../screens/NutrientTrendsScreen'; +import FamilyMembersScreen from '../screens/FamilyMembersScreen'; +import FamilyDiaryScreen from '../screens/FamilyDiaryScreen'; +import FamilyMealDetailScreen from '../screens/FamilyMealDetailScreen'; +import FamilyCopyReviewScreen from '../screens/FamilyCopyReviewScreen'; import { withErrorBoundary } from '../components/ScreenErrorBoundary'; // Onboarding — no Go Back (initial route for new users) @@ -110,6 +114,10 @@ export const SafeAbout = withErrorBoundary(AboutScreen, 'About', { canGoBack: tr export const SafeWhatsNew = withErrorBoundary(WhatsNewScreen, 'WhatsNew', { canGoBack: true }); export const SafeDailyNutritionDetails = withErrorBoundary(DailyNutritionDetailsScreen, 'DailyNutritionDetails', { canGoBack: true }); export const SafeNutrientTrends = withErrorBoundary(NutrientTrendsScreen, 'NutrientTrends', { canGoBack: true }); +export const SafeFamilyMembers = withErrorBoundary(FamilyMembersScreen, 'FamilyMembers', { canGoBack: true }); +export const SafeFamilyDiary = withErrorBoundary(FamilyDiaryScreen, 'FamilyDiary', { canGoBack: true }); +export const SafeFamilyMealDetail = withErrorBoundary(FamilyMealDetailScreen, 'FamilyMealDetail', { canGoBack: true }); +export const SafeFamilyCopyReview = withErrorBoundary(FamilyCopyReviewScreen, 'FamilyCopyReview', { canGoBack: true }); export const SafeCycleSettings = withErrorBoundary(CycleSettingsScreen, 'CycleSettings', { canGoBack: true }); export const SafeCycleOnboarding = withErrorBoundary(CycleOnboardingScreen, 'CycleOnboarding', { canGoBack: true }); diff --git a/SparkyFitnessMobile/src/screens/DiaryScreen.tsx b/SparkyFitnessMobile/src/screens/DiaryScreen.tsx index 120d6b1e01..cb07e61e39 100644 --- a/SparkyFitnessMobile/src/screens/DiaryScreen.tsx +++ b/SparkyFitnessMobile/src/screens/DiaryScreen.tsx @@ -1,6 +1,5 @@ import React, { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from 'react'; import { View, Text, ScrollView, RefreshControl } from 'react-native'; -import { useTranslation } from 'react-i18next'; import Button from '../components/ui/Button'; import { Gesture, GestureDetector, Directions } from 'react-native-gesture-handler'; import { useFocusEffect } from '@react-navigation/native'; @@ -18,7 +17,14 @@ import EmptyDayIllustration from '../components/EmptyDayIllustration'; import DiaryCalorieMacroSummary from '../components/DiaryCalorieMacroSummary'; import StatusView from '../components/StatusView'; import { useActiveWorkoutBarPadding } from '../components/ActiveWorkoutBar'; -import { useServerConnection, useDailySummary, useCustomNutrients, useNutrientDisplayPreferences, useMealTypes } from '../hooks'; +import { + useServerConnection, + useDailySummary, + useCustomNutrients, + useFamilyUsers, + useNutrientDisplayPreferences, + useMealTypes, +} from '../hooks'; import { useMeasurements } from '../hooks/useMeasurements'; import { useCustomMeasurementsByDate } from '../hooks/useCustomMeasurements'; import { isManualSource } from '../utils/customMeasurementsForm'; @@ -39,6 +45,7 @@ import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { RootStackParamList, TabParamList } from '../types/navigation'; import { useHeaderActionColors } from '../hooks/useHeaderActionColors'; +import { useTranslation } from 'react-i18next'; type DiaryScreenProps = CompositeScreenProps< BottomTabScreenProps, @@ -46,9 +53,12 @@ type DiaryScreenProps = CompositeScreenProps< >; const DiaryScreen: React.FC = ({ navigation }) => { - const { t , i18n: translationI18n } = useTranslation(); + const { t, i18n: translationI18n } = useTranslation(); const dateLocale = translationI18n.language.startsWith('pl') ? 'pl-PL' : 'en-US'; const insets = useSafeAreaInsets(); + const { isConnected, isLoading: isConnectionLoading } = useServerConnection(); + const { data: familyUsers = [] } = useFamilyUsers({ enabled: isConnected }); + const hasFamilyDiaries = isConnected && familyUsers.length > 0; const selectedDate = useDiaryDateStore((s) => s.selectedDate); const setSelectedDate = useDiaryDateStore((s) => s.setSelectedDate); const goToPreviousDay = useDiaryDateStore((s) => s.goToPreviousDay); @@ -81,6 +91,10 @@ const DiaryScreen: React.FC = ({ navigation }) => { }, [navigation, selectedDate]); const openCalendar = useCallback(() => calendarRef.current?.present(), []); + const openFamilyDiaries = useCallback(() => navigation.navigate('FamilyMembers'), [navigation]); + const familyDiariesAccessibilityLabel = t('familyDiary.openFamilyDiaries', { + defaultValue: 'Open family diaries', + }); const accentColor = useCSSVariable('--color-accent-primary') as string; const usesNativeTabs = useNativeIOSTabsActive(); const { defaultColor: nativeHeaderActionColor } = useHeaderActionColors(); @@ -102,6 +116,14 @@ const DiaryScreen: React.FC = ({ navigation }) => { dateLabel: `${formatDateLabel(selectedDate, t, dateLocale)} ▾`, t, locale: dateLocale, + leadingAction: hasFamilyDiaries + ? { + sfSymbol: 'person.2.fill', + onPress: openFamilyDiaries, + accessibilityLabel: familyDiariesAccessibilityLabel, + identifier: 'family-diaries', + } + : undefined, }, ); }, [ @@ -109,8 +131,11 @@ const DiaryScreen: React.FC = ({ navigation }) => { goToPreviousDay, nativeHeaderActionColor, navigation, + openFamilyDiaries, openCalendar, selectedDate, + familyDiariesAccessibilityLabel, + hasFamilyDiaries, usesNativeTabs, t, dateLocale, @@ -160,7 +185,6 @@ const DiaryScreen: React.FC = ({ navigation }) => { const heightMode = preferences?.default_measurement_unit ?? 'cm'; const { getImageSource } = useExerciseImageSource(); - const { isConnected, isLoading: isConnectionLoading } = useServerConnection(); const { summary, isLoading, @@ -402,6 +426,15 @@ const DiaryScreen: React.FC = ({ navigation }) => { onToday={goToToday} onDatePress={openCalendar} showDateAlways + action={ + hasFamilyDiaries + ? { + icon: 'people', + accessibilityLabel: familyDiariesAccessibilityLabel, + onPress: openFamilyDiaries, + } + : undefined + } /> ) : !isConnectionLoading && ( ; + +function formatNutritionValue(value: number): number { + return Number(value.toFixed(1)); +} + +function initialQuantityTextById( + sourceEntries: FamilyCopyReviewScreenProps['route']['params']['sourceEntries'], + selectedEntryIds: string[], +): Record { + const selectedIds = new Set(selectedEntryIds); + return sourceEntries.reduce>((quantities, entry) => { + if (selectedIds.has(entry.id)) + quantities[entry.id] = String(entry.quantity); + return quantities; + }, {}); +} + +const FamilyCopyReviewScreen: React.FC = ({ + navigation, + route, +}) => { + const { + familyUser, + sourceDate, + mealTypeId, + mealTypeName, + sourceEntries, + selectedEntryIds, + } = route.params; + const { t, i18n } = useTranslation(); + const locale = i18n?.resolvedLanguage ?? i18n?.language ?? 'en-US'; + const displayName = familyDiaryUserName( + familyUser, + t('familyDiary.unnamedMember', { defaultValue: 'Family member' }), + ); + const insets = useSafeAreaInsets(); + const activeWorkoutBarPadding = useActiveWorkoutBarPadding('stack'); + const usesNativeHeader = useNativeIOSHeadersActive(); + const calendarRef = useRef(null); + const submitInFlightRef = useRef(false); + const wasPendingRef = useRef(false); + const [targetDate, setTargetDate] = useState(getTodayDate); + const [quantityTextById, setQuantityTextById] = useState(() => + initialQuantityTextById(sourceEntries, selectedEntryIds), + ); + const { mealTypes, defaultMealTypeId } = useMealTypes(); + const [targetMealTypeId, setTargetMealTypeId] = useState(null); + const sourceMealExists = mealTypeId + ? mealTypes.some(mealType => mealType.id === mealTypeId) + : false; + const selectedOwnMealTypeId = + targetMealTypeId && + mealTypes.some(mealType => mealType.id === targetMealTypeId) + ? targetMealTypeId + : null; + const resolvedTargetMealTypeId = + selectedOwnMealTypeId ?? + (sourceMealExists ? mealTypeId : defaultMealTypeId); + + const sourceEntryById = useMemo( + () => new Map(sourceEntries.map(entry => [entry.id, entry])), + [sourceEntries], + ); + const selectionIdsAreUnique = + new Set(selectedEntryIds).size === selectedEntryIds.length; + const selectedEntries = useMemo(() => { + if (!selectionIdsAreUnique) return []; + return selectedEntryIds.flatMap(entryId => { + const entry = sourceEntryById.get(entryId); + return entry ? [entry] : []; + }); + }, [selectedEntryIds, selectionIdsAreUnique, sourceEntryById]); + const hasInvalidSelection = + !selectionIdsAreUnique || + selectedEntries.length !== selectedEntryIds.length; + const quantitiesById = useMemo( + () => + selectedEntries.reduce>((quantities, entry) => { + quantities[entry.id] = parseDecimalInput(quantityTextById[entry.id]); + return quantities; + }, {}), + [quantityTextById, selectedEntries], + ); + const invalidEntryIds = useMemo( + () => + selectedEntries + .filter(entry => { + const quantity = quantitiesById[entry.id]; + return !Number.isFinite(quantity) || quantity <= 0; + }) + .map(entry => entry.id), + [quantitiesById, selectedEntries], + ); + const totals = useMemo( + () => + calculateFamilyCopyTotals( + selectedEntries + .filter(entry => !invalidEntryIds.includes(entry.id)) + .map(entry => ({ entry, quantity: quantitiesById[entry.id] })), + ), + [invalidEntryIds, quantitiesById, selectedEntries], + ); + const { copyFromFamilyAsync, isPending } = useCopyFamilyFoodEntries({ + onSuccess: request => { + useDiaryDateStore.getState().setSelectedDate(request.payload.targetDate); + navigation.navigate('Tabs', { + screen: 'Diary', + params: { selectedDate: request.payload.targetDate }, + }); + }, + onStale: () => { + navigation.popTo('FamilyDiary', { familyUser }); + }, + }); + + useEffect(() => { + if (wasPendingRef.current && !isPending) submitInFlightRef.current = false; + wasPendingRef.current = isPending; + }, [isPending]); + + const header = useScreenHeader({ + title: t('familyDiary.copyReview', { defaultValue: 'Review copy' }), + nativeTitle: t('familyDiary.copyReview', { defaultValue: 'Review copy' }), + left: { kind: 'back' }, + }); + const selectedIds = new Set(selectedEntries.map(entry => entry.id)); + const cannotSubmit = + isPending || + hasInvalidSelection || + selectedEntries.length === 0 || + invalidEntryIds.length > 0 || + !resolvedTargetMealTypeId; + + const submit = () => { + if (cannotSubmit || submitInFlightRef.current || !resolvedTargetMealTypeId) + return; + + submitInFlightRef.current = true; + const request = isUnchangedWholeMeal( + sourceEntries, + selectedIds, + quantitiesById, + ) + ? { + kind: 'whole' as const, + payload: { + familyUserId: familyUser.userId, + sourceDate, + sourceMealType: mealTypeId ?? mealTypeName, + targetDate, + targetMealType: resolvedTargetMealTypeId, + entries: sourceEntries.map(entry => ({ + entryId: entry.id, + sourceFingerprint: foodEntryCopyFingerprint(entry), + })), + }, + } + : { + kind: 'selected' as const, + payload: { + familyUserId: familyUser.userId, + sourceDate, + targetDate, + targetMealType: resolvedTargetMealTypeId, + entries: selectedEntries.map(entry => ({ + entryId: entry.id, + quantity: quantitiesById[entry.id], + sourceFingerprint: foodEntryCopyFingerprint(entry), + })), + }, + }; + + void copyFromFamilyAsync(request).catch(() => { + submitInFlightRef.current = false; + }); + }; + + return ( + + {header} + + + {t('familyDiary.copyFrom', { + name: displayName, + defaultValue: 'Copying from {{name}}', + })} + + + {t('familyDiary.copyMealTitle', { + meal: mealTypeName, + defaultValue: '{{meal}} meal', + })} + + + + {selectedEntries.map(entry => { + const foodName = + entry.food_name ?? + t('familyDiary.unnamedFood', { defaultValue: 'Unnamed food' }); + const invalid = invalidEntryIds.includes(entry.id); + return ( + + + {foodName} + + + + setQuantityTextById(current => ({ + ...current, + [entry.id]: quantityText, + })) + } + className="min-w-24 rounded-lg border border-border-subtle bg-background px-3 py-2 text-base text-text-primary" + style={{ minHeight: 44 }} + /> + {entry.unit} + + {invalid ? ( + + {t('familyDiary.quantityMustBePositive', { + defaultValue: 'Enter a quantity greater than zero.', + })} + + ) : null} + + ); + })} + + + {hasInvalidSelection ? ( + + {t('familyDiary.copyInvalidSelection', { + defaultValue: 'Selected foods are no longer available.', + })} + + ) : null} + + + + {t('familyDiary.copyNutrition', { defaultValue: 'Copy nutrition' })} + + + {t('familyDiary.copyCalories', { + calories: formatNutritionValue(totals.calories), + defaultValue: '{{calories}} kcal', + })} + + + {t('familyDiary.copyProtein', { + protein: formatNutritionValue(totals.protein), + defaultValue: '{{protein}} g protein', + })} + + + {t('familyDiary.copyCarbs', { + carbs: formatNutritionValue(totals.carbs), + defaultValue: '{{carbs}} g carbs', + })} + + + {t('familyDiary.copyFat', { + fat: formatNutritionValue(totals.fat), + defaultValue: '{{fat}} g fat', + })} + + + + + calendarRef.current?.present()} + /> + + + + {t('familyDiary.copyTargetMeal', { defaultValue: 'Copy to meal' })} + + + {mealTypes.map(mealType => { + const selected = mealType.id === resolvedTargetMealTypeId; + return ( + setTargetMealTypeId(mealType.id)} + > + + {mealType.name} + + + ); + })} + + {!resolvedTargetMealTypeId ? ( + + {t('familyDiary.copyTargetMealRequired', { + defaultValue: 'Choose a meal before copying.', + })} + + ) : null} + + + + + + ); +}; + +export default FamilyCopyReviewScreen; diff --git a/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx new file mode 100644 index 0000000000..d6f99e5acf --- /dev/null +++ b/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; +import { useActiveWorkoutBarPadding } from '../components/ActiveWorkoutBar'; +import CalendarSheet, { + type CalendarSheetRef, +} from '../components/CalendarSheet'; +import DateNavigator from '../components/DateNavigator'; +import Icon from '../components/Icon'; +import StatusView from '../components/StatusView'; +import Button from '../components/ui/Button'; +import { useFamilyDailySummary } from '../hooks'; +import { familyUsersQueryKey } from '../hooks/queryKeys'; +import { useScreenHeader } from '../hooks/useScreenHeader'; +import { useNativeIOSHeadersActive } from '../services/nativeTabBarPreference'; +import { ApiError } from '../services/api/errors'; +import type { RootStackScreenProps } from '../types/navigation'; +import { + calculateFamilyCopyTotals, + familyDiaryUserName, + groupFamilyFoodEntries, +} from '../utils/familyDiary'; +import { addDays, formatDate, getTodayDate } from '../utils/dateUtils'; + +type FamilyDiaryScreenProps = RootStackScreenProps<'FamilyDiary'>; + +const FamilyDiaryScreen: React.FC = ({ + navigation, + route, +}) => { + const { familyUser } = route.params; + const { t, i18n } = useTranslation(); + const insets = useSafeAreaInsets(); + const activeWorkoutBarPadding = useActiveWorkoutBarPadding('stack'); + const usesNativeHeader = useNativeIOSHeadersActive(); + const queryClient = useQueryClient(); + const calendarRef = useRef(null); + const [selectedDate, setSelectedDate] = useState(getTodayDate); + const locale = i18n?.resolvedLanguage ?? i18n?.language ?? 'en-US'; + const displayName = familyDiaryUserName( + familyUser, + t('familyDiary.unnamedMember', { defaultValue: 'Family member' }), + ); + const { data, error, isError, isLoading, refetch } = useFamilyDailySummary({ + familyUserId: familyUser.userId, + date: selectedDate, + }); + const header = useScreenHeader({ + title: displayName, + nativeTitle: displayName, + left: { kind: 'back' }, + }); + + useEffect(() => { + if (isError && error instanceof ApiError && error.statusCode === 403) { + void queryClient.invalidateQueries({ queryKey: familyUsersQueryKey }); + } + }, [error, isError, queryClient]); + + const groups = groupFamilyFoodEntries(data?.foodEntries ?? []); + + const content = isLoading ? ( + + ) : isError ? ( + + void refetch(), + variant: 'primary', + }} + /> + + + ) : groups.length === 0 ? ( + + ) : ( + + + {t('familyDiary.diaryForDate', { + date: formatDate(selectedDate, locale), + defaultValue: 'Family diary · {{date}}', + })} + + {groups.map(group => { + const totals = calculateFamilyCopyTotals( + group.entries.map(entry => ({ entry, quantity: entry.quantity })), + ); + + return ( + + navigation.navigate('FamilyMealDetail', { + familyUser, + sourceDate: selectedDate, + mealTypeId: group.mealTypeId, + mealTypeName: group.mealTypeName, + entries: group.entries, + }) + } + > + + + + {group.mealTypeName} + + + {t('familyDiary.mealCalories', { + calories: Math.round(totals.calories), + defaultValue: '{{calories}} kcal', + })} + + + + + + {group.entries.map(entry => ( + + + {entry.food_name ?? + t('familyDiary.unnamedFood', { + defaultValue: 'Unnamed food', + })} + + + {entry.quantity} {entry.unit} + + + ))} + + + ); + })} + + ); + + return ( + + {header} + setSelectedDate(date => addDays(date, -1))} + onNextDay={() => setSelectedDate(date => addDays(date, 1))} + onToday={() => setSelectedDate(getTodayDate())} + onDatePress={() => calendarRef.current?.present()} + dateControls={{ + previousDayLabel: t('familyDiary.previousDay', { + defaultValue: 'Previous day', + }), + previousDayHint: t('familyDiary.previousDayHint', { + defaultValue: 'Shows the previous day', + }), + nextDayLabel: t('familyDiary.nextDay', { + defaultValue: 'Next day', + }), + nextDayHint: t('familyDiary.nextDayHint', { + defaultValue: 'Shows the next day', + }), + chooseDateLabel: t('familyDiary.chooseDate', { + defaultValue: 'Choose date', + }), + chooseDateHint: t('familyDiary.chooseDateHint', { + defaultValue: 'Opens the date picker', + }), + goToTodayLabel: t('familyDiary.goToToday', { + defaultValue: 'Go to today', + }), + goToTodayHint: t('familyDiary.goToTodayHint', { + defaultValue: 'Returns to today', + }), + }} + skipTopInset + /> + {content} + + + ); +}; + +export default FamilyDiaryScreen; diff --git a/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx new file mode 100644 index 0000000000..c72c7b732e --- /dev/null +++ b/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx @@ -0,0 +1,219 @@ +import React, { useMemo, useState } from 'react'; +import { Pressable, ScrollView, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; +import { useActiveWorkoutBarPadding } from '../components/ActiveWorkoutBar'; +import Button from '../components/ui/Button'; +import { useScreenHeader } from '../hooks/useScreenHeader'; +import { useNativeIOSHeadersActive } from '../services/nativeTabBarPreference'; +import type { RootStackScreenProps } from '../types/navigation'; +import { + calculateFamilyCopyTotals, + familyDiaryUserName, +} from '../utils/familyDiary'; + +type FamilyMealDetailScreenProps = RootStackScreenProps<'FamilyMealDetail'>; + +function formatNutritionValue(value: number): number { + return Number(value.toFixed(1)); +} + +const FamilyMealDetailScreen: React.FC = ({ + navigation, + route, +}) => { + const { familyUser, sourceDate, mealTypeId, mealTypeName, entries } = + route.params; + const { t } = useTranslation(); + const displayName = familyDiaryUserName( + familyUser, + t('familyDiary.unnamedMember', { defaultValue: 'Family member' }), + ); + const insets = useSafeAreaInsets(); + const activeWorkoutBarPadding = useActiveWorkoutBarPadding('stack'); + const usesNativeHeader = useNativeIOSHeadersActive(); + const [selectedIds, setSelectedIds] = useState( + () => new Set(entries.map(entry => entry.id)), + ); + const header = useScreenHeader({ + title: mealTypeName, + nativeTitle: mealTypeName, + left: { kind: 'back' }, + }); + const selectedEntries = useMemo( + () => entries.filter(entry => selectedIds.has(entry.id)), + [entries, selectedIds], + ); + const selectedTotals = calculateFamilyCopyTotals( + selectedEntries.map(entry => ({ entry, quantity: entry.quantity })), + ); + const allSelected = entries.length > 0 && selectedIds.size === entries.length; + const selectAllLabel = allSelected + ? t('familyDiary.deselectAll', { defaultValue: 'Deselect all' }) + : t('familyDiary.selectAll', { defaultValue: 'Select all' }); + + const toggleEntry = (entryId: string) => { + setSelectedIds(current => { + const next = new Set(current); + if (next.has(entryId)) { + next.delete(entryId); + } else { + next.add(entryId); + } + return next; + }); + }; + + const toggleAll = () => { + setSelectedIds( + allSelected ? new Set() : new Set(entries.map(entry => entry.id)), + ); + }; + + const continueToReview = () => { + navigation.navigate('FamilyCopyReview', { + familyUser, + sourceDate, + mealTypeId, + mealTypeName, + sourceEntries: entries, + selectedEntryIds: entries + .filter(entry => selectedIds.has(entry.id)) + .map(entry => entry.id), + }); + }; + + return ( + + {header} + + {displayName} + + {mealTypeName} + + + {t('familyDiary.selectedNutrition', { + calories: formatNutritionValue(selectedTotals.calories), + protein: formatNutritionValue(selectedTotals.protein), + carbs: formatNutritionValue(selectedTotals.carbs), + fat: formatNutritionValue(selectedTotals.fat), + defaultValue: + 'Selected: {{calories}} kcal · P {{protein}} g · C {{carbs}} g · F {{fat}} g', + })} + + + {familyUser.canCopy ? ( + + ) : ( + + {t('familyDiary.viewingOnly', { defaultValue: 'Viewing only' })} + + )} + + + {entries.map(entry => { + const selected = selectedIds.has(entry.id); + const foodName = + entry.food_name ?? + t('familyDiary.unnamedFood', { + defaultValue: 'Unnamed food', + }); + const selectionLabel = selected + ? t('familyDiary.deselectFood', { + food: foodName, + defaultValue: 'Deselect {{food}}', + }) + : t('familyDiary.selectFood', { + food: foodName, + defaultValue: 'Select {{food}}', + }); + const entryTotals = calculateFamilyCopyTotals([ + { entry, quantity: entry.quantity }, + ]); + const row = ( + <> + + + {foodName} + + + {entry.quantity} {entry.unit} + + + {t('familyDiary.nutritionSummary', { + calories: formatNutritionValue(entryTotals.calories), + protein: formatNutritionValue(entryTotals.protein), + carbs: formatNutritionValue(entryTotals.carbs), + fat: formatNutritionValue(entryTotals.fat), + defaultValue: + '{{calories}} kcal · P {{protein}} g · C {{carbs}} g · F {{fat}} g', + })} + + + {familyUser.canCopy ? ( + + {selected ? '✓' : '○'} + + ) : null} + + ); + + if (!familyUser.canCopy) { + return ( + + {row} + + ); + } + + return ( + toggleEntry(entry.id)} + > + {row} + + ); + })} + + + {familyUser.canCopy ? ( + + ) : null} + + + ); +}; + +export default FamilyMealDetailScreen; diff --git a/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx new file mode 100644 index 0000000000..215adad9db --- /dev/null +++ b/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { FlatList, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTranslation } from 'react-i18next'; +import SettingsRow from '../components/SettingsRow'; +import StatusView from '../components/StatusView'; +import { useActiveWorkoutBarPadding } from '../components/ActiveWorkoutBar'; +import { useFamilyUsers } from '../hooks'; +import { useScreenHeader } from '../hooks/useScreenHeader'; +import { useNativeIOSHeadersActive } from '../services/nativeTabBarPreference'; +import type { RootStackScreenProps } from '../types/navigation'; +import { familyDiaryUserName } from '../utils/familyDiary'; + +type FamilyMembersScreenProps = RootStackScreenProps<'FamilyMembers'>; + +const FamilyMembersScreen: React.FC = ({ + navigation, +}) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const activeWorkoutBarPadding = useActiveWorkoutBarPadding('stack'); + const usesNativeHeader = useNativeIOSHeadersActive(); + const { data: users = [], isLoading, isError, refetch } = useFamilyUsers(); + const header = useScreenHeader({ + title: t('familyDiary.title', { defaultValue: 'Family Diaries' }), + nativeTitle: t('familyDiary.title', { defaultValue: 'Family Diaries' }), + left: { kind: 'back' }, + }); + + return ( + + {header} + {isLoading ? ( + + ) : isError ? ( + void refetch(), + variant: 'primary', + }} + /> + ) : users.length === 0 ? ( + + ) : ( + user.userId} + testID="family-members-list" + contentContainerStyle={{ + padding: 16, + paddingBottom: insets.bottom + 16 + activeWorkoutBarPadding, + }} + renderItem={({ item }) => { + const displayName = familyDiaryUserName( + item, + t('familyDiary.unnamedMember', { + defaultValue: 'Family member', + }), + ); + const capabilityLabel = item.canCopy + ? t('familyDiary.canCopy', { defaultValue: 'Can copy' }) + : t('familyDiary.viewOnly', { defaultValue: 'View only' }); + + return ( + + navigation.navigate('FamilyDiary', { familyUser: item }) + } + /> + ); + }} + /> + )} + + ); +}; + +export default FamilyMembersScreen; diff --git a/SparkyFitnessMobile/src/screens/SettingsScreen.tsx b/SparkyFitnessMobile/src/screens/SettingsScreen.tsx index dc2192111c..78669c0e81 100644 --- a/SparkyFitnessMobile/src/screens/SettingsScreen.tsx +++ b/SparkyFitnessMobile/src/screens/SettingsScreen.tsx @@ -29,7 +29,7 @@ type SettingsScreenProps = CompositeScreenProps< >; const SettingsScreen: React.FC = ({ navigation }) => { - const { t , i18n: translationI18n } = useTranslation(); + const { t, i18n: translationI18n } = useTranslation(); const dateLocale = translationI18n.language.startsWith('pl') ? 'pl-PL' : 'en-US'; const insets = useSafeAreaInsets(); const activeWorkoutBarPadding = useActiveWorkoutBarPadding(); @@ -178,6 +178,14 @@ const SettingsScreen: React.FC = ({ navigation }) => { onPress={() => navigation.navigate('AppSettings')} iconColor={catViolet} /> + {isConnected && ( + navigation.navigate('FamilyMembers')} + iconColor={catTeal} + /> + )} {isConnected && ( => - apiFetch({ - endpoint: `/api/daily-summary?date=${encodeURIComponent(date)}`, +export const fetchDailySummary = (date: string, userId?: string): Promise => { + const params = new URLSearchParams({ date }); + if (userId) params.set('userId', userId); + + return apiFetch({ + endpoint: `/api/daily-summary?${params.toString()}`, serviceName: 'Daily Summary API', - operation: 'fetch daily summary', + operation: userId ? 'fetch family daily summary' : 'fetch daily summary', }); +}; diff --git a/SparkyFitnessMobile/src/services/api/familyApi.ts b/SparkyFitnessMobile/src/services/api/familyApi.ts new file mode 100644 index 0000000000..99f367a33e --- /dev/null +++ b/SparkyFitnessMobile/src/services/api/familyApi.ts @@ -0,0 +1,47 @@ +import type { FamilyDiaryUser } from '../../types/familyDiary'; +import { apiFetch } from './apiClient'; + +interface AccessibleFamilyUserResponse { + user_id: string; + full_name: string | null; + email: string | null; + permissions: Record | null; + access_end_date: string | null; +} + +const hasDiaryPermission = ( + permissions: AccessibleFamilyUserResponse['permissions'], +) => + Boolean( + permissions?.diary || + permissions?.calorie || + permissions?.can_manage_diary || + permissions?.can_view_reports || + permissions?.can_view_food_library, + ); + +const hasCopyPermission = ( + permissions: AccessibleFamilyUserResponse['permissions'], +) => + Boolean( + permissions?.can_manage_diary && + (permissions.food_list || permissions.can_view_food_library), + ); + +export async function fetchFamilyDiaryUsers(): Promise { + const users = await apiFetch({ + endpoint: '/api/identity/users/accessible-users', + serviceName: 'Family Diary API', + operation: 'fetch accessible family users', + }); + + return users + .filter(user => hasDiaryPermission(user.permissions)) + .map(user => ({ + userId: user.user_id, + displayName: user.full_name ?? user.email ?? '', + email: user.email, + canCopy: hasCopyPermission(user.permissions), + accessEndDate: user.access_end_date, + })); +} diff --git a/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts b/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts index 0513d8de75..996026633d 100644 --- a/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts +++ b/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts @@ -1,5 +1,6 @@ import { apiFetch } from './apiClient'; import type { FoodEntry } from '../../types/foodEntries'; +import type { CopyReviewedFoodEntriesFromUserPayload, CopySelectedFoodEntriesFromUserPayload } from '@workspace/shared'; export interface CreateFoodEntryPayload { meal_type_id: string; @@ -125,6 +126,33 @@ export const copyFoodEntries = async (payload: CopyFoodEntriesPayload): Promise< }); }; +/** + * Copies a complete meal from an explicitly selected family diary user. + * The server preserves composite-meal containers for this whole-meal route. + */ +export const copyReviewedFoodEntriesFromUser = async (payload: CopyReviewedFoodEntriesFromUserPayload): Promise => { + await apiFetch({ + endpoint: '/api/food-entries/copy-reviewed-from-user', + serviceName: 'Food Entries API', + operation: 'copy reviewed food entries from family user', + method: 'POST', + body: payload, + }); +}; + +/** + * Copies selected entries from an explicitly selected family diary user. + */ +export const copySelectedFoodEntriesFromUser = async (payload: CopySelectedFoodEntriesFromUserPayload): Promise => { + await apiFetch({ + endpoint: '/api/food-entries/copy-selected-from-user', + serviceName: 'Food Entries API', + operation: 'copy selected food entries from family user', + method: 'POST', + body: payload, + }); +}; + /** * Fetches food entries for a given date. */ diff --git a/SparkyFitnessMobile/src/types/familyDiary.ts b/SparkyFitnessMobile/src/types/familyDiary.ts new file mode 100644 index 0000000000..0bb45653c6 --- /dev/null +++ b/SparkyFitnessMobile/src/types/familyDiary.ts @@ -0,0 +1,13 @@ +export interface FamilyDiaryUser { + userId: string; + displayName: string; + email: string | null; + canCopy: boolean; + accessEndDate: string | null; +} + + +export type { + CopyReviewedFoodEntriesFromUserPayload, + CopySelectedFoodEntriesFromUserPayload, +} from '@workspace/shared'; diff --git a/SparkyFitnessMobile/src/types/navigation.ts b/SparkyFitnessMobile/src/types/navigation.ts index 96bd4bd12f..4d6981fb45 100644 --- a/SparkyFitnessMobile/src/types/navigation.ts +++ b/SparkyFitnessMobile/src/types/navigation.ts @@ -22,6 +22,7 @@ import type { MealTypeKey } from '../utils/mealNutrition'; import type { SaveFoodPayload } from '../services/api/foodsApi'; import type { CompletedSetMap, PrSetMap } from '../stores/activeWorkoutStore'; import type { AssumedSetValues } from '../utils/workoutSession'; +import type { FamilyDiaryUser } from './familyDiary'; export type FoodPickerMode = 'log-entry' | 'meal-builder' | 'library'; @@ -36,6 +37,23 @@ export type TabParamList = { export type RootStackParamList = { Onboarding: undefined; Tabs: NavigatorScreenParams; + FamilyMembers: undefined; + FamilyDiary: { familyUser: FamilyDiaryUser }; + FamilyMealDetail: { + familyUser: FamilyDiaryUser; + sourceDate: string; + mealTypeId: string | null; + mealTypeName: string; + entries: FoodEntry[]; + }; + FamilyCopyReview: { + familyUser: FamilyDiaryUser; + sourceDate: string; + mealTypeId: string | null; + mealTypeName: string; + sourceEntries: FoodEntry[]; + selectedEntryIds: string[]; + }; CycleSettings: undefined; CycleOnboarding: undefined; CycleHub: undefined; diff --git a/SparkyFitnessMobile/src/utils/dateUtils.ts b/SparkyFitnessMobile/src/utils/dateUtils.ts index 698382d98b..4aaf1b088e 100644 --- a/SparkyFitnessMobile/src/utils/dateUtils.ts +++ b/SparkyFitnessMobile/src/utils/dateUtils.ts @@ -6,7 +6,10 @@ import type { TFunction } from 'i18next'; * Delegates to the shared localDateToDay helper to ensure device-local calendar day consistency. */ export const toLocalDateString = (timestamp: string | Date): string => { - const localDate = typeof timestamp === 'string' || typeof timestamp === 'number' ? new Date(timestamp) : timestamp; + const localDate = + typeof timestamp === 'string' || typeof timestamp === 'number' + ? new Date(timestamp) + : timestamp; return localDateToDay(localDate); }; @@ -34,7 +37,8 @@ export const addDays = (dateString: string, days: number): string => { }; // Strip any time/timezone suffix from a date string, returning just YYYY-MM-DD -export const normalizeDate = (dateString: string): string => dateString.split('T')[0]; +export const normalizeDate = (dateString: string): string => + dateString.split('T')[0]; // Format a YYYY-MM-DD date for display ("Mon, Jan 6") export const formatDate = (dateString: string, locale: string): string => { diff --git a/SparkyFitnessMobile/src/utils/familyDiary.ts b/SparkyFitnessMobile/src/utils/familyDiary.ts new file mode 100644 index 0000000000..63a14d72c5 --- /dev/null +++ b/SparkyFitnessMobile/src/utils/familyDiary.ts @@ -0,0 +1,95 @@ +import type { FoodEntry } from '../types/foodEntries'; + +export interface FamilyMealGroup { + key: string; + mealTypeId: string | null; + mealTypeName: string; + entries: FoodEntry[]; +} + +export interface FamilyCopySelection { + entry: FoodEntry; + quantity: number; +} + +export interface FamilyCopyTotals { + calories: number; + protein: number; + carbs: number; + fat: number; +} + +export function familyDiaryUserName( + user: { displayName: string }, + fallback: string, +): string { + return user.displayName.trim() || fallback; +} + +export function groupFamilyFoodEntries( + entries: FoodEntry[], +): FamilyMealGroup[] { + const groups = new Map(); + + for (const entry of entries) { + const key = entry.meal_type_id ?? `legacy:${entry.meal_type.toLowerCase()}`; + const current = groups.get(key); + if (current) { + current.entries.push(entry); + } else { + groups.set(key, { + key, + mealTypeId: entry.meal_type_id ?? null, + mealTypeName: entry.meal_type, + entries: [entry], + }); + } + } + + return [...groups.values()]; +} + +function nutrientForQuantity( + entry: FoodEntry, + field: 'calories' | 'protein' | 'carbs' | 'fat', + quantity: number, +): number { + const servingSize = Number(entry.serving_size); + const nutrient = Number(entry[field] ?? 0); + return servingSize > 0 ? (nutrient * quantity) / servingSize : 0; +} + +export function calculateFamilyCopyTotals( + selections: FamilyCopySelection[], +): FamilyCopyTotals { + return selections.reduce( + (totals, { entry, quantity }) => { + totals.calories += nutrientForQuantity(entry, 'calories', quantity); + totals.protein += nutrientForQuantity(entry, 'protein', quantity); + totals.carbs += nutrientForQuantity(entry, 'carbs', quantity); + totals.fat += nutrientForQuantity(entry, 'fat', quantity); + return totals; + }, + { calories: 0, protein: 0, carbs: 0, fat: 0 }, + ); +} + +export function isUnchangedWholeMeal( + sourceEntries: FoodEntry[], + selectedEntryIds: Set, + quantitiesById: Record, +): boolean { + if (sourceEntries.length !== selectedEntryIds.size) return false; + + return sourceEntries.every(entry => { + if (!selectedEntryIds.has(entry.id)) return false; + const selectedQuantity = quantitiesById[entry.id]; + return ( + Number.isFinite(entry.quantity) && + entry.quantity > 0 && + Number.isFinite(selectedQuantity) && + selectedQuantity > 0 && + Math.abs(selectedQuantity - entry.quantity) <= 1e-9 + ); + }); +} diff --git a/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts b/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts index 7220eab79c..31085554c8 100644 --- a/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts +++ b/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts @@ -1,5 +1,6 @@ import type { NativeStackHeaderItem } from '@react-navigation/native-stack'; import { formatDateLabel } from './dateUtils'; +import { createNativeHeaderIconButtonItem } from './nativeHeaderItems'; export type NativeHeaderDatePickerOptions = { selectedDate: string; @@ -13,11 +14,18 @@ export type NativeHeaderDatePickerOptions = { dateLabel?: string; t: import('i18next').TFunction; locale: string; + leadingAction?: { + sfSymbol: string; + onPress: () => void; + accessibilityLabel: string; + identifier: string; + }; }; export type NativeHeaderDatePickerNavigation = { setOptions: (options: { unstable_headerRightItems: () => NativeStackHeaderItem[]; + unstable_headerLeftItems?: () => NativeStackHeaderItem[]; }) => void; }; @@ -25,9 +33,21 @@ export function setNativeHeaderDatePickerOptions( navigation: NativeHeaderDatePickerNavigation, options: NativeHeaderDatePickerOptions, ) { + const leadingAction = options.leadingAction; + navigation.setOptions({ - unstable_headerRightItems: () => - createNativeHeaderDatePickerItems(options), + unstable_headerRightItems: () => createNativeHeaderDatePickerItems(options), + unstable_headerLeftItems: leadingAction + ? () => [ + createNativeHeaderIconButtonItem({ + sfSymbol: leadingAction.sfSymbol, + onPress: leadingAction.onPress, + tintColor: options.tintColor, + accessibilityLabel: leadingAction.accessibilityLabel, + identifier: leadingAction.identifier, + }), + ] + : undefined, }); } diff --git a/SparkyFitnessServer/models/foodEntry.ts b/SparkyFitnessServer/models/foodEntry.ts index 27409fd2c1..810e8a56f5 100644 --- a/SparkyFitnessServer/models/foodEntry.ts +++ b/SparkyFitnessServer/models/foodEntry.ts @@ -5,6 +5,45 @@ import format from 'pg-format'; import { sanitizeCustomNutrients } from '../utils/foodUtils.js'; import { toImageArray } from '../utils/imageLocalizer.js'; import type { FoodEntryInput, FoodEntrySnapshot } from '../types/nutrition.js'; +import { + hasExactReviewedFoodEntrySnapshot, + type ReviewedFoodEntryFingerprint, +} from '@workspace/shared'; + +interface ReviewedFoodEntryCopyInput { + targetUserId: string; + actingUserId: string; + sourceUserId: string; + sourceDate: string; + sourceMealTypeId: string; + targetDate: string; + targetMealTypeId: string; + reviewedEntries: ReviewedFoodEntryFingerprint[]; +} + +interface ReviewedSourceEntry extends FoodEntryInput { + id: string; + food_entry_meal_id: string | null; +} + +interface SourceMealContainer { + id: string; + meal_template_id: string | null; + entry_time: string | null; + name: string; + description: string | null; + quantity: number | null; + unit: string | null; + legacy_serving_unit_math: boolean; +} + +function reviewedCopyConflict() { + return Object.assign( + new Error('One or more source entries changed. Refresh the family diary.'), + { statusCode: 409 } + ); +} + /** * @swagger * components: @@ -743,6 +782,250 @@ async function getFoodEntryByDetails( } } +// Copies a complete, reviewed family meal in one serializable transaction. +// The source snapshot check belongs next to the writes so an update that races +// the service's preliminary check cannot leave partially-created containers. +async function copyReviewedFoodEntriesFromUser({ + targetUserId, + actingUserId, + sourceUserId, + sourceDate, + sourceMealTypeId, + targetDate, + targetMealTypeId, + reviewedEntries, +}: ReviewedFoodEntryCopyInput) { + const client = await getClient(targetUserId, actingUserId); + let transactionStarted = false; + let releaseError: Error | undefined; + + try { + await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE'); + transactionStarted = true; + + const sourceResult = (await client.query( + `SELECT + fe.id, + fe.food_id, + fe.meal_type_id, + fe.quantity, + fe.unit, + fe.entry_date, + fe.entry_time, + fe.variant_id, + fe.meal_plan_template_id, + fe.food_entry_meal_id, + fe.food_name, + fe.brand_name, + fe.serving_size, + fe.serving_unit, + fe.calories, + fe.protein, + fe.carbs, + fe.fat, + fe.saturated_fat, + fe.polyunsaturated_fat, + fe.monounsaturated_fat, + fe.trans_fat, + fe.cholesterol, + fe.sodium, + fe.potassium, + fe.dietary_fiber, + fe.sugars, + fe.vitamin_a, + fe.vitamin_c, + fe.calcium, + fe.iron, + fe.glycemic_index, + fe.custom_nutrients + FROM food_entries fe + WHERE fe.user_id = $1 + AND fe.entry_date = $2 + AND fe.meal_type_id = $3 + FOR SHARE`, + [sourceUserId, sourceDate, sourceMealTypeId] + )) as { rows: ReviewedSourceEntry[] }; + const sourceEntries = sourceResult.rows; + if (!hasExactReviewedFoodEntrySnapshot(sourceEntries, reviewedEntries)) { + throw reviewedCopyConflict(); + } + + const copiedEntries: unknown[] = []; + const targetMealIdBySourceMealId = new Map(); + const existingStandaloneResult = (await client.query( + `SELECT food_id, variant_id + FROM food_entries + WHERE user_id = $1 + AND meal_type_id = $2 + AND entry_date = $3 + AND food_entry_meal_id IS NULL + AND food_id IS NOT NULL`, + [targetUserId, targetMealTypeId, targetDate] + )) as { + rows: Array<{ food_id: string | null; variant_id: string | null }>; + }; + const existingStandaloneKeys = new Set( + existingStandaloneResult.rows + .filter(({ food_id }) => food_id !== null) + .map( + ({ food_id, variant_id }) => + `${food_id ?? 'null'}:${variant_id ?? 'null'}` + ) + ); + + for (const entry of sourceEntries) { + let targetFoodEntryMealId: string | null = null; + if (entry.food_entry_meal_id) { + targetFoodEntryMealId = + targetMealIdBySourceMealId.get(entry.food_entry_meal_id) ?? null; + + if (!targetFoodEntryMealId) { + const sourceMealResult = (await client.query( + `SELECT + id, + meal_template_id, + entry_time, + name, + description, + quantity, + unit, + legacy_serving_unit_math + FROM food_entry_meals + WHERE id = $1 AND user_id = $2 + FOR SHARE`, + [entry.food_entry_meal_id, sourceUserId] + )) as { rows: SourceMealContainer[] }; + const sourceMeal = sourceMealResult.rows[0]; + if (!sourceMeal) throw reviewedCopyConflict(); + + const targetMealResult = (await client.query( + `INSERT INTO food_entry_meals ( + user_id, + meal_template_id, + meal_type_id, + entry_date, + entry_time, + name, + description, + quantity, + unit, + legacy_serving_unit_math, + created_by_user_id, + updated_by_user_id, + images + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + COALESCE((SELECT images FROM meals WHERE id = $2), '[]'::jsonb) + ) + RETURNING id`, + [ + targetUserId, + sourceMeal.meal_template_id, + targetMealTypeId, + targetDate, + sourceMeal.entry_time, + sourceMeal.name, + sourceMeal.description, + sourceMeal.quantity, + sourceMeal.unit, + sourceMeal.legacy_serving_unit_math, + actingUserId, + actingUserId, + ] + )) as { rows: Array<{ id: string }> }; + targetFoodEntryMealId = targetMealResult.rows[0]?.id ?? null; + if (!targetFoodEntryMealId) { + throw new Error('Could not create copied meal container.'); + } + targetMealIdBySourceMealId.set( + entry.food_entry_meal_id, + targetFoodEntryMealId + ); + } + } else { + const standaloneKey = `${entry.food_id ?? 'null'}:${entry.variant_id ?? 'null'}`; + if ( + entry.food_id !== null && + entry.food_id !== undefined && + existingStandaloneKeys.has(standaloneKey) + ) + continue; + } + + const inserted = await client.query( + `INSERT INTO food_entries ( + user_id, food_id, meal_type_id, quantity, unit, entry_date, + entry_time, variant_id, meal_plan_template_id, food_entry_meal_id, + created_by_user_id, updated_by_user_id, food_name, brand_name, + serving_size, serving_unit, calories, protein, carbs, fat, + saturated_fat, polyunsaturated_fat, monounsaturated_fat, trans_fat, + cholesterol, sodium, potassium, dietary_fiber, sugars, vitamin_a, + vitamin_c, calcium, iron, glycemic_index, custom_nutrients + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, + $27, $28, $29, $30, $31, $32, $33, $34, $35 + ) RETURNING *`, + [ + targetUserId, + entry.food_id, + targetMealTypeId, + entry.quantity, + entry.unit, + targetDate, + entry.entry_time ?? null, + entry.variant_id, + null, + targetFoodEntryMealId, + actingUserId, + actingUserId, + entry.food_name, + entry.brand_name, + entry.serving_size, + entry.serving_unit, + entry.calories, + entry.protein, + entry.carbs, + entry.fat, + entry.saturated_fat, + entry.polyunsaturated_fat, + entry.monounsaturated_fat, + entry.trans_fat, + entry.cholesterol, + entry.sodium, + entry.potassium, + entry.dietary_fiber, + entry.sugars, + entry.vitamin_a, + entry.vitamin_c, + entry.calcium, + entry.iron, + entry.glycemic_index, + sanitizeCustomNutrients(entry.custom_nutrients), + ] + ); + copiedEntries.push(...inserted.rows); + } + + await client.query('COMMIT'); + return copiedEntries; + } catch (error) { + if (transactionStarted) { + try { + await client.query('ROLLBACK'); + } catch (rollbackError) { + releaseError = rollbackError as Error; + } + } + if ((error as { code?: string }).code === '40001') { + throw reviewedCopyConflict(); + } + throw error; + } finally { + client.release(releaseError); + } +} + async function bulkCreateFoodEntries( entriesData: FoodEntryInput[], authenticatedUserId: string @@ -1059,6 +1342,7 @@ export { getFoodEntriesByDate }; export { getFoodEntriesByDateAndMealType }; export { getFoodEntriesByDateRange }; export { getFoodEntryByDetails }; +export { copyReviewedFoodEntriesFromUser }; export { bulkCreateFoodEntries }; export { getFoodEntryById }; export { getFoodEntryComponentsByFoodEntryMealId }; @@ -1076,6 +1360,7 @@ export default { getFoodEntriesByDateAndMealType, getFoodEntriesByDateRange, getFoodEntryByDetails, + copyReviewedFoodEntriesFromUser, bulkCreateFoodEntries, getFoodEntryById, getFoodEntryComponentsByFoodEntryMealId, diff --git a/SparkyFitnessServer/routes/foodEntryRoutes.ts b/SparkyFitnessServer/routes/foodEntryRoutes.ts index 1094cb99d0..acaca18bdf 100644 --- a/SparkyFitnessServer/routes/foodEntryRoutes.ts +++ b/SparkyFitnessServer/routes/foodEntryRoutes.ts @@ -4,7 +4,11 @@ import checkPermissionMiddleware from '../middleware/checkPermissionMiddleware.j import foodEntryService from '../services/foodEntryService.js'; import { canAccessUserData } from '../utils/permissionUtils.js'; import { clearUserTdeeCache } from '../services/AdaptiveTdeeService.js'; -import { isEntryTimeString } from '@workspace/shared'; +import { + CopyReviewedFoodEntriesFromUserBodySchema, + CopySelectedFoodEntriesFromUserBodySchema, + isEntryTimeString, +} from '@workspace/shared'; import { uploadImages, applyImageOrder, @@ -17,8 +21,22 @@ import { const router = express.Router(); router.use(express.json()); -// Apply diary permission check to all food entry routes -router.use(checkPermissionMiddleware('diary')); +const actorScopedCopyPaths = new Set([ + '/copy-reviewed-from-user', + '/copy-selected-from-user', +]); +const diaryPermissionMiddleware = checkPermissionMiddleware('diary'); + +// Actor-scoped copy routes perform their own source-copy permission check and +// always write to the authenticated actor, independent of an active family +// context. All remaining routes retain the context-scoped diary middleware. +router.use((req, res, next) => { + if (req.method === 'POST' && actorScopedCopyPaths.has(req.path)) { + next(); + return; + } + diaryPermissionMiddleware(req, res, next); +}); /** * @swagger @@ -354,6 +372,201 @@ router.post( } } ); +/** + * @swagger + * /food-entries/copy-reviewed-from-user: + * post: + * summary: Copy an unchanged reviewed family meal into the authenticated user's diary + * tags: [Nutrition & Meals] + * description: > + * Preserves logged composite meal containers. The submitted entry IDs and + * source fingerprints are an optimistic-concurrency snapshot; any added, + * removed, or changed source row returns 409 and writes nothing. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: false + * required: + * - familyUserId + * - sourceDate + * - sourceMealType + * - targetDate + * - targetMealType + * - entries + * properties: + * familyUserId: + * type: string + * format: uuid + * sourceDate: + * type: string + * format: date + * sourceMealType: + * type: string + * targetDate: + * type: string + * format: date + * targetMealType: + * type: string + * format: uuid + * entries: + * type: array + * minItems: 1 + * maxItems: 100 + * items: + * type: object + * additionalProperties: false + * required: [entryId, sourceFingerprint] + * properties: + * entryId: + * type: string + * format: uuid + * sourceFingerprint: + * type: string + * description: Exact reviewed source-row snapshot. + * responses: + * 201: + * description: The reviewed meal was copied successfully. + * 400: + * description: Invalid request body. + * 403: + * description: Forbidden. + * 409: + * description: The reviewed source meal changed before it could be copied. + */ +router.post( + '/copy-reviewed-from-user', + authenticate, + async (req, res, next) => { + const parsed = CopyReviewedFoodEntriesFromUserBodySchema.safeParse( + req.body + ); + if (!parsed.success) { + res.status(400).json({ + error: 'Invalid request', + details: parsed.error.flatten().fieldErrors, + }); + return; + } + + try { + const actorUserId = + req.originalUserId || req.authenticatedUserId || req.userId; + const copiedEntries = + await foodEntryService.copyReviewedFoodEntriesFromUser( + actorUserId, + actorUserId, + parsed.data.familyUserId, + parsed.data.sourceDate, + parsed.data.sourceMealType, + parsed.data.targetDate, + parsed.data.targetMealType, + parsed.data.entries + ); + clearUserTdeeCache(actorUserId); + res.status(201).json(copiedEntries); + } catch (error) { + next(error); + } + } +); +/** + * @swagger + * /food-entries/copy-selected-from-user: + * post: + * summary: Copy selected food entries from a family member's diary + * tags: [Nutrition & Meals] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: false + * required: + * - familyUserId + * - sourceDate + * - targetDate + * - targetMealType + * - entries + * properties: + * familyUserId: + * type: string + * format: uuid + * sourceDate: + * type: string + * format: date + * targetDate: + * type: string + * format: date + * targetMealType: + * type: string + * format: uuid + * entries: + * type: array + * minItems: 1 + * maxItems: 100 + * items: + * type: object + * additionalProperties: false + * required: [entryId, quantity, sourceFingerprint] + * properties: + * entryId: + * type: string + * format: uuid + * quantity: + * type: number + * exclusiveMinimum: 0 + * sourceFingerprint: + * type: string + * description: Exact reviewed source-row snapshot. + * responses: + * 201: + * description: The selected food entries were copied successfully. + * 400: + * description: Invalid request body. + * 403: + * description: Forbidden. + * 409: + * description: A selected source entry changed after it was reviewed. + */ +router.post( + '/copy-selected-from-user', + authenticate, + async (req, res, next) => { + const parsed = CopySelectedFoodEntriesFromUserBodySchema.safeParse( + req.body + ); + if (!parsed.success) { + res.status(400).json({ + error: 'Invalid request', + details: parsed.error.flatten().fieldErrors, + }); + return; + } + + try { + const actorUserId = + req.originalUserId || req.authenticatedUserId || req.userId; + const copiedEntries = + await foodEntryService.copySelectedFoodEntriesFromUser( + actorUserId, + actorUserId, + parsed.data.familyUserId, + parsed.data.sourceDate, + parsed.data.targetDate, + parsed.data.targetMealType, + parsed.data.entries + ); + clearUserTdeeCache(actorUserId); + res.status(201).json(copiedEntries); + } catch (error) { + next(error); + } + } +); /** * @swagger * /food-entries/copy-to-user: diff --git a/SparkyFitnessServer/services/foodEntryService.ts b/SparkyFitnessServer/services/foodEntryService.ts index ff157ac4d2..9ab4575dd5 100644 --- a/SparkyFitnessServer/services/foodEntryService.ts +++ b/SparkyFitnessServer/services/foodEntryService.ts @@ -16,9 +16,14 @@ import goalRepository from '../models/goalRepository.js'; import measurementRepository from '../models/measurementRepository.js'; import reportRepository from '../models/reportRepository.js'; import { sanitizeCustomNutrients } from '../utils/foodUtils.js'; - import Papa from 'papaparse'; -import { isDayString } from '@workspace/shared'; +import { + type CopyReviewedFoodEntriesFromUserBody, + type CopySelectedFoodEntriesFromUserBody, + foodEntryCopyFingerprint, + hasExactReviewedFoodEntrySnapshot, + isDayString, +} from '@workspace/shared'; import customNutrientService from './customNutrientService.js'; import { removeOrphanedImages } from '../middleware/imageUpload.js'; import express from 'express'; @@ -137,6 +142,12 @@ interface MealTypeRow { user_id: string | null; } +type HttpStatusError = Error & { statusCode: number }; + +function copyStatusError(message: string, statusCode: number): HttpStatusError { + return Object.assign(new Error(message), { statusCode }); +} + // Resolves a meal type selector (a UUID or a legacy name) to its canonical // id. Resolution order: // 1. Exact id match (a caller holding a meal_type_id gets exactly that @@ -1313,6 +1324,194 @@ async function copyFoodEntriesFromUser( throw error; } } + +async function copySelectedFoodEntriesFromUser( + targetUserId: string, + actingUserId: string, + sourceUserId: string, + sourceDate: string, + targetDate: string, + targetMealType: string, + selections: CopySelectedFoodEntriesFromUserBody['entries'] +) { + // checkCopyPermissions requires both diary management and food-library + // access, and must be evaluated for the real actor rather than a switched + // active-user context. + const hasAccess = await familyAccessRepository.checkCopyPermissions( + actingUserId, + sourceUserId + ); + if (!hasAccess) { + throw copyStatusError( + 'Forbidden: You do not have permissions to copy from this family member.', + 403 + ); + } + + const targetMealTypeId = await resolveMealTypeId( + targetUserId, + targetMealType + ); + if (!targetMealTypeId) { + throw copyStatusError('Invalid target meal type.', 400); + } + + // Do not trust client-side diary rows. Re-fetch every requested source row + // before preparing anything for insertion, so an unavailable or changed row + // fails the entire selected-copy operation. + const selectedEntries = await Promise.all( + selections.map(async (selection) => ({ + selection, + entry: await foodRepository.getFoodEntryById( + selection.entryId, + sourceUserId + ), + })) + ); + + for (const { selection, entry } of selectedEntries) { + if ( + !entry || + entry.id !== selection.entryId || + entry.user_id !== sourceUserId || + entry.entry_date !== sourceDate || + foodEntryCopyFingerprint(entry) !== selection.sourceFingerprint || + !Number.isFinite(Number(entry.serving_size)) || + Number(entry.serving_size) <= 0 + ) { + throw copyStatusError( + 'One or more source entries changed. Refresh the family diary.', + 409 + ); + } + } + + const entriesToCreate: FoodEntryInput[] = []; + for (const { selection, entry } of selectedEntries) { + // Catalog-linked rows have a stable identity for duplicate detection. + // Rows without food_id are not de-duplicated here, but they cannot be + // copied today: chk_food_or_meal_id requires meal_id when food_id is null, + // and neither this path nor the existing web copy path inserts meal_id. + const existingEntry = entry.food_id + ? await foodRepository.getFoodEntryByDetails( + targetUserId, + entry.food_id, + targetMealTypeId, + targetDate, + entry.variant_id, + null + ) + : null; + if (existingEntry) continue; + + entriesToCreate.push({ + user_id: targetUserId, + created_by_user_id: actingUserId, + food_id: entry.food_id, + variant_id: entry.variant_id, + meal_type_id: targetMealTypeId, + food_entry_meal_id: null, + meal_plan_template_id: null, + entry_date: targetDate, + entry_time: entry.entry_time ?? null, + quantity: selection.quantity, + unit: entry.unit, + food_name: entry.food_name, + brand_name: entry.brand_name, + serving_size: entry.serving_size, + serving_unit: entry.serving_unit, + calories: entry.calories, + protein: entry.protein, + carbs: entry.carbs, + fat: entry.fat, + saturated_fat: entry.saturated_fat, + polyunsaturated_fat: entry.polyunsaturated_fat, + monounsaturated_fat: entry.monounsaturated_fat, + trans_fat: entry.trans_fat, + cholesterol: entry.cholesterol, + sodium: entry.sodium, + potassium: entry.potassium, + dietary_fiber: entry.dietary_fiber, + sugars: entry.sugars, + vitamin_a: entry.vitamin_a, + vitamin_c: entry.vitamin_c, + calcium: entry.calcium, + iron: entry.iron, + glycemic_index: entry.glycemic_index, + custom_nutrients: sanitizeCustomNutrients(entry.custom_nutrients), + }); + } + + return entriesToCreate.length === 0 + ? [] + : foodRepository.bulkCreateFoodEntries(entriesToCreate, targetUserId); +} + +async function copyReviewedFoodEntriesFromUser( + targetUserId: string, + actingUserId: string, + sourceUserId: string, + sourceDate: string, + sourceMealType: string, + targetDate: string, + targetMealType: string, + reviewedEntries: CopyReviewedFoodEntriesFromUserBody['entries'] +) { + // Keep the reviewed-entry fingerprint contract isolated to this endpoint. + // Consolidating the otherwise overlapping copy paths is separate work. + const hasAccess = await familyAccessRepository.checkCopyPermissions( + actingUserId, + sourceUserId + ); + if (!hasAccess) { + throw copyStatusError( + 'Forbidden: You do not have permissions to copy from this family member.', + 403 + ); + } + + const sourceMealTypeId = await resolveMealTypeId( + sourceUserId, + sourceMealType + ); + const targetMealTypeId = await resolveMealTypeId( + targetUserId, + targetMealType + ); + if (!sourceMealTypeId || !targetMealTypeId) { + throw copyStatusError('Invalid source or target meal type.', 400); + } + + // This early check produces the clear 409 without creating a container. The + // repository repeats the same comparison inside its serializable write + // transaction so a concurrent source mutation cannot slip through. + const currentSourceEntries = + await foodRepository.getFoodEntriesByDateAndMealType( + sourceUserId, + sourceDate, + sourceMealTypeId + ); + if ( + !hasExactReviewedFoodEntrySnapshot(currentSourceEntries, reviewedEntries) + ) { + throw copyStatusError( + 'One or more source entries changed. Refresh the family diary.', + 409 + ); + } + + return foodRepository.copyReviewedFoodEntriesFromUser({ + targetUserId, + actingUserId, + sourceUserId, + sourceDate, + sourceMealTypeId, + targetDate, + targetMealTypeId, + reviewedEntries, + }); +} + async function copyFoodEntriesToUser( authenticatedUserId: string, actingUserId: string, @@ -3448,7 +3647,9 @@ export { getFoodEntryMealsByDate }; export { deleteFoodEntryMeal }; export { exportAllDiaryEntriesToCSVStream }; export { copyFoodEntriesFromUser }; +export { copyReviewedFoodEntriesFromUser }; export { copyFoodEntriesToUser }; +export { copySelectedFoodEntriesFromUser }; export { importFoodDiaryEntriesInBulk }; export default { createFoodEntry, @@ -3470,6 +3671,8 @@ export default { deleteFoodEntryMeal, exportAllDiaryEntriesToCSVStream, copyFoodEntriesFromUser, + copyReviewedFoodEntriesFromUser, copyFoodEntriesToUser, + copySelectedFoodEntriesFromUser, importFoodDiaryEntriesInBulk, }; diff --git a/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts b/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts new file mode 100644 index 0000000000..08b8f64da8 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + foodEntryCopyFingerprint, + hasExactReviewedFoodEntrySnapshot, +} from '@workspace/shared'; + +describe('foodEntryCopyFingerprint', () => { + it('normalizes database numeric strings and custom nutrient key order', () => { + const left = foodEntryCopyFingerprint({ + quantity: '150', + serving_size: '100', + food_name: 'Pasta', + custom_nutrients: { Zinc: 2, magnesium: '12' }, + }); + const right = foodEntryCopyFingerprint({ + quantity: 150, + serving_size: 100, + food_name: 'Pasta', + custom_nutrients: { magnesium: '12', Zinc: 2 }, + }); + + expect(left).toBe(right); + expect(left).toContain('"custom_nutrients":{"Zinc":2,"magnesium":"12"}'); + }); + + it.each([ + ['name', { food_name: 'Changed' }], + ['meal type', { meal_type_id: 'dinner-id' }], + ['unit', { unit: 'oz' }], + ['serving size', { serving_size: 90 }], + ['nutrition', { calories: 250 }], + ])('changes when reviewed %s changes', (_field, change) => { + const original = { + quantity: 150, + unit: 'g', + serving_size: 100, + calories: 180, + food_name: 'Pasta', + }; + expect(foodEntryCopyFingerprint({ ...original, ...change })).not.toBe( + foodEntryCopyFingerprint(original) + ); + }); + + it('rejects duplicate reviewed or source IDs in an exact snapshot', () => { + const source = [ + { id: 'entry-a', quantity: 100 }, + { id: 'entry-b', quantity: 200 }, + ]; + const reviewed = source.map((entry) => ({ + entryId: entry.id, + sourceFingerprint: foodEntryCopyFingerprint(entry), + })); + + expect(hasExactReviewedFoodEntrySnapshot(source, reviewed)).toBe(true); + expect( + hasExactReviewedFoodEntrySnapshot(source, [reviewed[0], reviewed[0]]) + ).toBe(false); + expect( + hasExactReviewedFoodEntrySnapshot([source[0], source[0]], reviewed) + ).toBe(false); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts new file mode 100644 index 0000000000..c2d8dcf96f --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { copySelectedFoodEntriesFromUser } from '../services/foodEntryService.js'; +import familyAccessRepository from '../models/familyAccessRepository.js'; +import foodRepository from '../models/foodRepository.js'; +import mealTypeRepository from '../models/mealType.js'; +import { foodEntryCopyFingerprint } from '@workspace/shared'; + +vi.mock('../models/familyAccessRepository'); +vi.mock('../models/foodRepository'); +vi.mock('../models/foodEntryMealRepository'); +vi.mock('../models/mealType.js'); +vi.mock('../config/logging', () => ({ log: vi.fn() })); + +const ACTOR = 'actor-a'; +const SOURCE = 'member-b'; +const ENTRY_ID = '33333333-3333-4333-8333-333333333333'; +const SECOND_ENTRY_ID = '44444444-4444-4444-8444-444444444444'; +const TARGET_MEAL = '22222222-2222-4222-8222-222222222222'; +const SOURCE_DATE = '2026-08-23'; +const TARGET_DATE = '2026-08-24'; + +const validSourceEntry = { + id: ENTRY_ID, + user_id: SOURCE, + entry_date: SOURCE_DATE, + food_id: 'food-1', + variant_id: 'variant-1', + food_entry_meal_id: 'source-container', + quantity: 100, + unit: 'g', + serving_size: 100, + serving_unit: 'g', + food_name: 'Family Pasta', + calories: 180, + protein: 6, + carbs: 32, + fat: 3, + custom_nutrients: { magnesium: 12 }, +}; +const selection = (entryId = ENTRY_ID, quantity = 150) => ({ + entryId, + quantity, + sourceFingerprint: foodEntryCopyFingerprint({ + ...validSourceEntry, + id: entryId, + food_id: entryId === ENTRY_ID ? 'food-1' : 'food-2', + }), +}); + +describe('copySelectedFoodEntriesFromUser', () => { + beforeEach(() => vi.clearAllMocks()); + + it('requires diary and food-library copy permission for the real actor', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + false + ); + + await expect( + copySelectedFoodEntriesFromUser( + ACTOR, + ACTOR, + SOURCE, + SOURCE_DATE, + TARGET_DATE, + TARGET_MEAL, + [selection()] + ) + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(familyAccessRepository.checkCopyPermissions).toHaveBeenCalledWith( + ACTOR, + SOURCE + ); + expect(foodRepository.getFoodEntryById).not.toHaveBeenCalled(); + }); + + it.each([ + ['is unavailable', undefined], + [ + 'belongs to another owner', + { ...validSourceEntry, user_id: 'other-owner' }, + ], + [ + 'belongs to another date', + { ...validSourceEntry, entry_date: '2026-08-22' }, + ], + ])( + 'fails atomically when a selected entry %s', + async (_reason, sourceEntry) => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockResolvedValue([ + { id: TARGET_MEAL, name: 'Lunch', user_id: null }, + ]); + vi.mocked(foodRepository.getFoodEntryById).mockResolvedValue(sourceEntry); + + await expect( + copySelectedFoodEntriesFromUser( + ACTOR, + ACTOR, + SOURCE, + SOURCE_DATE, + TARGET_DATE, + TARGET_MEAL, + [selection()] + ) + ).rejects.toMatchObject({ statusCode: 409 }); + + expect(foodRepository.bulkCreateFoodEntries).not.toHaveBeenCalled(); + } + ); + + it('copies selected rows as standalone entries while retaining serving-basis nutrients', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockResolvedValue([ + { id: TARGET_MEAL, name: 'Lunch', user_id: null }, + ]); + vi.mocked(foodRepository.getFoodEntryById).mockResolvedValue( + validSourceEntry + ); + vi.mocked(foodRepository.getFoodEntryByDetails).mockResolvedValue( + undefined + ); + vi.mocked(foodRepository.bulkCreateFoodEntries).mockResolvedValue([ + { id: 'copy-1' }, + ]); + + const result = await copySelectedFoodEntriesFromUser( + ACTOR, + ACTOR, + SOURCE, + SOURCE_DATE, + TARGET_DATE, + TARGET_MEAL, + [selection()] + ); + + expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledWith( + [ + expect.objectContaining({ + user_id: ACTOR, + created_by_user_id: ACTOR, + meal_type_id: TARGET_MEAL, + entry_date: TARGET_DATE, + food_entry_meal_id: null, + quantity: 150, + serving_size: 100, + calories: 180, + protein: 6, + custom_nutrients: { magnesium: 12 }, + }), + ], + ACTOR + ); + expect(result).toEqual([{ id: 'copy-1' }]); + }); + + it('re-fetches every selection and sends all validated rows in one bulk insert', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockResolvedValue([ + { id: TARGET_MEAL, name: 'Lunch', user_id: null }, + ]); + vi.mocked(foodRepository.getFoodEntryById) + .mockResolvedValueOnce(validSourceEntry) + .mockResolvedValueOnce({ + ...validSourceEntry, + id: SECOND_ENTRY_ID, + food_id: 'food-2', + }); + vi.mocked(foodRepository.getFoodEntryByDetails).mockResolvedValue( + undefined + ); + vi.mocked(foodRepository.bulkCreateFoodEntries).mockResolvedValue([ + { id: 'copy-1' }, + { id: 'copy-2' }, + ]); + + await copySelectedFoodEntriesFromUser( + ACTOR, + ACTOR, + SOURCE, + SOURCE_DATE, + TARGET_DATE, + TARGET_MEAL, + [selection(), selection(SECOND_ENTRY_ID, 75)] + ); + + expect(foodRepository.getFoodEntryById).toHaveBeenNthCalledWith( + 1, + ENTRY_ID, + SOURCE + ); + expect(foodRepository.getFoodEntryById).toHaveBeenNthCalledWith( + 2, + SECOND_ENTRY_ID, + SOURCE + ); + expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledTimes(1); + expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledWith( + [ + expect.objectContaining({ food_id: 'food-1', quantity: 150 }), + expect.objectContaining({ food_id: 'food-2', quantity: 75 }), + ], + ACTOR + ); + }); + + it('rejects a row whose reviewed food or nutrition snapshot changed', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockResolvedValue([ + { id: TARGET_MEAL, name: 'Lunch', user_id: null }, + ]); + vi.mocked(foodRepository.getFoodEntryById).mockResolvedValue({ + ...validSourceEntry, + calories: 250, + }); + + await expect( + copySelectedFoodEntriesFromUser( + ACTOR, + ACTOR, + SOURCE, + SOURCE_DATE, + TARGET_DATE, + TARGET_MEAL, + [selection()] + ) + ).rejects.toMatchObject({ statusCode: 409 }); + + expect(foodRepository.bulkCreateFoodEntries).not.toHaveBeenCalled(); + }); + + it('does not conflate null food ids during the duplicate lookup', async () => { + const customEntry = { + ...validSourceEntry, + food_id: null, + food_name: 'Family recipe', + }; + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockResolvedValue([ + { id: TARGET_MEAL, name: 'Lunch', user_id: null }, + ]); + vi.mocked(foodRepository.getFoodEntryById).mockResolvedValue(customEntry); + vi.mocked(foodRepository.getFoodEntryByDetails).mockResolvedValue({ + id: 'unrelated-target-row', + }); + vi.mocked(foodRepository.bulkCreateFoodEntries).mockResolvedValue([ + { id: 'copied-custom-row' }, + ]); + + await expect( + copySelectedFoodEntriesFromUser( + ACTOR, + ACTOR, + SOURCE, + SOURCE_DATE, + TARGET_DATE, + TARGET_MEAL, + [ + { + entryId: ENTRY_ID, + quantity: 150, + sourceFingerprint: foodEntryCopyFingerprint(customEntry), + }, + ] + ) + ).resolves.toEqual([{ id: 'copied-custom-row' }]); + + expect(foodRepository.getFoodEntryByDetails).not.toHaveBeenCalled(); + expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledWith( + [expect.objectContaining({ food_id: null })], + ACTOR + ); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts new file mode 100644 index 0000000000..202a9cc940 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts @@ -0,0 +1,131 @@ +import express from 'express'; +// @ts-expect-error TS(7016): Could not find a declaration file for module 'supertest' +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import foodEntryRoutes from '../routes/foodEntryRoutes.js'; +import foodEntryService from '../services/foodEntryService.js'; +import errorHandler from '../middleware/errorHandler.js'; + +const { diaryPermissionMiddleware } = vi.hoisted(() => ({ + diaryPermissionMiddleware: vi.fn( + (_req: unknown, _res: unknown, next: () => void) => next() + ), +})); + +vi.mock('../services/foodEntryService.js'); +vi.mock('../services/AdaptiveTdeeService.js', () => ({ + clearUserTdeeCache: vi.fn(), +})); +vi.mock('../middleware/checkPermissionMiddleware.js', () => ({ + default: vi.fn(() => diaryPermissionMiddleware), +})); +vi.mock('../middleware/authMiddleware.js', () => ({ + authenticate: vi.fn( + ( + req: express.Request & { + userId?: string; + authenticatedUserId?: string; + originalUserId?: string; + }, + _res: express.Response, + next: express.NextFunction + ) => { + req.userId = 'active-family-context'; + req.authenticatedUserId = '11111111-1111-4111-8111-111111111111'; + req.originalUserId = '11111111-1111-4111-8111-111111111111'; + next(); + } + ), +})); + +const app = express(); +app.use(express.json()); +app.use('/', foodEntryRoutes); +app.use(errorHandler); + +const body = { + familyUserId: '22222222-2222-4222-8222-222222222222', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: '33333333-3333-4333-8333-333333333333', + entries: [ + { + entryId: '44444444-4444-4444-8444-444444444444', + quantity: 150, + sourceFingerprint: 'snapshot', + }, + ], +}; + +describe('POST /copy-selected-from-user', () => { + beforeEach(() => vi.clearAllMocks()); + + it('bypasses the active-context diary permission middleware', async () => { + vi.mocked( + foodEntryService.copySelectedFoodEntriesFromUser + ).mockResolvedValue([{ id: 'copy-1' }]); + + const response = await request(app) + .post('/copy-selected-from-user') + .send(body); + + expect(response.status).toBe(201); + expect(diaryPermissionMiddleware).not.toHaveBeenCalled(); + }); + + it('rejects unknown fields before calling the selected-copy service', async () => { + const response = await request(app) + .post('/copy-selected-from-user') + .send({ ...body, extra: true }); + + expect(response.status).toBe(400); + expect( + foodEntryService.copySelectedFoodEntriesFromUser + ).not.toHaveBeenCalled(); + }); + + it('uses the authenticated actor as both target and actor when family context is active', async () => { + vi.mocked( + foodEntryService.copySelectedFoodEntriesFromUser + ).mockResolvedValue([{ id: 'copy-1' }]); + + const response = await request(app) + .post('/copy-selected-from-user') + .send(body); + + expect(response.status).toBe(201); + expect(response.body).toEqual([{ id: 'copy-1' }]); + expect( + foodEntryService.copySelectedFoodEntriesFromUser + ).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111', + '11111111-1111-4111-8111-111111111111', + body.familyUserId, + body.sourceDate, + body.targetDate, + body.targetMealType, + body.entries + ); + }); + + it('preserves a service conflict response through the error handler', async () => { + const conflict = Object.assign( + new Error( + 'One or more source entries changed. Refresh the family diary.' + ), + { statusCode: 409 } + ); + vi.mocked( + foodEntryService.copySelectedFoodEntriesFromUser + ).mockRejectedValue(conflict); + + const response = await request(app) + .post('/copy-selected-from-user') + .send(body); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + error: 'One or more source entries changed. Refresh the family diary.', + }); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts new file mode 100644 index 0000000000..bc7309a2bf --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { CopySelectedFoodEntriesFromUserBodySchema } from '@workspace/shared'; + +const valid = { + familyUserId: '11111111-1111-4111-8111-111111111111', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: '22222222-2222-4222-8222-222222222222', + entries: [ + { + entryId: '33333333-3333-4333-8333-333333333333', + quantity: 150, + sourceFingerprint: 'snapshot', + }, + ], +}; + +describe('CopySelectedFoodEntriesFromUserBodySchema', () => { + it('accepts a strict valid request', () => { + expect( + CopySelectedFoodEntriesFromUserBodySchema.safeParse(valid).success + ).toBe(true); + }); + + it.each([ + { ...valid, sourceDate: '2026-02-30' }, + { ...valid, entries: [] }, + { ...valid, entries: [{ ...valid.entries[0], quantity: 0 }] }, + { ...valid, entries: [{ ...valid.entries[0], sourceFingerprint: '' }] }, + { ...valid, unexpected: true }, + ])('rejects invalid request %#', (input) => { + expect( + CopySelectedFoodEntriesFromUserBodySchema.safeParse(input).success + ).toBe(false); + }); + + it('rejects duplicate source entry IDs', () => { + expect( + CopySelectedFoodEntriesFromUserBodySchema.safeParse({ + ...valid, + entries: [valid.entries[0], { ...valid.entries[0], quantity: 200 }], + }).success + ).toBe(false); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts new file mode 100644 index 0000000000..8c92b03e84 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { copyReviewedFoodEntriesFromUser } from '../services/foodEntryService.js'; +import familyAccessRepository from '../models/familyAccessRepository.js'; +import foodRepository from '../models/foodRepository.js'; +import mealTypeRepository from '../models/mealType.js'; +import { foodEntryCopyFingerprint } from '@workspace/shared'; + +vi.mock('../models/familyAccessRepository'); +vi.mock('../models/foodRepository'); +vi.mock('../models/foodEntryMealRepository'); +vi.mock('../models/mealType.js'); +vi.mock('../config/logging', () => ({ log: vi.fn() })); + +const ACTOR_A = 'actor-a'; +const SOURCE_B = 'source-b'; +const SOURCE_DATE = '2026-08-23'; +const TARGET_DATE = '2026-08-24'; +const TARGET_MEAL = 'target-lunch-id'; +const sourceEntry = { + id: '33333333-3333-4333-8333-333333333333', + user_id: SOURCE_B, + entry_date: SOURCE_DATE, + quantity: 150, +}; +const reviewedEntries = [ + { + entryId: sourceEntry.id, + sourceFingerprint: foodEntryCopyFingerprint(sourceEntry), + }, +]; + +describe('copyReviewedFoodEntriesFromUser', () => { + beforeEach(() => vi.clearAllMocks()); + + it('rejects the reviewed path when the actor lacks copy permission', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + false + ); + + await expect( + copyReviewedFoodEntriesFromUser( + ACTOR_A, + ACTOR_A, + SOURCE_B, + SOURCE_DATE, + 'Lunch', + TARGET_DATE, + TARGET_MEAL, + reviewedEntries + ) + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(mealTypeRepository.getAllMealTypes).not.toHaveBeenCalled(); + expect( + foodRepository.copyReviewedFoodEntriesFromUser + ).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'added', + [ + ...[sourceEntry], + { ...sourceEntry, id: '44444444-4444-4444-8444-444444444444' }, + ], + ], + ['removed', []], + ['quantity-changed', [{ ...sourceEntry, quantity: 175 }]], + ])( + 'returns a 409 and creates neither rows nor meal containers when a source entry is %s after review', + async (_change, currentEntries) => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockImplementation( + async (userId) => [ + { + id: userId === SOURCE_B ? 'source-lunch-id' : TARGET_MEAL, + name: 'Lunch', + user_id: null, + }, + ] + ); + vi.mocked( + foodRepository.getFoodEntriesByDateAndMealType + ).mockResolvedValue(currentEntries); + + await expect( + copyReviewedFoodEntriesFromUser( + ACTOR_A, + ACTOR_A, + SOURCE_B, + SOURCE_DATE, + 'Lunch', + TARGET_DATE, + TARGET_MEAL, + reviewedEntries + ) + ).rejects.toMatchObject({ statusCode: 409 }); + + expect( + foodRepository.copyReviewedFoodEntriesFromUser + ).not.toHaveBeenCalled(); + } + ); + + it('delegates an exact reviewed snapshot to the atomic repository with server-derived source rows', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockImplementation( + async (userId) => [ + { + id: userId === SOURCE_B ? 'source-lunch-id' : TARGET_MEAL, + name: 'Lunch', + user_id: null, + }, + ] + ); + vi.mocked(foodRepository.getFoodEntriesByDateAndMealType).mockResolvedValue( + [sourceEntry] + ); + vi.mocked(foodRepository.copyReviewedFoodEntriesFromUser).mockResolvedValue( + [{ id: 'copy-1' }] + ); + + await expect( + copyReviewedFoodEntriesFromUser( + ACTOR_A, + ACTOR_A, + SOURCE_B, + SOURCE_DATE, + 'Lunch', + TARGET_DATE, + TARGET_MEAL, + reviewedEntries + ) + ).resolves.toEqual([{ id: 'copy-1' }]); + + expect(foodRepository.copyReviewedFoodEntriesFromUser).toHaveBeenCalledWith( + expect.objectContaining({ + targetUserId: ACTOR_A, + actingUserId: ACTOR_A, + sourceUserId: SOURCE_B, + sourceDate: SOURCE_DATE, + sourceMealTypeId: 'source-lunch-id', + targetDate: TARGET_DATE, + targetMealTypeId: TARGET_MEAL, + reviewedEntries, + }) + ); + }); + + it('rejects a source row whose reviewed nutrition changed', async () => { + vi.mocked(familyAccessRepository.checkCopyPermissions).mockResolvedValue( + true + ); + vi.mocked(mealTypeRepository.getAllMealTypes).mockImplementation( + async (userId) => [ + { + id: userId === SOURCE_B ? 'source-lunch-id' : TARGET_MEAL, + name: 'Lunch', + user_id: null, + }, + ] + ); + vi.mocked(foodRepository.getFoodEntriesByDateAndMealType).mockResolvedValue( + [{ ...sourceEntry, calories: 250 }] + ); + + await expect( + copyReviewedFoodEntriesFromUser( + ACTOR_A, + ACTOR_A, + SOURCE_B, + SOURCE_DATE, + 'Lunch', + TARGET_DATE, + TARGET_MEAL, + reviewedEntries + ) + ).rejects.toMatchObject({ statusCode: 409 }); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts new file mode 100644 index 0000000000..fed30d29b1 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClient } from '../db/poolManager.js'; +import { copyReviewedFoodEntriesFromUser } from '../models/foodEntry.js'; +import { foodEntryCopyFingerprint } from '@workspace/shared'; + +vi.mock('../db/poolManager.js', () => ({ getClient: vi.fn() })); +vi.mock('../config/logging.js', () => ({ log: vi.fn() })); + +const reviewedRow = { + id: '33333333-3333-4333-8333-333333333333', + meal_type_id: 'source-lunch-id', + quantity: 150, + food_entry_meal_id: null, +}; + +const input = { + targetUserId: 'actor-a', + actingUserId: 'actor-a', + sourceUserId: 'source-b', + sourceDate: '2026-08-23', + sourceMealTypeId: 'source-lunch-id', + targetDate: '2026-08-24', + targetMealTypeId: 'target-lunch-id', + reviewedEntries: [ + { + entryId: reviewedRow.id, + sourceFingerprint: foodEntryCopyFingerprint(reviewedRow), + }, + ], +}; + +describe('copyReviewedFoodEntriesFromUser repository transaction', () => { + const query = vi.fn(); + const release = vi.fn(); + + beforeEach(() => { + query.mockReset(); + release.mockReset(); + vi.mocked(getClient).mockReset(); + vi.mocked(getClient).mockResolvedValue({ query, release }); + }); + + it('preserves the original conflict and discards the client when rollback fails', async () => { + const serializationFailure = Object.assign( + new Error('serialization failure'), + { code: '40001' } + ); + const rollbackFailure = new Error('rollback failure'); + + query.mockImplementation(async (sql) => { + if (sql === 'BEGIN ISOLATION LEVEL SERIALIZABLE') return { rows: [] }; + if (sql === 'ROLLBACK') throw rollbackFailure; + throw serializationFailure; + }); + + await expect(copyReviewedFoodEntriesFromUser(input)).rejects.toMatchObject({ + statusCode: 409, + }); + expect(release).toHaveBeenCalledWith(rollbackFailure); + }); + + it('preserves an ordinary original error and discards the client when rollback fails', async () => { + const originalFailure = new Error('insert failure'); + const rollbackFailure = new Error('rollback failure'); + + query.mockImplementation(async (sql) => { + if (sql === 'BEGIN ISOLATION LEVEL SERIALIZABLE') return { rows: [] }; + if (sql === 'ROLLBACK') throw rollbackFailure; + throw originalFailure; + }); + + await expect(copyReviewedFoodEntriesFromUser(input)).rejects.toBe( + originalFailure + ); + expect(release).toHaveBeenCalledWith(rollbackFailure); + }); + + it.each([ + [ + 'added', + [ + reviewedRow, + { ...reviewedRow, id: '44444444-4444-4444-8444-444444444444' }, + ], + ], + ['removed', []], + ['quantity-changed', [{ ...reviewedRow, quantity: 175 }]], + ])( + 'rolls back before any food-entry or meal-container insert when the source is %s after review', + async (_change, sourceRows) => { + query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ + rows: sourceRows, + }); + + await expect( + copyReviewedFoodEntriesFromUser(input) + ).rejects.toMatchObject({ statusCode: 409 }); + + const executedSql = query.mock.calls.map(([sql]) => String(sql)); + expect(executedSql).toEqual( + expect.arrayContaining([ + 'BEGIN ISOLATION LEVEL SERIALIZABLE', + 'ROLLBACK', + ]) + ); + expect( + executedSql.some((sql) => /^\s*INSERT INTO food_entries/i.test(sql)) + ).toBe(false); + expect( + executedSql.some((sql) => /^\s*INSERT INTO food_entry_meals/i.test(sql)) + ).toBe(false); + expect(release).toHaveBeenCalledOnce(); + } + ); + + it.each([ + [ + 'unlinked rows despite an unrelated unlinked target row', + null, + null, + [{ food_id: null, variant_id: null }], + ], + ['rows with the same food and variant', 'food-1', 'variant-1', []], + ])( + 'copies every reviewed standalone row when the source contains repeated %s', + async (_case, foodId, variantId, existingTargetRows) => { + const secondRow = { + ...reviewedRow, + id: '44444444-4444-4444-8444-444444444444', + food_id: foodId, + variant_id: variantId, + }; + const firstRow = { + ...reviewedRow, + food_id: foodId, + variant_id: variantId, + }; + const repeatedInput = { + ...input, + reviewedEntries: [firstRow, secondRow].map((row) => ({ + entryId: row.id, + sourceFingerprint: foodEntryCopyFingerprint(row), + })), + }; + + query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [firstRow, secondRow] }) + .mockResolvedValueOnce({ rows: existingTargetRows }) + .mockResolvedValueOnce({ rows: [{ id: 'copy-1' }] }) + .mockResolvedValueOnce({ rows: [{ id: 'copy-2' }] }) + .mockResolvedValueOnce({ rows: [] }); + + await expect( + copyReviewedFoodEntriesFromUser(repeatedInput) + ).resolves.toEqual([{ id: 'copy-1' }, { id: 'copy-2' }]); + + const insertCalls = query.mock.calls.filter(([sql]) => + /^\s*INSERT INTO food_entries/i.test(String(sql)) + ); + expect(String(query.mock.calls[1][0])).toContain('fe.meal_type_id'); + expect(insertCalls).toHaveLength(2); + expect(query).toHaveBeenLastCalledWith('COMMIT'); + expect(release).toHaveBeenCalledOnce(); + } + ); +}); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts new file mode 100644 index 0000000000..b273f76f53 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts @@ -0,0 +1,136 @@ +import express from 'express'; +// @ts-expect-error TS(7016): Could not find a declaration file for module 'supertest' +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import foodEntryRoutes from '../routes/foodEntryRoutes.js'; +import foodEntryService from '../services/foodEntryService.js'; +import { clearUserTdeeCache } from '../services/AdaptiveTdeeService.js'; +import errorHandler from '../middleware/errorHandler.js'; + +const { diaryPermissionMiddleware } = vi.hoisted(() => ({ + diaryPermissionMiddleware: vi.fn( + (_req: unknown, _res: unknown, next: () => void) => next() + ), +})); + +vi.mock('../services/foodEntryService.js'); +vi.mock('../services/AdaptiveTdeeService.js', () => ({ + clearUserTdeeCache: vi.fn(), +})); +vi.mock('../middleware/checkPermissionMiddleware.js', () => ({ + default: vi.fn(() => diaryPermissionMiddleware), +})); +vi.mock('../middleware/authMiddleware.js', () => ({ + authenticate: vi.fn( + ( + req: express.Request & { + userId?: string; + authenticatedUserId?: string; + originalUserId?: string; + }, + _res: express.Response, + next: express.NextFunction + ) => { + req.userId = 'active-family-context-c'; + req.authenticatedUserId = '11111111-1111-4111-8111-111111111111'; + req.originalUserId = '11111111-1111-4111-8111-111111111111'; + next(); + } + ), +})); + +const app = express(); +app.use(express.json()); +app.use('/', foodEntryRoutes); +app.use(errorHandler); + +const body = { + familyUserId: '22222222-2222-4222-8222-222222222222', + sourceDate: '2026-08-23', + sourceMealType: 'Lunch', + targetDate: '2026-08-24', + targetMealType: '33333333-3333-4333-8333-333333333333', + entries: [ + { + entryId: '44444444-4444-4444-8444-444444444444', + sourceFingerprint: 'snapshot', + }, + ], +}; + +describe('POST /copy-reviewed-from-user', () => { + beforeEach(() => vi.clearAllMocks()); + + it('bypasses the active-context diary permission middleware', async () => { + vi.mocked( + foodEntryService.copyReviewedFoodEntriesFromUser + ).mockResolvedValue([{ id: 'copy-1' }]); + + const response = await request(app) + .post('/copy-reviewed-from-user') + .send(body); + + expect(response.status).toBe(201); + expect(diaryPermissionMiddleware).not.toHaveBeenCalled(); + }); + + it('uses actor A as target, actor, and cache owner when active context C copies source B', async () => { + vi.mocked( + foodEntryService.copyReviewedFoodEntriesFromUser + ).mockResolvedValue([{ id: 'copy-1' }]); + + const response = await request(app) + .post('/copy-reviewed-from-user') + .send(body); + + expect(response.status).toBe(201); + expect(response.body).toEqual([{ id: 'copy-1' }]); + expect( + foodEntryService.copyReviewedFoodEntriesFromUser + ).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111', + '11111111-1111-4111-8111-111111111111', + body.familyUserId, + body.sourceDate, + body.sourceMealType, + body.targetDate, + body.targetMealType, + body.entries + ); + expect(clearUserTdeeCache).toHaveBeenCalledWith( + '11111111-1111-4111-8111-111111111111' + ); + }); + + it('rejects unknown fields before calling the reviewed whole-copy service', async () => { + const response = await request(app) + .post('/copy-reviewed-from-user') + .send({ ...body, unexpected: true }); + + expect(response.status).toBe(400); + expect( + foodEntryService.copyReviewedFoodEntriesFromUser + ).not.toHaveBeenCalled(); + }); + + it('preserves a stale reviewed snapshot conflict through the error handler', async () => { + const conflict = Object.assign( + new Error( + 'One or more source entries changed. Refresh the family diary.' + ), + { statusCode: 409 } + ); + vi.mocked( + foodEntryService.copyReviewedFoodEntriesFromUser + ).mockRejectedValue(conflict); + + const response = await request(app) + .post('/copy-reviewed-from-user') + .send(body); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + error: 'One or more source entries changed. Refresh the family diary.', + }); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts new file mode 100644 index 0000000000..3bd80c6dce --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { CopyReviewedFoodEntriesFromUserBodySchema } from '@workspace/shared'; + +const valid = { + familyUserId: '11111111-1111-4111-8111-111111111111', + sourceDate: '2026-08-23', + sourceMealType: 'Lunch', + targetDate: '2026-08-24', + targetMealType: '22222222-2222-4222-8222-222222222222', + entries: [ + { + entryId: '33333333-3333-4333-8333-333333333333', + sourceFingerprint: 'snapshot', + }, + ], +}; + +describe('CopyReviewedFoodEntriesFromUserBodySchema', () => { + it('accepts an exact reviewed whole-meal request', () => { + expect( + CopyReviewedFoodEntriesFromUserBodySchema.safeParse(valid).success + ).toBe(true); + }); + + it.each([ + { ...valid, sourceDate: '2026-02-30' }, + { ...valid, sourceMealType: ' ' }, + { ...valid, entries: [] }, + { ...valid, entries: [{ ...valid.entries[0], sourceFingerprint: '' }] }, + { ...valid, unexpected: true }, + ])('rejects invalid reviewed request %#', (input) => { + expect( + CopyReviewedFoodEntriesFromUserBodySchema.safeParse(input).success + ).toBe(false); + }); + + it('rejects duplicate reviewed source entry IDs', () => { + expect( + CopyReviewedFoodEntriesFromUserBodySchema.safeParse({ + ...valid, + entries: [ + valid.entries[0], + { ...valid.entries[0], sourceFingerprint: 'other' }, + ], + }).success + ).toBe(false); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index dca5d1b1aa..25ded3b0c9 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -10,6 +10,7 @@ export * from "./schemas/api/ExerciseEntries.api.zod.ts"; export * from "./schemas/api/ExerciseStats.api.zod.ts"; export * from "./schemas/api/Exercises.api.zod.ts"; export * from "./schemas/api/FoodEntries.api.zod.ts"; +export * from "./schemas/api/FoodEntryCopy.api.zod.ts"; export * from "./schemas/api/FoodPhotoEstimate.api.zod.ts"; export * from "./schemas/api/Pagination.api.zod.ts"; export * from "./schemas/api/SleepScience.api.zod.ts"; @@ -110,6 +111,7 @@ export * from "./utils/csvValue.ts"; export * from "./utils/csvFormat.ts"; export * from "./utils/parseCsv.ts"; export * from "./utils/activitySport.ts"; +export * from "./utils/foodEntryCopyFingerprint.ts"; export * from "./ai/unitConversion.ts"; export * from "./ai/confidenceLabels.ts"; export * from "./medications/contracts.ts"; diff --git a/shared/src/schemas/api/FoodEntryCopy.api.zod.ts b/shared/src/schemas/api/FoodEntryCopy.api.zod.ts new file mode 100644 index 0000000000..84a4480bc6 --- /dev/null +++ b/shared/src/schemas/api/FoodEntryCopy.api.zod.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { isDayString } from "../../utils/timezone.ts"; + +const dayString = z + .string() + .refine(isDayString, { message: "Expected YYYY-MM-DD" }); + +const selectedFoodEntrySchema = z + .object({ + entryId: z.string().uuid(), + quantity: z.number().finite().positive(), + sourceFingerprint: z.string().min(1).max(20_000), + }) + .strict(); + +const reviewedFoodEntrySchema = z + .object({ + entryId: z.string().uuid(), + sourceFingerprint: z.string().min(1).max(20_000), + }) + .strict(); + +function rejectDuplicateEntryIds( + entries: Array<{ entryId: string }>, + context: z.RefinementCtx, + message: string, +) { + const seen = new Set(); + entries.forEach(({ entryId }, index) => { + if (seen.has(entryId)) { + context.addIssue({ + code: "custom", + path: ["entries", index, "entryId"], + message, + }); + } + seen.add(entryId); + }); +} + +export const CopySelectedFoodEntriesFromUserBodySchema = z + .object({ + familyUserId: z.string().uuid(), + sourceDate: dayString, + targetDate: dayString, + targetMealType: z.string().uuid(), + entries: z.array(selectedFoodEntrySchema).min(1).max(100), + }) + .strict() + .superRefine(({ entries }, context) => { + rejectDuplicateEntryIds( + entries, + context, + "Source entry IDs must be unique", + ); + }); + +export type CopySelectedFoodEntriesFromUserPayload = z.infer< + typeof CopySelectedFoodEntriesFromUserBodySchema +>; +export type CopySelectedFoodEntriesFromUserBody = + CopySelectedFoodEntriesFromUserPayload; + +export const CopyReviewedFoodEntriesFromUserBodySchema = z + .object({ + familyUserId: z.string().uuid(), + sourceDate: dayString, + sourceMealType: z.string().trim().min(1), + targetDate: dayString, + targetMealType: z.string().uuid(), + entries: z.array(reviewedFoodEntrySchema).min(1).max(100), + }) + .strict() + .superRefine(({ entries }, context) => { + rejectDuplicateEntryIds( + entries, + context, + "Reviewed source entry IDs must be unique", + ); + }); + +export type CopyReviewedFoodEntriesFromUserPayload = z.infer< + typeof CopyReviewedFoodEntriesFromUserBodySchema +>; +export type CopyReviewedFoodEntriesFromUserBody = + CopyReviewedFoodEntriesFromUserPayload; diff --git a/shared/src/utils/foodEntryCopyFingerprint.ts b/shared/src/utils/foodEntryCopyFingerprint.ts new file mode 100644 index 0000000000..0121e3d1c4 --- /dev/null +++ b/shared/src/utils/foodEntryCopyFingerprint.ts @@ -0,0 +1,138 @@ +export interface FoodEntryCopyFingerprintInput { + id?: unknown; + food_id?: unknown; + meal_type_id?: unknown; + quantity?: unknown; + unit?: unknown; + entry_time?: unknown; + variant_id?: unknown; + meal_plan_template_id?: unknown; + food_entry_meal_id?: unknown; + food_name?: unknown; + brand_name?: unknown; + serving_size?: unknown; + serving_unit?: unknown; + calories?: unknown; + protein?: unknown; + carbs?: unknown; + fat?: unknown; + saturated_fat?: unknown; + polyunsaturated_fat?: unknown; + monounsaturated_fat?: unknown; + trans_fat?: unknown; + cholesterol?: unknown; + sodium?: unknown; + potassium?: unknown; + dietary_fiber?: unknown; + sugars?: unknown; + vitamin_a?: unknown; + vitamin_c?: unknown; + calcium?: unknown; + iron?: unknown; + glycemic_index?: unknown; + custom_nutrients?: unknown; +} + +export interface ReviewedFoodEntryFingerprint { + entryId: string; + sourceFingerprint: string; +} + +const numericFields = [ + "quantity", + "serving_size", + "calories", + "protein", + "carbs", + "fat", + "saturated_fat", + "polyunsaturated_fat", + "monounsaturated_fat", + "trans_fat", + "cholesterol", + "sodium", + "potassium", + "dietary_fiber", + "sugars", + "vitamin_a", + "vitamin_c", + "calcium", + "iron", +] as const; + +const stringFields = [ + "food_id", + "meal_type_id", + "unit", + "entry_time", + "variant_id", + "meal_plan_template_id", + "food_entry_meal_id", + "food_name", + "brand_name", + "serving_unit", + "glycemic_index", +] as const; + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, child]) => [key, stableValue(child)]), + ); + } + return value ?? null; +} + +/** + * Exact, deterministic snapshot used to reject copies when a diary row changes + * after it was reviewed. It is deliberately not a hash: exact serialized data + * avoids collision risk and lets server and mobile share one implementation. + */ +export function foodEntryCopyFingerprint( + entry: FoodEntryCopyFingerprintInput, +): string { + const snapshot: Record = {}; + for (const field of stringFields) snapshot[field] = entry[field] ?? null; + for (const field of numericFields) { + const value = entry[field]; + snapshot[field] = value == null ? null : Number(value); + } + snapshot.custom_nutrients = stableValue(entry.custom_nutrients); + return JSON.stringify(snapshot); +} + +/** Checks that reviewed IDs and fingerprints exactly match the current rows. */ +export function hasExactReviewedFoodEntrySnapshot( + sourceEntries: ReadonlyArray< + FoodEntryCopyFingerprintInput & { id?: unknown } + >, + reviewedEntries: readonly ReviewedFoodEntryFingerprint[], +): boolean { + if (sourceEntries.length !== reviewedEntries.length) return false; + + const reviewedFingerprintById = new Map( + reviewedEntries.map(({ entryId, sourceFingerprint }) => [ + entryId, + sourceFingerprint, + ]), + ); + if (reviewedFingerprintById.size !== reviewedEntries.length) return false; + + const sourceIds = new Set(); + return sourceEntries.every((entry) => { + if ( + typeof entry.id !== "string" || + entry.id.length === 0 || + sourceIds.has(entry.id) + ) { + return false; + } + sourceIds.add(entry.id); + return ( + reviewedFingerprintById.get(entry.id) === foodEntryCopyFingerprint(entry) + ); + }); +}