From 954b4bbc2b791fde25b0bbfd002272c475e46c02 Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Mon, 24 Aug 2026 19:37:05 +0200 Subject: [PATCH 01/10] feat(server): support secure family diary copies --- SparkyFitnessServer/models/foodEntry.ts | 288 ++++++++++++++++++ SparkyFitnessServer/routes/foodEntryRoutes.ts | 198 ++++++++++++ .../schemas/foodEntryCopySchemas.ts | 77 +++++ .../services/foodEntryService.ts | 215 +++++++++++++ .../tests/foodEntrySelectedCopy.test.ts | 205 +++++++++++++ .../tests/foodEntrySelectedCopyRoute.test.ts | 105 +++++++ .../tests/foodEntrySelectedCopySchema.test.ts | 38 +++ .../tests/foodEntryWholeCopy.test.ts | 123 ++++++++ .../foodEntryWholeCopyRepository.test.ts | 73 +++++ .../tests/foodEntryWholeCopyRoute.test.ts | 114 +++++++ .../tests/foodEntryWholeCopySchema.test.ts | 40 +++ 11 files changed, 1476 insertions(+) create mode 100644 SparkyFitnessServer/schemas/foodEntryCopySchemas.ts create mode 100644 SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts create mode 100644 SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts create mode 100644 SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts create mode 100644 SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts create mode 100644 SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts create mode 100644 SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts create mode 100644 SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts diff --git a/SparkyFitnessServer/models/foodEntry.ts b/SparkyFitnessServer/models/foodEntry.ts index 27409fd2c1..5f54a2c5de 100644 --- a/SparkyFitnessServer/models/foodEntry.ts +++ b/SparkyFitnessServer/models/foodEntry.ts @@ -5,6 +5,65 @@ 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'; + +interface ReviewedFoodEntry { + entryId: string; + quantity: number; +} + +interface ReviewedFoodEntryCopyInput { + targetUserId: string; + actingUserId: string; + sourceUserId: string; + sourceDate: string; + sourceMealTypeId: string; + targetDate: string; + targetMealTypeId: string; + reviewedEntries: ReviewedFoodEntry[]; +} + +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 } + ); +} + +function exactReviewedSnapshot( + sourceEntries: ReviewedSourceEntry[], + reviewedEntries: ReviewedFoodEntry[] +) { + if (sourceEntries.length !== reviewedEntries.length) return false; + const quantitiesById = new Map( + reviewedEntries.map(({ entryId, quantity }) => [entryId, quantity]) + ); + if (quantitiesById.size !== reviewedEntries.length) return false; + + return sourceEntries.every((entry) => { + const reviewedQuantity = quantitiesById.get(entry.id); + return ( + reviewedQuantity !== undefined && + Number.isFinite(Number(entry.quantity)) && + Number(entry.quantity) === reviewedQuantity + ); + }); +} /** * @swagger * components: @@ -743,6 +802,233 @@ 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; + + try { + await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE'); + transactionStarted = true; + + const sourceResult = (await client.query( + `SELECT + fe.id, + fe.food_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 (!exactReviewedSnapshot(sourceEntries, reviewedEntries)) { + throw reviewedCopyConflict(); + } + + const copiedEntries: unknown[] = []; + const targetMealIdBySourceMealId = new Map(); + + 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 existingEntry = (await client.query( + `SELECT id + FROM food_entries + WHERE user_id = $1 + AND food_id IS NOT DISTINCT FROM $2 + AND meal_type_id = $3 + AND entry_date = $4 + AND variant_id IS NOT DISTINCT FROM $5 + AND food_entry_meal_id IS NULL`, + [ + targetUserId, + entry.food_id, + targetMealTypeId, + targetDate, + entry.variant_id, + ] + )) as { rows: Array<{ id: string }> }; + if (existingEntry.rows[0]) 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) await client.query('ROLLBACK'); + if ((error as { code?: string }).code === '40001') { + throw reviewedCopyConflict(); + } + throw error; + } finally { + client.release(); + } +} + async function bulkCreateFoodEntries( entriesData: FoodEntryInput[], authenticatedUserId: string @@ -1059,6 +1345,7 @@ export { getFoodEntriesByDate }; export { getFoodEntriesByDateAndMealType }; export { getFoodEntriesByDateRange }; export { getFoodEntryByDetails }; +export { copyReviewedFoodEntriesFromUser }; export { bulkCreateFoodEntries }; export { getFoodEntryById }; export { getFoodEntryComponentsByFoodEntryMealId }; @@ -1076,6 +1363,7 @@ export default { getFoodEntriesByDateAndMealType, getFoodEntriesByDateRange, getFoodEntryByDetails, + copyReviewedFoodEntriesFromUser, bulkCreateFoodEntries, getFoodEntryById, getFoodEntryComponentsByFoodEntryMealId, diff --git a/SparkyFitnessServer/routes/foodEntryRoutes.ts b/SparkyFitnessServer/routes/foodEntryRoutes.ts index 1094cb99d0..c508f6cf67 100644 --- a/SparkyFitnessServer/routes/foodEntryRoutes.ts +++ b/SparkyFitnessServer/routes/foodEntryRoutes.ts @@ -4,6 +4,10 @@ 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 { + CopyReviewedFoodEntriesFromUserBodySchema, + CopySelectedFoodEntriesFromUserBodySchema, +} from '../schemas/foodEntryCopySchemas.js'; import { isEntryTimeString } from '@workspace/shared'; import { uploadImages, @@ -354,6 +358,200 @@ 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 + * quantities are an optimistic-concurrency snapshot; any added, removed, + * or quantity-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, quantity] + * properties: + * entryId: + * type: string + * format: uuid + * quantity: + * type: number + * exclusiveMinimum: 0 + * 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, + checkPermissionMiddleware('diary'), + 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] + * properties: + * entryId: + * type: string + * format: uuid + * quantity: + * type: number + * exclusiveMinimum: 0 + * responses: + * 201: + * description: The selected food entries were copied successfully. + * 400: + * description: Invalid request body. + * 403: + * description: Forbidden. + * 409: + * description: A copied entry conflicts with the target diary. + */ +router.post( + '/copy-selected-from-user', + authenticate, + checkPermissionMiddleware('diary'), + 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/schemas/foodEntryCopySchemas.ts b/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts new file mode 100644 index 0000000000..ed6f5fb43e --- /dev/null +++ b/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts @@ -0,0 +1,77 @@ +import { isDayString } from '@workspace/shared'; +import { z } from 'zod/v4'; + +const dayString = z + .string() + .refine(isDayString, { message: 'Expected YYYY-MM-DD' }); + +const SelectedFoodEntrySchema = z + .object({ + entryId: z.string().uuid(), + quantity: z.number().finite().positive(), + }) + .strict(); + +const ReviewedFoodEntrySchema = z + .object({ + entryId: z.string().uuid(), + // This is an optimistic-concurrency snapshot, never a client-provided + // quantity to persist. The service reloads and compares it before copy. + quantity: z.number().finite().positive(), + }) + .strict(); + +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) => { + const seen = new Set(); + entries.forEach(({ entryId }, index) => { + if (seen.has(entryId)) { + context.addIssue({ + code: 'custom', + path: ['entries', index, 'entryId'], + message: 'Source entry IDs must be unique', + }); + } + seen.add(entryId); + }); + }); + +export type CopySelectedFoodEntriesFromUserBody = z.infer< + typeof CopySelectedFoodEntriesFromUserBodySchema +>; + +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) => { + const seen = new Set(); + entries.forEach(({ entryId }, index) => { + if (seen.has(entryId)) { + context.addIssue({ + code: 'custom', + path: ['entries', index, 'entryId'], + message: 'Reviewed source entry IDs must be unique', + }); + } + seen.add(entryId); + }); + }); + +export type CopyReviewedFoodEntriesFromUserBody = z.infer< + typeof CopyReviewedFoodEntriesFromUserBodySchema +>; diff --git a/SparkyFitnessServer/services/foodEntryService.ts b/SparkyFitnessServer/services/foodEntryService.ts index ff157ac4d2..8555cc3d57 100644 --- a/SparkyFitnessServer/services/foodEntryService.ts +++ b/SparkyFitnessServer/services/foodEntryService.ts @@ -16,6 +16,10 @@ 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 type { + CopyReviewedFoodEntriesFromUserBody, + CopySelectedFoodEntriesFromUserBody, +} from '../schemas/foodEntryCopySchemas.js'; import Papa from 'papaparse'; import { isDayString } from '@workspace/shared'; @@ -137,6 +141,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 +1323,207 @@ 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 || + !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) { + const existingEntry = await foodRepository.getFoodEntryByDetails( + targetUserId, + entry.food_id, + targetMealTypeId, + targetDate, + entry.variant_id, + 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); +} + +function hasExactReviewedEntries( + sourceEntries: Array<{ id?: string; quantity?: number | string | null }>, + reviewedEntries: CopyReviewedFoodEntriesFromUserBody['entries'] +) { + if (sourceEntries.length !== reviewedEntries.length) return false; + + const reviewedQuantityById = new Map( + reviewedEntries.map(({ entryId, quantity }) => [entryId, quantity]) + ); + + return sourceEntries.every((entry) => { + if (!entry.id) return false; + const reviewedQuantity = reviewedQuantityById.get(entry.id); + return ( + reviewedQuantity !== undefined && + Number.isFinite(Number(entry.quantity)) && + Number(entry.quantity) === reviewedQuantity + ); + }); +} + +async function copyReviewedFoodEntriesFromUser( + targetUserId: string, + actingUserId: string, + sourceUserId: string, + sourceDate: string, + sourceMealType: string, + targetDate: string, + targetMealType: string, + reviewedEntries: CopyReviewedFoodEntriesFromUserBody['entries'] +) { + // This path is deliberately separate from copyFoodEntriesFromUser: the + // latter remains the web route whose target is the active context. The + // reviewed mobile path always writes into the authenticated actor's diary. + 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 (!hasExactReviewedEntries(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 +3659,9 @@ export { getFoodEntryMealsByDate }; export { deleteFoodEntryMeal }; export { exportAllDiaryEntriesToCSVStream }; export { copyFoodEntriesFromUser }; +export { copyReviewedFoodEntriesFromUser }; export { copyFoodEntriesToUser }; +export { copySelectedFoodEntriesFromUser }; export { importFoodDiaryEntriesInBulk }; export default { createFoodEntry, @@ -3470,6 +3683,8 @@ export default { deleteFoodEntryMeal, exportAllDiaryEntriesToCSVStream, copyFoodEntriesFromUser, + copyReviewedFoodEntriesFromUser, copyFoodEntriesToUser, + copySelectedFoodEntriesFromUser, importFoodDiaryEntriesInBulk, }; diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts new file mode 100644 index 0000000000..803a37ab56 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts @@ -0,0 +1,205 @@ +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'; + +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 }, +}; + +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, + [{ entryId: ENTRY_ID, quantity: 150 }] + ) + ).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, + [{ entryId: ENTRY_ID, quantity: 150 }] + ) + ).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, + [{ entryId: ENTRY_ID, quantity: 150 }] + ); + + 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, + [ + { entryId: ENTRY_ID, quantity: 150 }, + { entryId: SECOND_ENTRY_ID, quantity: 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 + ); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts new file mode 100644 index 0000000000..d80fceb3e4 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts @@ -0,0 +1,105 @@ +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'; + +vi.mock('../services/foodEntryService.js'); +vi.mock('../services/AdaptiveTdeeService.js', () => ({ + clearUserTdeeCache: vi.fn(), +})); +vi.mock('../middleware/checkPermissionMiddleware.js', () => ({ + default: vi.fn( + () => (_req: unknown, _res: unknown, next: () => void) => next() + ), +})); +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 }], +}; + +describe('POST /copy-selected-from-user', () => { + beforeEach(() => vi.clearAllMocks()); + + 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('Copy conflicts with target'), { + 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: 'Copy conflicts with target', + }); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts new file mode 100644 index 0000000000..b323739ea4 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { CopySelectedFoodEntriesFromUserBodySchema } from '../schemas/foodEntryCopySchemas.js'; + +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 }], +}; + +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, 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..1939bd6f16 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts @@ -0,0 +1,123 @@ +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'; + +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, quantity: 150 }]; + +describe('copyReviewedFoodEntriesFromUser', () => { + beforeEach(() => vi.clearAllMocks()); + + 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, + }) + ); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts new file mode 100644 index 0000000000..764352b1ad --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClient } from '../db/poolManager.js'; +import { copyReviewedFoodEntriesFromUser } from '../models/foodEntry.js'; + +vi.mock('../db/poolManager.js', () => ({ getClient: vi.fn() })); +vi.mock('../config/logging.js', () => ({ log: vi.fn() })); + +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: '33333333-3333-4333-8333-333333333333', quantity: 150 }, + ], +}; + +const reviewedRow = { + id: input.reviewedEntries[0].entryId, + quantity: 150, + food_entry_meal_id: null, +}; + +describe('copyReviewedFoodEntriesFromUser repository transaction', () => { + const query = vi.fn(); + const release = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClient).mockResolvedValue({ query, release }); + }); + + 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(); + } + ); +}); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts new file mode 100644 index 0000000000..ce4df7b2e7 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts @@ -0,0 +1,114 @@ +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'; + +vi.mock('../services/foodEntryService.js'); +vi.mock('../services/AdaptiveTdeeService.js', () => ({ + clearUserTdeeCache: vi.fn(), +})); +vi.mock('../middleware/checkPermissionMiddleware.js', () => ({ + default: vi.fn( + () => (_req: unknown, _res: unknown, next: () => void) => next() + ), +})); +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', quantity: 150 }], +}; + +describe('POST /copy-reviewed-from-user', () => { + beforeEach(() => vi.clearAllMocks()); + + 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..3707568c3a --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { CopyReviewedFoodEntriesFromUserBodySchema } from '../schemas/foodEntryCopySchemas.js'; + +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', quantity: 150 }], +}; + +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], quantity: 0 }] }, + { ...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], quantity: 200 }], + }).success + ).toBe(false); + }); +}); From 94ee156ca87021eb2c53c68d0dc57f4b6e5ac5f8 Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Mon, 24 Aug 2026 19:37:16 +0200 Subject: [PATCH 02/10] feat(mobile): add family diary data and copy flows --- .../hooks/useCopyFamilyFoodEntries.test.ts | 202 ++++++++++++++++++ .../__tests__/hooks/useFamilyDiary.test.ts | 108 ++++++++++ .../__tests__/services/familyApi.test.ts | 156 ++++++++++++++ .../__tests__/services/foodEntriesApi.test.ts | 51 +++++ .../__tests__/utils/familyDiary.test.ts | 142 ++++++++++++ SparkyFitnessMobile/src/hooks/index.ts | 5 + SparkyFitnessMobile/src/hooks/queryKeys.ts | 4 + .../src/hooks/useCopyFamilyFoodEntries.ts | 93 ++++++++ .../src/hooks/useFamilyDiary.ts | 29 +++ .../src/services/api/dailySummaryApi.ts | 14 +- .../src/services/api/familyApi.ts | 39 ++++ .../src/services/api/foodEntriesApi.ts | 28 +++ SparkyFitnessMobile/src/types/familyDiary.ts | 24 +++ SparkyFitnessMobile/src/utils/familyDiary.ts | 95 ++++++++ 14 files changed, 985 insertions(+), 5 deletions(-) create mode 100644 SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts create mode 100644 SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts create mode 100644 SparkyFitnessMobile/__tests__/services/familyApi.test.ts create mode 100644 SparkyFitnessMobile/__tests__/utils/familyDiary.test.ts create mode 100644 SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts create mode 100644 SparkyFitnessMobile/src/hooks/useFamilyDiary.ts create mode 100644 SparkyFitnessMobile/src/services/api/familyApi.ts create mode 100644 SparkyFitnessMobile/src/types/familyDiary.ts create mode 100644 SparkyFitnessMobile/src/utils/familyDiary.ts diff --git a/SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts b/SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts new file mode 100644 index 0000000000..3d215ada96 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/hooks/useCopyFamilyFoodEntries.test.ts @@ -0,0 +1,202 @@ +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', quantity: 100 }], + }, +}; + +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 }], + }, +}; + +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('keeps the review and explains how to recover from a stale 409 source', async () => { + (copySelectedFoodEntriesFromUser as jest.Mock).mockRejectedValue( + new ApiError('Conflict', 409), + ); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const { result } = renderHook(() => useCopyFamilyFoodEntries(), { + wrapper: createQueryWrapper(queryClient), + }); + + await expect( + act(async () => { + await result.current.copyFromFamilyAsync(selectedRequest); + }), + ).rejects.toThrow('Conflict'); + + expect(invalidateSpy).not.toHaveBeenCalled(); + await waitFor(() => + expect(Toast.show).toHaveBeenCalledWith({ + type: 'error', + text1: 'Family diary changed', + text2: 'Refresh the family diary and review the foods again.', + }), + ); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts b/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts new file mode 100644 index 0000000000..a2e10e5ec0 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts @@ -0,0 +1,108 @@ +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('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__/services/familyApi.test.ts b/SparkyFitnessMobile/__tests__/services/familyApi.test.ts new file mode 100644 index 0000000000..74badb5141 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/services/familyApi.test.ts @@ -0,0 +1,156 @@ +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: true, + accessEndDate: '2026-12-31', + }, + { + userId: 'member-c', + displayName: 'Member C', + email: 'c@example.test', + canCopy: false, + accessEndDate: null, + }, + ]); + }); + + 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..1bbcd7d13f 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', quantity: 150 }], + }; + + const selectedPayload = { + familyUserId: 'member-b', + sourceDate: '2026-08-23', + targetDate: '2026-08-24', + targetMealType: 'lunch', + entries: [{ entryId: 'entry-1', quantity: 150 }], + }; + + 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/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/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..ba96bdfb11 --- /dev/null +++ b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts @@ -0,0 +1,93 @@ +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 '../types/familyDiary'; +import { ApiError } from '../services/api/errors'; +import { dailySummaryQueryKey, familyUsersQueryKey } from './queryKeys'; + +export type FamilyCopyRequest = + | { kind: 'whole'; payload: CopyReviewedFoodEntriesFromUserPayload } + | { kind: 'selected'; payload: CopySelectedFoodEntriesFromUserPayload }; + +interface UseCopyFamilyFoodEntriesOptions { + onSuccess?: (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 => { + 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) { + Toast.show({ + type: 'error', + text1: t('familyDiary.copyStale', { + defaultValue: 'Family diary changed', + }), + text2: t('familyDiary.copyStaleGuidance', { + defaultValue: + 'Refresh the family diary and review the foods again.', + }), + }); + 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..33aea86d38 --- /dev/null +++ b/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts @@ -0,0 +1,29 @@ +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; +} + +export function useFamilyUsers() { + return useQuery({ + queryKey: familyUsersQueryKey, + queryFn: fetchFamilyDiaryUsers, + }); +} + +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/services/api/dailySummaryApi.ts b/SparkyFitnessMobile/src/services/api/dailySummaryApi.ts index b563388936..f7a8fbd3c7 100644 --- a/SparkyFitnessMobile/src/services/api/dailySummaryApi.ts +++ b/SparkyFitnessMobile/src/services/api/dailySummaryApi.ts @@ -3,7 +3,7 @@ import type { DailyGoals } from '../../types/goals'; import type { FoodEntry } from '../../types/foodEntries'; import type { ExerciseSessionResponse, CalorieBalance, SupplementTotals } from '@workspace/shared'; -interface DailySummaryApiResponse { +export interface DailySummaryApiResponse { goals: DailyGoals; foodEntries: FoodEntry[]; exerciseSessions: ExerciseSessionResponse[]; @@ -16,9 +16,13 @@ interface DailySummaryApiResponse { adjustedGoals?: { calories: number; protein: number; carbs: number; fat: number } | null; } -export const fetchDailySummary = (date: string): Promise => - 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..475f097112 --- /dev/null +++ b/SparkyFitnessMobile/src/services/api/familyApi.ts @@ -0,0 +1,39 @@ +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, + ); + +const hasFoodLibraryPermission = ( + permissions: AccessibleFamilyUserResponse['permissions'], +) => Boolean(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: hasFoodLibraryPermission(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..f6186fddf9 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 '../../types/familyDiary'; 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..70bc04f622 --- /dev/null +++ b/SparkyFitnessMobile/src/types/familyDiary.ts @@ -0,0 +1,24 @@ +export interface FamilyDiaryUser { + userId: string; + displayName: string; + email: string | null; + canCopy: boolean; + accessEndDate: string | null; +} + +export interface CopyReviewedFoodEntriesFromUserPayload { + familyUserId: string; + sourceDate: string; + sourceMealType: string; + targetDate: string; + targetMealType: string; + entries: { entryId: string; quantity: number }[]; +} + +export interface CopySelectedFoodEntriesFromUserPayload { + familyUserId: string; + sourceDate: string; + targetDate: string; + targetMealType: string; + entries: { entryId: string; quantity: number }[]; +} 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 + ); + }); +} From 4f23e7712b446b0125daead0aaafe40169bcb22a Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Mon, 24 Aug 2026 19:37:22 +0200 Subject: [PATCH 03/10] feat(mobile): add family diary browsing and review --- SparkyFitnessMobile/App.tsx | 32 ++ .../components/DateNavigator.test.tsx | 124 ++++++ .../navigation/nativeHeaderContract.test.ts | 56 +++ .../__tests__/screens/DiaryScreen.test.tsx | 46 +- .../screens/FamilyCopyReviewScreen.test.tsx | 417 ++++++++++++++++++ .../screens/FamilyDiaryScreen.test.tsx | 303 +++++++++++++ .../screens/FamilyMealDetailScreen.test.tsx | 215 +++++++++ .../screens/FamilyMembersScreen.test.tsx | 201 +++++++++ .../screens/SettingsScreen.family.test.tsx | 106 +++++ .../__tests__/utils/dateUtils.test.ts | 21 + .../utils/nativeHeaderDatePicker.test.ts | 34 +- .../src/components/DateNavigator.tsx | 97 +++- .../localization/locales/en/translation.json | 65 ++- .../localization/locales/pl/translation.json | 65 ++- .../src/navigation/safeScreens.tsx | 8 + .../src/screens/DiaryScreen.tsx | 21 +- .../src/screens/FamilyCopyReviewScreen.tsx | 392 ++++++++++++++++ .../src/screens/FamilyDiaryScreen.tsx | 231 ++++++++++ .../src/screens/FamilyMealDetailScreen.tsx | 218 +++++++++ .../src/screens/FamilyMembersScreen.tsx | 102 +++++ .../src/screens/SettingsScreen.tsx | 11 +- SparkyFitnessMobile/src/types/navigation.ts | 18 + SparkyFitnessMobile/src/utils/dateUtils.ts | 8 +- .../src/utils/nativeHeaderDatePicker.ts | 26 +- 24 files changed, 2797 insertions(+), 20 deletions(-) create mode 100644 SparkyFitnessMobile/__tests__/components/DateNavigator.test.tsx create mode 100644 SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx create mode 100644 SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx create mode 100644 SparkyFitnessMobile/__tests__/screens/FamilyMealDetailScreen.test.tsx create mode 100644 SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx create mode 100644 SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx create mode 100644 SparkyFitnessMobile/src/screens/FamilyCopyReviewScreen.tsx create mode 100644 SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx create mode 100644 SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx create mode 100644 SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx diff --git a/SparkyFitnessMobile/App.tsx b/SparkyFitnessMobile/App.tsx index 800658ee7d..fdfdb3e7cb 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,34 @@ function AppContent() { /> )} + + + + { + 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 localized relative dates and accessible controls', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2025, 0, 15, 12)); + const screen = render( + + + , + ); + + expect(screen.getByText('Dzisiaj')).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Poprzedni dzień' }), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Następny dzień' })).toBeTruthy(); + jest.useRealTimers(); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts b/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts index c8532af601..053ac890f5 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', + title: 'Family Diaries', + backOption: "headerBackButtonDisplayMode: 'minimal'", + }, + { + routeName: 'FamilyDiary', + component: 'SafeFamilyDiary', + title: 'Family Diary', + backOption: "headerBackTitle: 'Family Diaries'", + }, + { + routeName: 'FamilyMealDetail', + component: 'SafeFamilyMealDetail', + title: 'Select Foods', + backOption: "headerBackTitle: 'Family Diary'", + }, + { + routeName: 'FamilyCopyReview', + component: 'SafeFamilyCopyReview', + title: 'Review Copy', + backOption: "headerBackTitle: 'Select Foods'", + }, + ] as const; + const rootStackScreenNames = extractScreenNames(appSource, 'Stack'); + const stackComponentsByRoute = extractStackComponentsByRoute(appSource); + + for (const { routeName, component, title, 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(`createStackScreenOptions('${title}', {`); + 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..ea3c08205e 100644 --- a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx @@ -13,6 +13,8 @@ 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'; const mockNavigation = { setOptions: jest.fn(), @@ -69,6 +71,12 @@ 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, + }), +})); + jest.mock('../../src/services/nativeTabBarPreference', () => ({ useNativeIOSTabsActive: jest.fn(() => false), useNativeIOSHeadersActive: jest.fn(() => false), @@ -101,12 +109,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 }: any) => ( {title} + {action ? ( + + {action.accessibilityLabel} + + ) : null} ), }; @@ -181,6 +194,10 @@ const mockUseMeasurements = useMeasurements as jest.MockedFunction; +const mockUseNativeIOSTabsActive = useNativeIOSTabsActive as jest.MockedFunction; +const mockSetNativeHeaderDatePickerOptions = setNativeHeaderDatePickerOptions as jest.MockedFunction< + typeof setNativeHeaderDatePickerOptions +>; const baseSummary = { foodEntries: [], @@ -395,4 +412,29 @@ 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'); + }); + }); diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx new file mode 100644 index 0000000000..072dd6df9c --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx @@ -0,0 +1,417 @@ +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'; + +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(), + setOptions: jest.fn(), +}; +const copyFromFamilyAsync = jest.fn(); +let onCopySuccess: ((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; + copyFromFamilyAsync.mockResolvedValue(undefined); + useDiaryDateStore.setState({ + selectedDate: '2026-08-23', + lastKnownToday: '2026-08-24', + }); + mockCopyMutation.mockImplementation(options => { + onCopySuccess = options?.onSuccess as typeof onCopySuccess; + 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 }], + }, + }); + }); + + 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', quantity: 150 }, + { entryId: 'sauce-id', quantity: 50 }, + ], + }, + }); + }); + + 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 }], + }, + }); + }); + + 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 }, + { entryId: 'pasta-id', quantity: 200 }, + ], + }, + }); + }); + + 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' }, + }); + }); +}); diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx new file mode 100644 index 0000000000..b6cb102778 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx @@ -0,0 +1,303 @@ +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, + dateFormat, + }: { + title: string; + selectedDate: string; + onPreviousDay: () => void; + onNextDay: () => void; + onToday: () => void; + onDatePress: () => void; + dateControls: { previousDayLabel: string; nextDayLabel: string }; + dateFormat: { todayLabel: string; yesterdayLabel: string }; + }) => ( + + {title} + {selectedDate} + {dateFormat.todayLabel} + {dateFormat.yesterdayLabel} + + + + + + ); +}); + +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), + ), + }), +})); + +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..fc151194d6 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx @@ -0,0 +1,201 @@ +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'; + +const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), + setOptions: jest.fn(), +} as any; + +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..cc7bb0b3b8 --- /dev/null +++ b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx @@ -0,0 +1,106 @@ +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'; + +const navigation = { navigate: jest.fn() } as any; + +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), + }), +})); + +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__/utils/dateUtils.test.ts b/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts index 19794a04fc..4f5d342cf5 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,26 @@ describe('with a pinned clock', () => { expect(other).not.toBe('Today'); expect(other).not.toBe('Yesterday'); }); + + test('accepts localized relative labels and date formatting', () => { + expect( + formatDateLabel('2024-06-15', { + locale: 'pl', + todayLabel: 'Dzisiaj', + yesterdayLabel: 'Wczoraj', + }), + ).toBe('Dzisiaj'); + expect( + formatDateLabel('2024-06-14', { + locale: 'pl', + todayLabel: 'Dzisiaj', + yesterdayLabel: 'Wczoraj', + }), + ).toBe('Wczoraj'); + expect(formatDate('2024-06-13', 'pl')).not.toBe( + formatDate('2024-06-13', 'en-US'), + ); + }); }); describe('formatRelativeTime', () => { diff --git a/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts b/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts index af2839aaf0..4cb02e447a 100644 --- a/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts +++ b/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts @@ -24,12 +24,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(); @@ -53,4 +53,34 @@ describe('nativeHeaderDatePicker', () => { }); 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); + }); }); diff --git a/SparkyFitnessMobile/src/components/DateNavigator.tsx b/SparkyFitnessMobile/src/components/DateNavigator.tsx index 3a8ab0dcae..8d6c4a4051 100644 --- a/SparkyFitnessMobile/src/components/DateNavigator.tsx +++ b/SparkyFitnessMobile/src/components/DateNavigator.tsx @@ -4,7 +4,9 @@ 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'; +import type { DateLabelOptions } from '../utils/dateUtils'; interface DateNavigatorProps { title: string; @@ -18,8 +20,35 @@ 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; + }; + dateFormat?: DateLabelOptions; } +const defaultDateControls = { + previousDayLabel: 'Previous day', + previousDayHint: 'Shows the previous day', + nextDayLabel: 'Next day', + nextDayHint: 'Shows the next day', + chooseDateLabel: 'Choose date', + chooseDateHint: 'Opens the date picker', + goToTodayLabel: 'Go to today', + goToTodayHint: 'Returns to today', +}; + const DateNavigator: React.FC = ({ title, selectedDate, @@ -32,6 +61,9 @@ const DateNavigator: React.FC = ({ skipTopInset, skipHorizontalPadding, compact, + action, + dateControls = defaultDateControls, + dateFormat, }) => { // Subscribe to the reactive app language so the date label re-localizes // immediately on a runtime PL <-> EN switch without an app restart. @@ -48,25 +80,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/localization/locales/en/translation.json b/SparkyFitnessMobile/src/localization/locales/en/translation.json index 80d9df76b7..2a3dfaeb4e 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,68 @@ "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": "Refresh the family diary and review the foods again.", + "unnamedMember": "Family member", + "today": "Today", + "yesterday": "Yesterday", + "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..e86cb5ee4e 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,68 @@ "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": "Odśwież dziennik rodzinny i ponownie sprawdź produkty.", + "unnamedMember": "Członek rodziny", + "today": "Dzisiaj", + "yesterday": "Wczoraj", + "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..3b9751ad4a 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'; @@ -39,6 +38,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,7 +46,7 @@ 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 selectedDate = useDiaryDateStore((s) => s.selectedDate); @@ -81,6 +81,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 +106,12 @@ const DiaryScreen: React.FC = ({ navigation }) => { dateLabel: `${formatDateLabel(selectedDate, t, dateLocale)} ▾`, t, locale: dateLocale, + leadingAction: { + sfSymbol: 'person.2.fill', + onPress: openFamilyDiaries, + accessibilityLabel: familyDiariesAccessibilityLabel, + identifier: 'family-diaries', + }, }, ); }, [ @@ -109,8 +119,10 @@ const DiaryScreen: React.FC = ({ navigation }) => { goToPreviousDay, nativeHeaderActionColor, navigation, + openFamilyDiaries, openCalendar, selectedDate, + familyDiariesAccessibilityLabel, usesNativeTabs, t, dateLocale, @@ -402,6 +414,11 @@ const DiaryScreen: React.FC = ({ navigation }) => { onToday={goToToday} onDatePress={openCalendar} showDateAlways + action={{ + icon: 'people', + accessibilityLabel: familyDiariesAccessibilityLabel, + onPress: openFamilyDiaries, + }} /> ) : !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 }, + }); + }, + }); + + useEffect(() => { + if (wasPendingRef.current && !isPending) submitInFlightRef.current = false; + wasPendingRef.current = isPending; + }, [isPending]); + + const header = useScreenHeader({ + title: 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, + quantity: entry.quantity, + })), + }, + } + : { + kind: 'selected' as const, + payload: { + familyUserId: familyUser.userId, + sourceDate, + targetDate, + targetMealType: resolvedTargetMealTypeId, + entries: selectedEntries.map(entry => ({ + entryId: entry.id, + quantity: quantitiesById[entry.id], + })), + }, + }; + + 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..c41baecbeb --- /dev/null +++ b/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx @@ -0,0 +1,231 @@ +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, + 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()} + dateFormat={{ + locale, + todayLabel: t('familyDiary.today', { defaultValue: 'Today' }), + yesterdayLabel: t('familyDiary.yesterday', { + defaultValue: 'Yesterday', + }), + }} + 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..5af72f322b --- /dev/null +++ b/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx @@ -0,0 +1,218 @@ +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, + 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..6c5947f1f2 --- /dev/null +++ b/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx @@ -0,0 +1,102 @@ +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' }), + 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..27399f5b12 100644 --- a/SparkyFitnessMobile/src/screens/SettingsScreen.tsx +++ b/SparkyFitnessMobile/src/screens/SettingsScreen.tsx @@ -18,6 +18,7 @@ import { formatRelativeTime } from '../utils/dateUtils'; import type { DiagnosticQueryState } from '../types/diagnosticReport'; import Constants from 'expo-constants'; import { useDiscreetMode } from '../hooks/useDiscreetMode'; +import { useTranslation } from 'react-i18next'; import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; @@ -29,7 +30,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 +179,14 @@ const SettingsScreen: React.FC = ({ navigation }) => { onPress={() => navigation.navigate('AppSettings')} iconColor={catViolet} /> + {isConnected && ( + navigation.navigate('FamilyMembers')} + iconColor={catTeal} + /> + )} {isConnected && ( ; + 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/nativeHeaderDatePicker.ts b/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts index 7220eab79c..0588fa5f93 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,23 @@ export function setNativeHeaderDatePickerOptions( navigation: NativeHeaderDatePickerNavigation, options: NativeHeaderDatePickerOptions, ) { + const leadingAction = options.leadingAction; + navigation.setOptions({ - unstable_headerRightItems: () => - createNativeHeaderDatePickerItems(options), + unstable_headerRightItems: () => createNativeHeaderDatePickerItems(options), + ...(leadingAction + ? { + unstable_headerLeftItems: () => [ + createNativeHeaderIconButtonItem({ + sfSymbol: leadingAction.sfSymbol, + onPress: leadingAction.onPress, + tintColor: options.tintColor, + accessibilityLabel: leadingAction.accessibilityLabel, + identifier: leadingAction.identifier, + }), + ], + } + : {}), }); } From 415cb2c73de3d32d5aa19e05dfe20ffb726013ca Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Mon, 24 Aug 2026 20:08:28 +0200 Subject: [PATCH 04/10] fix: keep reviewed family diary copies consistent --- SparkyFitnessMobile/App.tsx | 17 +-- .../hooks/useCopyFamilyFoodEntries.test.ts | 24 +++-- .../navigation/nativeHeaderContract.test.ts | 18 ++-- .../screens/FamilyCopyReviewScreen.test.tsx | 53 ++++++++-- .../__tests__/services/foodEntriesApi.test.ts | 4 +- .../src/hooks/useCopyFamilyFoodEntries.ts | 19 +++- .../localization/locales/en/translation.json | 2 +- .../localization/locales/pl/translation.json | 2 +- .../src/screens/FamilyCopyReviewScreen.tsx | 8 +- .../src/screens/FamilyDiaryScreen.tsx | 1 + .../src/screens/FamilyMealDetailScreen.tsx | 1 + .../src/screens/FamilyMembersScreen.tsx | 1 + SparkyFitnessMobile/src/types/familyDiary.ts | 8 +- SparkyFitnessServer/models/foodEntry.ts | 56 +++++----- SparkyFitnessServer/routes/foodEntryRoutes.ts | 19 ++-- .../schemas/foodEntryCopySchemas.ts | 5 +- .../services/foodEntryService.ts | 21 ++-- .../tests/foodEntryCopyFingerprint.test.ts | 40 +++++++ .../tests/foodEntrySelectedCopy.test.ts | 48 +++++++-- .../tests/foodEntrySelectedCopyRoute.test.ts | 20 +++- .../tests/foodEntrySelectedCopySchema.test.ts | 9 +- .../tests/foodEntryWholeCopy.test.ts | 39 ++++++- .../foodEntryWholeCopyRepository.test.ts | 64 +++++++++-- .../tests/foodEntryWholeCopyRoute.test.ts | 19 +++- .../tests/foodEntryWholeCopySchema.test.ts | 14 ++- shared/src/index.ts | 1 + shared/src/utils/foodEntryCopyFingerprint.ts | 100 ++++++++++++++++++ 27 files changed, 504 insertions(+), 109 deletions(-) create mode 100644 SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts create mode 100644 shared/src/utils/foodEntryCopyFingerprint.ts diff --git a/SparkyFitnessMobile/App.tsx b/SparkyFitnessMobile/App.tsx index fdfdb3e7cb..fdc8ea6f9b 100644 --- a/SparkyFitnessMobile/App.tsx +++ b/SparkyFitnessMobile/App.tsx @@ -343,29 +343,30 @@ function AppContent() { createStackScreenOptions( + route.params.familyUser.displayName.trim() || t('familyDiary.unnamedMember', { defaultValue: 'Family member' }), + { headerBackButtonDisplayMode: 'minimal' }, + )} /> createStackScreenOptions(route.params.mealTypeName, { + headerBackButtonDisplayMode: 'minimal', })} /> { }); }); - test('keeps the review and explains how to recover from a stale 409 source', async () => { + 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 { result } = renderHook(() => useCopyFamilyFoodEntries(), { + const refetchSpy = jest.spyOn(queryClient, 'refetchQueries'); + const onStale = jest.fn(); + const { result } = renderHook(() => useCopyFamilyFoodEntries({ onStale }), { wrapper: createQueryWrapper(queryClient), }); @@ -190,12 +194,20 @@ describe('useCopyFamilyFoodEntries', () => { }), ).rejects.toThrow('Conflict'); - expect(invalidateSpy).not.toHaveBeenCalled(); + 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: 'Refresh the family diary and review the foods again.', + text2: 'The latest family diary is opening for review.', }), ); }); diff --git a/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts b/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts index 053ac890f5..1b5c458fb2 100644 --- a/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts +++ b/SparkyFitnessMobile/__tests__/navigation/nativeHeaderContract.test.ts @@ -344,39 +344,39 @@ describe('native header navigation contract', () => { { routeName: 'FamilyMembers', component: 'SafeFamilyMembers', - title: 'Family Diaries', + optionSnippet: "t('familyDiary.title', { defaultValue: 'Family Diaries' })", backOption: "headerBackButtonDisplayMode: 'minimal'", }, { routeName: 'FamilyDiary', component: 'SafeFamilyDiary', - title: 'Family Diary', - backOption: "headerBackTitle: 'Family Diaries'", + optionSnippet: 'route.params.familyUser.displayName.trim()', + backOption: "headerBackButtonDisplayMode: 'minimal'", }, { routeName: 'FamilyMealDetail', component: 'SafeFamilyMealDetail', - title: 'Select Foods', - backOption: "headerBackTitle: 'Family Diary'", + optionSnippet: 'route.params.mealTypeName', + backOption: "headerBackButtonDisplayMode: 'minimal'", }, { routeName: 'FamilyCopyReview', component: 'SafeFamilyCopyReview', - title: 'Review Copy', - backOption: "headerBackTitle: 'Select Foods'", + 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, title, backOption } of familyRoutes) { + 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(`createStackScreenOptions('${title}', {`); + expect(screenBlock).toContain(optionSnippet); expect(screenBlock).toContain(backOption); expect(screenBlock).not.toMatch(/\bpresentation\s*:/); } diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx index 072dd6df9c..f84da0d5d9 100644 --- a/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/FamilyCopyReviewScreen.test.tsx @@ -7,6 +7,7 @@ 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', @@ -48,10 +49,12 @@ const sauce: FoodEntry = { 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, @@ -164,6 +167,7 @@ describe('FamilyCopyReviewScreen', () => { 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', @@ -171,6 +175,7 @@ describe('FamilyCopyReviewScreen', () => { }); mockCopyMutation.mockImplementation(options => { onCopySuccess = options?.onSuccess as typeof onCopySuccess; + onCopyStale = options?.onStale as typeof onCopyStale; return { copyFromFamily: jest.fn(), copyFromFamilyAsync, @@ -258,7 +263,13 @@ describe('FamilyCopyReviewScreen', () => { sourceDate: '2026-08-23', targetDate: '2026-08-24', targetMealType: 'dinner-id', - entries: [{ entryId: 'pasta-id', quantity: 150.5 }], + entries: [ + { + entryId: 'pasta-id', + quantity: 150.5, + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + ], }, }); }); @@ -294,8 +305,14 @@ describe('FamilyCopyReviewScreen', () => { targetDate: '2026-08-24', targetMealType: 'dinner-id', entries: [ - { entryId: 'pasta-id', quantity: 150 }, - { entryId: 'sauce-id', quantity: 50 }, + { + entryId: 'pasta-id', + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + { + entryId: 'sauce-id', + sourceFingerprint: foodEntryCopyFingerprint(sauce), + }, ], }, }); @@ -312,7 +329,13 @@ describe('FamilyCopyReviewScreen', () => { sourceDate: '2026-08-23', targetDate: '2026-08-24', targetMealType: 'dinner-id', - entries: [{ entryId: 'pasta-id', quantity: 150 }], + entries: [ + { + entryId: 'pasta-id', + quantity: 150, + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, + ], }, }); }); @@ -333,8 +356,16 @@ describe('FamilyCopyReviewScreen', () => { targetDate: '2026-08-24', targetMealType: 'dinner-id', entries: [ - { entryId: 'sauce-id', quantity: 50 }, - { entryId: 'pasta-id', quantity: 200 }, + { + entryId: 'sauce-id', + quantity: 50, + sourceFingerprint: foodEntryCopyFingerprint(sauce), + }, + { + entryId: 'pasta-id', + quantity: 200, + sourceFingerprint: foodEntryCopyFingerprint(pasta), + }, ], }, }); @@ -414,4 +445,14 @@ describe('FamilyCopyReviewScreen', () => { 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__/services/foodEntriesApi.test.ts b/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts index 1bbcd7d13f..0589ab1b65 100644 --- a/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts +++ b/SparkyFitnessMobile/__tests__/services/foodEntriesApi.test.ts @@ -523,7 +523,7 @@ describe('foodEntriesApi', () => { sourceMealType: 'breakfast', targetDate: '2026-08-24', targetMealType: 'lunch', - entries: [{ entryId: 'entry-1', quantity: 150 }], + entries: [{ entryId: 'entry-1', sourceFingerprint: 'snapshot' }], }; const selectedPayload = { @@ -531,7 +531,7 @@ describe('foodEntriesApi', () => { sourceDate: '2026-08-23', targetDate: '2026-08-24', targetMealType: 'lunch', - entries: [{ entryId: 'entry-1', quantity: 150 }], + entries: [{ entryId: 'entry-1', quantity: 150, sourceFingerprint: 'snapshot' }], }; test('posts a reviewed whole family meal with its exact snapshot', async () => { diff --git a/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts index ba96bdfb11..f5932df26b 100644 --- a/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts +++ b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts @@ -10,7 +10,11 @@ import type { CopySelectedFoodEntriesFromUserPayload, } from '../types/familyDiary'; import { ApiError } from '../services/api/errors'; -import { dailySummaryQueryKey, familyUsersQueryKey } from './queryKeys'; +import { + dailySummaryQueryKey, + familyDailySummaryQueryKey, + familyUsersQueryKey, +} from './queryKeys'; export type FamilyCopyRequest = | { kind: 'whole'; payload: CopyReviewedFoodEntriesFromUserPayload } @@ -18,6 +22,7 @@ export type FamilyCopyRequest = interface UseCopyFamilyFoodEntriesOptions { onSuccess?: (request: FamilyCopyRequest) => void; + onStale?: (request: FamilyCopyRequest) => void; } export function useCopyFamilyFoodEntries( @@ -43,7 +48,7 @@ export function useCopyFamilyFoodEntries( }); options?.onSuccess?.(request); }, - onError: error => { + onError: (error, request) => { if (error instanceof ApiError && error.statusCode === 403) { void queryClient.invalidateQueries({ queryKey: familyUsersQueryKey }); void queryClient.refetchQueries({ queryKey: familyUsersQueryKey }); @@ -60,16 +65,22 @@ export function useCopyFamilyFoodEntries( } 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: - 'Refresh the family diary and review the foods again.', + defaultValue: 'The latest family diary is opening for review.', }), }); + options?.onStale?.(request); return; } diff --git a/SparkyFitnessMobile/src/localization/locales/en/translation.json b/SparkyFitnessMobile/src/localization/locales/en/translation.json index 2a3dfaeb4e..8f3acacb0b 100644 --- a/SparkyFitnessMobile/src/localization/locales/en/translation.json +++ b/SparkyFitnessMobile/src/localization/locales/en/translation.json @@ -191,7 +191,7 @@ "copyPermissionRevoked": "Copy permission was removed", "copyPermissionRevokedGuidance": "Refresh family diaries to see your current access.", "copyStale": "Family diary changed", - "copyStaleGuidance": "Refresh the family diary and review the foods again.", + "copyStaleGuidance": "The latest family diary is opening for review.", "unnamedMember": "Family member", "today": "Today", "yesterday": "Yesterday", diff --git a/SparkyFitnessMobile/src/localization/locales/pl/translation.json b/SparkyFitnessMobile/src/localization/locales/pl/translation.json index e86cb5ee4e..76570aaa4f 100644 --- a/SparkyFitnessMobile/src/localization/locales/pl/translation.json +++ b/SparkyFitnessMobile/src/localization/locales/pl/translation.json @@ -193,7 +193,7 @@ "copyPermissionRevoked": "Uprawnienie do kopiowania zostało odebrane", "copyPermissionRevokedGuidance": "Odśwież dzienniki rodzinne, aby zobaczyć bieżący dostęp.", "copyStale": "Dziennik rodzinny został zmieniony", - "copyStaleGuidance": "Odśwież dziennik rodzinny i ponownie sprawdź produkty.", + "copyStaleGuidance": "Otwieramy najnowszy dziennik rodzinny do ponownego sprawdzenia.", "unnamedMember": "Członek rodziny", "today": "Dzisiaj", "yesterday": "Wczoraj", diff --git a/SparkyFitnessMobile/src/screens/FamilyCopyReviewScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyCopyReviewScreen.tsx index 8f78e9c2ae..ce692e055e 100644 --- a/SparkyFitnessMobile/src/screens/FamilyCopyReviewScreen.tsx +++ b/SparkyFitnessMobile/src/screens/FamilyCopyReviewScreen.tsx @@ -21,6 +21,7 @@ import { } from '../utils/familyDiary'; import { formatDate, getTodayDate } from '../utils/dateUtils'; import { parseDecimalInput } from '../utils/numericInput'; +import { foodEntryCopyFingerprint } from '@workspace/shared'; type FamilyCopyReviewScreenProps = RootStackScreenProps<'FamilyCopyReview'>; @@ -133,6 +134,9 @@ const FamilyCopyReviewScreen: React.FC = ({ params: { selectedDate: request.payload.targetDate }, }); }, + onStale: () => { + navigation.popTo('FamilyDiary', { familyUser }); + }, }); useEffect(() => { @@ -142,6 +146,7 @@ const FamilyCopyReviewScreen: React.FC = ({ 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)); @@ -172,7 +177,7 @@ const FamilyCopyReviewScreen: React.FC = ({ targetMealType: resolvedTargetMealTypeId, entries: sourceEntries.map(entry => ({ entryId: entry.id, - quantity: entry.quantity, + sourceFingerprint: foodEntryCopyFingerprint(entry), })), }, } @@ -186,6 +191,7 @@ const FamilyCopyReviewScreen: React.FC = ({ entries: selectedEntries.map(entry => ({ entryId: entry.id, quantity: quantitiesById[entry.id], + sourceFingerprint: foodEntryCopyFingerprint(entry), })), }, }; diff --git a/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx index c41baecbeb..8504ce705a 100644 --- a/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx +++ b/SparkyFitnessMobile/src/screens/FamilyDiaryScreen.tsx @@ -49,6 +49,7 @@ const FamilyDiaryScreen: React.FC = ({ }); const header = useScreenHeader({ title: displayName, + nativeTitle: displayName, left: { kind: 'back' }, }); diff --git a/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx index 5af72f322b..c72c7b732e 100644 --- a/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx +++ b/SparkyFitnessMobile/src/screens/FamilyMealDetailScreen.tsx @@ -37,6 +37,7 @@ const FamilyMealDetailScreen: React.FC = ({ ); const header = useScreenHeader({ title: mealTypeName, + nativeTitle: mealTypeName, left: { kind: 'back' }, }); const selectedEntries = useMemo( diff --git a/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx b/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx index 6c5947f1f2..215adad9db 100644 --- a/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx +++ b/SparkyFitnessMobile/src/screens/FamilyMembersScreen.tsx @@ -23,6 +23,7 @@ const FamilyMembersScreen: React.FC = ({ 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' }, }); diff --git a/SparkyFitnessMobile/src/types/familyDiary.ts b/SparkyFitnessMobile/src/types/familyDiary.ts index 70bc04f622..f884ecae40 100644 --- a/SparkyFitnessMobile/src/types/familyDiary.ts +++ b/SparkyFitnessMobile/src/types/familyDiary.ts @@ -12,7 +12,7 @@ export interface CopyReviewedFoodEntriesFromUserPayload { sourceMealType: string; targetDate: string; targetMealType: string; - entries: { entryId: string; quantity: number }[]; + entries: { entryId: string; sourceFingerprint: string }[]; } export interface CopySelectedFoodEntriesFromUserPayload { @@ -20,5 +20,9 @@ export interface CopySelectedFoodEntriesFromUserPayload { sourceDate: string; targetDate: string; targetMealType: string; - entries: { entryId: string; quantity: number }[]; + entries: { + entryId: string; + quantity: number; + sourceFingerprint: string; + }[]; } diff --git a/SparkyFitnessServer/models/foodEntry.ts b/SparkyFitnessServer/models/foodEntry.ts index 5f54a2c5de..84f155b155 100644 --- a/SparkyFitnessServer/models/foodEntry.ts +++ b/SparkyFitnessServer/models/foodEntry.ts @@ -5,10 +5,11 @@ 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 { foodEntryCopyFingerprint } from '@workspace/shared'; interface ReviewedFoodEntry { entryId: string; - quantity: number; + sourceFingerprint: string; } interface ReviewedFoodEntryCopyInput { @@ -50,18 +51,16 @@ function exactReviewedSnapshot( reviewedEntries: ReviewedFoodEntry[] ) { if (sourceEntries.length !== reviewedEntries.length) return false; - const quantitiesById = new Map( - reviewedEntries.map(({ entryId, quantity }) => [entryId, quantity]) + const fingerprintsById = new Map( + reviewedEntries.map(({ entryId, sourceFingerprint }) => [ + entryId, + sourceFingerprint, + ]) ); - if (quantitiesById.size !== reviewedEntries.length) return false; + if (fingerprintsById.size !== reviewedEntries.length) return false; return sourceEntries.every((entry) => { - const reviewedQuantity = quantitiesById.get(entry.id); - return ( - reviewedQuantity !== undefined && - Number.isFinite(Number(entry.quantity)) && - Number(entry.quantity) === reviewedQuantity - ); + return fingerprintsById.get(entry.id) === foodEntryCopyFingerprint(entry); }); } /** @@ -870,6 +869,23 @@ async function copyReviewedFoodEntriesFromUser({ 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`, + [targetUserId, targetMealTypeId, targetDate] + )) as { + rows: Array<{ food_id: string | null; variant_id: string | null }>; + }; + const existingStandaloneKeys = new Set( + existingStandaloneResult.rows.map( + ({ food_id, variant_id }) => + `${food_id ?? 'null'}:${variant_id ?? 'null'}` + ) + ); for (const entry of sourceEntries) { let targetFoodEntryMealId: string | null = null; @@ -941,24 +957,8 @@ async function copyReviewedFoodEntriesFromUser({ ); } } else { - const existingEntry = (await client.query( - `SELECT id - FROM food_entries - WHERE user_id = $1 - AND food_id IS NOT DISTINCT FROM $2 - AND meal_type_id = $3 - AND entry_date = $4 - AND variant_id IS NOT DISTINCT FROM $5 - AND food_entry_meal_id IS NULL`, - [ - targetUserId, - entry.food_id, - targetMealTypeId, - targetDate, - entry.variant_id, - ] - )) as { rows: Array<{ id: string }> }; - if (existingEntry.rows[0]) continue; + const standaloneKey = `${entry.food_id ?? 'null'}:${entry.variant_id ?? 'null'}`; + if (existingStandaloneKeys.has(standaloneKey)) continue; } const inserted = await client.query( diff --git a/SparkyFitnessServer/routes/foodEntryRoutes.ts b/SparkyFitnessServer/routes/foodEntryRoutes.ts index c508f6cf67..cf60d5f304 100644 --- a/SparkyFitnessServer/routes/foodEntryRoutes.ts +++ b/SparkyFitnessServer/routes/foodEntryRoutes.ts @@ -366,8 +366,8 @@ router.post( * tags: [Nutrition & Meals] * description: > * Preserves logged composite meal containers. The submitted entry IDs and - * quantities are an optimistic-concurrency snapshot; any added, removed, - * or quantity-changed source row returns 409 and writes nothing. + * source fingerprints are an optimistic-concurrency snapshot; any added, + * removed, or changed source row returns 409 and writes nothing. * requestBody: * required: true * content: @@ -404,14 +404,14 @@ router.post( * items: * type: object * additionalProperties: false - * required: [entryId, quantity] + * required: [entryId, 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 reviewed meal was copied successfully. @@ -425,7 +425,6 @@ router.post( router.post( '/copy-reviewed-from-user', authenticate, - checkPermissionMiddleware('diary'), async (req, res, next) => { const parsed = CopyReviewedFoodEntriesFromUserBodySchema.safeParse( req.body @@ -498,7 +497,7 @@ router.post( * items: * type: object * additionalProperties: false - * required: [entryId, quantity] + * required: [entryId, quantity, sourceFingerprint] * properties: * entryId: * type: string @@ -506,6 +505,9 @@ router.post( * quantity: * type: number * exclusiveMinimum: 0 + * sourceFingerprint: + * type: string + * description: Exact reviewed source-row snapshot. * responses: * 201: * description: The selected food entries were copied successfully. @@ -519,7 +521,6 @@ router.post( router.post( '/copy-selected-from-user', authenticate, - checkPermissionMiddleware('diary'), async (req, res, next) => { const parsed = CopySelectedFoodEntriesFromUserBodySchema.safeParse( req.body diff --git a/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts b/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts index ed6f5fb43e..5d143e3400 100644 --- a/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts +++ b/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts @@ -9,15 +9,14 @@ 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(), - // This is an optimistic-concurrency snapshot, never a client-provided - // quantity to persist. The service reloads and compares it before copy. - quantity: z.number().finite().positive(), + sourceFingerprint: z.string().min(1).max(20_000), }) .strict(); diff --git a/SparkyFitnessServer/services/foodEntryService.ts b/SparkyFitnessServer/services/foodEntryService.ts index 8555cc3d57..a8a69c554d 100644 --- a/SparkyFitnessServer/services/foodEntryService.ts +++ b/SparkyFitnessServer/services/foodEntryService.ts @@ -22,7 +22,11 @@ import type { } from '../schemas/foodEntryCopySchemas.js'; import Papa from 'papaparse'; -import { isDayString } from '@workspace/shared'; +import { + foodEntryCopyFingerprint, + type FoodEntryCopyFingerprintInput, + isDayString, +} from '@workspace/shared'; import customNutrientService from './customNutrientService.js'; import { removeOrphanedImages } from '../middleware/imageUpload.js'; import express from 'express'; @@ -1374,6 +1378,7 @@ async function copySelectedFoodEntriesFromUser( 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 ) { @@ -1440,22 +1445,22 @@ async function copySelectedFoodEntriesFromUser( } function hasExactReviewedEntries( - sourceEntries: Array<{ id?: string; quantity?: number | string | null }>, + sourceEntries: Array, reviewedEntries: CopyReviewedFoodEntriesFromUserBody['entries'] ) { if (sourceEntries.length !== reviewedEntries.length) return false; - const reviewedQuantityById = new Map( - reviewedEntries.map(({ entryId, quantity }) => [entryId, quantity]) + const reviewedFingerprintById = new Map( + reviewedEntries.map(({ entryId, sourceFingerprint }) => [ + entryId, + sourceFingerprint, + ]) ); return sourceEntries.every((entry) => { if (!entry.id) return false; - const reviewedQuantity = reviewedQuantityById.get(entry.id); return ( - reviewedQuantity !== undefined && - Number.isFinite(Number(entry.quantity)) && - Number(entry.quantity) === reviewedQuantity + reviewedFingerprintById.get(entry.id) === foodEntryCopyFingerprint(entry) ); }); } diff --git a/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts b/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts new file mode 100644 index 0000000000..f2e40a7ff3 --- /dev/null +++ b/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { foodEntryCopyFingerprint } 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); + }); + + 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) + ); + }); +}); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts index 803a37ab56..ea7d73c7c5 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts @@ -3,6 +3,7 @@ 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'); @@ -36,6 +37,15 @@ const validSourceEntry = { 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()); @@ -53,7 +63,7 @@ describe('copySelectedFoodEntriesFromUser', () => { SOURCE_DATE, TARGET_DATE, TARGET_MEAL, - [{ entryId: ENTRY_ID, quantity: 150 }] + [selection()] ) ).rejects.toMatchObject({ statusCode: 403 }); @@ -93,7 +103,7 @@ describe('copySelectedFoodEntriesFromUser', () => { SOURCE_DATE, TARGET_DATE, TARGET_MEAL, - [{ entryId: ENTRY_ID, quantity: 150 }] + [selection()] ) ).rejects.toMatchObject({ statusCode: 409 }); @@ -125,7 +135,7 @@ describe('copySelectedFoodEntriesFromUser', () => { SOURCE_DATE, TARGET_DATE, TARGET_MEAL, - [{ entryId: ENTRY_ID, quantity: 150 }] + [selection()] ); expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledWith( @@ -177,10 +187,7 @@ describe('copySelectedFoodEntriesFromUser', () => { SOURCE_DATE, TARGET_DATE, TARGET_MEAL, - [ - { entryId: ENTRY_ID, quantity: 150 }, - { entryId: SECOND_ENTRY_ID, quantity: 75 }, - ] + [selection(), selection(SECOND_ENTRY_ID, 75)] ); expect(foodRepository.getFoodEntryById).toHaveBeenNthCalledWith( @@ -202,4 +209,31 @@ describe('copySelectedFoodEntriesFromUser', () => { 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(); + }); }); diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts index d80fceb3e4..fa70bc0d2f 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts @@ -44,12 +44,30 @@ const body = { sourceDate: '2026-08-23', targetDate: '2026-08-24', targetMealType: '33333333-3333-4333-8333-333333333333', - entries: [{ entryId: '44444444-4444-4444-8444-444444444444', quantity: 150 }], + entries: [ + { + entryId: '44444444-4444-4444-8444-444444444444', + quantity: 150, + sourceFingerprint: 'snapshot', + }, + ], }; describe('POST /copy-selected-from-user', () => { beforeEach(() => vi.clearAllMocks()); + it('does not attach the active-context diary permission middleware', () => { + const routeLayer = ( + foodEntryRoutes as unknown as { + stack: Array<{ + route?: { path: string; stack: unknown[] }; + }>; + } + ).stack.find((layer) => layer.route?.path === '/copy-selected-from-user'); + + expect(routeLayer?.route?.stack).toHaveLength(2); + }); + it('rejects unknown fields before calling the selected-copy service', async () => { const response = await request(app) .post('/copy-selected-from-user') diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts index b323739ea4..ec449afac9 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts @@ -6,7 +6,13 @@ const valid = { sourceDate: '2026-08-23', targetDate: '2026-08-24', targetMealType: '22222222-2222-4222-8222-222222222222', - entries: [{ entryId: '33333333-3333-4333-8333-333333333333', quantity: 150 }], + entries: [ + { + entryId: '33333333-3333-4333-8333-333333333333', + quantity: 150, + sourceFingerprint: 'snapshot', + }, + ], }; describe('CopySelectedFoodEntriesFromUserBodySchema', () => { @@ -20,6 +26,7 @@ describe('CopySelectedFoodEntriesFromUserBodySchema', () => { { ...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( diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts index 1939bd6f16..64b5f6ae2d 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts @@ -3,6 +3,7 @@ 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'); @@ -21,7 +22,12 @@ const sourceEntry = { entry_date: SOURCE_DATE, quantity: 150, }; -const reviewedEntries = [{ entryId: sourceEntry.id, quantity: 150 }]; +const reviewedEntries = [ + { + entryId: sourceEntry.id, + sourceFingerprint: foodEntryCopyFingerprint(sourceEntry), + }, +]; describe('copyReviewedFoodEntriesFromUser', () => { beforeEach(() => vi.clearAllMocks()); @@ -120,4 +126,35 @@ describe('copyReviewedFoodEntriesFromUser', () => { }) ); }); + + 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 index 764352b1ad..03a679bd86 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts @@ -1,10 +1,17 @@ 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', + quantity: 150, + food_entry_meal_id: null, +}; + const input = { targetUserId: 'actor-a', actingUserId: 'actor-a', @@ -14,16 +21,13 @@ const input = { targetDate: '2026-08-24', targetMealTypeId: 'target-lunch-id', reviewedEntries: [ - { entryId: '33333333-3333-4333-8333-333333333333', quantity: 150 }, + { + entryId: reviewedRow.id, + sourceFingerprint: foodEntryCopyFingerprint(reviewedRow), + }, ], }; -const reviewedRow = { - id: input.reviewedEntries[0].entryId, - quantity: 150, - food_entry_meal_id: null, -}; - describe('copyReviewedFoodEntriesFromUser repository transaction', () => { const query = vi.fn(); const release = vi.fn(); @@ -70,4 +74,50 @@ describe('copyReviewedFoodEntriesFromUser repository transaction', () => { expect(release).toHaveBeenCalledOnce(); } ); + + it.each([ + ['unlinked rows', null, 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) => { + 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: [] }) + .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(insertCalls).toHaveLength(2); + expect(query).toHaveBeenLastCalledWith('COMMIT'); + expect(release).toHaveBeenCalledOnce(); + } + ); }); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts index ce4df7b2e7..567f8e9475 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts @@ -46,12 +46,29 @@ const body = { sourceMealType: 'Lunch', targetDate: '2026-08-24', targetMealType: '33333333-3333-4333-8333-333333333333', - entries: [{ entryId: '44444444-4444-4444-8444-444444444444', quantity: 150 }], + entries: [ + { + entryId: '44444444-4444-4444-8444-444444444444', + sourceFingerprint: 'snapshot', + }, + ], }; describe('POST /copy-reviewed-from-user', () => { beforeEach(() => vi.clearAllMocks()); + it('does not attach the active-context diary permission middleware', () => { + const routeLayer = ( + foodEntryRoutes as unknown as { + stack: Array<{ + route?: { path: string; stack: unknown[] }; + }>; + } + ).stack.find((layer) => layer.route?.path === '/copy-reviewed-from-user'); + + expect(routeLayer?.route?.stack).toHaveLength(2); + }); + it('uses actor A as target, actor, and cache owner when active context C copies source B', async () => { vi.mocked( foodEntryService.copyReviewedFoodEntriesFromUser diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts index 3707568c3a..78a92608bf 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts @@ -7,7 +7,12 @@ const valid = { sourceMealType: 'Lunch', targetDate: '2026-08-24', targetMealType: '22222222-2222-4222-8222-222222222222', - entries: [{ entryId: '33333333-3333-4333-8333-333333333333', quantity: 150 }], + entries: [ + { + entryId: '33333333-3333-4333-8333-333333333333', + sourceFingerprint: 'snapshot', + }, + ], }; describe('CopyReviewedFoodEntriesFromUserBodySchema', () => { @@ -21,7 +26,7 @@ describe('CopyReviewedFoodEntriesFromUserBodySchema', () => { { ...valid, sourceDate: '2026-02-30' }, { ...valid, sourceMealType: ' ' }, { ...valid, entries: [] }, - { ...valid, entries: [{ ...valid.entries[0], quantity: 0 }] }, + { ...valid, entries: [{ ...valid.entries[0], sourceFingerprint: '' }] }, { ...valid, unexpected: true }, ])('rejects invalid reviewed request %#', (input) => { expect( @@ -33,7 +38,10 @@ describe('CopyReviewedFoodEntriesFromUserBodySchema', () => { expect( CopyReviewedFoodEntriesFromUserBodySchema.safeParse({ ...valid, - entries: [valid.entries[0], { ...valid.entries[0], quantity: 200 }], + 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..fe4477b6df 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -110,6 +110,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/utils/foodEntryCopyFingerprint.ts b/shared/src/utils/foodEntryCopyFingerprint.ts new file mode 100644 index 0000000000..34745005ec --- /dev/null +++ b/shared/src/utils/foodEntryCopyFingerprint.ts @@ -0,0 +1,100 @@ +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; +} + +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.localeCompare(right)) + .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); +} From 3f8ec5940fc8b2dae149198a4caeac603118a2fd Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Mon, 24 Aug 2026 20:13:39 +0200 Subject: [PATCH 05/10] fix(server): preserve unlinked family diary rows --- SparkyFitnessServer/models/foodEntry.ts | 21 +++++++--- .../services/foodEntryService.ts | 21 ++++++---- .../tests/foodEntrySelectedCopy.test.ts | 42 +++++++++++++++++++ .../foodEntryWholeCopyRepository.test.ts | 15 +++++-- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/SparkyFitnessServer/models/foodEntry.ts b/SparkyFitnessServer/models/foodEntry.ts index 84f155b155..7beb3727ac 100644 --- a/SparkyFitnessServer/models/foodEntry.ts +++ b/SparkyFitnessServer/models/foodEntry.ts @@ -825,6 +825,7 @@ async function copyReviewedFoodEntriesFromUser({ `SELECT fe.id, fe.food_id, + fe.meal_type_id, fe.quantity, fe.unit, fe.entry_date, @@ -875,16 +876,19 @@ async function copyReviewedFoodEntriesFromUser({ WHERE user_id = $1 AND meal_type_id = $2 AND entry_date = $3 - AND food_entry_meal_id IS NULL`, + 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.map( - ({ food_id, variant_id }) => - `${food_id ?? 'null'}:${variant_id ?? 'null'}` - ) + existingStandaloneResult.rows + .filter(({ food_id }) => food_id !== null) + .map( + ({ food_id, variant_id }) => + `${food_id ?? 'null'}:${variant_id ?? 'null'}` + ) ); for (const entry of sourceEntries) { @@ -958,7 +962,12 @@ async function copyReviewedFoodEntriesFromUser({ } } else { const standaloneKey = `${entry.food_id ?? 'null'}:${entry.variant_id ?? 'null'}`; - if (existingStandaloneKeys.has(standaloneKey)) continue; + if ( + entry.food_id !== null && + entry.food_id !== undefined && + existingStandaloneKeys.has(standaloneKey) + ) + continue; } const inserted = await client.query( diff --git a/SparkyFitnessServer/services/foodEntryService.ts b/SparkyFitnessServer/services/foodEntryService.ts index a8a69c554d..b2e94fee56 100644 --- a/SparkyFitnessServer/services/foodEntryService.ts +++ b/SparkyFitnessServer/services/foodEntryService.ts @@ -1391,14 +1391,19 @@ async function copySelectedFoodEntriesFromUser( const entriesToCreate: FoodEntryInput[] = []; for (const { selection, entry } of selectedEntries) { - const existingEntry = await foodRepository.getFoodEntryByDetails( - targetUserId, - entry.food_id, - targetMealTypeId, - targetDate, - entry.variant_id, - null - ); + // Catalog-linked rows have a stable identity for duplicate detection. + // Custom snapshot rows do not: treating every null food_id as the same + // food silently drops unrelated entries, so they must remain copyable. + const existingEntry = entry.food_id + ? await foodRepository.getFoodEntryByDetails( + targetUserId, + entry.food_id, + targetMealTypeId, + targetDate, + entry.variant_id, + null + ) + : null; if (existingEntry) continue; entriesToCreate.push({ diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts index ea7d73c7c5..72785d39ae 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts @@ -236,4 +236,46 @@ describe('copySelectedFoodEntriesFromUser', () => { expect(foodRepository.bulkCreateFoodEntries).not.toHaveBeenCalled(); }); + + it('copies a custom source row even when an unrelated custom target row exists', 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).toHaveBeenCalledOnce(); + }); }); diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts index 03a679bd86..6826c19d7c 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts @@ -8,6 +8,7 @@ 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, }; @@ -76,11 +77,16 @@ describe('copyReviewedFoodEntriesFromUser repository transaction', () => { ); it.each([ - ['unlinked rows', null, null], - ['rows with the same food and variant', 'food-1', 'variant-1'], + [ + '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) => { + async (_case, foodId, variantId, existingTargetRows) => { const secondRow = { ...reviewedRow, id: '44444444-4444-4444-8444-444444444444', @@ -103,7 +109,7 @@ describe('copyReviewedFoodEntriesFromUser repository transaction', () => { query .mockResolvedValueOnce({ rows: [] }) .mockResolvedValueOnce({ rows: [firstRow, secondRow] }) - .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: existingTargetRows }) .mockResolvedValueOnce({ rows: [{ id: 'copy-1' }] }) .mockResolvedValueOnce({ rows: [{ id: 'copy-2' }] }) .mockResolvedValueOnce({ rows: [] }); @@ -115,6 +121,7 @@ describe('copyReviewedFoodEntriesFromUser repository transaction', () => { 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(); From 6321f5ec2bb9bad6f27da70bfd71ad10bcaf7c83 Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Mon, 24 Aug 2026 21:06:52 +0200 Subject: [PATCH 06/10] fix: address family diary review findings --- .../__tests__/screens/DiaryScreen.test.tsx | 44 +++++++--- .../screens/FamilyMembersScreen.test.tsx | 13 ++- .../screens/SettingsScreen.family.test.tsx | 15 +++- .../src/hooks/useCopyFamilyFoodEntries.ts | 2 +- .../src/screens/DiaryScreen.tsx | 17 ++-- .../src/services/api/foodEntriesApi.ts | 2 +- SparkyFitnessMobile/src/types/familyDiary.ts | 23 +---- SparkyFitnessServer/models/foodEntry.ts | 31 ++----- SparkyFitnessServer/routes/foodEntryRoutes.ts | 24 ++++-- .../schemas/foodEntryCopySchemas.ts | 76 ---------------- .../services/foodEntryService.ts | 34 ++------ .../tests/foodEntryCopyFingerprint.test.ts | 29 ++++++- .../tests/foodEntrySelectedCopyRoute.test.ts | 40 +++++---- .../tests/foodEntrySelectedCopySchema.test.ts | 2 +- .../tests/foodEntryWholeCopy.test.ts | 24 ++++++ .../tests/foodEntryWholeCopyRoute.test.ts | 29 ++++--- .../tests/foodEntryWholeCopySchema.test.ts | 2 +- shared/src/index.ts | 1 + .../src/schemas/api/FoodEntryCopy.api.zod.ts | 86 +++++++++++++++++++ shared/src/utils/foodEntryCopyFingerprint.ts | 40 ++++++++- 20 files changed, 322 insertions(+), 212 deletions(-) delete mode 100644 SparkyFitnessServer/schemas/foodEntryCopySchemas.ts create mode 100644 shared/src/schemas/api/FoodEntryCopy.api.zod.ts diff --git a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx index ea3c08205e..a4d89637bb 100644 --- a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx @@ -3,6 +3,7 @@ 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, @@ -16,6 +17,9 @@ 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(), goBack: jest.fn(), @@ -23,7 +27,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'); @@ -112,7 +122,7 @@ jest.mock('../../src/components/DateNavigator', () => { const { Pressable, Text, View } = require('react-native'); return { __esModule: true, - default: ({ title, action }: any) => ( + default: ({ title, action }: DateNavigatorProps) => ( {title} {action ? ( @@ -180,7 +190,7 @@ jest.mock('../../src/components/Icon', () => { const { View } = require('react-native'); return { __esModule: true, - default: ({ name }: any) => , + default: ({ name }: { name: string }) => , }; }); @@ -217,7 +227,7 @@ const configureConnection = (isConnected: boolean, isLoading = false) => { isLoading, isError: false, refetch: jest.fn(), - } as any); + } as ReturnType); }; const configureOnlineData = (overrides: { @@ -236,25 +246,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 }; @@ -263,7 +273,7 @@ const frame = { x: 0, y: 0, width: 390, height: 844 }; const renderScreen = () => render( - + , ); @@ -393,7 +403,7 @@ 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); @@ -437,4 +447,16 @@ describe('DiaryScreen custom queries', () => { 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(); + }); + }); diff --git a/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx index fc151194d6..5d2e1d3cba 100644 --- a/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/FamilyMembersScreen.test.tsx @@ -3,12 +3,21 @@ 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 any; +} 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(), @@ -56,7 +65,7 @@ const renderScreen = (bottomInset = 0) => insets: { top: 0, bottom: bottomInset, left: 0, right: 0 }, }} > - + , ); diff --git a/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx index cc7bb0b3b8..8c9742f69c 100644 --- a/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx @@ -8,7 +8,16 @@ import { useServerConnection, } from '../../src/hooks'; -const navigation = { navigate: jest.fn() } as any; +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'); @@ -76,7 +85,7 @@ describe('SettingsScreen family diary entry', () => { insets: { top: 0, bottom: 0, left: 0, right: 0 }, }} > - + , ); @@ -97,7 +106,7 @@ describe('SettingsScreen family diary entry', () => { insets: { top: 0, bottom: 0, left: 0, right: 0 }, }} > - + , ); diff --git a/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts index f5932df26b..5979ed20a2 100644 --- a/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts +++ b/SparkyFitnessMobile/src/hooks/useCopyFamilyFoodEntries.ts @@ -8,7 +8,7 @@ import { import type { CopyReviewedFoodEntriesFromUserPayload, CopySelectedFoodEntriesFromUserPayload, -} from '../types/familyDiary'; +} from '@workspace/shared'; import { ApiError } from '../services/api/errors'; import { dailySummaryQueryKey, diff --git a/SparkyFitnessMobile/src/screens/DiaryScreen.tsx b/SparkyFitnessMobile/src/screens/DiaryScreen.tsx index 3b9751ad4a..db98ae35e2 100644 --- a/SparkyFitnessMobile/src/screens/DiaryScreen.tsx +++ b/SparkyFitnessMobile/src/screens/DiaryScreen.tsx @@ -49,6 +49,7 @@ const DiaryScreen: React.FC = ({ navigation }) => { const { t, i18n: translationI18n } = useTranslation(); const dateLocale = translationI18n.language.startsWith('pl') ? 'pl-PL' : 'en-US'; const insets = useSafeAreaInsets(); + const { isConnected, isLoading: isConnectionLoading } = useServerConnection(); const selectedDate = useDiaryDateStore((s) => s.selectedDate); const setSelectedDate = useDiaryDateStore((s) => s.setSelectedDate); const goToPreviousDay = useDiaryDateStore((s) => s.goToPreviousDay); @@ -106,12 +107,14 @@ const DiaryScreen: React.FC = ({ navigation }) => { dateLabel: `${formatDateLabel(selectedDate, t, dateLocale)} ▾`, t, locale: dateLocale, - leadingAction: { - sfSymbol: 'person.2.fill', - onPress: openFamilyDiaries, - accessibilityLabel: familyDiariesAccessibilityLabel, - identifier: 'family-diaries', - }, + leadingAction: isConnected + ? { + sfSymbol: 'person.2.fill', + onPress: openFamilyDiaries, + accessibilityLabel: familyDiariesAccessibilityLabel, + identifier: 'family-diaries', + } + : undefined, }, ); }, [ @@ -123,6 +126,7 @@ const DiaryScreen: React.FC = ({ navigation }) => { openCalendar, selectedDate, familyDiariesAccessibilityLabel, + isConnected, usesNativeTabs, t, dateLocale, @@ -172,7 +176,6 @@ const DiaryScreen: React.FC = ({ navigation }) => { const heightMode = preferences?.default_measurement_unit ?? 'cm'; const { getImageSource } = useExerciseImageSource(); - const { isConnected, isLoading: isConnectionLoading } = useServerConnection(); const { summary, isLoading, diff --git a/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts b/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts index f6186fddf9..996026633d 100644 --- a/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts +++ b/SparkyFitnessMobile/src/services/api/foodEntriesApi.ts @@ -1,6 +1,6 @@ import { apiFetch } from './apiClient'; import type { FoodEntry } from '../../types/foodEntries'; -import type { CopyReviewedFoodEntriesFromUserPayload, CopySelectedFoodEntriesFromUserPayload } from '../../types/familyDiary'; +import type { CopyReviewedFoodEntriesFromUserPayload, CopySelectedFoodEntriesFromUserPayload } from '@workspace/shared'; export interface CreateFoodEntryPayload { meal_type_id: string; diff --git a/SparkyFitnessMobile/src/types/familyDiary.ts b/SparkyFitnessMobile/src/types/familyDiary.ts index f884ecae40..0bb45653c6 100644 --- a/SparkyFitnessMobile/src/types/familyDiary.ts +++ b/SparkyFitnessMobile/src/types/familyDiary.ts @@ -6,23 +6,8 @@ export interface FamilyDiaryUser { accessEndDate: string | null; } -export interface CopyReviewedFoodEntriesFromUserPayload { - familyUserId: string; - sourceDate: string; - sourceMealType: string; - targetDate: string; - targetMealType: string; - entries: { entryId: string; sourceFingerprint: string }[]; -} -export interface CopySelectedFoodEntriesFromUserPayload { - familyUserId: string; - sourceDate: string; - targetDate: string; - targetMealType: string; - entries: { - entryId: string; - quantity: number; - sourceFingerprint: string; - }[]; -} +export type { + CopyReviewedFoodEntriesFromUserPayload, + CopySelectedFoodEntriesFromUserPayload, +} from '@workspace/shared'; diff --git a/SparkyFitnessServer/models/foodEntry.ts b/SparkyFitnessServer/models/foodEntry.ts index 7beb3727ac..cf3c0cf4ee 100644 --- a/SparkyFitnessServer/models/foodEntry.ts +++ b/SparkyFitnessServer/models/foodEntry.ts @@ -5,12 +5,10 @@ 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 { foodEntryCopyFingerprint } from '@workspace/shared'; - -interface ReviewedFoodEntry { - entryId: string; - sourceFingerprint: string; -} +import { + hasExactReviewedFoodEntrySnapshot, + type ReviewedFoodEntryFingerprint, +} from '@workspace/shared'; interface ReviewedFoodEntryCopyInput { targetUserId: string; @@ -20,7 +18,7 @@ interface ReviewedFoodEntryCopyInput { sourceMealTypeId: string; targetDate: string; targetMealTypeId: string; - reviewedEntries: ReviewedFoodEntry[]; + reviewedEntries: ReviewedFoodEntryFingerprint[]; } interface ReviewedSourceEntry extends FoodEntryInput { @@ -46,23 +44,6 @@ function reviewedCopyConflict() { ); } -function exactReviewedSnapshot( - sourceEntries: ReviewedSourceEntry[], - reviewedEntries: ReviewedFoodEntry[] -) { - if (sourceEntries.length !== reviewedEntries.length) return false; - const fingerprintsById = new Map( - reviewedEntries.map(({ entryId, sourceFingerprint }) => [ - entryId, - sourceFingerprint, - ]) - ); - if (fingerprintsById.size !== reviewedEntries.length) return false; - - return sourceEntries.every((entry) => { - return fingerprintsById.get(entry.id) === foodEntryCopyFingerprint(entry); - }); -} /** * @swagger * components: @@ -864,7 +845,7 @@ async function copyReviewedFoodEntriesFromUser({ [sourceUserId, sourceDate, sourceMealTypeId] )) as { rows: ReviewedSourceEntry[] }; const sourceEntries = sourceResult.rows; - if (!exactReviewedSnapshot(sourceEntries, reviewedEntries)) { + if (!hasExactReviewedFoodEntrySnapshot(sourceEntries, reviewedEntries)) { throw reviewedCopyConflict(); } diff --git a/SparkyFitnessServer/routes/foodEntryRoutes.ts b/SparkyFitnessServer/routes/foodEntryRoutes.ts index cf60d5f304..acaca18bdf 100644 --- a/SparkyFitnessServer/routes/foodEntryRoutes.ts +++ b/SparkyFitnessServer/routes/foodEntryRoutes.ts @@ -7,8 +7,8 @@ import { clearUserTdeeCache } from '../services/AdaptiveTdeeService.js'; import { CopyReviewedFoodEntriesFromUserBodySchema, CopySelectedFoodEntriesFromUserBodySchema, -} from '../schemas/foodEntryCopySchemas.js'; -import { isEntryTimeString } from '@workspace/shared'; + isEntryTimeString, +} from '@workspace/shared'; import { uploadImages, applyImageOrder, @@ -21,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 @@ -516,7 +530,7 @@ router.post( * 403: * description: Forbidden. * 409: - * description: A copied entry conflicts with the target diary. + * description: A selected source entry changed after it was reviewed. */ router.post( '/copy-selected-from-user', diff --git a/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts b/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts deleted file mode 100644 index 5d143e3400..0000000000 --- a/SparkyFitnessServer/schemas/foodEntryCopySchemas.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isDayString } from '@workspace/shared'; -import { z } from 'zod/v4'; - -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(); - -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) => { - const seen = new Set(); - entries.forEach(({ entryId }, index) => { - if (seen.has(entryId)) { - context.addIssue({ - code: 'custom', - path: ['entries', index, 'entryId'], - message: 'Source entry IDs must be unique', - }); - } - seen.add(entryId); - }); - }); - -export type CopySelectedFoodEntriesFromUserBody = z.infer< - typeof CopySelectedFoodEntriesFromUserBodySchema ->; - -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) => { - const seen = new Set(); - entries.forEach(({ entryId }, index) => { - if (seen.has(entryId)) { - context.addIssue({ - code: 'custom', - path: ['entries', index, 'entryId'], - message: 'Reviewed source entry IDs must be unique', - }); - } - seen.add(entryId); - }); - }); - -export type CopyReviewedFoodEntriesFromUserBody = z.infer< - typeof CopyReviewedFoodEntriesFromUserBodySchema ->; diff --git a/SparkyFitnessServer/services/foodEntryService.ts b/SparkyFitnessServer/services/foodEntryService.ts index b2e94fee56..23c05bb3f6 100644 --- a/SparkyFitnessServer/services/foodEntryService.ts +++ b/SparkyFitnessServer/services/foodEntryService.ts @@ -16,15 +16,12 @@ 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 type { - CopyReviewedFoodEntriesFromUserBody, - CopySelectedFoodEntriesFromUserBody, -} from '../schemas/foodEntryCopySchemas.js'; - import Papa from 'papaparse'; import { + type CopyReviewedFoodEntriesFromUserBody, + type CopySelectedFoodEntriesFromUserBody, foodEntryCopyFingerprint, - type FoodEntryCopyFingerprintInput, + hasExactReviewedFoodEntrySnapshot, isDayString, } from '@workspace/shared'; import customNutrientService from './customNutrientService.js'; @@ -1449,27 +1446,6 @@ async function copySelectedFoodEntriesFromUser( : foodRepository.bulkCreateFoodEntries(entriesToCreate, targetUserId); } -function hasExactReviewedEntries( - sourceEntries: Array, - reviewedEntries: CopyReviewedFoodEntriesFromUserBody['entries'] -) { - if (sourceEntries.length !== reviewedEntries.length) return false; - - const reviewedFingerprintById = new Map( - reviewedEntries.map(({ entryId, sourceFingerprint }) => [ - entryId, - sourceFingerprint, - ]) - ); - - return sourceEntries.every((entry) => { - if (!entry.id) return false; - return ( - reviewedFingerprintById.get(entry.id) === foodEntryCopyFingerprint(entry) - ); - }); -} - async function copyReviewedFoodEntriesFromUser( targetUserId: string, actingUserId: string, @@ -1515,7 +1491,9 @@ async function copyReviewedFoodEntriesFromUser( sourceDate, sourceMealTypeId ); - if (!hasExactReviewedEntries(currentSourceEntries, reviewedEntries)) { + if ( + !hasExactReviewedFoodEntrySnapshot(currentSourceEntries, reviewedEntries) + ) { throw copyStatusError( 'One or more source entries changed. Refresh the family diary.', 409 diff --git a/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts b/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts index f2e40a7ff3..08b8f64da8 100644 --- a/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts +++ b/SparkyFitnessServer/tests/foodEntryCopyFingerprint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { foodEntryCopyFingerprint } from '@workspace/shared'; +import { + foodEntryCopyFingerprint, + hasExactReviewedFoodEntrySnapshot, +} from '@workspace/shared'; describe('foodEntryCopyFingerprint', () => { it('normalizes database numeric strings and custom nutrient key order', () => { @@ -7,16 +10,17 @@ describe('foodEntryCopyFingerprint', () => { quantity: '150', serving_size: '100', food_name: 'Pasta', - custom_nutrients: { zinc: 2, magnesium: '12' }, + custom_nutrients: { Zinc: 2, magnesium: '12' }, }); const right = foodEntryCopyFingerprint({ quantity: 150, serving_size: 100, food_name: 'Pasta', - custom_nutrients: { magnesium: '12', zinc: 2 }, + custom_nutrients: { magnesium: '12', Zinc: 2 }, }); expect(left).toBe(right); + expect(left).toContain('"custom_nutrients":{"Zinc":2,"magnesium":"12"}'); }); it.each([ @@ -37,4 +41,23 @@ describe('foodEntryCopyFingerprint', () => { 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/foodEntrySelectedCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts index fa70bc0d2f..202a9cc940 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopyRoute.test.ts @@ -6,14 +6,18 @@ 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( - () => (_req: unknown, _res: unknown, next: () => void) => next() - ), + default: vi.fn(() => diaryPermissionMiddleware), })); vi.mock('../middleware/authMiddleware.js', () => ({ authenticate: vi.fn( @@ -56,16 +60,17 @@ const body = { describe('POST /copy-selected-from-user', () => { beforeEach(() => vi.clearAllMocks()); - it('does not attach the active-context diary permission middleware', () => { - const routeLayer = ( - foodEntryRoutes as unknown as { - stack: Array<{ - route?: { path: string; stack: unknown[] }; - }>; - } - ).stack.find((layer) => layer.route?.path === '/copy-selected-from-user'); + 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(routeLayer?.route?.stack).toHaveLength(2); + expect(response.status).toBe(201); + expect(diaryPermissionMiddleware).not.toHaveBeenCalled(); }); it('rejects unknown fields before calling the selected-copy service', async () => { @@ -104,9 +109,12 @@ describe('POST /copy-selected-from-user', () => { }); it('preserves a service conflict response through the error handler', async () => { - const conflict = Object.assign(new Error('Copy conflicts with target'), { - statusCode: 409, - }); + const conflict = Object.assign( + new Error( + 'One or more source entries changed. Refresh the family diary.' + ), + { statusCode: 409 } + ); vi.mocked( foodEntryService.copySelectedFoodEntriesFromUser ).mockRejectedValue(conflict); @@ -117,7 +125,7 @@ describe('POST /copy-selected-from-user', () => { expect(response.status).toBe(409); expect(response.body).toMatchObject({ - error: 'Copy conflicts with target', + 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 index ec449afac9..bc7309a2bf 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopySchema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CopySelectedFoodEntriesFromUserBodySchema } from '../schemas/foodEntryCopySchemas.js'; +import { CopySelectedFoodEntriesFromUserBodySchema } from '@workspace/shared'; const valid = { familyUserId: '11111111-1111-4111-8111-111111111111', diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts index 64b5f6ae2d..8c92b03e84 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopy.test.ts @@ -32,6 +32,30 @@ const reviewedEntries = [ 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', diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts index 567f8e9475..b273f76f53 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRoute.test.ts @@ -7,14 +7,18 @@ 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( - () => (_req: unknown, _res: unknown, next: () => void) => next() - ), + default: vi.fn(() => diaryPermissionMiddleware), })); vi.mock('../middleware/authMiddleware.js', () => ({ authenticate: vi.fn( @@ -57,16 +61,17 @@ const body = { describe('POST /copy-reviewed-from-user', () => { beforeEach(() => vi.clearAllMocks()); - it('does not attach the active-context diary permission middleware', () => { - const routeLayer = ( - foodEntryRoutes as unknown as { - stack: Array<{ - route?: { path: string; stack: unknown[] }; - }>; - } - ).stack.find((layer) => layer.route?.path === '/copy-reviewed-from-user'); + 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(routeLayer?.route?.stack).toHaveLength(2); + 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 () => { diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts index 78a92608bf..3bd80c6dce 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopySchema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CopyReviewedFoodEntriesFromUserBodySchema } from '../schemas/foodEntryCopySchemas.js'; +import { CopyReviewedFoodEntriesFromUserBodySchema } from '@workspace/shared'; const valid = { familyUserId: '11111111-1111-4111-8111-111111111111', diff --git a/shared/src/index.ts b/shared/src/index.ts index fe4477b6df..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"; 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 index 34745005ec..0121e3d1c4 100644 --- a/shared/src/utils/foodEntryCopyFingerprint.ts +++ b/shared/src/utils/foodEntryCopyFingerprint.ts @@ -33,6 +33,11 @@ export interface FoodEntryCopyFingerprintInput { custom_nutrients?: unknown; } +export interface ReviewedFoodEntryFingerprint { + entryId: string; + sourceFingerprint: string; +} + const numericFields = [ "quantity", "serving_size", @@ -74,7 +79,7 @@ function stableValue(value: unknown): unknown { if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([key, child]) => [key, stableValue(child)]), ); } @@ -98,3 +103,36 @@ export function foodEntryCopyFingerprint( 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) + ); + }); +} From 0e761c909c2d3d35ea13d3163b23d1509e524124 Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Wed, 26 Aug 2026 17:13:26 +0200 Subject: [PATCH 07/10] fix: align family diary access and navigation --- SparkyFitnessMobile/AGENTS.md | 2 +- .../components/DateNavigator.test.tsx | 46 +++++++++-- .../__tests__/hooks/useFamilyDiary.test.ts | 8 ++ .../__tests__/screens/DiaryScreen.test.tsx | 52 ++++++++++++- .../DiaryScreenSupplementOnlyDay.test.tsx | 1 + .../screens/FamilyDiaryScreen.test.tsx | 5 +- .../screens/SettingsScreen.family.test.tsx | 1 + .../__tests__/services/familyApi.test.ts | 77 ++++++++++++++++++- .../utils/nativeHeaderDatePicker.test.ts | 32 ++++++++ .../src/components/DateNavigator.tsx | 56 ++++++++------ .../src/hooks/useFamilyDiary.ts | 7 +- .../src/screens/DiaryScreen.tsx | 29 +++++-- .../src/screens/FamilyDiaryScreen.tsx | 7 -- .../src/screens/SettingsScreen.tsx | 1 - .../src/services/api/familyApi.ts | 16 +++- .../src/utils/nativeHeaderDatePicker.ts | 24 +++--- .../services/foodEntryService.ts | 10 +-- .../tests/foodEntrySelectedCopy.test.ts | 7 +- 18 files changed, 301 insertions(+), 80 deletions(-) diff --git a/SparkyFitnessMobile/AGENTS.md b/SparkyFitnessMobile/AGENTS.md index d9bf082328..d03554d0b3 100644 --- a/SparkyFitnessMobile/AGENTS.md +++ b/SparkyFitnessMobile/AGENTS.md @@ -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. diff --git a/SparkyFitnessMobile/__tests__/components/DateNavigator.test.tsx b/SparkyFitnessMobile/__tests__/components/DateNavigator.test.tsx index 99a7080d63..efea5b6ec7 100644 --- a/SparkyFitnessMobile/__tests__/components/DateNavigator.test.tsx +++ b/SparkyFitnessMobile/__tests__/components/DateNavigator.test.tsx @@ -2,8 +2,17 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import DateNavigator from '../../src/components/DateNavigator'; +import i18n, { initializeI18n } from '../../src/localization/i18n'; describe('DateNavigator action', () => { + 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( @@ -79,7 +88,7 @@ describe('DateNavigator action', () => { expect(onNextDay).toHaveBeenCalledTimes(1); }); - test('renders localized relative dates and accessible controls', () => { + test('renders the global relative date and translated accessible controls', () => { jest.useFakeTimers(); jest.setSystemTime(new Date(2025, 0, 15, 12)); const screen = render( @@ -95,11 +104,6 @@ describe('DateNavigator action', () => { onPreviousDay={jest.fn()} onNextDay={jest.fn()} onToday={jest.fn()} - dateFormat={{ - locale: 'pl', - todayLabel: 'Dzisiaj', - yesterdayLabel: 'Wczoraj', - }} dateControls={{ previousDayLabel: 'Poprzedni dzień', previousDayHint: 'Pokazuje poprzedni dzień', @@ -114,11 +118,39 @@ describe('DateNavigator action', () => { , ); - expect(screen.getByText('Dzisiaj')).toBeTruthy(); + 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/useFamilyDiary.test.ts b/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts index a2e10e5ec0..7313f09e16 100644 --- a/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts +++ b/SparkyFitnessMobile/__tests__/hooks/useFamilyDiary.test.ts @@ -64,6 +64,14 @@ describe('useFamilyDiary', () => { ); }); + 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: {}, diff --git a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx index a4d89637bb..fcbb4760db 100644 --- a/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/DiaryScreen.test.tsx @@ -7,6 +7,7 @@ import type DateNavigatorComponent from '../../src/components/DateNavigator'; import { useDailySummary, useCustomNutrients, + useFamilyUsers, useNutrientDisplayPreferences, useServerConnection, } from '../../src/hooks'; @@ -50,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 })), })); @@ -84,6 +86,7 @@ jest.mock('../../src/hooks/useHeaderActionColors', () => ({ jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key === 'familyDiary.openFamilyDiaries' ? 'Open family diaries' : key, + i18n: { language: 'en-US' }, }), })); @@ -197,6 +200,7 @@ jest.mock('../../src/components/Icon', () => { 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 >; @@ -230,6 +234,18 @@ const configureConnection = (isConnected: boolean, isLoading = false) => { } 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: { customMeasurementsRefetching?: boolean; summaryRefetching?: boolean; @@ -285,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(); }); @@ -409,9 +435,9 @@ describe('DiaryScreen custom queries', () => { 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(); @@ -459,4 +485,24 @@ describe('DiaryScreen custom queries', () => { 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/FamilyDiaryScreen.test.tsx b/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx index b6cb102778..7e059ad952 100644 --- a/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/FamilyDiaryScreen.test.tsx @@ -74,7 +74,6 @@ jest.mock('../../src/components/DateNavigator', () => { onToday, onDatePress, dateControls, - dateFormat, }: { title: string; selectedDate: string; @@ -83,13 +82,10 @@ jest.mock('../../src/components/DateNavigator', () => { onToday: () => void; onDatePress: () => void; dateControls: { previousDayLabel: string; nextDayLabel: string }; - dateFormat: { todayLabel: string; yesterdayLabel: string }; }) => ( {title} {selectedDate} - {dateFormat.todayLabel} - {dateFormat.yesterdayLabel} ({ /\{\{(\w+)\}\}/g, (match, name: string) => String(options?.[name] ?? match), ), + i18n: { language: 'en-US' }, }), })); diff --git a/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx index 8c9742f69c..d5ef91595c 100644 --- a/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx +++ b/SparkyFitnessMobile/__tests__/screens/SettingsScreen.family.test.tsx @@ -50,6 +50,7 @@ jest.mock('../../src/services/storage', () => ({ jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => (key === 'familyDiary.title' ? 'Family Diaries' : key), + i18n: { language: 'en-US' }, }), })); diff --git a/SparkyFitnessMobile/__tests__/services/familyApi.test.ts b/SparkyFitnessMobile/__tests__/services/familyApi.test.ts index 74badb5141..09942a71cd 100644 --- a/SparkyFitnessMobile/__tests__/services/familyApi.test.ts +++ b/SparkyFitnessMobile/__tests__/services/familyApi.test.ts @@ -127,7 +127,7 @@ describe('familyApi', () => { userId: 'member-b', displayName: 'Member B', email: null, - canCopy: true, + canCopy: false, accessEndDate: '2026-12-31', }, { @@ -140,6 +140,81 @@ describe('familyApi', () => { ]); }); + 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, diff --git a/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts b/SparkyFitnessMobile/__tests__/utils/nativeHeaderDatePicker.test.ts index 4cb02e447a..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(() => { @@ -50,6 +54,7 @@ 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); }); @@ -83,4 +88,31 @@ describe('nativeHeaderDatePicker', () => { 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 8d6c4a4051..10a8cc93cd 100644 --- a/SparkyFitnessMobile/src/components/DateNavigator.tsx +++ b/SparkyFitnessMobile/src/components/DateNavigator.tsx @@ -6,7 +6,6 @@ import { useCSSVariable } from 'uniwind'; import Icon from './Icon'; import type { IconName } from './Icon'; import { formatDateLabel, formatDate } from '../utils/dateUtils'; -import type { DateLabelOptions } from '../utils/dateUtils'; interface DateNavigatorProps { title: string; @@ -35,20 +34,8 @@ interface DateNavigatorProps { goToTodayLabel: string; goToTodayHint: string; }; - dateFormat?: DateLabelOptions; } -const defaultDateControls = { - previousDayLabel: 'Previous day', - previousDayHint: 'Shows the previous day', - nextDayLabel: 'Next day', - nextDayHint: 'Shows the next day', - chooseDateLabel: 'Choose date', - chooseDateHint: 'Opens the date picker', - goToTodayLabel: 'Go to today', - goToTodayHint: 'Returns to today', -}; - const DateNavigator: React.FC = ({ title, selectedDate, @@ -62,13 +49,36 @@ const DateNavigator: React.FC = ({ skipHorizontalPadding, compact, action, - dateControls = defaultDateControls, - dateFormat, + 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; @@ -105,8 +115,8 @@ const DateNavigator: React.FC = ({ @@ -118,13 +128,13 @@ const DateNavigator: React.FC = ({ accessibilityRole="button" accessibilityLabel={ onDatePress - ? dateControls.chooseDateLabel - : dateControls.goToTodayLabel + ? resolvedDateControls.chooseDateLabel + : resolvedDateControls.goToTodayLabel } accessibilityHint={ onDatePress - ? dateControls.chooseDateHint - : dateControls.goToTodayHint + ? resolvedDateControls.chooseDateHint + : resolvedDateControls.goToTodayHint } className="flex-row items-center justify-center px-2" style={{ minWidth: 44, minHeight: 44 }} @@ -145,8 +155,8 @@ const DateNavigator: React.FC = ({ diff --git a/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts b/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts index 33aea86d38..7406d129d3 100644 --- a/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts +++ b/SparkyFitnessMobile/src/hooks/useFamilyDiary.ts @@ -9,10 +9,15 @@ interface UseFamilyDailySummaryOptions { enabled?: boolean; } -export function useFamilyUsers() { +interface UseFamilyUsersOptions { + enabled?: boolean; +} + +export function useFamilyUsers({ enabled = true }: UseFamilyUsersOptions = {}) { return useQuery({ queryKey: familyUsersQueryKey, queryFn: fetchFamilyDiaryUsers, + enabled, }); } diff --git a/SparkyFitnessMobile/src/screens/DiaryScreen.tsx b/SparkyFitnessMobile/src/screens/DiaryScreen.tsx index db98ae35e2..cb07e61e39 100644 --- a/SparkyFitnessMobile/src/screens/DiaryScreen.tsx +++ b/SparkyFitnessMobile/src/screens/DiaryScreen.tsx @@ -17,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'; @@ -50,6 +57,8 @@ const DiaryScreen: React.FC = ({ navigation }) => { 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); @@ -107,7 +116,7 @@ const DiaryScreen: React.FC = ({ navigation }) => { dateLabel: `${formatDateLabel(selectedDate, t, dateLocale)} ▾`, t, locale: dateLocale, - leadingAction: isConnected + leadingAction: hasFamilyDiaries ? { sfSymbol: 'person.2.fill', onPress: openFamilyDiaries, @@ -126,7 +135,7 @@ const DiaryScreen: React.FC = ({ navigation }) => { openCalendar, selectedDate, familyDiariesAccessibilityLabel, - isConnected, + hasFamilyDiaries, usesNativeTabs, t, dateLocale, @@ -417,11 +426,15 @@ const DiaryScreen: React.FC = ({ navigation }) => { onToday={goToToday} onDatePress={openCalendar} showDateAlways - action={{ - icon: 'people', - accessibilityLabel: familyDiariesAccessibilityLabel, - onPress: openFamilyDiaries, - }} + action={ + hasFamilyDiaries + ? { + icon: 'people', + accessibilityLabel: familyDiariesAccessibilityLabel, + onPress: openFamilyDiaries, + } + : undefined + } /> ) : !isConnectionLoading && ( = ({ onNextDay={() => setSelectedDate(date => addDays(date, 1))} onToday={() => setSelectedDate(getTodayDate())} onDatePress={() => calendarRef.current?.present()} - dateFormat={{ - locale, - todayLabel: t('familyDiary.today', { defaultValue: 'Today' }), - yesterdayLabel: t('familyDiary.yesterday', { - defaultValue: 'Yesterday', - }), - }} dateControls={{ previousDayLabel: t('familyDiary.previousDay', { defaultValue: 'Previous day', diff --git a/SparkyFitnessMobile/src/screens/SettingsScreen.tsx b/SparkyFitnessMobile/src/screens/SettingsScreen.tsx index 27399f5b12..78669c0e81 100644 --- a/SparkyFitnessMobile/src/screens/SettingsScreen.tsx +++ b/SparkyFitnessMobile/src/screens/SettingsScreen.tsx @@ -18,7 +18,6 @@ import { formatRelativeTime } from '../utils/dateUtils'; import type { DiagnosticQueryState } from '../types/diagnosticReport'; import Constants from 'expo-constants'; import { useDiscreetMode } from '../hooks/useDiscreetMode'; -import { useTranslation } from 'react-i18next'; import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; diff --git a/SparkyFitnessMobile/src/services/api/familyApi.ts b/SparkyFitnessMobile/src/services/api/familyApi.ts index 475f097112..99f367a33e 100644 --- a/SparkyFitnessMobile/src/services/api/familyApi.ts +++ b/SparkyFitnessMobile/src/services/api/familyApi.ts @@ -13,12 +13,20 @@ const hasDiaryPermission = ( permissions: AccessibleFamilyUserResponse['permissions'], ) => Boolean( - permissions?.diary || permissions?.calorie || permissions?.can_manage_diary, + permissions?.diary || + permissions?.calorie || + permissions?.can_manage_diary || + permissions?.can_view_reports || + permissions?.can_view_food_library, ); -const hasFoodLibraryPermission = ( +const hasCopyPermission = ( permissions: AccessibleFamilyUserResponse['permissions'], -) => Boolean(permissions?.food_list || permissions?.can_view_food_library); +) => + Boolean( + permissions?.can_manage_diary && + (permissions.food_list || permissions.can_view_food_library), + ); export async function fetchFamilyDiaryUsers(): Promise { const users = await apiFetch({ @@ -33,7 +41,7 @@ export async function fetchFamilyDiaryUsers(): Promise { userId: user.user_id, displayName: user.full_name ?? user.email ?? '', email: user.email, - canCopy: hasFoodLibraryPermission(user.permissions), + canCopy: hasCopyPermission(user.permissions), accessEndDate: user.access_end_date, })); } diff --git a/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts b/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts index 0588fa5f93..31085554c8 100644 --- a/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts +++ b/SparkyFitnessMobile/src/utils/nativeHeaderDatePicker.ts @@ -37,19 +37,17 @@ export function setNativeHeaderDatePickerOptions( navigation.setOptions({ unstable_headerRightItems: () => createNativeHeaderDatePickerItems(options), - ...(leadingAction - ? { - unstable_headerLeftItems: () => [ - createNativeHeaderIconButtonItem({ - sfSymbol: leadingAction.sfSymbol, - onPress: leadingAction.onPress, - tintColor: options.tintColor, - accessibilityLabel: leadingAction.accessibilityLabel, - identifier: leadingAction.identifier, - }), - ], - } - : {}), + unstable_headerLeftItems: leadingAction + ? () => [ + createNativeHeaderIconButtonItem({ + sfSymbol: leadingAction.sfSymbol, + onPress: leadingAction.onPress, + tintColor: options.tintColor, + accessibilityLabel: leadingAction.accessibilityLabel, + identifier: leadingAction.identifier, + }), + ] + : undefined, }); } diff --git a/SparkyFitnessServer/services/foodEntryService.ts b/SparkyFitnessServer/services/foodEntryService.ts index 23c05bb3f6..9ab4575dd5 100644 --- a/SparkyFitnessServer/services/foodEntryService.ts +++ b/SparkyFitnessServer/services/foodEntryService.ts @@ -1389,8 +1389,9 @@ async function copySelectedFoodEntriesFromUser( const entriesToCreate: FoodEntryInput[] = []; for (const { selection, entry } of selectedEntries) { // Catalog-linked rows have a stable identity for duplicate detection. - // Custom snapshot rows do not: treating every null food_id as the same - // food silently drops unrelated entries, so they must remain copyable. + // 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, @@ -1456,9 +1457,8 @@ async function copyReviewedFoodEntriesFromUser( targetMealType: string, reviewedEntries: CopyReviewedFoodEntriesFromUserBody['entries'] ) { - // This path is deliberately separate from copyFoodEntriesFromUser: the - // latter remains the web route whose target is the active context. The - // reviewed mobile path always writes into the authenticated actor's diary. + // 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 diff --git a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts index 72785d39ae..c2d8dcf96f 100644 --- a/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts +++ b/SparkyFitnessServer/tests/foodEntrySelectedCopy.test.ts @@ -237,7 +237,7 @@ describe('copySelectedFoodEntriesFromUser', () => { expect(foodRepository.bulkCreateFoodEntries).not.toHaveBeenCalled(); }); - it('copies a custom source row even when an unrelated custom target row exists', async () => { + it('does not conflate null food ids during the duplicate lookup', async () => { const customEntry = { ...validSourceEntry, food_id: null, @@ -276,6 +276,9 @@ describe('copySelectedFoodEntriesFromUser', () => { ).resolves.toEqual([{ id: 'copied-custom-row' }]); expect(foodRepository.getFoodEntryByDetails).not.toHaveBeenCalled(); - expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledOnce(); + expect(foodRepository.bulkCreateFoodEntries).toHaveBeenCalledWith( + [expect.objectContaining({ food_id: null })], + ACTOR + ); }); }); From bf804d79ef40fc5a930721406374063ae70f699d Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Wed, 26 Aug 2026 17:19:07 +0200 Subject: [PATCH 08/10] chore(mobile): remove unused family date labels --- .../src/localization/locales/en/translation.json | 2 -- .../src/localization/locales/pl/translation.json | 2 -- 2 files changed, 4 deletions(-) diff --git a/SparkyFitnessMobile/src/localization/locales/en/translation.json b/SparkyFitnessMobile/src/localization/locales/en/translation.json index 8f3acacb0b..13a99530a6 100644 --- a/SparkyFitnessMobile/src/localization/locales/en/translation.json +++ b/SparkyFitnessMobile/src/localization/locales/en/translation.json @@ -193,8 +193,6 @@ "copyStale": "Family diary changed", "copyStaleGuidance": "The latest family diary is opening for review.", "unnamedMember": "Family member", - "today": "Today", - "yesterday": "Yesterday", "previousDay": "Previous day", "previousDayHint": "Shows the previous day", "nextDay": "Next day", diff --git a/SparkyFitnessMobile/src/localization/locales/pl/translation.json b/SparkyFitnessMobile/src/localization/locales/pl/translation.json index 76570aaa4f..5f3d9a0c96 100644 --- a/SparkyFitnessMobile/src/localization/locales/pl/translation.json +++ b/SparkyFitnessMobile/src/localization/locales/pl/translation.json @@ -195,8 +195,6 @@ "copyStale": "Dziennik rodzinny został zmieniony", "copyStaleGuidance": "Otwieramy najnowszy dziennik rodzinny do ponownego sprawdzenia.", "unnamedMember": "Członek rodziny", - "today": "Dzisiaj", - "yesterday": "Wczoraj", "previousDay": "Poprzedni dzień", "previousDayHint": "Pokazuje poprzedni dzień", "nextDay": "Następny dzień", From b243da9397d72eb655a55844321454f1c4e5601e Mon Sep 17 00:00:00 2001 From: Bl4nk24 Date: Wed, 26 Aug 2026 17:33:38 +0200 Subject: [PATCH 09/10] fix(server): discard clients after rollback failures --- SparkyFitnessMobile/AGENTS.md | 5 ++- SparkyFitnessServer/models/foodEntry.ts | 11 +++++- .../foodEntryWholeCopyRepository.test.ts | 39 ++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/SparkyFitnessMobile/AGENTS.md b/SparkyFitnessMobile/AGENTS.md index d03554d0b3..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. @@ -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/SparkyFitnessServer/models/foodEntry.ts b/SparkyFitnessServer/models/foodEntry.ts index cf3c0cf4ee..810e8a56f5 100644 --- a/SparkyFitnessServer/models/foodEntry.ts +++ b/SparkyFitnessServer/models/foodEntry.ts @@ -797,6 +797,7 @@ async function copyReviewedFoodEntriesFromUser({ }: ReviewedFoodEntryCopyInput) { const client = await getClient(targetUserId, actingUserId); let transactionStarted = false; + let releaseError: Error | undefined; try { await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE'); @@ -1009,13 +1010,19 @@ async function copyReviewedFoodEntriesFromUser({ await client.query('COMMIT'); return copiedEntries; } catch (error) { - if (transactionStarted) await client.query('ROLLBACK'); + 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(); + client.release(releaseError); } } diff --git a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts index 6826c19d7c..fed30d29b1 100644 --- a/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts +++ b/SparkyFitnessServer/tests/foodEntryWholeCopyRepository.test.ts @@ -34,10 +34,47 @@ describe('copyReviewedFoodEntriesFromUser repository transaction', () => { const release = vi.fn(); beforeEach(() => { - vi.clearAllMocks(); + 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', From 88de457292ed2b5c398f530b978f05e4f8d79e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CCodewithCJ=E2=80=9D?= <“chandrasjr@gmail.com”> Date: Wed, 26 Aug 2026 17:59:39 -0400 Subject: [PATCH 10/10] fix(mobile): restore dateUtils test to the translator signature The formatDateLabel localization test still called the removed DateLabelOptions object API, so the options object landed in the TFunction parameter and threw "t is not a function". Use the Polish fixed translator, matching the relative-time test in the same file. --- .../__tests__/utils/dateUtils.test.ts | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts b/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts index 4f5d342cf5..0aef84e82f 100644 --- a/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts +++ b/SparkyFitnessMobile/__tests__/utils/dateUtils.test.ts @@ -113,24 +113,16 @@ describe('with a pinned clock', () => { expect(other).not.toBe('Yesterday'); }); - test('accepts localized relative labels and date formatting', () => { - expect( - formatDateLabel('2024-06-15', { - locale: 'pl', - todayLabel: 'Dzisiaj', - yesterdayLabel: 'Wczoraj', - }), - ).toBe('Dzisiaj'); - expect( - formatDateLabel('2024-06-14', { - locale: 'pl', - todayLabel: 'Dzisiaj', - yesterdayLabel: 'Wczoraj', - }), - ).toBe('Wczoraj'); + 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'); }); });