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)
+ );
+ });
+}