diff --git a/android/app/build.gradle b/android/app/build.gradle index 6fb253cd..4b2ed948 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,8 +92,8 @@ android { applicationId 'com.brewtracker.android' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 191 - versionName "3.2.6" + versionCode 207 + versionName "3.3.15" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a7bd023e..71527157 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ BrewTracker automatic - 3.2.6 + 3.3.15 contain false \ No newline at end of file diff --git a/app.json b/app.json index 9d3b4108..1ff3df9d 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "BrewTracker", "slug": "brewtracker-android", "orientation": "portrait", - "version": "3.2.6", + "version": "3.3.15", "icon": "./assets/images/BrewTrackerAndroidLogo.png", "scheme": "brewtracker", "userInterfaceStyle": "automatic", @@ -16,7 +16,7 @@ }, "edgeToEdgeEnabled": true, "package": "com.brewtracker.android", - "versionCode": 191, + "versionCode": 207, "permissions": [ "CAMERA", "VIBRATE", @@ -58,7 +58,7 @@ } }, "owner": "jackmisner", - "runtimeVersion": "3.2.6", + "runtimeVersion": "3.3.15", "updates": { "url": "https://u.expo.dev/edf222a8-b532-4d12-9b69-4e7fbb1d41c2" } diff --git a/app/(auth)/resetPassword.tsx b/app/(auth)/resetPassword.tsx index 4546b65b..3199116d 100644 --- a/app/(auth)/resetPassword.tsx +++ b/app/(auth)/resetPassword.tsx @@ -47,6 +47,7 @@ import { useAuth } from "@contexts/AuthContext"; import { useTheme } from "@contexts/ThemeContext"; import { loginStyles } from "@styles/auth/loginStyles"; import { TEST_IDS } from "@src/constants/testIDs"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; type Strength = "" | "weak" | "medium" | "strong"; @@ -188,7 +189,11 @@ const ResetPasswordScreen: React.FC = () => { setSuccess(true); } catch (err: unknown) { // Error is handled by the context and displayed through error state - console.error("Password reset failed:", err); + void UnifiedLogger.error( + "resetPassword.handleResetPassword", + "Password reset failed:", + err + ); // Optionally show a fallback alert if context error handling fails if (!error) { Alert.alert("Error", "Failed to reset password. Please try again."); diff --git a/app/(modals)/(beerxml)/importBeerXML.tsx b/app/(modals)/(beerxml)/importBeerXML.tsx index d0ec4c47..409fa13a 100644 --- a/app/(modals)/(beerxml)/importBeerXML.tsx +++ b/app/(modals)/(beerxml)/importBeerXML.tsx @@ -25,38 +25,34 @@ import { import { MaterialIcons } from "@expo/vector-icons"; import { router } from "expo-router"; import { useTheme } from "@contexts/ThemeContext"; +import { useUnits } from "@contexts/UnitContext"; import { createRecipeStyles } from "@styles/modals/createRecipeStyles"; -import BeerXMLService from "@services/beerxml/BeerXMLService"; +import BeerXMLService, { + type BeerXMLRecipe, +} from "@services/beerxml/BeerXMLService"; import { TEST_IDS } from "@src/constants/testIDs"; import { ModalHeader } from "@src/components/ui/ModalHeader"; - -interface Recipe { - name?: string; - style?: string; - batch_size?: number; - batch_size_unit?: "l" | "gal"; - ingredients?: { - type: "grain" | "hop" | "yeast" | "other"; - [key: string]: any; - }[]; - [key: string]: any; -} +import { UnitConversionChoiceModal } from "@src/components/beerxml/UnitConversionChoiceModal"; +import { UnitSystem } from "@src/types"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; interface ImportState { - step: "file_selection" | "parsing" | "recipe_selection"; + step: "file_selection" | "parsing" | "unit_choice" | "recipe_selection"; isLoading: boolean; error: string | null; selectedFile: { content: string; filename: string; } | null; - parsedRecipes: Recipe[]; - selectedRecipe: Recipe | null; + parsedRecipes: BeerXMLRecipe[]; + selectedRecipe: BeerXMLRecipe | null; + convertingTarget: UnitSystem | null; } export default function ImportBeerXMLScreen() { const theme = useTheme(); const styles = createRecipeStyles(theme); + const { unitSystem } = useUnits(); const [importState, setImportState] = useState({ step: "file_selection", @@ -65,6 +61,7 @@ export default function ImportBeerXMLScreen() { selectedFile: null, parsedRecipes: [], selectedRecipe: null, + convertingTarget: null, }); /** @@ -99,7 +96,11 @@ export default function ImportBeerXMLScreen() { // Automatically proceed to parsing await parseBeerXML(result.content, result.filename); } catch (error) { - console.error("🍺 BeerXML Import - File selection error:", error); + void UnifiedLogger.error( + "beerxml", + "🍺 BeerXML Import - File selection error:", + error + ); setImportState(prev => ({ ...prev, isLoading: false, @@ -110,24 +111,33 @@ export default function ImportBeerXMLScreen() { /** * Parse the selected BeerXML content + * BeerXML files are always in metric per spec */ const parseBeerXML = async (content: string, _filename: string) => { try { - // Parse using backend service + // Parse using backend service (returns metric recipes per BeerXML spec) const recipes = await BeerXMLService.parseBeerXML(content); if (recipes.length === 0) { throw new Error("No recipes found in the BeerXML file"); } + + // Select first recipe by default + const firstRecipe = recipes[0]; + setImportState(prev => ({ ...prev, isLoading: false, parsedRecipes: recipes, - selectedRecipe: recipes[0], - step: "recipe_selection", + selectedRecipe: firstRecipe, + step: "unit_choice", // Always show unit choice after parsing })); } catch (error) { - console.error("🍺 BeerXML Import - Parsing error:", error); + void UnifiedLogger.error( + "beerxml", + "🍺 BeerXML Import - Parsing error:", + error + ); setImportState(prev => ({ ...prev, isLoading: false, @@ -137,11 +147,74 @@ export default function ImportBeerXMLScreen() { } }; + /** + * Handle unit system choice and conversion + * Applies normalization to both metric and imperial imports + */ + const handleUnitSystemChoice = async (targetSystem: UnitSystem) => { + const recipe = importState.selectedRecipe; + if (!recipe) { + return; + } + + setImportState(prev => ({ ...prev, convertingTarget: targetSystem })); + + try { + // Convert recipe to target system with normalization + // Even metric imports need normalization (e.g., 28.3g -> 30g) + const { recipe: convertedRecipe, warnings } = + await BeerXMLService.convertRecipeUnits( + recipe, + targetSystem, + true // Always normalize to brewing-friendly increments + ); + + // Log warnings if any + if (warnings && warnings.length > 0) { + void UnifiedLogger.warn( + "beerxml", + "🍺 BeerXML Conversion warnings:", + warnings + ); + } + + setImportState(prev => ({ + ...prev, + selectedRecipe: convertedRecipe, + step: "recipe_selection", + convertingTarget: null, + })); + } catch (error) { + void UnifiedLogger.error( + "beerxml", + "🍺 BeerXML Import - Conversion error:", + error + ); + setImportState(prev => ({ ...prev, convertingTarget: null })); + Alert.alert( + "Conversion Error", + "Failed to convert recipe units. Please try again or select a different file.", + [ + { + text: "OK", + onPress: () => + setImportState(prev => ({ + ...prev, + step: "file_selection", + })), + }, + ] + ); + } + }; + /** * Proceed to ingredient matching workflow */ - const proceedToIngredientMatching = () => { - if (!importState.selectedRecipe) { + const proceedToIngredientMatching = (recipe?: BeerXMLRecipe) => { + const recipeToImport = recipe || importState.selectedRecipe; + + if (!recipeToImport) { Alert.alert("Error", "Please select a recipe to import"); return; } @@ -150,7 +223,7 @@ export default function ImportBeerXMLScreen() { router.push({ pathname: "/(modals)/(beerxml)/ingredientMatching" as any, params: { - recipeData: JSON.stringify(importState.selectedRecipe), + recipeData: JSON.stringify(recipeToImport), filename: importState.selectedFile?.filename || "imported_recipe", }, }); @@ -167,6 +240,7 @@ export default function ImportBeerXMLScreen() { selectedFile: null, parsedRecipes: [], selectedRecipe: null, + convertingTarget: null, }); }; @@ -319,7 +393,7 @@ export default function ImportBeerXMLScreen() { proceedToIngredientMatching()} testID={TEST_IDS.patterns.touchableOpacityAction( "proceed-to-matching" )} @@ -403,6 +477,22 @@ export default function ImportBeerXMLScreen() { importState.step === "recipe_selection" && renderRecipeSelection()} + + {/* Unit Conversion Choice Modal */} + handleUnitSystemChoice("metric")} + onChooseImperial={() => handleUnitSystemChoice("imperial")} + onCancel={() => + setImportState(prev => ({ + ...prev, + step: "file_selection", + })) + } + /> ); } diff --git a/app/(modals)/(beerxml)/importReview.tsx b/app/(modals)/(beerxml)/importReview.tsx index c27f1fbb..00e76c44 100644 --- a/app/(modals)/(beerxml)/importReview.tsx +++ b/app/(modals)/(beerxml)/importReview.tsx @@ -12,7 +12,7 @@ * - Import statistics and metadata display */ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { View, Text, @@ -26,13 +26,74 @@ import { router, useLocalSearchParams } from "expo-router"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; import { useTheme } from "@contexts/ThemeContext"; import { createRecipeStyles } from "@styles/modals/createRecipeStyles"; -import ApiService from "@services/api/apiService"; -import { IngredientInput } from "@src/types"; +import { + Recipe, + RecipeIngredient, + RecipeMetricsInput, + TemperatureUnit, + UnitSystem, + BatchSizeUnit, +} from "@src/types"; import { TEST_IDS } from "@src/constants/testIDs"; import { generateUniqueId } from "@utils/keyUtils"; import { QUERY_KEYS } from "@services/api/queryClient"; import { ModalHeader } from "@src/components/ui/ModalHeader"; +import { useRecipes } from "@src/hooks/offlineV2"; +import { OfflineMetricsCalculator } from "@services/brewing/OfflineMetricsCalculator"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; + +/** + * Derive unit system from batch_size_unit with fallback to metric + * Centralizes the "l" => metric logic used throughout import + */ +function deriveUnitSystem( + batchSizeUnit: string | undefined, + explicitUnitSystem?: string +): UnitSystem { + if (explicitUnitSystem === "metric" || explicitUnitSystem === "imperial") { + return explicitUnitSystem; + } + if (explicitUnitSystem !== undefined) { + void UnifiedLogger.warn( + "import-review", + `Invalid explicit unit_system "${explicitUnitSystem}", deriving from batch_size_unit` + ); + } + // Default based on batch size unit + const lowerCasedUnit = batchSizeUnit?.toLowerCase(); + return lowerCasedUnit === "gal" || lowerCasedUnit === "gallons" + ? "imperial" + : "metric"; +} + +/** + * Derive mash temperature unit with normalization and validation + * Only accepts valid "C" or "F" values, falls back to unit system default + */ +function deriveMashTempUnit( + mashTempUnit: string | undefined, + unitSystem: UnitSystem +): TemperatureUnit { + // Normalize and validate provided unit + if (mashTempUnit) { + const normalized = mashTempUnit.toUpperCase(); + if (normalized === "C" || normalized === "F") { + return normalized as TemperatureUnit; + } + // Invalid unit provided, log warning and fall through to default + void UnifiedLogger.warn( + "import-review", + `Invalid mash_temp_unit "${mashTempUnit}", using default for ${unitSystem}` + ); + } + // Fall back to unit system default + return unitSystem === "metric" ? "C" : "F"; +} + +/** + * Coerce ingredient time values to valid numbers or undefined + */ function coerceIngredientTime(input: unknown): number | undefined { if (input == null) { return undefined; @@ -47,6 +108,173 @@ function coerceIngredientTime(input: unknown): number | undefined { return Number.isFinite(n) && n >= 0 ? n : undefined; // reject NaN/±Inf/negatives } +/** + * Type for imported recipe data from BeerXML + */ +interface ImportedRecipeData { + name: string; + style?: string; + description?: string; + notes?: string; + batch_size: number | string; + batch_size_unit?: string; + boil_time?: number | string; + efficiency?: number; + unit_system?: string; + mash_temp_unit?: string; + mash_temperature?: number; + ingredients?: any[]; +} + +/** + * Normalized recipe values for consistent use across metrics, creation, and preview + */ +interface NormalizedRecipeValues { + unitSystem: UnitSystem; + batchSize: number; + batchSizeUnit: BatchSizeUnit; + boilTime: number; + displayBatchSize: string; + displayBoilTime: string; +} + +/** + * Normalize batch size and boil time values with consistent defaults + * Returns values ready for metrics calculation, recipe creation, and preview display + */ +function normalizeRecipeValues( + recipeData: ImportedRecipeData +): NormalizedRecipeValues { + // Derive unit system using centralized logic + const unitSystem = deriveUnitSystem( + recipeData.batch_size_unit, + recipeData.unit_system + ); + + // Normalize batch size with fallback to 19.0 + const rawBatchSize = + typeof recipeData.batch_size === "number" + ? recipeData.batch_size + : Number(recipeData.batch_size); + const batchSize = + Number.isFinite(rawBatchSize) && rawBatchSize > 0 ? rawBatchSize : 19.0; + + // Normalize batch size unit + const batchSizeUnit = + recipeData.batch_size_unit || (unitSystem === "metric" ? "l" : "gal"); + + // Normalize boil time with fallback to 60 + const boilTime = coerceIngredientTime(recipeData.boil_time) ?? 60; + + // Generate display strings (consistent with what's shown and saved) + const displayBatchSize = batchSize.toFixed(1); + const displayBoilTime = String(boilTime); + + // Determine display unit label + const rawUnit = String(batchSizeUnit).toLowerCase(); + const unitLabel = + rawUnit === "l" + ? "L" + : rawUnit === "gal" || rawUnit === "gallons" + ? "gal" + : unitSystem === "metric" + ? "L" + : "gal"; + + return { + unitSystem, + batchSize, + batchSizeUnit: unitLabel === "L" ? "l" : "gal", // Normalized for API + boilTime, + displayBatchSize: `${displayBatchSize} ${unitLabel}`, + displayBoilTime: `${displayBoilTime} minutes`, + }; +} + +/** + * Normalize and validate imported ingredients + * Filters out invalid ingredients and logs errors + * Returns array ready for both metrics calculation and recipe creation + */ +function normalizeImportedIngredients( + ingredients: any[] | undefined +): RecipeIngredient[] { + if (!ingredients || !Array.isArray(ingredients)) { + return []; + } + + return ingredients + .filter((ing: any) => { + // Validate required fields before mapping + if (!ing.ingredient_id) { + void UnifiedLogger.warn( + "import-review", + "Ingredient missing ingredient_id", + ing + ); + return false; + } + if (!ing.name || !ing.type || !ing.unit) { + void UnifiedLogger.error( + "import-review", + "Ingredient missing required fields", + ing + ); + return false; + } + if (ing.amount === "" || ing.amount == null) { + void UnifiedLogger.error( + "import-review", + "Ingredient has missing amount", + ing + ); + return false; + } + const amountNumber = + typeof ing.amount === "number" ? ing.amount : Number(ing.amount); + if (!Number.isFinite(amountNumber) || amountNumber <= 0) { + void UnifiedLogger.error( + "import-review", + "Ingredient has invalid amount", + ing + ); + return false; + } + return true; + }) + .map((ing: any): RecipeIngredient => { + const type = String(ing.type).toLowerCase(); + return { + // No id - backend generates on creation + ingredient_id: ing.ingredient_id, + name: ing.name, + type: type, + amount: + typeof ing.amount === "number" ? ing.amount : Number(ing.amount), + unit: ing.unit, + use: ing.use, + time: coerceIngredientTime(ing.time), + instance_id: generateUniqueId("ing"), + // Include type-specific fields for proper ingredient matching and metrics + ...(type === "grain" && { + potential: ing.potential, + color: ing.color, + grain_type: ing.grain_type, + }), + ...(type === "hop" && { + alpha_acid: ing.alpha_acid, + }), + ...(type === "yeast" && { + attenuation: ing.attenuation, + }), + // Preserve BeerXML metadata if available + ...(ing.beerxml_data && { + beerxml_data: ing.beerxml_data, + }), + }; + }); +} + export default function ImportReviewScreen() { const theme = useTheme(); const styles = createRecipeStyles(theme); @@ -57,66 +285,126 @@ export default function ImportReviewScreen() { }>(); const queryClient = useQueryClient(); - const [recipeData] = useState(() => { + const { create: createRecipe } = useRecipes(); + const [recipeData] = useState(() => { try { return JSON.parse(params.recipeData); } catch (error) { - console.error("Failed to parse recipe data:", error); + void UnifiedLogger.error( + "import-review", + "Failed to parse recipe data:", + error + ); return null; } }); /** - * Calculate recipe metrics before creation + * Normalize recipe values once - use this for metrics, creation, and preview + * This ensures "what you see is what you get" across all three contexts + */ + const normalizedValues = useMemo(() => { + if (!recipeData) { + return null; + } + return normalizeRecipeValues(recipeData); + }, [recipeData]); + + /** + * Normalize ingredients once - use this for both preview and creation + * This ensures "what you see is what you get" + */ + const normalizedIngredients = useMemo(() => { + return normalizeImportedIngredients(recipeData?.ingredients); + }, [recipeData?.ingredients]); + + /** + * Count of ingredients that were filtered out during normalization + */ + const filteredOutCount = useMemo(() => { + const originalCount = recipeData?.ingredients?.length || 0; + const normalizedCount = normalizedIngredients.length; + return originalCount - normalizedCount; + }, [recipeData?.ingredients, normalizedIngredients.length]); + + /** + * Calculate recipe metrics before creation using offline-first approach */ const { data: calculatedMetrics, isLoading: metricsLoading, error: metricsError, } = useQuery({ - queryKey: ["recipeMetrics", "beerxml-import", recipeData], + queryKey: [ + "recipeMetrics", + "beerxml-import-offline", + recipeData?.batch_size, + recipeData?.batch_size_unit, + recipeData?.efficiency, + recipeData?.boil_time, + recipeData?.mash_temperature, + recipeData?.mash_temp_unit, + // Include ingredient fingerprint for cache invalidation + normalizedIngredients.length, + normalizedIngredients + .map(i => `${i.ingredient_id}:${i.amount}:${i.unit}`) + .join("|"), + ], queryFn: async () => { - if (!recipeData || !recipeData.ingredients) { + if ( + !recipeData || + !normalizedValues || + normalizedIngredients.length === 0 + ) { return null; } - const metricsPayload = { - batch_size: recipeData.batch_size || 5, - batch_size_unit: recipeData.batch_size_unit || "gal", + // Use pre-normalized values for consistent metrics calculation + const recipeFormData: RecipeMetricsInput = { + batch_size: normalizedValues.batchSize, + batch_size_unit: normalizedValues.batchSizeUnit, efficiency: recipeData.efficiency || 75, - boil_time: recipeData.boil_time || 60, - // Respect provided unit when present; default sensibly per system. - mash_temp_unit: ((recipeData.mash_temp_unit as "C" | "F") ?? - ((String(recipeData.batch_size_unit).toLowerCase() === "l" - ? "C" - : "F") as "C" | "F")) as "C" | "F", - mash_temperature: - typeof recipeData.mash_temperature === "number" - ? recipeData.mash_temperature - : String(recipeData.batch_size_unit).toLowerCase() === "l" - ? 67 - : 152, - ingredients: recipeData.ingredients.filter( - (ing: any) => ing.ingredient_id + boil_time: normalizedValues.boilTime, + mash_temp_unit: deriveMashTempUnit( + recipeData.mash_temp_unit, + normalizedValues.unitSystem ), + mash_temperature: + recipeData.mash_temperature ?? + (normalizedValues.unitSystem === "metric" ? 67 : 152), + ingredients: normalizedIngredients, }; - const response = - await ApiService.recipes.calculateMetricsPreview(metricsPayload); + // Calculate metrics offline (always, no network dependency) + // Validation failures return null, but internal errors throw to set metricsError + const validation = + OfflineMetricsCalculator.validateRecipeData(recipeFormData); + if (!validation.isValid) { + void UnifiedLogger.warn( + "import-review", + "Invalid recipe data for metrics calculation", + validation.errors + ); + return null; // Validation failure - no error state, just no metrics + } - return response.data; - }, - enabled: - !!recipeData && - !!recipeData.ingredients && - recipeData.ingredients.length > 0, - staleTime: 30000, - retry: (failureCount, error: any) => { - if (error?.response?.status === 400) { - return false; + try { + const metrics = + OfflineMetricsCalculator.calculateMetrics(recipeFormData); + return metrics; + } catch (error) { + // Internal calculator error - throw to set metricsError state + void UnifiedLogger.error( + "import-review", + "Unexpected metrics calculation failure", + error + ); + throw error; // Re-throw to trigger error state } - return failureCount < 2; }, + enabled: !!recipeData && normalizedIngredients.length > 0, + staleTime: Infinity, // Deterministic calculation, never stale + retry: false, // Local calculation doesn't need retries }); /** @@ -124,30 +412,29 @@ export default function ImportReviewScreen() { */ const createRecipeMutation = useMutation({ mutationFn: async () => { + if (!normalizedValues || !recipeData) { + throw new Error("Recipe values not normalized"); + } + + // Use pre-normalized values (same as used for metrics and preview) // Prepare recipe data for creation - const recipePayload = { + const recipePayload: Partial = { name: recipeData.name, style: recipeData.style || "", description: recipeData.description || "", notes: recipeData.notes || "", - batch_size: recipeData.batch_size, - batch_size_unit: recipeData.batch_size_unit || "gal", - boil_time: recipeData.boil_time || 60, + batch_size: normalizedValues.batchSize, + batch_size_unit: normalizedValues.batchSizeUnit, + boil_time: normalizedValues.boilTime, efficiency: recipeData.efficiency || 75, - unit_system: (recipeData.batch_size_unit === "l" - ? "metric" - : "imperial") as "metric" | "imperial", - // Respect provided unit when present; default sensibly per system. - mash_temp_unit: ((recipeData.mash_temp_unit as "C" | "F") ?? - ((String(recipeData.batch_size_unit).toLowerCase() === "l" - ? "C" - : "F") as "C" | "F")) as "C" | "F", + unit_system: normalizedValues.unitSystem, + mash_temp_unit: deriveMashTempUnit( + recipeData.mash_temp_unit, + normalizedValues.unitSystem + ), mash_temperature: - typeof recipeData.mash_temperature === "number" - ? recipeData.mash_temperature - : String(recipeData.batch_size_unit).toLowerCase() === "l" - ? 67 - : 152, + recipeData.mash_temperature ?? + (normalizedValues.unitSystem === "metric" ? 67 : 152), is_public: false, // Import as private by default // Include calculated metrics if available ...(calculatedMetrics && { @@ -157,56 +444,39 @@ export default function ImportReviewScreen() { estimated_ibu: calculatedMetrics.ibu, estimated_srm: calculatedMetrics.srm, }), - ingredients: (recipeData.ingredients || []) - .filter((ing: any) => { - // Validate required fields before mapping - if (!ing.ingredient_id) { - console.error("Ingredient missing ingredient_id:", ing); - return false; - } - if (!ing.name || !ing.type || !ing.unit) { - console.error("Ingredient missing required fields:", ing); - return false; - } - if (isNaN(Number(ing.amount))) { - console.error("Ingredient has invalid amount:", ing); - return false; - } - return true; - }) - .map( - (ing: any): IngredientInput => ({ - ingredient_id: ing.ingredient_id, - name: ing.name, - type: ing.type, - amount: Number(ing.amount) || 0, - unit: ing.unit, - use: ing.use, - time: coerceIngredientTime(ing.time), - instance_id: generateUniqueId("ing"), // Generate unique instance ID for each imported ingredient - }) - ), + ingredients: normalizedIngredients, }; - const response = await ApiService.recipes.create(recipePayload); - return response.data; + // Use offline V2 createRecipe which creates temp recipe first, then syncs to server + // This ensures immediate display with temp ID, then updates with server ID after sync + return await createRecipe(recipePayload); }, onSuccess: createdRecipe => { // Invalidate queries to refresh recipe lists queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RECIPES }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER_RECIPES }); + queryClient.invalidateQueries({ + queryKey: [...QUERY_KEYS.RECIPES, "offline"], + }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.DASHBOARD }); + // Prime the detail cache for immediate access + queryClient.setQueryData( + QUERY_KEYS.RECIPE(createdRecipe.id), + createdRecipe + ); + // Show success message and navigate to recipe Alert.alert( "Import Successful", - `"${recipeData.name}" has been imported successfully!`, + `"${recipeData?.name || "Recipe"}" has been imported successfully!`, [ { text: "View Recipe", onPress: () => { - // Navigate to the newly created recipe + // Replace current modal with ViewRecipe modal (like createRecipe does) router.dismissAll(); - router.push({ + router.replace({ pathname: "/(modals)/(recipes)/viewRecipe", params: { recipe_id: createdRecipe.id }, }); @@ -224,10 +494,10 @@ export default function ImportReviewScreen() { ); }, onError: (error: any) => { - console.error("🍺 Import Review - Recipe creation error:", error); + void UnifiedLogger.error("import-review", "Recipe creation error", error); Alert.alert( "Import Failed", - `Failed to create recipe "${recipeData.name}". Please try again.`, + `Failed to create recipe "${recipeData?.name || "Recipe"}". Please try again.`, [{ text: "OK" }] ); }, @@ -239,7 +509,7 @@ export default function ImportReviewScreen() { const handleCreateRecipe = () => { Alert.alert( "Create Recipe", - `Create "${recipeData.name}" in your recipe collection?`, + `Create "${recipeData?.name || "Recipe"}" in your recipe collection?`, [ { text: "Cancel", style: "cancel" }, { @@ -360,7 +630,13 @@ export default function ImportReviewScreen() { Ingredients - {recipeData.ingredients?.length || 0} ingredients + {normalizedIngredients.length} ingredients + {filteredOutCount > 0 && ( + + {" "} + ({filteredOutCount} invalid filtered out) + + )} @@ -411,20 +687,14 @@ export default function ImportReviewScreen() { Batch Size: - {(() => { - const n = Number(recipeData.batch_size); - return Number.isFinite(n) ? n.toFixed(1) : "N/A"; - })()}{" "} - {String(recipeData.batch_size_unit).toLowerCase() === "l" - ? "L" - : "gal"} + {normalizedValues?.displayBatchSize || "N/A"} Boil Time: - {coerceIngredientTime(recipeData.boil_time) ?? 60} minutes + {normalizedValues?.displayBoilTime || "N/A"} @@ -508,7 +778,7 @@ export default function ImportReviewScreen() { ) : null} - {calculatedMetrics.ibu ? ( + {calculatedMetrics.ibu != null ? ( IBU: @@ -516,7 +786,7 @@ export default function ImportReviewScreen() { ) : null} - {calculatedMetrics.srm ? ( + {calculatedMetrics.srm != null ? ( SRM: @@ -536,10 +806,9 @@ export default function ImportReviewScreen() { {["grain", "hop", "yeast", "other"].map(type => { - const ingredients = - recipeData.ingredients?.filter( - (ing: any) => ing.type === type - ) || []; + const ingredients = normalizedIngredients.filter( + ing => ing.type === type + ); if (ingredients.length === 0) { return null; @@ -551,17 +820,23 @@ export default function ImportReviewScreen() { {type.charAt(0).toUpperCase() + type.slice(1)}s ( {ingredients.length}) - {ingredients.map((ingredient: any, index: number) => ( - - - {ingredient.name} - - - {ingredient.amount || 0} {ingredient.unit || ""} - {ingredient.use && ` • ${ingredient.use}`} - {ingredient.time > 0 && - ` • ${coerceIngredientTime(ingredient.time)} min`} - + {ingredients.map(ingredient => ( + + + + {ingredient.name} + + + {ingredient.amount || 0} {ingredient.unit || ""} + {ingredient.use && ` • ${ingredient.use}`} + {ingredient.time !== undefined && ingredient.time > 0 + ? ` • ${ingredient.time} min` + : ""} + + ))} diff --git a/app/(modals)/(brewSessions)/createBrewSession.tsx b/app/(modals)/(brewSessions)/createBrewSession.tsx index c227e928..0df91102 100644 --- a/app/(modals)/(brewSessions)/createBrewSession.tsx +++ b/app/(modals)/(brewSessions)/createBrewSession.tsx @@ -59,6 +59,7 @@ import { } from "@utils/formatUtils"; import DateTimePicker from "@react-native-community/datetimepicker"; import { TEST_IDS } from "@src/constants/testIDs"; +import { TemperatureUnit } from "@/src/types"; function toLocalISODateString(d: Date) { const year = d.getFullYear(); @@ -102,9 +103,8 @@ export default function CreateBrewSessionScreen() { const { create: createBrewSession } = useBrewSessions(); const { getById: getRecipeById } = useRecipes(); const [showDatePicker, setShowDatePicker] = useState(false); - const [selectedTemperatureUnit, setSelectedTemperatureUnit] = useState< - "F" | "C" | null - >(null); + const [selectedTemperatureUnit, setSelectedTemperatureUnit] = + useState(null); const [showUnitPrompt, setShowUnitPrompt] = useState(false); const [recipe, setRecipe] = useState(null); const [isLoadingRecipe, setIsLoadingRecipe] = useState(true); diff --git a/app/(modals)/(brewSessions)/viewBrewSession.tsx b/app/(modals)/(brewSessions)/viewBrewSession.tsx index 2349c49b..af9f2f77 100644 --- a/app/(modals)/(brewSessions)/viewBrewSession.tsx +++ b/app/(modals)/(brewSessions)/viewBrewSession.tsx @@ -23,7 +23,7 @@ import { DryHopTracker } from "@src/components/brewSessions/DryHopTracker"; import { useBrewSessions } from "@hooks/offlineV2/useUserData"; import { ModalHeader } from "@src/components/ui/ModalHeader"; import { QUERY_KEYS } from "@services/api/queryClient"; -import UnifiedLogger from "@services/logger/UnifiedLogger"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; import { DevIdDebugger } from "@src/components/debug/DevIdDebugger"; export default function ViewBrewSession() { @@ -164,17 +164,8 @@ export default function ViewBrewSession() { const currentRecipeId = brewSessionData?.recipe_id; try { // First try to refresh the overall brew sessions data - await UnifiedLogger.debug( - "ViewBrewSession.onRefresh", - "Calling brewSessionsHook.refresh() to fetch from server" - ); await brewSessionsHook.refresh(); - await UnifiedLogger.debug( - "ViewBrewSession.onRefresh", - "Server refresh complete, now getting specific session from cache" - ); - // Then reload the specific session const session = await brewSessionsHook.getById(brewSessionId); @@ -801,19 +792,6 @@ export default function ViewBrewSession() { const updatedSession = await getById(brewSession.id); if (updatedSession) { setBrewSessionData(updatedSession); - await UnifiedLogger.debug( - "viewBrewSession.onAddDryHop", - `Session reloaded with ${updatedSession.dry_hop_additions?.length || 0} dry-hops`, - { - dryHopAdditions: updatedSession.dry_hop_additions?.map( - dh => ({ - hop_name: dh.hop_name, - recipe_instance_id: dh.recipe_instance_id, - hasInstanceId: !!dh.recipe_instance_id, - }) - ), - } - ); } }} onRemoveDryHop={async dryHopIndex => { diff --git a/app/(modals)/(calculators)/hydrometerCorrection.tsx b/app/(modals)/(calculators)/hydrometerCorrection.tsx index d962ae03..09ab84c4 100644 --- a/app/(modals)/(calculators)/hydrometerCorrection.tsx +++ b/app/(modals)/(calculators)/hydrometerCorrection.tsx @@ -44,8 +44,8 @@ import { calculatorScreenStyles } from "@styles/modals/calculators/calculatorScr * Temperature unit options for the calculator */ const TEMP_UNIT_OPTIONS = [ - { label: "°F", value: "f" as const, description: "Fahrenheit" }, - { label: "°C", value: "c" as const, description: "Celsius" }, + { label: "°F", value: "F" as const, description: "Fahrenheit" }, + { label: "°C", value: "C" as const, description: "Celsius" }, ]; export default function HydrometerCorrectionCalculatorScreen() { @@ -165,10 +165,10 @@ export default function HydrometerCorrectionCalculatorScreen() { } let converted: number; - if (fromUnit === "f" && toUnit === "c") { + if (fromUnit === "F" && toUnit === "C") { // °F to °C: (°F - 32) * 5/9 converted = (numValue - 32) * (5 / 9); - } else if (fromUnit === "c" && toUnit === "f") { + } else if (fromUnit === "C" && toUnit === "F") { // °C to °F: °C * 9/5 + 32 converted = numValue * (9 / 5) + 32; } else { @@ -206,7 +206,7 @@ export default function HydrometerCorrectionCalculatorScreen() { }; const getTempPlaceholder = (isCalibration: boolean = false) => { - if (hydrometerCorrection.tempUnit === "f") { + if (hydrometerCorrection.tempUnit === "F") { return isCalibration ? "68" : "75"; } else { return isCalibration ? "20" : "24"; @@ -258,8 +258,8 @@ export default function HydrometerCorrectionCalculatorScreen() { value={hydrometerCorrection.wortTemp} onChangeText={handleMeasuredTempChange} placeholder={getTempPlaceholder()} - min={hydrometerCorrection.tempUnit === "c" ? 0 : 32} - max={hydrometerCorrection.tempUnit === "c" ? 100 : 212} + min={hydrometerCorrection.tempUnit === "C" ? 0 : 32} + max={hydrometerCorrection.tempUnit === "C" ? 100 : 212} unit={`°${tempUnit}`} testID="hydrometer-sample-temp" /> @@ -271,8 +271,8 @@ export default function HydrometerCorrectionCalculatorScreen() { value={hydrometerCorrection.calibrationTemp} onChangeText={handleCalibrationTempChange} placeholder={getTempPlaceholder(true)} - min={hydrometerCorrection.tempUnit === "c" ? 0 : 32} - max={hydrometerCorrection.tempUnit === "c" ? 100 : 212} + min={hydrometerCorrection.tempUnit === "C" ? 0 : 32} + max={hydrometerCorrection.tempUnit === "C" ? 100 : 212} unit={`°${tempUnit}`} helperText="Temperature your hydrometer was calibrated at" testID="hydrometer-calibration-temp" diff --git a/app/(modals)/(calculators)/strikeWater.tsx b/app/(modals)/(calculators)/strikeWater.tsx index a8fb33f2..7a76046c 100644 --- a/app/(modals)/(calculators)/strikeWater.tsx +++ b/app/(modals)/(calculators)/strikeWater.tsx @@ -46,13 +46,14 @@ import { import { useTheme } from "@contexts/ThemeContext"; import { UnitConverter } from "@/src/services/calculators/UnitConverter"; import { calculatorScreenStyles } from "@styles/modals/calculators/calculatorScreenStyles"; +import { TemperatureUnit } from "@/src/types/common"; /** * Temperature unit options for the calculator */ const TEMP_UNIT_OPTIONS = [ - { label: "°F", value: "f" as const, description: "Fahrenheit" }, - { label: "°C", value: "c" as const, description: "Celsius" }, + { label: "°F", value: "F" as const, description: "Fahrenheit" }, + { label: "°C", value: "C" as const, description: "Celsius" }, ]; /** @@ -213,8 +214,8 @@ export default function StrikeWaterCalculatorScreen() { }; const handleTempUnitChange = (tempUnit: string) => { - const fromUnit = strikeWater.tempUnit as "f" | "c"; - const toUnit = tempUnit as "f" | "c"; + const fromUnit = strikeWater.tempUnit as TemperatureUnit; + const toUnit = tempUnit as TemperatureUnit; const convert = (v?: string) => { const n = parseFloat(v ?? ""); @@ -255,7 +256,7 @@ export default function StrikeWaterCalculatorScreen() { }; const getTempPlaceholder = (isTarget: boolean = false) => { - if (strikeWater.tempUnit === "f") { + if (strikeWater.tempUnit === "F") { return isTarget ? "152" : "70"; } else { return isTarget ? "67" : "21"; @@ -263,7 +264,7 @@ export default function StrikeWaterCalculatorScreen() { }; const getTempRange = () => { - if (strikeWater.tempUnit === "f") { + if (strikeWater.tempUnit === "F") { return { grain: "32-120°F", mash: "140-170°F" }; } else { return { grain: "0-50°C", mash: "60-77°C" }; diff --git a/app/(modals)/(calculators)/unitConverter.tsx b/app/(modals)/(calculators)/unitConverter.tsx index 3df159bd..1b905bd1 100644 --- a/app/(modals)/(calculators)/unitConverter.tsx +++ b/app/(modals)/(calculators)/unitConverter.tsx @@ -76,8 +76,8 @@ const VOLUME_UNITS = [ * Temperature unit options for conversion */ const TEMPERATURE_UNITS = [ - { label: "°F", value: "f", description: "Fahrenheit" }, - { label: "°C", value: "c", description: "Celsius" }, + { label: "°F", value: "F", description: "Fahrenheit" }, + { label: "°C", value: "C", description: "Celsius" }, { label: "K", value: "k", description: "Kelvin" }, ]; diff --git a/app/(modals)/(recipes)/createRecipe.tsx b/app/(modals)/(recipes)/createRecipe.tsx index 356a77fd..664b1d52 100644 --- a/app/(modals)/(recipes)/createRecipe.tsx +++ b/app/(modals)/(recipes)/createRecipe.tsx @@ -44,6 +44,7 @@ import { RecipeIngredient, Recipe, AIAnalysisResponse, + UnitSystem, } from "@src/types"; import { createRecipeStyles } from "@styles/modals/createRecipeStyles"; import { BasicInfoForm } from "@src/components/recipes/RecipeForm/BasicInfoForm"; @@ -84,9 +85,7 @@ const STEP_TITLES = ["Basic Info", "Parameters", "Ingredients", "Review"]; * @param unitSystem - User's preferred unit system ('imperial' or 'metric') * @returns Initial recipe form data with appropriate units and defaults */ -const createInitialRecipeState = ( - unitSystem: "imperial" | "metric" -): RecipeFormData => ({ +const createInitialRecipeState = (unitSystem: UnitSystem): RecipeFormData => ({ name: "", style: "", description: "", @@ -109,7 +108,7 @@ type RecipeBuilderAction = | { type: "ADD_INGREDIENT"; ingredient: RecipeIngredient } | { type: "REMOVE_INGREDIENT"; index: number } | { type: "UPDATE_INGREDIENT"; index: number; ingredient: RecipeIngredient } - | { type: "RESET"; unitSystem: "imperial" | "metric" }; + | { type: "RESET"; unitSystem: UnitSystem }; /** * Updates the recipe form state based on the specified action. @@ -277,7 +276,7 @@ export default function CreateRecipeScreen() { }, onSuccess: response => { // Invalidate relevant recipe caches to ensure fresh data - queryClient.invalidateQueries({ queryKey: ["userRecipes"] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER_RECIPES }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RECIPES }); // AllRecipes cache queryClient.invalidateQueries({ queryKey: [...QUERY_KEYS.RECIPES, "offline"], diff --git a/app/(modals)/(recipes)/editRecipe.tsx b/app/(modals)/(recipes)/editRecipe.tsx index b7a9960a..5645fe7e 100644 --- a/app/(modals)/(recipes)/editRecipe.tsx +++ b/app/(modals)/(recipes)/editRecipe.tsx @@ -16,7 +16,13 @@ import { useTheme } from "@contexts/ThemeContext"; import { useUnits } from "@contexts/UnitContext"; import { useRecipes } from "@src/hooks/offlineV2"; import { useAuth } from "@contexts/AuthContext"; -import { RecipeFormData, RecipeIngredient, Recipe } from "@src/types"; +import { + RecipeFormData, + RecipeIngredient, + Recipe, + RecipeMetrics, + UnitSystem, +} from "@src/types"; import { createRecipeStyles } from "@styles/modals/createRecipeStyles"; import { BasicInfoForm } from "@src/components/recipes/RecipeForm/BasicInfoForm"; import { ParametersForm } from "@src/components/recipes/RecipeForm/ParametersForm"; @@ -141,7 +147,7 @@ const toOptionalNumber = (v: any): number | undefined => { // Create unit-aware initial recipe state from existing recipe const createRecipeStateFromExisting = ( existingRecipe: Recipe, - unitSystem: "imperial" | "metric" + unitSystem: UnitSystem ): RecipeFormData => ({ name: existingRecipe.name ?? "", style: existingRecipe.style ?? "", @@ -380,6 +386,24 @@ export default function EditRecipeScreen() { return sanitized; }); + const getMetricOrFallback = ( + metricKey: keyof RecipeMetrics, + estimatedKey: keyof Pick< + Recipe, + | "estimated_og" + | "estimated_fg" + | "estimated_abv" + | "estimated_ibu" + | "estimated_srm" + > + ): number | undefined => { + const metricValue = metricsData?.[metricKey]; + if (metricValue !== undefined && Number.isFinite(metricValue)) { + return metricValue as number; + } + return existingRecipe?.[estimatedKey] as number | undefined; + }; + const updateData = { name: formData.name || "", style: formData.style || "", @@ -416,27 +440,13 @@ export default function EditRecipeScreen() { is_public: Boolean(formData.is_public), notes: formData.notes || "", ingredients: sanitizedIngredients, - // Include estimated metrics only when finite - ...(metricsData && - Number.isFinite(metricsData.og) && { - estimated_og: metricsData.og, - }), - ...(metricsData && - Number.isFinite(metricsData.fg) && { - estimated_fg: metricsData.fg, - }), - ...(metricsData && - Number.isFinite(metricsData.abv) && { - estimated_abv: metricsData.abv, - }), - ...(metricsData && - Number.isFinite(metricsData.ibu) && { - estimated_ibu: metricsData.ibu, - }), - ...(metricsData && - Number.isFinite(metricsData.srm) && { - estimated_srm: metricsData.srm, - }), + // Include estimated metrics - use newly calculated metrics if available and finite, + // otherwise preserve existing recipe metrics to avoid resetting them + estimated_og: getMetricOrFallback("og", "estimated_og"), + estimated_fg: getMetricOrFallback("fg", "estimated_fg"), + estimated_abv: getMetricOrFallback("abv", "estimated_abv"), + estimated_ibu: getMetricOrFallback("ibu", "estimated_ibu"), + estimated_srm: getMetricOrFallback("srm", "estimated_srm"), }; const updatedRecipe = await updateRecipeV2(recipe_id, updateData); diff --git a/app/(modals)/(recipes)/ingredientPicker.tsx b/app/(modals)/(recipes)/ingredientPicker.tsx index 78feb06a..a80042f6 100644 --- a/app/(modals)/(recipes)/ingredientPicker.tsx +++ b/app/(modals)/(recipes)/ingredientPicker.tsx @@ -48,6 +48,7 @@ import { IngredientUnit, Ingredient, HopFormat, + UnitSystem, } from "@src/types"; import { ingredientPickerStyles } from "@styles/modals/ingredientPickerStyles"; import { IngredientDetailEditor } from "@src/components/recipes/IngredientEditor/IngredientDetailEditor"; @@ -154,7 +155,7 @@ const convertIngredientToRecipeIngredient = ( const createRecipeIngredientWithDefaults = ( baseIngredient: RecipeIngredient, ingredientType: IngredientType, - unitSystem: "imperial" | "metric" + unitSystem: UnitSystem ): RecipeIngredient => { // Default amounts by type and unit system const getDefaultAmount = (type: IngredientType): number => { diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 4d679a2e..3c464ac6 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -104,6 +104,7 @@ export default function DashboardScreen() { data: brewSessions, isLoading: brewSessionsLoading, refresh: refreshBrewSessions, + delete: deleteBrewSession, } = useBrewSessions(); // Separate query for public recipes (online-only feature) @@ -215,7 +216,10 @@ export default function DashboardScreen() { await ApiService.recipes.delete(recipeId); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [...QUERY_KEYS.RECIPES] }); + // Invalidate both recipes and dashboard queries to immediately update UI + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RECIPES }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.DASHBOARD }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER_RECIPES }); }, onError: (error: unknown) => { console.error("Failed to delete recipe:", error); @@ -241,8 +245,8 @@ export default function DashboardScreen() { } }, onSuccess: (response, recipe) => { - queryClient.invalidateQueries({ queryKey: [...QUERY_KEYS.RECIPES] }); - queryClient.invalidateQueries({ queryKey: [...QUERY_KEYS.DASHBOARD] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RECIPES }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.DASHBOARD }); // Ensure offline lists reflect the new clone queryClient.invalidateQueries({ queryKey: [...QUERY_KEYS.RECIPES, "offline"], @@ -410,9 +414,34 @@ export default function DashboardScreen() { ); }, onDelete: (brewSession: BrewSession) => { + // Close the context menu before prompting + brewSessionContextMenu.hideMenu(); Alert.alert( "Delete Session", - `Deleting "${brewSession.name}" - Feature coming soon!` + `Are you sure you want to delete "${brewSession.name}"? This action cannot be undone.`, + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + try { + await deleteBrewSession(brewSession.id); + Alert.alert("Success", "Brew session deleted successfully"); + } catch (error) { + console.error("Failed to delete brew session:", error); + Alert.alert( + "Delete Failed", + "Failed to delete brew session. Please try again.", + [{ text: "OK" }] + ); + } + }, + }, + ] ); }, }); diff --git a/package-lock.json b/package-lock.json index db462527..a4f4de1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "brewtracker", - "version": "3.2.6", + "version": "3.3.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "brewtracker", - "version": "3.2.6", + "version": "3.3.15", "license": "GPL-3.0-or-later", "dependencies": { "@expo/metro-runtime": "~6.1.2", @@ -24,31 +24,31 @@ "ajv": "^8.17.1", "axios": "^1.12.2", "expo": "~54.0.25", - "expo-blur": "~15.0.7", - "expo-build-properties": "~1.0.7", + "expo-blur": "~15.0.8", + "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.9", - "expo-crypto": "~15.0.7", - "expo-dev-client": "~6.0.18", - "expo-device": "~8.0.9", - "expo-document-picker": "~14.0.7", + "expo-crypto": "~15.0.8", + "expo-dev-client": "~6.0.20", + "expo-device": "~8.0.10", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.14", "expo-font": "~14.0.8", - "expo-haptics": "~15.0.7", - "expo-image": "~3.0.10", - "expo-linear-gradient": "~15.0.7", - "expo-linking": "~8.0.9", - "expo-local-authentication": "~17.0.7", - "expo-media-library": "~18.2.0", - "expo-notifications": "~0.32.13", - "expo-router": "~6.0.15", - "expo-secure-store": "~15.0.7", - "expo-sharing": "~14.0.7", - "expo-splash-screen": "~31.0.11", - "expo-status-bar": "~3.0.8", - "expo-symbols": "~1.0.7", - "expo-system-ui": "~6.0.8", - "expo-updates": "~29.0.13", - "expo-web-browser": "~15.0.9", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-linear-gradient": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-local-authentication": "~17.0.8", + "expo-media-library": "~18.2.1", + "expo-notifications": "~0.32.14", + "expo-router": "~6.0.17", + "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", + "expo-splash-screen": "~31.0.12", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-updates": "~29.0.14", + "expo-web-browser": "~15.0.10", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -84,7 +84,7 @@ "eslint-config-expo": "~10.0.0", "express": "^5.1.0", "jest": "~29.7.0", - "jest-expo": "~54.0.13", + "jest-expo": "~54.0.14", "jsdom": "^26.1.0", "oxlint": "^1.14.0", "prettier": "^3.4.2", @@ -2185,7 +2185,7 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@csstools/color-helpers": { @@ -2545,40 +2545,40 @@ } }, "node_modules/@expo/config": { - "version": "12.0.10", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.10.tgz", - "integrity": "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w==", + "version": "12.0.11", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.11.tgz", + "integrity": "sha512-bGKNCbHirwgFlcOJHXpsAStQvM0nU3cmiobK0o07UkTfcUxl9q9lOQQh2eoMGqpm6Vs1IcwBpYye6thC3Nri/w==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.2", - "@expo/config-types": "^54.0.8", + "@expo/config-plugins": "~54.0.3", + "@expo/config-types": "^54.0.9", "@expo/json-file": "^10.0.7", "deepmerge": "^4.3.1", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4", - "sucrase": "3.35.0" + "sucrase": "~3.35.1" } }, "node_modules/@expo/config-plugins": { - "version": "54.0.2", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.2.tgz", - "integrity": "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg==", + "version": "54.0.3", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.3.tgz", + "integrity": "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw==", "license": "MIT", "dependencies": { - "@expo/config-types": "^54.0.8", + "@expo/config-types": "^54.0.9", "@expo/json-file": "~10.0.7", "@expo/plist": "^0.4.7", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slash": "^3.0.0", @@ -2587,45 +2587,42 @@ "xml2js": "0.6.0" } }, - "node_modules/@expo/config-plugins/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@expo/config-plugins/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@expo/config-plugins/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@expo/config-plugins/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2640,6 +2637,22 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/@expo/config-plugins/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@expo/config-plugins/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -2653,9 +2666,9 @@ } }, "node_modules/@expo/config-types": { - "version": "54.0.8", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.8.tgz", - "integrity": "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A==", + "version": "54.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.9.tgz", + "integrity": "sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw==", "license": "MIT" }, "node_modules/@expo/config/node_modules/@babel/code-frame": { @@ -2667,45 +2680,42 @@ "@babel/highlight": "^7.10.4" } }, - "node_modules/@expo/config/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/@expo/config/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@expo/config/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@expo/config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2720,6 +2730,22 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/@expo/config/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@expo/config/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -2733,23 +2759,13 @@ } }, "node_modules/@expo/devcert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.0.tgz", - "integrity": "sha512-Uilcv3xGELD5t/b0eM4cxBFEKQRIivB3v7i+VhWLV/gL98aw810unLKKJbGAxAIhY6Ipyz8ChWibFsKFXYwstA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", "license": "MIT", "dependencies": { "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0", - "glob": "^10.4.2" - } - }, - "node_modules/@expo/devcert/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "debug": "^3.1.0" } }, "node_modules/@expo/devcert/node_modules/debug": { @@ -2761,54 +2777,10 @@ "ms": "^2.1.1" } }, - "node_modules/@expo/devcert/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/devcert/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@expo/devcert/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@expo/devtools": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.7.tgz", - "integrity": "sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", "license": "MIT", "dependencies": { "chalk": "^4.1.2" @@ -2827,9 +2799,9 @@ } }, "node_modules/@expo/env": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.7.tgz", - "integrity": "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz", + "integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -2852,9 +2824,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.3.tgz", - "integrity": "sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ==", + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", + "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2862,7 +2834,7 @@ "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^9.0.0", "p-limit": "^3.1.0", @@ -2883,25 +2855,46 @@ } }, "node_modules/@expo/fingerprint/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@expo/fingerprint/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@expo/fingerprint/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2926,6 +2919,22 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/@expo/fingerprint/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@expo/fingerprint/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2939,9 +2948,9 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.7.tgz", - "integrity": "sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w==", + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", + "integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2969,9 +2978,9 @@ } }, "node_modules/@expo/json-file": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.7.tgz", - "integrity": "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", + "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "~7.10.4", @@ -2987,25 +2996,6 @@ "@babel/highlight": "^7.10.4" } }, - "node_modules/@expo/mcp-tunnel": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@expo/mcp-tunnel/-/mcp-tunnel-0.1.0.tgz", - "integrity": "sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw==", - "license": "MIT", - "dependencies": { - "ws": "^8.18.3", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.24.6" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.13.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@expo/metro": { "version": "54.1.0", "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.1.0.tgz", @@ -3027,15 +3017,15 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.9", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.9.tgz", - "integrity": "sha512-CRI4WgFXrQ2Owyr8q0liEBJveUIF9DcYAKadMRsJV7NxGNBdrIIKzKvqreDfsGiRqivbLsw6UoNb3UE7/SvPfg==", + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.10.tgz", + "integrity": "sha512-AkSTwaWbMMDOiV4RRy4Mv6MZEOW5a7BZlgtrWxvzs6qYKRxKLKH/qqAuKe0bwGepF1+ws9oIX5nQjtnXRwezvQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.10", + "@expo/config": "~12.0.11", "@expo/env": "~2.0.7", "@expo/json-file": "~10.0.7", "@expo/metro": "~54.1.0", @@ -3046,7 +3036,7 @@ "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "hermes-parser": "^0.29.1", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", @@ -3085,25 +3075,46 @@ } }, "node_modules/@expo/metro-config/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/metro-config/node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@expo/metro-config/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@expo/metro-config/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3128,6 +3139,22 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/@expo/metro-config/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@expo/metro-runtime": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", @@ -3484,9 +3511,9 @@ } }, "node_modules/@expo/osascript": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.7.tgz", - "integrity": "sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz", + "integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -3497,12 +3524,12 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.9.8", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.8.tgz", - "integrity": "sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA==", + "version": "1.9.9", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.9.tgz", + "integrity": "sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.7", + "@expo/json-file": "^10.0.8", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -3511,9 +3538,9 @@ } }, "node_modules/@expo/plist": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.7.tgz", - "integrity": "sha512-dGxqHPvCZKeRKDU1sJZMmuyVtcASuSYh1LPFVaM1DuffqPL36n6FMEL0iUqq2Tx3xhWk8wCnWl34IKplUjJDdA==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", + "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", @@ -3522,16 +3549,16 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "54.0.6", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.6.tgz", - "integrity": "sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA==", + "version": "54.0.7", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.7.tgz", + "integrity": "sha512-cKqBsiwcFFzpDWgtvemrCqJULJRLDLKo2QMF74NusoGNpfPI3vQVry1iwnYLeGht02AeD3dvfhpqBczD3wchxA==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.10", - "@expo/config-plugins": "~54.0.2", - "@expo/config-types": "^54.0.8", - "@expo/image-utils": "^0.8.7", - "@expo/json-file": "^10.0.7", + "@expo/config": "~12.0.11", + "@expo/config-plugins": "~54.0.3", + "@expo/config-types": "^54.0.9", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", "@react-native/normalize-colors": "0.81.5", "debug": "^4.3.1", "resolve-from": "^5.0.0", @@ -3543,9 +3570,9 @@ } }, "node_modules/@expo/prebuild-config/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3555,9 +3582,9 @@ } }, "node_modules/@expo/schema-utils": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.7.tgz", - "integrity": "sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", + "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", "license": "MIT" }, "node_modules/@expo/sdk-runtime-versions": { @@ -3747,121 +3774,46 @@ "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", "license": "MIT" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "minipass": "^7.0.4" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@isaacs/fs-minipass/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.17" } }, "node_modules/@isaacs/ttlcache": { @@ -3902,7 +3854,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -3920,7 +3872,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -3980,7 +3932,7 @@ "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4019,7 +3971,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "expect": "^29.7.0", @@ -4033,7 +3985,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3" @@ -4077,7 +4029,7 @@ "version": "30.1.0", "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4087,7 +4039,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -4103,7 +4055,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -4118,7 +4070,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", @@ -4163,7 +4115,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4184,7 +4136,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.23.9", @@ -4201,7 +4153,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4226,7 +4178,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", @@ -4241,7 +4193,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -4257,7 +4209,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -4540,66 +4492,12 @@ "win32" ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -4630,60 +4528,6 @@ } } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -4699,33 +4543,6 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", @@ -4741,31 +4558,6 @@ } } }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -4784,126 +4576,6 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", @@ -4922,36 +4594,6 @@ } } }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -5536,7 +5178,7 @@ "version": "13.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "jest-matcher-utils": "^30.0.5", @@ -5563,7 +5205,7 @@ "version": "30.0.5", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.34.0" @@ -5576,14 +5218,14 @@ "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@testing-library/react-native/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5596,7 +5238,7 @@ "version": "30.1.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/diff-sequences": "30.0.1", @@ -5612,7 +5254,7 @@ "version": "30.1.2", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", @@ -5628,7 +5270,7 @@ "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "30.0.5", @@ -5643,7 +5285,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tootallnate/once": { @@ -5708,35 +5350,11 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/graceful-fs": { @@ -5805,7 +5423,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -5828,7 +5446,7 @@ "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -6445,182 +6063,6 @@ "@urql/core": "^5.0.0" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "devOptional": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -6630,22 +6072,6 @@ "node": ">=10.0.0" } }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "devOptional": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "devOptional": true, - "license": "Apache-2.0", - "peer": true - }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -6702,20 +6128,6 @@ "acorn-walk": "^8.0.2" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6726,19 +6138,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-loose": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", - "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -6777,39 +6176,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -7255,9 +6621,9 @@ } }, "node_modules/babel-plugin-react-native-web": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.1.tgz", - "integrity": "sha512-7XywfJ5QIRMwjOL+pwJt2w47Jmi5fFLvK7/So4fV4jIN6PcRbylCp9/l3cJY4VJbSz3lnWTeHDTD1LKIc1C09Q==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", "license": "MIT" }, "node_modules/babel-plugin-syntax-hermes-parser": { @@ -7305,9 +6671,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "54.0.7", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.7.tgz", - "integrity": "sha512-JENWk0bvxW4I1ftveO8GRtX2t2TH6N4Z0TPvIHxroZ/4SswUfyNsUNbbP7Fm4erj3ar/JHGri5kTZ+s3xdjHZw==", + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.8.tgz", + "integrity": "sha512-3ZJ4Q7uQpm8IR/C9xbKhE/IUjGpLm+OIjF8YCedLgqoe/wN1Ns2wLT7HwG6ZXXb6/rzN8IMCiKFQ2F93qlN6GA==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", @@ -7647,7 +7013,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7702,7 +7068,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -7735,17 +7101,6 @@ "node": ">=12.13.0" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -7779,7 +7134,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cli-cursor": { @@ -7839,7 +7194,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "iojs": ">= 1.0.0", @@ -7850,7 +7205,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/color": { @@ -8071,7 +7426,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -8196,7 +7551,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -8304,7 +7659,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -8436,7 +7791,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8452,7 +7807,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -8606,12 +7961,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8628,7 +7977,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -8646,25 +7995,10 @@ "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">= 0.8" } }, "node_modules/entities": { @@ -8693,7 +8027,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -8823,14 +8157,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "devOptional": true, - "license": "MIT", - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9394,7 +8720,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -9407,7 +8733,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -9441,17 +8767,6 @@ "node": ">=6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/exec-async": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", @@ -9462,7 +8777,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -9486,7 +8801,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "devOptional": true, + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -9495,7 +8810,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.7.0", @@ -9509,29 +8824,29 @@ } }, "node_modules/expo": { - "version": "54.0.25", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.25.tgz", - "integrity": "sha512-+iSeBJfHRHzNPnHMZceEXhSGw4t5bNqFyd/5xMUoGfM+39rO7F72wxiLRpBKj0M6+0GQtMaEs+eTbcCrO7XyJQ==", + "version": "54.0.27", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.27.tgz", + "integrity": "sha512-50BcJs8eqGwRiMUoWwphkRGYtKFS2bBnemxLzy0lrGVA1E6F4Q7L5h3WT6w1ehEZybtOVkfJu4Z6GWo2IJcpEA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.16", - "@expo/config": "~12.0.10", - "@expo/config-plugins": "~54.0.2", - "@expo/devtools": "0.1.7", - "@expo/fingerprint": "0.15.3", + "@expo/cli": "54.0.18", + "@expo/config": "~12.0.11", + "@expo/config-plugins": "~54.0.3", + "@expo/devtools": "0.1.8", + "@expo/fingerprint": "0.15.4", "@expo/metro": "~54.1.0", - "@expo/metro-config": "54.0.9", + "@expo/metro-config": "54.0.10", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~54.0.7", - "expo-asset": "~12.0.10", - "expo-constants": "~18.0.10", - "expo-file-system": "~19.0.19", - "expo-font": "~14.0.9", - "expo-keep-awake": "~15.0.7", - "expo-modules-autolinking": "3.0.22", - "expo-modules-core": "3.0.26", + "babel-preset-expo": "~54.0.8", + "expo-asset": "~12.0.11", + "expo-constants": "~18.0.11", + "expo-file-system": "~19.0.20", + "expo-font": "~14.0.10", + "expo-keep-awake": "~15.0.8", + "expo-modules-autolinking": "3.0.23", + "expo-modules-core": "3.0.28", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" @@ -9561,22 +8876,22 @@ } }, "node_modules/expo-application": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.7.tgz", - "integrity": "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-asset": { - "version": "12.0.10", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.10.tgz", - "integrity": "sha512-pZyeJkoDsALh4gpCQDzTA/UCLaPH/1rjQNGubmLn/uDM27S4iYJb/YWw4+CNZOtd5bCUOhDPg5DtGQnydNFSXg==", + "version": "12.0.11", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.11.tgz", + "integrity": "sha512-pnK/gQ5iritDPBeK54BV35ZpG7yeW5DtgGvJHruIXkyDT9BCoQq3i0AAxfcWG/e4eiRmTzAt5kNVYFJi48uo+A==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.7", - "expo-constants": "~18.0.10" + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.11" }, "peerDependencies": { "expo": "*", @@ -9585,9 +8900,9 @@ } }, "node_modules/expo-blur": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.7.tgz", - "integrity": "sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz", + "integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9596,9 +8911,9 @@ } }, "node_modules/expo-build-properties": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-1.0.9.tgz", - "integrity": "sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-1.0.10.tgz", + "integrity": "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q==", "license": "MIT", "dependencies": { "ajv": "^8.11.0", @@ -9621,13 +8936,13 @@ } }, "node_modules/expo-constants": { - "version": "18.0.10", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz", - "integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==", + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.11.tgz", + "integrity": "sha512-xnfrfZ7lHjb+03skhmDSYeFF7OU2K3Xn/lAeP+7RhkV2xp2f5RCKtOUYajCnYeZesvMrsUxOsbGOP2JXSOH3NA==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.10", - "@expo/env": "~2.0.7" + "@expo/config": "~12.0.11", + "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", @@ -9635,9 +8950,9 @@ } }, "node_modules/expo-crypto": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.7.tgz", - "integrity": "sha512-FUo41TwwGT2e5rA45PsjezI868Ch3M6wbCZsmqTWdF/hr+HyPcrp1L//dsh/hsrsyrQdpY/U96Lu71/wXePJeg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", + "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", "license": "MIT", "dependencies": { "base64-js": "^1.3.0" @@ -9647,15 +8962,15 @@ } }, "node_modules/expo-dev-client": { - "version": "6.0.18", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.18.tgz", - "integrity": "sha512-8QKWvhsoZpMkecAMlmWoRHnaTNiPS3aO7E42spZOMjyiaNRJMHZsnB8W2b63dt3Yg3oLyskLAoI8IOmnqVX8vA==", + "version": "6.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.20.tgz", + "integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "6.0.18", - "expo-dev-menu": "7.0.17", + "expo-dev-launcher": "6.0.20", + "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", - "expo-manifests": "~1.0.9", + "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { @@ -9663,22 +8978,23 @@ } }, "node_modules/expo-dev-launcher": { - "version": "6.0.18", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.18.tgz", - "integrity": "sha512-JTtcIfNvHO9PTdRJLmHs+7HJILXXZjF95jxgzu6hsJrgsTg/AZDtEsIt/qa6ctEYQTqrLdsLDgDhiXVel3AoQA==", + "version": "6.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz", + "integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==", "license": "MIT", "dependencies": { - "expo-dev-menu": "7.0.17", - "expo-manifests": "~1.0.9" + "ajv": "^8.11.0", + "expo-dev-menu": "7.0.18", + "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-menu": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.17.tgz", - "integrity": "sha512-NIu7TdaZf+A8+DROa6BB6lDfxjXxwaD+Q8QbNSVa0E0x6yl3P0ZJ80QbD2cCQeBzlx3Ufd3hNhczQWk4+A29HQ==", + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz", + "integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "2.0.0" @@ -9697,9 +9013,9 @@ } }, "node_modules/expo-device": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.9.tgz", - "integrity": "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", + "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", "license": "MIT", "dependencies": { "ua-parser-js": "^0.7.33" @@ -9735,9 +9051,9 @@ } }, "node_modules/expo-document-picker": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.7.tgz", - "integrity": "sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -9750,9 +9066,9 @@ "license": "MIT" }, "node_modules/expo-file-system": { - "version": "19.0.19", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.19.tgz", - "integrity": "sha512-OrpOV4fEBFMFv+jy7PnENpPbsWoBmqWGidSwh1Ai52PLl6JIInYGfZTc6kqyPNGtFTwm7Y9mSWnE8g+dtLxu7g==", + "version": "19.0.20", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.20.tgz", + "integrity": "sha512-Jr/nNvJmUlptS3cHLKVBNyTyGMHNyxYBKRph1KRe0Nb3RzZza1gZLZXMG5Ky//sO2azTn+OaT0dv/lAyL0vJNA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9760,9 +9076,9 @@ } }, "node_modules/expo-font": { - "version": "14.0.9", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz", - "integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==", + "version": "14.0.10", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", + "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" @@ -9774,18 +9090,18 @@ } }, "node_modules/expo-haptics": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.7.tgz", - "integrity": "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-image": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.10.tgz", - "integrity": "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz", + "integrity": "sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9806,9 +9122,9 @@ "license": "MIT" }, "node_modules/expo-keep-awake": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz", - "integrity": "sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9816,9 +9132,9 @@ } }, "node_modules/expo-linear-gradient": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz", - "integrity": "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", + "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9827,12 +9143,12 @@ } }, "node_modules/expo-linking": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz", - "integrity": "sha512-a0UHhlVyfwIbn8b1PSFPoFiIDJeps2iEq109hVH3CHd0CMKuRxFfNio9Axe2BjXhiJCYWR4OV1iIyzY/GjiVkQ==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.10.tgz", + "integrity": "sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA==", "license": "MIT", "dependencies": { - "expo-constants": "~18.0.10", + "expo-constants": "~18.0.11", "invariant": "^2.2.4" }, "peerDependencies": { @@ -9841,9 +9157,9 @@ } }, "node_modules/expo-local-authentication": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.7.tgz", - "integrity": "sha512-yRWcgYn/OIwxEDEk7cM7tRjQSHaTp5hpKwzq+g9NmSMJ1etzUzt0yGzkDiOjObj3YqFo0ucyDJ8WfanLhZDtMw==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-17.0.8.tgz", + "integrity": "sha512-Q5fXHhu6w3pVPlFCibU72SYIAN+9wX7QpFn9h49IUqs0Equ44QgswtGrxeh7fdnDqJrrYGPet5iBzjnE70uolA==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -9853,12 +9169,12 @@ } }, "node_modules/expo-manifests": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.9.tgz", - "integrity": "sha512-5uVgvIo0o+xBcEJiYn4uVh72QSIqyHePbYTWXYa4QamXd+AmGY/yWmtHaNqCqjsPLCwXyn4OxPr7jXJCeTWLow==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", + "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.10", + "@expo/config": "~12.0.11", "expo-json-utils": "~0.15.0" }, "peerDependencies": { @@ -9866,9 +9182,9 @@ } }, "node_modules/expo-media-library": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-18.2.0.tgz", - "integrity": "sha512-aIYLIqmU8LFWrQcfZdwg9f/iWm0wC8uhZ7HiUiTnrigtxf417cVvNokX9afXpIOKBHAHRjVIbcs1nN8KZDE2Fw==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-18.2.1.tgz", + "integrity": "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -9876,9 +9192,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz", - "integrity": "sha512-Ej4SsZAnUUVFmbn6SoBso8K308mRKg8xgapdhP7v7IaSgfbexUoqxoiV31949HQQXuzmgvpkXCfp6Ex+mDW0EQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz", + "integrity": "sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -9892,9 +9208,9 @@ } }, "node_modules/expo-modules-core": { - "version": "3.0.26", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.26.tgz", - "integrity": "sha512-WWjficXz32VmQ+xDoO+c0+jwDME0n/47wONrJkRvtm32H9W8n3MXkOMGemDl95HyPKYsaYKhjFGUOVOxIF3hcQ==", + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.28.tgz", + "integrity": "sha512-8EDpksNxnN4HXWE+yhYUYAZAWTEDRzK2VpZjPSp+UBF2LtWZicXKLOCODCvsjCkTCVVA2JKKcWtGxWiteV3ueA==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -9905,18 +9221,18 @@ } }, "node_modules/expo-notifications": { - "version": "0.32.13", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.13.tgz", - "integrity": "sha512-PL0R1ulLVUgAswlXtRDKxBlcipNM3YA6+P5nB5JIhXbsjLJ7y+EKVaEhHhbaGzuK1QVsRQSJNm/4oISX+vsmFQ==", + "version": "0.32.14", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.14.tgz", + "integrity": "sha512-IRxzsd94+c1sim7R9OWdICPINmL4iwsLWcG3n6FKgzZal2ZZbBym2/m/k5yv3NQORUpytqB373WBJDZvaPCtgw==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.7", + "@expo/image-utils": "^0.8.8", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", - "expo-application": "~7.0.7", - "expo-constants": "~18.0.10" + "expo-application": "~7.0.8", + "expo-constants": "~18.0.11" }, "peerDependencies": { "expo": "*", @@ -9925,13 +9241,13 @@ } }, "node_modules/expo-router": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.15.tgz", - "integrity": "sha512-PAettvLifQzb6hibCmBqxbR9UljlH61GvDRLyarGxs/tG9OpMXCoZHZo8gGCO24K1/6cchBKBcjvQ0PRrKwPew==", + "version": "6.0.17", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.17.tgz", + "integrity": "sha512-2n0lTidH2H+dOjk/Lu+krKIgK7b1qQ3O/9RWmf9P5IEuFiu7BSUgSDc+g69bUEElTnca8FR+zPTyk15kMXHrXg==", "license": "MIT", "dependencies": { "@expo/metro-runtime": "^6.1.2", - "@expo/schema-utils": "^0.1.7", + "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", @@ -9940,7 +9256,7 @@ "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", - "expo-server": "^1.0.4", + "expo-server": "^1.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", @@ -9959,8 +9275,8 @@ "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", - "expo-constants": "^18.0.10", - "expo-linking": "^8.0.9", + "expo-constants": "^18.0.11", + "expo-linking": "^8.0.10", "react": "*", "react-dom": "*", "react-native": "*", @@ -9969,7 +9285,7 @@ "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", - "react-server-dom-webpack": ">= 19.0.0" + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "peerDependenciesMeta": { "@react-navigation/drawer": { @@ -9995,6 +9311,176 @@ } } }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/expo-router/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -10008,48 +9494,48 @@ } }, "node_modules/expo-secure-store": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.7.tgz", - "integrity": "sha512-9q7+G1Zxr5P6J5NRIlm86KulvmYwc6UnQlYPjQLDu1drDnerz6AT6l884dPu29HgtDTn4rR0heYeeGFhMKM7/Q==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-server": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.4.tgz", - "integrity": "sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", + "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", "license": "MIT", "engines": { "node": ">=20.16.0" } }, "node_modules/expo-sharing": { - "version": "14.0.7", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", - "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-splash-screen": { - "version": "31.0.11", - "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.11.tgz", - "integrity": "sha512-D7MQflYn/PAN3+fACSyxHO4oxZMBezllbgFdVY8roAS1gXpCy8SS6LrGHTD0VpOPEp3X4Gn7evTnXSI9nFoI5Q==", + "version": "31.0.12", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.12.tgz", + "integrity": "sha512-o466xFYh7Fld7CuBrzx5I12LONo7a4xzOSbxS+buOEObL/Wp4Xu4QhXg80ZY7puCGbJbtm7Ltjgg5olnWOU/Rg==", "license": "MIT", "dependencies": { - "@expo/prebuild-config": "^54.0.6" + "@expo/prebuild-config": "^54.0.7" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-status-bar": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.8.tgz", - "integrity": "sha512-L248XKPhum7tvREoS1VfE0H6dPCaGtoUWzRsUv7hGKdiB4cus33Rc0sxkWkoQ77wE8stlnUlL5lvmT0oqZ3ZBw==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", + "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" @@ -10066,9 +9552,9 @@ "license": "MIT" }, "node_modules/expo-symbols": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-1.0.7.tgz", - "integrity": "sha512-ZqFUeTXbwO6BrE00n37wTXYfJmsjFrfB446jeB9k9w7aA8a6eugNUIzNsUIUfbFWoOiY4wrGmpLSLPBwk4PH+g==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-1.0.8.tgz", + "integrity": "sha512-7bNjK350PaQgxBf0owpmSYkdZIpdYYmaPttDBb2WIp6rIKtcEtdzdfmhsc2fTmjBURHYkg36+eCxBFXO25/1hw==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.0.0" @@ -10079,9 +9565,9 @@ } }, "node_modules/expo-system-ui": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.8.tgz", - "integrity": "sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ==", + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz", + "integrity": "sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==", "license": "MIT", "dependencies": { "@react-native/normalize-colors": "0.81.5", @@ -10099,9 +9585,9 @@ } }, "node_modules/expo-updates": { - "version": "29.0.13", - "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.13.tgz", - "integrity": "sha512-tf/yex7U7betbIyDNwaSyDWDxMQVgmJ5qyghGEDlHP0052CPKUvbNEdtdf4DNCpsL3uxn8+71A4O4NxQdJEFuA==", + "version": "29.0.14", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.14.tgz", + "integrity": "sha512-VgXtjczQ4A/r4Jy/XEj+jWimk0vSd+GdDsYfLzl3CG/9fyQ6NXDP20PgiGfeF+A9rfA4IU3VyWdNJFBPyPPIgg==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "0.0.5", @@ -10115,7 +9601,7 @@ "expo-structured-headers": "~5.0.0", "expo-updates-interface": "~2.0.0", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, @@ -10143,45 +9629,42 @@ "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", "license": "MIT" }, - "node_modules/expo-updates/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/expo-updates/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/expo-updates/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/expo-updates/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10196,10 +9679,26 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/expo-updates/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expo-web-browser": { - "version": "15.0.9", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.9.tgz", - "integrity": "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg==", + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -10207,27 +9706,26 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.16", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.16.tgz", - "integrity": "sha512-hY/OdRaJMs5WsVPuVSZ+RLH3VObJmL/pv5CGCHEZHN2PxZjSZSdctyKV8UcFBXTF0yIKNAJ9XLs1dlNYXHh4Cw==", + "version": "54.0.18", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.18.tgz", + "integrity": "sha512-hN4kolUXLah9T8DQJ8ue1ZTvRNbeNJOEOhLBak6EU7h90FKfjLA32nz99jRnHmis+aF+9qsrQG9yQx9eCSVDcg==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.5", - "@expo/config": "~12.0.10", - "@expo/config-plugins": "~54.0.2", - "@expo/devcert": "^1.1.2", - "@expo/env": "~2.0.7", - "@expo/image-utils": "^0.8.7", - "@expo/json-file": "^10.0.7", - "@expo/mcp-tunnel": "~0.1.0", + "@expo/config": "~12.0.11", + "@expo/config-plugins": "~54.0.3", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", "@expo/metro": "~54.1.0", - "@expo/metro-config": "~54.0.9", - "@expo/osascript": "^2.3.7", - "@expo/package-manager": "^1.9.8", - "@expo/plist": "^0.4.7", - "@expo/prebuild-config": "^54.0.6", - "@expo/schema-utils": "^0.1.7", + "@expo/metro-config": "~54.0.10", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.9", + "@expo/plist": "^0.4.8", + "@expo/prebuild-config": "^54.0.7", + "@expo/schema-utils": "^0.1.8", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", @@ -10245,10 +9743,10 @@ "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", - "expo-server": "^1.0.4", + "expo-server": "^1.0.5", "freeport-async": "^2.0.0", "getenv": "^2.0.0", - "glob": "^10.4.2", + "glob": "^13.0.0", "lan-network": "^0.1.6", "minimatch": "^9.0.0", "node-forge": "^1.3.1", @@ -10271,7 +9769,7 @@ "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", - "tar": "^7.4.3", + "tar": "^7.5.2", "terminal-link": "^2.1.1", "undici": "^6.18.2", "wrap-ansi": "^7.0.0", @@ -10304,25 +9802,46 @@ } }, "node_modules/expo/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/expo/node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/expo/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/expo/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -10347,6 +9866,22 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/expo/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expo/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -10663,7 +10198,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10835,34 +10369,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -11044,7 +10550,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11136,14 +10642,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "devOptional": true, - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -11391,7 +10889,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/http-errors": { @@ -11450,7 +10948,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -11550,7 +11048,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", @@ -11579,7 +11077,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11689,7 +11187,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -11884,7 +11382,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12063,7 +11561,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12222,7 +11720,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -12237,7 +11735,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", @@ -12252,7 +11750,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -12280,26 +11778,11 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", @@ -12326,7 +11809,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "execa": "^5.0.0", @@ -12341,7 +11824,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -12373,7 +11856,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", @@ -12407,7 +11890,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -12454,7 +11937,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -12475,7 +11958,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -12491,7 +11974,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" @@ -12504,7 +11987,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -12817,14 +12300,14 @@ } }, "node_modules/jest-expo": { - "version": "54.0.13", - "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-54.0.13.tgz", - "integrity": "sha512-V0xefV7VJ9RD6v6Jo64I8RzQCchgEWVn6ip5r+u4TlgsGau0DA8CAqzitn4ShoSKlmjmpuaMqcGxeCz1p9Cfvg==", + "version": "54.0.14", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-54.0.14.tgz", + "integrity": "sha512-94EgBAfmP+TVbMzNaVh2c4vhrZe9isZJEUIONiVn6wK+DgI+UUbOr7BnlMFtd2M/5s0eawK3yC3SZurhCFXiyg==", "dev": true, "license": "MIT", "dependencies": { - "@expo/config": "~12.0.10", - "@expo/json-file": "^10.0.7", + "@expo/config": "~12.0.11", + "@expo/json-file": "^10.0.8", "@jest/create-cache-key-function": "^29.2.1", "@jest/globals": "^29.2.1", "babel-jest": "^29.2.1", @@ -12834,7 +12317,6 @@ "jest-watch-typeahead": "2.2.1", "json5": "^2.2.3", "lodash": "^4.17.19", - "react-server-dom-webpack": "~19.0.0", "react-test-renderer": "19.1.0", "server-only": "^0.0.1", "stacktrace-js": "^2.0.2" @@ -12844,7 +12326,13 @@ }, "peerDependencies": { "expo": "*", - "react-native": "*" + "react-native": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "react-server-dom-webpack": { + "optional": true + } } }, "node_modules/jest-get-type": { @@ -12885,7 +12373,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "jest-get-type": "^29.6.3", @@ -12899,7 +12387,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -12935,7 +12423,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12962,7 +12450,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -12983,7 +12471,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "^29.6.3", @@ -12997,7 +12485,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/console": "^29.7.0", @@ -13030,7 +12518,7 @@ "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -13041,7 +12529,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -13076,7 +12564,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -13097,7 +12585,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -13112,7 +12600,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -13144,7 +12632,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13345,7 +12833,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jest/test-result": "^29.7.0", @@ -13485,7 +12973,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -13860,17 +13348,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - } - }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -14017,7 +13494,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -14033,7 +13510,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14553,7 +14030,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -14563,7 +14040,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -14688,7 +14165,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -14700,13 +14177,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "devOptional": true, - "license": "MIT" - }, "node_modules/nested-error-stacks": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", @@ -14816,7 +14286,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -15032,7 +14502,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -15291,12 +14761,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -15314,7 +14778,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -15400,6 +14864,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -15416,12 +14881,14 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/path-scurry/node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -15469,7 +14936,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -15798,7 +15265,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -15890,17 +15357,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -16383,9 +15839,9 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -16429,26 +15885,6 @@ } } }, - "node_modules/react-server-dom-webpack": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-server-dom-webpack/-/react-server-dom-webpack-19.0.0.tgz", - "integrity": "sha512-hLug9KEXLc8vnU9lDNe2b2rKKDaqrp5gNiES4uyu2Up3FZfZJZmdwLFXlWzdA9gTB/6/cWduSB2K1Lfag2pSvw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "acorn-loose": "^8.3.0", - "neo-async": "^2.6.1", - "webpack-sources": "^3.2.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", - "webpack": "^5.59.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -16475,7 +15911,7 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz", "integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "react-is": "^19.1.0", @@ -16489,7 +15925,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "indent-string": "^4.0.0", @@ -16692,7 +16128,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" @@ -16983,27 +16419,6 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, - "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -17091,17 +16506,6 @@ "node": ">=0.10.0" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "devOptional": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -17639,7 +17043,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "char-regex": "^1.0.2", @@ -17663,21 +17067,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -17788,24 +17177,11 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17815,7 +17191,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -17825,7 +17201,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "min-indent": "^1.0.0" @@ -17838,7 +17214,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17860,17 +17236,17 @@ "license": "MIT" }, "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { @@ -17881,15 +17257,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -17899,50 +17266,6 @@ "node": ">= 6" } }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17987,21 +17310,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", @@ -18067,85 +17375,16 @@ "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "license": "BSD-2-Clause", "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/terser/node_modules/commander": { @@ -18220,7 +17459,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -18237,7 +17475,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -18851,7 +18088,7 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", @@ -18893,6 +18130,183 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/vaul/node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -18927,21 +18341,6 @@ "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", "license": "MIT" }, - "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -18961,92 +18360,6 @@ "node": ">=12" } }, - "node_modules/webpack": { - "version": "5.101.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", - "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "devOptional": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "devOptional": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -19249,24 +18562,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -19443,24 +18738,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", diff --git a/package.json b/package.json index 5802bc6c..245fb081 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "brewtracker", "main": "expo-router/entry", - "version": "3.2.6", + "version": "3.3.15", "license": "GPL-3.0-or-later", "scripts": { "start": "expo start", @@ -49,31 +49,31 @@ "ajv": "^8.17.1", "axios": "^1.12.2", "expo": "~54.0.25", - "expo-blur": "~15.0.7", - "expo-build-properties": "~1.0.7", + "expo-blur": "~15.0.8", + "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.9", - "expo-crypto": "~15.0.7", - "expo-dev-client": "~6.0.18", - "expo-device": "~8.0.9", - "expo-document-picker": "~14.0.7", + "expo-crypto": "~15.0.8", + "expo-dev-client": "~6.0.20", + "expo-device": "~8.0.10", + "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.14", "expo-font": "~14.0.8", - "expo-haptics": "~15.0.7", - "expo-image": "~3.0.10", - "expo-linear-gradient": "~15.0.7", - "expo-linking": "~8.0.9", - "expo-local-authentication": "~17.0.7", - "expo-media-library": "~18.2.0", - "expo-notifications": "~0.32.13", - "expo-router": "~6.0.15", - "expo-secure-store": "~15.0.7", - "expo-sharing": "~14.0.7", - "expo-splash-screen": "~31.0.11", - "expo-status-bar": "~3.0.8", - "expo-symbols": "~1.0.7", - "expo-system-ui": "~6.0.8", - "expo-updates": "~29.0.13", - "expo-web-browser": "~15.0.9", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-linear-gradient": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-local-authentication": "~17.0.8", + "expo-media-library": "~18.2.1", + "expo-notifications": "~0.32.14", + "expo-router": "~6.0.17", + "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", + "expo-splash-screen": "~31.0.12", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-updates": "~29.0.14", + "expo-web-browser": "~15.0.10", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -109,7 +109,7 @@ "eslint-config-expo": "~10.0.0", "express": "^5.1.0", "jest": "~29.7.0", - "jest-expo": "~54.0.13", + "jest-expo": "~54.0.14", "jsdom": "^26.1.0", "oxlint": "^1.14.0", "prettier": "^3.4.2", diff --git a/src/components/banners/StaleDataBanner.tsx b/src/components/banners/StaleDataBanner.tsx index 1387921a..a6dee7c4 100644 --- a/src/components/banners/StaleDataBanner.tsx +++ b/src/components/banners/StaleDataBanner.tsx @@ -111,16 +111,6 @@ export const StaleDataBanner: React.FC = ({ const staleThresholdMs = staleThresholdHours * 60 * 60 * 1000; const now = Date.now(); - await UnifiedLogger.debug( - "StaleDataBanner.checkStaleData", - "Checking for stale data", - { - totalQueries: queries.length, - staleThresholdHours, - staleThresholdMs, - } - ); - let oldestDataTimestamp = now; let hasAnyStaleData = false; const staleQueries: any[] = []; @@ -198,15 +188,6 @@ export const StaleDataBanner: React.FC = ({ setOldestDataAge(`${ageDays}d ago`); } } else { - await UnifiedLogger.debug( - "StaleDataBanner.checkStaleData", - "No stale data found", - { - totalQueries: queries.length, - successfulQueries: queries.filter(q => q.state.status === "success") - .length, - } - ); } }, [queryClient, staleThresholdHours]); @@ -298,23 +279,7 @@ export const StaleDataBanner: React.FC = ({ // 2. Not dismissed recently if (!hasStaleData || isDismissed) { if (hasStaleData && isDismissed) { - void UnifiedLogger.debug( - "StaleDataBanner.render", - "Banner hidden - dismissed by user", - { - hasStaleData, - isDismissed, - } - ); } else if (!hasStaleData) { - void UnifiedLogger.debug( - "StaleDataBanner.render", - "Banner hidden - no stale data", - { - hasStaleData, - isDismissed, - } - ); } return null; } diff --git a/src/components/beerxml/UnitConversionChoiceModal.tsx b/src/components/beerxml/UnitConversionChoiceModal.tsx new file mode 100644 index 00000000..1820e2e3 --- /dev/null +++ b/src/components/beerxml/UnitConversionChoiceModal.tsx @@ -0,0 +1,341 @@ +/** + * Unit Conversion Choice Modal Component + * + * Modal for choosing which unit system to use when importing BeerXML recipes. + * BeerXML files are always in metric per spec, but users can choose to import + * as metric or convert to imperial. + * + * Features: + * - Clear explanation that BeerXML is metric + * - Recommendations based on user's preferred unit system + * - Applies normalization to both choices (e.g., 28.3g -> 30g) + * - Loading state during conversion + * - Accessible design with proper ARIA labels + * + * @example + * ```typescript + * + * ``` + */ + +import React from "react"; +import { + View, + Text, + TouchableOpacity, + Modal, + ActivityIndicator, +} from "react-native"; +import { MaterialIcons } from "@expo/vector-icons"; +import { useTheme } from "@contexts/ThemeContext"; +import { unitConversionModalStyles } from "@styles/components/beerxml/unitConversionModalStyles"; +import { UnitSystem } from "@src/types"; + +interface UnitConversionChoiceModalProps { + /** + * Whether the modal is visible + */ + visible: boolean; + /** + * The user's preferred unit system (for recommendation) + */ + userUnitSystem: UnitSystem; + /** + * Which unit system is currently being converted to, or null if no conversion in progress + */ + convertingTarget: UnitSystem | null; + /** + * Optional recipe name to display + */ + recipeName?: string; + /** + * Callback when user chooses metric + */ + onChooseMetric: () => void; + /** + * Callback when user chooses imperial + */ + onChooseImperial: () => void; + /** + * Callback when user cancels/closes the modal + */ + onCancel: () => void; +} + +/** + * Unit Conversion Choice Modal Component + * + * Presents the user with a choice of which unit system to use when + * importing a BeerXML recipe (always metric per spec). + */ +export const UnitConversionChoiceModal: React.FC< + UnitConversionChoiceModalProps +> = ({ + visible, + userUnitSystem, + convertingTarget, + recipeName, + onChooseMetric, + onChooseImperial, + onCancel, +}) => { + const { colors } = useTheme(); + + // Determine which button is in loading state and which is just disabled + const isConvertingMetric = convertingTarget === "metric"; + const isConvertingImperial = convertingTarget === "imperial"; + const isAnyConversion = convertingTarget !== null; + + return ( + + + + + {/* Header */} + + + + Choose Import Units + + + + {/* Message */} + + {recipeName && ( + + {recipeName} + + )} + + BeerXML files use metric units by default. Choose which unit + system you'd like to use in BrewTracker. + + + + Both options apply brewing-friendly normalization (e.g., 28.3g → + 30g) for practical measurements. + + + + {/* Action Buttons */} + + {/* Metric Choice */} + + {isConvertingMetric ? ( + <> + + + Converting... + + + ) : ( + <> + + + + Import as Metric (kg, L, °C) + + {userUnitSystem === "metric" && ( + + Recommended for your preference + + )} + + + )} + + + {/* Imperial Choice */} + + {isConvertingImperial ? ( + <> + + + Converting... + + + ) : ( + <> + + + + Import as Imperial (lbs, gal, °F) + + {userUnitSystem === "imperial" && ( + + Recommended for your preference + + )} + + + )} + + + + + + ); +}; diff --git a/src/components/brewSessions/DryHopTracker.tsx b/src/components/brewSessions/DryHopTracker.tsx index ec238b01..393df081 100644 --- a/src/components/brewSessions/DryHopTracker.tsx +++ b/src/components/brewSessions/DryHopTracker.tsx @@ -32,7 +32,7 @@ import { getDryHopsFromRecipe } from "@utils/recipeUtils"; import { useTheme } from "@contexts/ThemeContext"; import { TEST_IDS } from "@src/constants/testIDs"; import { dryHopTrackerStyles } from "@styles/components/brewSessions/dryHopTrackerStyles"; -import UnifiedLogger from "@services/logger/UnifiedLogger"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; interface DryHopTrackerProps { recipe: Recipe | null | undefined; diff --git a/src/components/brewSessions/FermentationChart.tsx b/src/components/brewSessions/FermentationChart.tsx index 2eace5d6..ece25afa 100644 --- a/src/components/brewSessions/FermentationChart.tsx +++ b/src/components/brewSessions/FermentationChart.tsx @@ -29,7 +29,7 @@ import React from "react"; import { View, Text, TouchableOpacity, Modal, Pressable } from "react-native"; import { LineChart } from "react-native-gifted-charts"; -import { FermentationEntry, Recipe } from "@src/types"; +import { FermentationEntry, Recipe, TemperatureUnit } from "@src/types"; import { useTheme } from "@contexts/ThemeContext"; import { useUnits } from "@contexts/UnitContext"; import { useScreenDimensions } from "@contexts/ScreenDimensionsContext"; @@ -244,7 +244,7 @@ interface FermentationChartProps { fermentationData: FermentationEntry[]; expectedFG?: number; actualOG?: number; - temperatureUnit?: "C" | "F"; // Session-specific temperature unit + temperatureUnit?: TemperatureUnit; // Session-specific temperature unit forceRefresh?: number; // External refresh trigger recipeData?: Recipe; // Recipe data for accessing estimated_fg } diff --git a/src/components/calculators/UnitToggle.tsx b/src/components/calculators/UnitToggle.tsx index 014ccf93..5b18b8e3 100644 --- a/src/components/calculators/UnitToggle.tsx +++ b/src/components/calculators/UnitToggle.tsx @@ -22,8 +22,8 @@ * value={tempUnit} * onChange={setTempUnit} * options={[ - * { label: "°F", value: "f", description: "Fahrenheit" }, - * { label: "°C", value: "c", description: "Celsius" } + * { label: "°F", value: "F", description: "Fahrenheit" }, + * { label: "°C", value: "C", description: "Celsius" } * ]} * /> * ``` diff --git a/src/components/recipes/AIAnalysisResultsModal.tsx b/src/components/recipes/AIAnalysisResultsModal.tsx index 24bf1b85..c082eb78 100644 --- a/src/components/recipes/AIAnalysisResultsModal.tsx +++ b/src/components/recipes/AIAnalysisResultsModal.tsx @@ -53,7 +53,6 @@ import { normalizeBackendMetrics, } from "@utils/aiHelpers"; import { beerStyleAnalysisService } from "@services/beerStyles/BeerStyleAnalysisService"; -import { UnifiedLogger } from "@services/logger/UnifiedLogger"; interface AIAnalysisResultsModalProps { /** @@ -285,25 +284,8 @@ export function AIAnalysisResultsModal({ ); // Debug logging - UnifiedLogger.debug("AIAnalysisResultsModal", "Rendering modal", { - hasOptimisation, - hasStyle: !!style, - styleName: style?.name, - styleId: style?.id, - recipeChangesCount: result.recipe_changes?.length || 0, - hasOriginalMetrics: !!result.original_metrics, - hasOptimizedMetrics: !!result.optimized_metrics, - iterationsCompleted: result.iterations_completed, - }); // Debug grouped changes - UnifiedLogger.debug("AIAnalysisResultsModal", "Grouped changes", { - hasGroupedChanges: !!groupedChanges, - parametersCount: groupedChanges?.parameters.length || 0, - modificationsCount: groupedChanges?.modifications.length || 0, - additionsCount: groupedChanges?.additions.length || 0, - removalsCount: groupedChanges?.removals.length || 0, - }); // Debug conditionals - ensure all are explicit booleans const showSummary = hasOptimisation; @@ -313,19 +295,6 @@ export function AIAnalysisResultsModal({ !!normalizedOptimizedMetrics; const showChanges = hasOptimisation && !!groupedChanges; - UnifiedLogger.debug("AIAnalysisResultsModal", "Boolean check", { - hasOptimisationType: typeof hasOptimisation, - hasOptimisationValue: hasOptimisation, - showSummaryType: typeof showSummary, - showSummaryValue: showSummary, - }); - - UnifiedLogger.debug("AIAnalysisResultsModal", "Section visibility", { - showSummary, - showMetrics, - showChanges, - }); - return ( { if (ingredientType === "grain") { switch (unit) { diff --git a/src/components/recipes/RecipeForm/ParametersForm.tsx b/src/components/recipes/RecipeForm/ParametersForm.tsx index 38c5a07f..a51278ba 100644 --- a/src/components/recipes/RecipeForm/ParametersForm.tsx +++ b/src/components/recipes/RecipeForm/ParametersForm.tsx @@ -4,7 +4,7 @@ import { MaterialIcons } from "@expo/vector-icons"; import { useTheme } from "@contexts/ThemeContext"; import { useUnits } from "@contexts/UnitContext"; -import { RecipeFormData } from "@src/types"; +import { RecipeFormData, TemperatureUnit } from "@src/types"; import { createRecipeStyles } from "@styles/modals/createRecipeStyles"; import { StyleAnalysis } from "@src/components/recipes/StyleAnalysis"; import { TEST_IDS } from "@src/constants/testIDs"; @@ -98,7 +98,7 @@ export function ParametersForm({ validateField(field, value); }; - const handleMashTempUnitChange = (unit: "F" | "C") => { + const handleMashTempUnitChange = (unit: TemperatureUnit) => { // Convert temperature when changing units let newTemp = recipeData.mash_temperature; @@ -224,7 +224,8 @@ export function ParametersForm({ ]} value={recipeData.mash_temperature.toString()} onChangeText={text => { - const numValue = parseFloat(text) || 0; + const numValue = + text.trim() === "" ? Number.NaN : parseFloat(text); handleFieldChange("mash_temperature", numValue); }} placeholder={unitSystem === "imperial" ? "152" : "67"} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 5066afe0..1a644529 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -351,20 +351,21 @@ export const AuthProvider: React.FC = ({ // Update state setUser(normalizedUser); - await UnifiedLogger.debug("auth", "Session applied successfully", { - userId: normalizedUser.id, - username: normalizedUser.username, - }); - // Cache ingredients in background (don't block) StaticDataService.updateIngredientsCache() .then(() => { StaticDataService.getCacheStats() .then(stats => console.log("V2 Cache Status:", stats)) - .catch(error => console.warn("Failed to get cache stats:", error)); + .catch(error => + UnifiedLogger.warn("auth", "Failed to get cache stats:", error) + ); }) .catch(error => { - console.warn("Failed to cache ingredients after login:", error); + UnifiedLogger.warn( + "auth", + "Failed to cache ingredients after login:", + error + ); }); } catch (error) { // Rollback: Clear token, cached data, and auth status if session setup fails @@ -379,7 +380,6 @@ export const AuthProvider: React.FC = ({ await AsyncStorage.removeItem(STORAGE_KEYS.USER_DATA); setAuthStatus("unauthenticated"); setUser(null); - await UnifiedLogger.debug("auth", "Session rollback completed"); } catch (rollbackError) { await UnifiedLogger.error( "auth", @@ -556,10 +556,13 @@ export const AuthProvider: React.FC = ({ .then(() => { StaticDataService.getCacheStats() .then(stats => console.log("V2 Cache Status:", stats)) - .catch(error => console.warn("Failed to get cache stats:", error)); + .catch(error => + UnifiedLogger.warn("auth", "Failed to get cache stats:", error) + ); }) .catch(error => { - console.warn( + UnifiedLogger.warn( + "auth", "Failed to cache ingredients during auth initialization:", error ); @@ -667,7 +670,7 @@ export const AuthProvider: React.FC = ({ // Note: Token should be handled if provided } } catch (error: any) { - console.error("Registration failed:", error); + UnifiedLogger.error("auth", "Registration failed:", error); setError(error.response?.data?.message || "Registration failed"); throw error; } finally { @@ -686,7 +689,7 @@ export const AuthProvider: React.FC = ({ // Apply session (token storage, auth status update, caching, etc.) await applyNewSession(access_token, userData); } catch (error: any) { - console.error("Google sign-in failed:", error); + UnifiedLogger.error("auth", "Google sign-in failed:", error); setError(error.response?.data?.message || "Google sign-in failed"); throw error; } finally { @@ -714,14 +717,9 @@ export const AuthProvider: React.FC = ({ // Clear JWT token from SecureStore await ApiService.token.removeToken(); - await UnifiedLogger.debug("auth", "JWT token removed from SecureStore"); // Clear device token (but keep biometric credentials for backward compatibility) await DeviceTokenService.clearDeviceToken(); - await UnifiedLogger.debug( - "auth", - "Device token cleared from SecureStore" - ); // Clear cached data await AsyncStorage.multiRemove([ @@ -729,19 +727,16 @@ export const AuthProvider: React.FC = ({ STORAGE_KEYS.USER_SETTINGS, STORAGE_KEYS.CACHED_INGREDIENTS, ]); - await UnifiedLogger.debug("auth", "AsyncStorage cleared"); // Clear React Query cache and persisted storage cacheUtils.clearAll(); await cacheUtils.clearUserPersistedCache(userId); - await UnifiedLogger.debug("auth", "React Query cache cleared"); // Clear user-scoped offline data const { UserCacheService } = await import( "@services/offlineV2/UserCacheService" ); await UserCacheService.clearUserData(userId); - await UnifiedLogger.debug("auth", "User offline data cleared"); await UnifiedLogger.info("auth", "Logout completed successfully", { userId, @@ -826,7 +821,7 @@ export const AuthProvider: React.FC = ({ setUser(userData); } catch (error: any) { - console.error("Failed to refresh user:", error); + UnifiedLogger.error("auth", "Failed to refresh user:", error); // Don't set error state for refresh failures unless it's a 401 if (error.response?.status === 401) { await logout(); @@ -850,7 +845,7 @@ export const AuthProvider: React.FC = ({ await refreshUser(); } } catch (error: any) { - console.error("Email verification failed:", error); + UnifiedLogger.error("auth", "Email verification failed:", error); setError(error.response?.data?.message || "Email verification failed"); throw error; } finally { @@ -863,7 +858,7 @@ export const AuthProvider: React.FC = ({ setError(null); await ApiService.auth.resendVerification(); } catch (error: any) { - console.error("Failed to resend verification:", error); + UnifiedLogger.error("auth", "Failed to resend verification:", error); setError( error.response?.data?.message || "Failed to resend verification" ); @@ -888,7 +883,11 @@ export const AuthProvider: React.FC = ({ ); } } catch (error: any) { - console.error("Failed to check verification status:", error); + UnifiedLogger.error( + "auth", + "Failed to check verification status:", + error + ); } }; @@ -898,7 +897,7 @@ export const AuthProvider: React.FC = ({ setError(null); await ApiService.auth.forgotPassword({ email }); } catch (error: any) { - console.error("Failed to send password reset:", error); + UnifiedLogger.error("auth", "Failed to send password reset:", error); setError( error.response?.data?.error || "Failed to send password reset email" ); @@ -917,7 +916,7 @@ export const AuthProvider: React.FC = ({ setError(null); await ApiService.auth.resetPassword({ token, new_password: newPassword }); } catch (error: any) { - console.error("Failed to reset password:", error); + UnifiedLogger.error("auth", "Failed to reset password:", error); setError(error.response?.data?.error || "Failed to reset password"); throw error; } finally { @@ -940,7 +939,11 @@ export const AuthProvider: React.FC = ({ setIsBiometricAvailable(available); setIsBiometricEnabled(enabled); } catch (error) { - console.error("Failed to check biometric availability:", error); + UnifiedLogger.error( + "auth", + "Failed to check biometric availability:", + error + ); setIsBiometricAvailable(false); setIsBiometricEnabled(false); } @@ -962,12 +965,6 @@ export const AuthProvider: React.FC = ({ const biometricEnabled = await BiometricService.isBiometricEnabled(); const hasDeviceToken = await BiometricService.hasStoredDeviceToken(); - await UnifiedLogger.debug("auth", "Biometric pre-flight check", { - biometricAvailable, - biometricEnabled, - hasDeviceToken, - }); - if (!hasDeviceToken) { await UnifiedLogger.warn( "auth", @@ -979,20 +976,17 @@ export const AuthProvider: React.FC = ({ } // Authenticate with biometrics and exchange device token for access token - await UnifiedLogger.debug( - "auth", - "Requesting biometric authentication from BiometricService" - ); - + if (!biometricAvailable || !biometricEnabled) { + await UnifiedLogger.warn( + "auth", + "Biometric login failed: Biometrics not available or not enabled" + ); + throw new Error( + "Biometric authentication is not available or not enabled on this device." + ); + } const result = await BiometricService.authenticateWithBiometrics(); - await UnifiedLogger.debug("auth", "Biometric authentication result", { - success: result.success, - errorCode: result.errorCode, - hasAccessToken: !!result.accessToken, - hasUser: !!result.user, - }); - if (!result.success || !result.accessToken || !result.user) { await UnifiedLogger.warn("auth", "Biometric authentication rejected", { error: result.error, @@ -1009,11 +1003,6 @@ export const AuthProvider: React.FC = ({ throw error; } - await UnifiedLogger.debug( - "auth", - "Biometric authentication successful, device token exchanged for access token" - ); - const { accessToken: access_token, user: userData } = result; // Validate user data structure @@ -1021,12 +1010,6 @@ export const AuthProvider: React.FC = ({ throw new Error("Invalid user data received from biometric login"); } - await UnifiedLogger.debug("auth", "Biometric login successful", { - userId: userData.id, - username: userData.username, - hasAccessToken: true, - }); - // Apply session (token storage, auth status update, caching, etc.) // Note: applyNewSession handles user data validation await applyNewSession(access_token, userData as User); @@ -1074,7 +1057,7 @@ export const AuthProvider: React.FC = ({ await BiometricService.enableBiometrics(username); await checkBiometricAvailability(); } catch (error: any) { - console.error("Failed to enable biometrics:", error); + UnifiedLogger.error("auth", "Failed to enable biometrics:", error); throw error; } }; @@ -1088,7 +1071,7 @@ export const AuthProvider: React.FC = ({ await BiometricService.disableBiometricsLocally(); await checkBiometricAvailability(); } catch (error: any) { - console.error("Failed to disable biometrics:", error); + UnifiedLogger.error("auth", "Failed to disable biometrics:", error); throw error; } }; @@ -1114,7 +1097,7 @@ export const AuthProvider: React.FC = ({ return extractUserIdFromJWT(token); } catch (error) { - console.warn("Failed to extract user ID:", error); + UnifiedLogger.warn("auth", "Failed to extract user ID:", error); return null; } }; diff --git a/src/contexts/CalculatorsContext.tsx b/src/contexts/CalculatorsContext.tsx index da782564..7d5f696f 100644 --- a/src/contexts/CalculatorsContext.tsx +++ b/src/contexts/CalculatorsContext.tsx @@ -49,6 +49,7 @@ import React, { ReactNode, } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { TemperatureUnit, UnitSystem } from "../types"; /** * Type definitions for individual calculator states @@ -75,7 +76,7 @@ export interface StrikeWaterState { grainWeightUnit: string; grainTemp: string; targetMashTemp: string; - tempUnit: "f" | "c"; + tempUnit: TemperatureUnit; waterToGrainRatio: string; result: { strikeTemp: number; @@ -87,7 +88,7 @@ export interface HydrometerCorrectionState { measuredGravity: string; wortTemp: string; calibrationTemp: string; - tempUnit: "f" | "c"; + tempUnit: TemperatureUnit; result: number | null; } @@ -169,8 +170,8 @@ export interface BoilTimerState { } export interface UserPreferences { - defaultUnits: "metric" | "imperial"; - temperatureUnit: "f" | "c"; + defaultUnits: UnitSystem; + temperatureUnit: TemperatureUnit; saveHistory: boolean; } @@ -254,7 +255,7 @@ const initialState: CalculatorState = { grainWeightUnit: "lb", grainTemp: "", targetMashTemp: "", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null, }, @@ -262,7 +263,7 @@ const initialState: CalculatorState = { measuredGravity: "", wortTemp: "", calibrationTemp: "60", - tempUnit: "f", + tempUnit: "F", result: null, }, dilution: { @@ -310,8 +311,8 @@ const initialState: CalculatorState = { timerStartedAt: undefined, }, preferences: { - defaultUnits: "imperial", - temperatureUnit: "f", + defaultUnits: "metric", + temperatureUnit: "C", saveHistory: true, }, history: {}, diff --git a/src/contexts/DeveloperContext.tsx b/src/contexts/DeveloperContext.tsx index 0e45c28c..3879f3f8 100644 --- a/src/contexts/DeveloperContext.tsx +++ b/src/contexts/DeveloperContext.tsx @@ -30,7 +30,7 @@ import React, { } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { STORAGE_KEYS } from "@services/config"; -import UnifiedLogger from "@services/logger/UnifiedLogger"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; /** * Network simulation modes for testing @@ -156,7 +156,11 @@ export const DeveloperProvider: React.FC = ({ } } } catch (error) { - console.warn("Failed to load developer settings:", error); + UnifiedLogger.warn( + "developer", + "Failed to load developer settings:", + error + ); // Self-heal on any error by ensuring we have a safe default setNetworkSimulationModeState("normal"); } @@ -220,7 +224,11 @@ export const DeveloperProvider: React.FC = ({ { error: errorMessage } ); - console.warn("Developer mode sync of pending operations failed:", error); + UnifiedLogger.warn( + "developer", + "Developer mode sync of pending operations failed:", + error + ); throw error; // Re-throw so callers can handle if needed } }; @@ -287,7 +295,11 @@ export const DeveloperProvider: React.FC = ({ error: error instanceof Error ? error.message : "Unknown error", } ); - console.error("Failed to set network simulation mode:", error); + UnifiedLogger.error( + "developer", + "Failed to set network simulation mode:", + error + ); } }; @@ -310,7 +322,11 @@ export const DeveloperProvider: React.FC = ({ setNetworkSimulationModeState("normal"); console.log("Developer settings reset to defaults"); } catch (error) { - console.error("Failed to reset developer settings:", error); + UnifiedLogger.error( + "developer", + "Failed to reset developer settings:", + error + ); } }; @@ -332,7 +348,7 @@ export const DeveloperProvider: React.FC = ({ console.log("Developer: V2 system handles cleanup automatically"); return { removedTombstones: 0, tombstoneNames: [] }; } catch (error) { - console.error("Failed to cleanup tombstones:", error); + UnifiedLogger.error("developer", "Failed to cleanup tombstones:", error); throw error; } }; @@ -375,7 +391,7 @@ export const DeveloperProvider: React.FC = ({ "Failed to make cache stale", { error } ); - console.error("Failed to make cache stale:", error); + UnifiedLogger.error("developer", "Failed to make cache stale:", error); throw error; } }; @@ -405,7 +421,7 @@ export const DeveloperProvider: React.FC = ({ "Failed to clear cache", { error } ); - console.error("Failed to clear cache:", error); + UnifiedLogger.error("developer", "Failed to clear cache:", error); throw error; } }; @@ -473,7 +489,7 @@ export const DeveloperProvider: React.FC = ({ "Failed to invalidate token", { error } ); - console.error("Failed to invalidate token:", error); + UnifiedLogger.error("developer", "Failed to invalidate token:", error); throw error; } }; diff --git a/src/contexts/NetworkContext.tsx b/src/contexts/NetworkContext.tsx index b312e292..efd7f5a6 100644 --- a/src/contexts/NetworkContext.tsx +++ b/src/contexts/NetworkContext.tsx @@ -166,7 +166,11 @@ export const NetworkProvider: React.FC = ({ // Load any cached network preferences await loadCachedNetworkState(); } catch (error) { - console.warn("Failed to initialize network monitoring:", error); + void UnifiedLogger.warn( + "network", + "Failed to initialize network monitoring:", + error + ); // Fallback to optimistic connected state setIsConnected(true); setConnectionType("unknown"); @@ -223,8 +227,10 @@ export const NetworkProvider: React.FC = ({ const isNowOnline = connected && (reachable ?? true); const shouldRefresh = wasOffline && isNowOnline; - // Log network state changes - void UnifiedLogger.info( + // Log network state changes (info when status flips, debug otherwise) + const logMethod = + previousOnlineState.current !== isNowOnline ? "info" : "debug"; + void UnifiedLogger[logMethod]( "NetworkContext.handleStateChange", "Network state change detected", { @@ -237,35 +243,6 @@ export const NetworkProvider: React.FC = ({ previousOnlineState: previousOnlineState.current, } ); - if (previousOnlineState.current !== isNowOnline) { - void UnifiedLogger.info( - "NetworkContext.handleStateChange", - "Network state change detected", - { - connected, - reachable, - type, - wasOffline, - isNowOnline, - shouldRefresh, - previousOnlineState: previousOnlineState.current, - } - ); - } else { - void UnifiedLogger.debug( - "NetworkContext.handleStateChange", - "Network state change detected", - { - connected, - reachable, - type, - wasOffline, - isNowOnline, - shouldRefresh, - previousOnlineState: previousOnlineState.current, - } - ); - } // Also refresh if it's been more than 4 hours since last refresh const timeSinceRefresh = Date.now() - lastCacheRefresh.current; @@ -275,16 +252,6 @@ export const NetworkProvider: React.FC = ({ if (shouldRefresh || shouldPeriodicRefresh) { lastCacheRefresh.current = Date.now(); - void UnifiedLogger.debug( - "NetworkContext.handleStateChange", - "Triggering background refresh and sync", - { - shouldRefresh, - shouldPeriodicRefresh, - isComingBackOnline: shouldRefresh, - } - ); - // Trigger comprehensive background cache refresh using V2 system (non-blocking) Promise.allSettled([ StaticDataService.updateIngredientsCache(), @@ -301,9 +268,6 @@ export const NetworkProvider: React.FC = ({ : "Background cache refresh completed", { results } ); - if (failures.length > 0) { - console.warn("Background cache refresh had failures:", failures); - } }) .catch(error => { void UnifiedLogger.error( @@ -311,7 +275,6 @@ export const NetworkProvider: React.FC = ({ "Background cache refresh failed", { error: error instanceof Error ? error.message : String(error) } ); - console.warn("Background cache refresh failed:", error); }); // **CRITICAL FIX**: Also trigger sync of pending operations when coming back online @@ -353,10 +316,6 @@ export const NetworkProvider: React.FC = ({ error instanceof Error ? error.message : "Unknown error", } ); - console.warn( - "Background sync of pending operations failed:", - error - ); }); }) .catch(error => { @@ -386,7 +345,11 @@ export const NetworkProvider: React.FC = ({ }) ); } catch (error) { - console.warn("Failed to cache network state:", error); + void UnifiedLogger.warn( + "network", + "Failed to cache network state:", + error + ); } }; @@ -411,7 +374,11 @@ export const NetworkProvider: React.FC = ({ } } } catch (error) { - console.warn("Failed to load cached network state:", error); + void UnifiedLogger.warn( + "network", + "Failed to load cached network state:", + error + ); } }; @@ -423,7 +390,11 @@ export const NetworkProvider: React.FC = ({ const state = await NetInfo.fetch(); await updateNetworkState(state); } catch (error) { - console.warn("Failed to refresh network state:", error); + void UnifiedLogger.warn( + "network", + "Failed to refresh network state:", + error + ); throw error; } }; diff --git a/src/contexts/UnitContext.tsx b/src/contexts/UnitContext.tsx index be0bb09b..f09b1059 100644 --- a/src/contexts/UnitContext.tsx +++ b/src/contexts/UnitContext.tsx @@ -37,6 +37,7 @@ import ApiService from "@services/api/apiService"; import { STORAGE_KEYS } from "@services/config"; import { UnitSystem, MeasurementType, UserSettings } from "@src/types"; import { useAuth } from "@contexts/AuthContext"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; // Unit option interface for common units interface UnitOption { @@ -158,7 +159,11 @@ export const UnitProvider: React.FC = ({ try { settings = JSON.parse(cachedSettings) as UserSettings; } catch (parseErr) { - console.warn("Corrupted cached user settings, removing:", parseErr); + void UnifiedLogger.warn( + "units", + "Corrupted cached user settings, removing:", + parseErr + ); await AsyncStorage.removeItem(STORAGE_KEYS.USER_SETTINGS); // Treat as no-cache: fetch if authed, else default. try { @@ -180,7 +185,8 @@ export const UnitProvider: React.FC = ({ } } catch (bgError: any) { if (bgError?.response?.status !== 401) { - console.warn( + void UnifiedLogger.warn( + "units", "Settings fetch after cache corruption failed:", bgError ); @@ -220,7 +226,11 @@ export const UnitProvider: React.FC = ({ } catch (bgError: any) { // Silently handle background fetch errors for unauthenticated users if (bgError.response?.status !== 401) { - console.warn("Background settings fetch failed:", bgError); + void UnifiedLogger.warn( + "units", + "Background settings fetch failed:", + bgError + ); } } } @@ -249,7 +259,11 @@ export const UnitProvider: React.FC = ({ } catch (err: any) { // Only log non-auth errors if (err.response?.status !== 401) { - console.warn("Failed to load unit preferences, using default:", err); + void UnifiedLogger.warn( + "units", + "Failed to load unit preferences, using default:", + err + ); } if (isMounted) { setUnitSystem("metric"); @@ -291,7 +305,8 @@ export const UnitProvider: React.FC = ({ try { settings = JSON.parse(cachedSettings); } catch { - console.warn( + void UnifiedLogger.warn( + "units", "Corrupted cached user settings during update; re-initializing." ); } @@ -308,7 +323,7 @@ export const UnitProvider: React.FC = ({ ); } } catch (err) { - console.error("Failed to update unit system:", err); + await UnifiedLogger.error("units", "Failed to update unit system:", err); setError("Failed to save unit preference"); setUnitSystem(previousSystem); // Revert on error } finally { @@ -332,7 +347,7 @@ export const UnitProvider: React.FC = ({ case "volume": return unitSystem === "metric" ? "l" : "gal"; case "temperature": - return unitSystem === "metric" ? "c" : "f"; + return unitSystem === "metric" ? "C" : "F"; default: return unitSystem === "metric" ? "kg" : "lb"; } @@ -401,15 +416,18 @@ export const UnitProvider: React.FC = ({ } // Temperature conversions - else if (fromUnit === "f" && toUnit === "c") { + else if (fromUnit === "F" && toUnit === "C") { convertedValue = ((numValue - 32) * 5) / 9; - } else if (fromUnit === "c" && toUnit === "f") { + } else if (fromUnit === "C" && toUnit === "F") { convertedValue = (numValue * 9) / 5 + 32; } // If no conversion found, return original else { - console.warn(`No conversion available from ${fromUnit} to ${toUnit}`); + void UnifiedLogger.warn( + "units", + `No conversion available from ${fromUnit} to ${toUnit}` + ); return { value: numValue, unit: fromUnit }; } @@ -610,8 +628,8 @@ export const UnitProvider: React.FC = ({ case "temperature": return unitSystem === "metric" - ? [{ value: "c", label: "°C", description: "Celsius" }] - : [{ value: "f", label: "°F", description: "Fahrenheit" }]; + ? [{ value: "C", label: "°C", description: "Celsius" }] + : [{ value: "F", label: "°F", description: "Fahrenheit" }]; default: return []; diff --git a/src/hooks/offlineV2/useUserData.ts b/src/hooks/offlineV2/useUserData.ts index 4ea5a298..a04ad610 100644 --- a/src/hooks/offlineV2/useUserData.ts +++ b/src/hooks/offlineV2/useUserData.ts @@ -10,7 +10,7 @@ import { UserCacheService } from "@services/offlineV2/UserCacheService"; import { UseUserDataReturn, SyncResult, Recipe, BrewSession } from "@src/types"; import { useAuth } from "@contexts/AuthContext"; import { useUnits } from "@contexts/UnitContext"; -import UnifiedLogger from "@services/logger/UnifiedLogger"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; /** * Hook for managing recipes with offline capabilities @@ -59,7 +59,11 @@ export function useRecipes(): UseUserDataReturn { const pending = await UserCacheService.getPendingOperationsCount(); setPendingCount(pending); } catch (err) { - console.error("Error loading recipes:", err); + await UnifiedLogger.error( + "useRecipes.loadData", + "Error loading recipes:", + err + ); setError(err instanceof Error ? err.message : "Failed to load recipes"); } finally { setIsLoading(false); @@ -215,7 +219,11 @@ export function useRecipes(): UseUserDataReturn { const pending = await UserCacheService.getPendingOperationsCount(); setPendingCount(pending); } catch (cacheError) { - console.error("Failed to load offline recipe cache:", cacheError); + await UnifiedLogger.error( + "useRecipes.refresh", + "Failed to load offline recipe cache:", + cacheError + ); // Only set error if we can't even load offline cache setError( error instanceof Error ? error.message : "Failed to refresh recipes" @@ -332,7 +340,11 @@ export function useBrewSessions(): UseUserDataReturn { const pending = await UserCacheService.getPendingOperationsCount(); setPendingCount(pending); } catch (err) { - console.error("Error loading brew sessions:", err); + await UnifiedLogger.error( + "useBrewSessions.loadData", + "Error loading brew sessions:", + err + ); setError( err instanceof Error ? err.message : "Failed to load brew sessions" ); @@ -479,7 +491,11 @@ export function useBrewSessions(): UseUserDataReturn { setPendingCount(pending); setLastSync(Date.now()); } catch (error) { - console.error("Brew sessions refresh failed:", error); + await UnifiedLogger.error( + "useBrewSessions.refresh", + "Brew sessions refresh failed:", + error + ); // Don't set error state for refresh failures - preserve offline cache // Try to load existing offline data to ensure offline-created sessions are available @@ -501,7 +517,11 @@ export function useBrewSessions(): UseUserDataReturn { const pending = await UserCacheService.getPendingOperationsCount(); setPendingCount(pending); } catch (cacheError) { - console.error("Failed to load offline brew session cache:", cacheError); + await UnifiedLogger.error( + "useBrewSessions.refresh", + "Failed to load offline brew session cache:", + cacheError + ); // Only set error if we can't even load offline cache setError( error instanceof Error ? error.message : "Failed to refresh sessions" diff --git a/src/hooks/useRecipeMetrics.ts b/src/hooks/useRecipeMetrics.ts index d1b06510..b6101878 100644 --- a/src/hooks/useRecipeMetrics.ts +++ b/src/hooks/useRecipeMetrics.ts @@ -1,16 +1,14 @@ import { useQuery } from "@tanstack/react-query"; -import ApiService from "@services/api/apiService"; import { RecipeFormData, RecipeMetrics } from "@src/types"; import { useDebounce } from "./useDebounce"; -import { useNetwork } from "@contexts/NetworkContext"; import { OfflineMetricsCalculator } from "@services/brewing/OfflineMetricsCalculator"; /** - * Hook for calculating recipe metrics with offline support + * Hook for calculating recipe metrics with offline-first approach * - * Automatically recalculates metrics when recipe data changes with offline fallback. - * Tries API calculation when online, provides reasonable fallback metrics when offline. - * Includes proper debouncing to prevent excessive calculations and network calls. + * Automatically recalculates metrics when recipe data changes using local calculations. + * Always calculates locally for instant results without network dependency. + * Includes proper debouncing to prevent excessive calculations during rapid changes. * * @param recipeData - Current recipe form data * @param enabled - Whether to enable the query (default: true when ingredients exist) @@ -20,7 +18,6 @@ export function useRecipeMetrics( recipeData: RecipeFormData, enabled?: boolean ) { - const { isConnected } = useNetwork(); const FALLBACK_METRICS: RecipeMetrics = { og: 1.05, fg: 1.012, @@ -42,8 +39,7 @@ export function useRecipeMetrics( return useQuery>({ queryKey: [ "recipeMetrics", - "offline-aware", - isConnected ? "online" : "offline", + "offline-first", // Include relevant recipe parameters in query key for proper caching debouncedRecipeData.batch_size, debouncedRecipeData.batch_size_unit, @@ -78,8 +74,7 @@ export function useRecipeMetrics( ), ], queryFn: async (): Promise => { - // Basic validation for recipe data (V2 system) - // Removed validationParams - not used in V2 system + // Basic validation for recipe data if ( !debouncedRecipeData.batch_size || debouncedRecipeData.batch_size <= 0 || @@ -88,52 +83,13 @@ export function useRecipeMetrics( return FALLBACK_METRICS; } - // Try API calculation when online - if (isConnected) { - try { - const response = await ApiService.recipes.calculateMetricsPreview({ - batch_size: debouncedRecipeData.batch_size, - batch_size_unit: debouncedRecipeData.batch_size_unit, - efficiency: debouncedRecipeData.efficiency, - boil_time: debouncedRecipeData.boil_time, - ingredients: debouncedRecipeData.ingredients, - mash_temperature: debouncedRecipeData.mash_temperature, - mash_temp_unit: debouncedRecipeData.mash_temp_unit, - }); - - return response.data; - } catch (error) { - // Fallback to offline calculation on API failure - console.warn( - "API metrics calculation failed, using offline calculation:", - error - ); - - // Try offline calculation instead of fallback values - try { - const validation = - OfflineMetricsCalculator.validateRecipeData(debouncedRecipeData); - if (validation.isValid) { - const calculatedMetrics = - OfflineMetricsCalculator.calculateMetrics(debouncedRecipeData); - return calculatedMetrics; - } - } catch (offlineError) { - console.error("Offline calculation also failed:", offlineError); - } - - // Only use fallback if everything fails - return FALLBACK_METRICS; - } - } - - // Calculate metrics offline using brewing formulas + // Always calculate metrics locally using brewing formulas (offline-first) try { const validation = OfflineMetricsCalculator.validateRecipeData(debouncedRecipeData); if (!validation.isValid) { console.warn( - "Invalid recipe data for offline calculation:", + "Invalid recipe data for metrics calculation:", validation.errors ); return FALLBACK_METRICS; @@ -143,30 +99,22 @@ export function useRecipeMetrics( OfflineMetricsCalculator.calculateMetrics(debouncedRecipeData); return calculatedMetrics; } catch (error) { - console.error("Offline metrics calculation failed:", error); + console.error("Metrics calculation failed:", error); return FALLBACK_METRICS; } }, enabled: shouldEnable, - // Cache configuration for offline support - staleTime: isConnected ? 30000 : Infinity, // 30s online, never stale offline + // Cache configuration - calculations are deterministic and fast + staleTime: Infinity, // Never stale - recalculate only when inputs change gcTime: 300000, // 5 minutes - keep in cache for quick access - refetchOnMount: isConnected ? "always" : false, - refetchOnReconnect: true, + refetchOnMount: false, // No need to refetch, calculations are deterministic + refetchOnReconnect: false, // No network dependency refetchOnWindowFocus: false, - // Retry configuration - retry: (failureCount, error: any) => { - // Don't retry on validation errors (400) - if (error?.response?.status === 400) { - return false; - } - // Only retry network errors when online - return isConnected && failureCount < 2; - }, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 5000), + // Retry configuration - only retry on unexpected errors + retry: false, // Local calculations don't need retries // Data transformation select: (data): Partial => { diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index de9b76d5..1095dbee 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -21,6 +21,7 @@ import * as Notifications from "expo-notifications"; import * as Haptics from "expo-haptics"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; export interface HopAlert { time: number; @@ -102,7 +103,10 @@ export class NotificationService { } if (finalStatus !== "granted") { - console.warn("Notification permissions not granted"); + UnifiedLogger.warn( + "notifications", + "Notification permissions not granted" + ); return false; } @@ -119,7 +123,11 @@ export class NotificationService { this.isInitialized = true; return true; } catch (error) { - console.error("Failed to initialize notifications:", error); + UnifiedLogger.error( + "notifications", + "Failed to initialize notifications:", + error + ); return false; } } @@ -158,7 +166,11 @@ export class NotificationService { this.notificationIdentifiers.push(identifier); return identifier; } catch (error) { - console.error("Failed to schedule hop alert:", error); + UnifiedLogger.error( + "notifications", + "Failed to schedule hop alert:", + error + ); return null; } } @@ -187,7 +199,7 @@ export class NotificationService { // Validate notification time - must be at least 1 second in the future if (timeInSeconds < 1) { // if (__DEV__) { - // console.warn( + // UnifiedLogger.warn("notifications", // `⚠️ Invalid notification time: ${timeInSeconds}s - must be >= 1s` // ); // } @@ -219,7 +231,11 @@ export class NotificationService { return identifier; } catch (error) { - console.error("Failed to schedule timer alert:", error); + UnifiedLogger.error( + "notifications", + "Failed to schedule timer alert:", + error + ); return null; } } @@ -263,7 +279,7 @@ export class NotificationService { { milestone: milestone.time } ); } else if (__DEV__) { - // console.warn( + // UnifiedLogger.warn("notifications", // `⚠️ Skipping milestone ${milestone.time / 60}min - notifyTime too small (${notifyTime}s)` // ); } @@ -304,7 +320,11 @@ export class NotificationService { await Notifications.cancelAllScheduledNotificationsAsync(); this.notificationIdentifiers = []; } catch (error) { - console.error("Failed to cancel notifications:", error); + UnifiedLogger.error( + "notifications", + "Failed to cancel notifications:", + error + ); } } @@ -318,7 +338,11 @@ export class NotificationService { (id: string) => id !== identifier ); } catch (error) { - console.error("Failed to cancel notification:", error); + UnifiedLogger.error( + "notifications", + "Failed to cancel notification:", + error + ); } } @@ -341,7 +365,11 @@ export class NotificationService { break; } } catch (error) { - console.error("Failed to trigger haptic feedback:", error); + UnifiedLogger.error( + "notifications", + "Failed to trigger haptic feedback:", + error + ); } } @@ -374,7 +402,11 @@ export class NotificationService { // Also trigger haptic feedback await this.triggerHapticFeedback("medium"); } catch (error) { - console.error("Failed to send immediate notification:", error); + UnifiedLogger.error( + "notifications", + "Failed to send immediate notification:", + error + ); } } @@ -386,7 +418,11 @@ export class NotificationService { const { status } = await Notifications.getPermissionsAsync(); return status === "granted"; } catch (error) { - console.error("Failed to check notification permissions:", error); + UnifiedLogger.error( + "notifications", + "Failed to check notification permissions:", + error + ); return false; } } @@ -400,7 +436,11 @@ export class NotificationService { try { return await Notifications.getAllScheduledNotificationsAsync(); } catch (error) { - console.error("Failed to get scheduled notifications:", error); + UnifiedLogger.error( + "notifications", + "Failed to get scheduled notifications:", + error + ); return []; } } @@ -482,7 +522,8 @@ export class NotificationService { } } else if (__DEV__) { // Debug logging for skipped hop alerts - console.warn( + UnifiedLogger.warn( + "notifications", `⚠️ Skipping hop alert for "${hop.name}" at ${hop.time}min - alertTime too small (${alertTime}s)` ); } diff --git a/src/services/api/apiService.ts b/src/services/api/apiService.ts index 0691d09a..3b1e400f 100644 --- a/src/services/api/apiService.ts +++ b/src/services/api/apiService.ts @@ -28,6 +28,7 @@ import axios, { } from "axios"; import * as SecureStore from "expo-secure-store"; import NetInfo from "@react-native-community/netinfo"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { STORAGE_KEYS, ENDPOINTS } from "@services/config"; import { setupIDInterceptors } from "./idInterceptor"; @@ -94,6 +95,10 @@ import { CreateDryHopFromRecipeRequest, UpdateDryHopRequest, + // BeerXML types + BeerXMLConvertRecipeRequest, + BeerXMLConvertRecipeResponse, + // Common types ID, IngredientType, @@ -369,7 +374,7 @@ async function logApiError(normalizedError: NormalizedApiError, err: unknown) { base.request?.url?.includes("/recipes/") ) ) { - console.error("API Error:", base); + UnifiedLogger.error("api", "API Error:", base); } } @@ -446,7 +451,7 @@ class TokenManager { try { return await SecureStore.getItemAsync(STORAGE_KEYS.ACCESS_TOKEN); } catch (error) { - console.error("Error getting token:", error); + UnifiedLogger.error("api", "Error getting token:", error); return null; } } @@ -459,7 +464,7 @@ class TokenManager { try { await SecureStore.setItemAsync(STORAGE_KEYS.ACCESS_TOKEN, token); } catch (error) { - console.error("Error setting token:", error); + UnifiedLogger.error("api", "Error setting token:", error); throw error; } } @@ -471,7 +476,7 @@ class TokenManager { try { await SecureStore.deleteItemAsync(STORAGE_KEYS.ACCESS_TOKEN); } catch (error) { - console.error("Error removing token:", error); + UnifiedLogger.error("api", "Error removing token:", error); } } } @@ -504,7 +509,11 @@ class DeveloperModeManager { } return false; } catch (error) { - console.warn("Failed to check simulated offline mode:", error); + UnifiedLogger.warn( + "api", + "Failed to check simulated offline mode:", + error + ); return false; } } @@ -1057,6 +1066,11 @@ const ApiService = { ingredients: any[]; }): Promise> => api.post(ENDPOINTS.BEERXML.CREATE_INGREDIENTS, data), + + convertRecipe: ( + data: BeerXMLConvertRecipeRequest + ): Promise> => + api.post(ENDPOINTS.BEERXML.CONVERT_RECIPE, data), }, // AI endpoints diff --git a/src/services/api/queryClient.ts b/src/services/api/queryClient.ts index 092105d3..07e02a37 100644 --- a/src/services/api/queryClient.ts +++ b/src/services/api/queryClient.ts @@ -105,6 +105,7 @@ export const QUERY_KEYS = { // Recipes RECIPES: ["recipes"] as const, + USER_RECIPES: ["userRecipes"] as const, RECIPE: (id: string) => ["recipes", id] as const, RECIPE_METRICS: (id: string) => ["recipes", id, "metrics"] as const, RECIPE_VERSIONS: (id: string) => ["recipes", id, "versions"] as const, diff --git a/src/services/beerxml/BeerXMLService.ts b/src/services/beerxml/BeerXMLService.ts index f5aaeaa3..746a1a89 100644 --- a/src/services/beerxml/BeerXMLService.ts +++ b/src/services/beerxml/BeerXMLService.ts @@ -1,6 +1,7 @@ import ApiService from "@services/api/apiService"; import { BeerXMLService as StorageBeerXMLService } from "@services/storageService"; -import { Recipe, RecipeIngredient } from "@src/types"; +import { Recipe, RecipeIngredient, UnitSystem } from "@src/types"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; // Service-specific interfaces for BeerXML operations @@ -9,7 +10,7 @@ interface FileValidationResult { errors: string[]; } -interface BeerXMLRecipe extends Partial { +export interface BeerXMLRecipe extends Partial { ingredients: RecipeIngredient[]; metadata?: BeerXMLMetadata; } @@ -125,7 +126,7 @@ class BeerXMLService { saveMethod: saveResult.method, }; } catch (error) { - console.error("🍺 BeerXML Export - Error:", error); + void UnifiedLogger.error("beerxml", "🍺 BeerXML Export - Error:", error); return { success: false, error: error instanceof Error ? error.message : "Export failed", @@ -155,7 +156,7 @@ class BeerXMLService { filename: result.filename, }; } catch (error) { - console.error("🍺 BeerXML Import - Error:", error); + void UnifiedLogger.error("beerxml", "🍺 BeerXML Import - Error:", error); return { success: false, error: error instanceof Error ? error.message : "Import failed", @@ -181,7 +182,10 @@ class BeerXMLService { ? response.data.recipes : []; if (!Array.isArray(recipes) || recipes.length === 0) { - console.warn("🍺 BeerXML Parse - No recipes found in response"); + void UnifiedLogger.warn( + "beerxml", + "🍺 BeerXML Parse - No recipes found in response" + ); } const transformedRecipes = recipes.map((recipeData: any) => ({ ...recipeData.recipe, @@ -191,7 +195,7 @@ class BeerXMLService { return transformedRecipes; } catch (error) { - console.error("🍺 BeerXML Parse - Error:", error); + void UnifiedLogger.error("beerxml", "🍺 BeerXML Parse - Error:", error); throw new Error( `Failed to parse BeerXML: ${error instanceof Error ? error.message : "Unknown error"}` ); @@ -245,7 +249,7 @@ class BeerXMLService { return matchingResults as IngredientMatchingResult[]; } catch (error) { - console.error("🍺 BeerXML Match - Error:", error); + void UnifiedLogger.error("beerxml", "🍺 BeerXML Match - Error:", error); throw new Error( `Failed to match ingredients: ${error instanceof Error ? error.message : "Unknown error"}` ); @@ -269,7 +273,7 @@ class BeerXMLService { return createdIngredients; } catch (error) { - console.error("🍺 BeerXML Create - Error:", error); + void UnifiedLogger.error("beerxml", "🍺 BeerXML Create - Error:", error); throw new Error( `Failed to create ingredients: ${error instanceof Error ? error.message : "Unknown error"}` ); @@ -396,6 +400,107 @@ class BeerXMLService { highConfidence, }; } + + /** + * Detect if recipe uses different unit system than user preference + * Returns the detected recipe unit system + */ + detectRecipeUnitSystem(recipe: BeerXMLRecipe): UnitSystem | "mixed" { + let metricCount = 0; + let imperialCount = 0; + + // Check batch size unit (trim to handle whitespace) + const batchUnit = recipe.batch_size_unit?.toLowerCase().trim() || ""; + if (["l", "liter", "liters", "litre", "litres", "ml"].includes(batchUnit)) { + metricCount++; + } else if (["gal", "gallon", "gallons"].includes(batchUnit)) { + imperialCount++; + } + + // Check ingredient units (trim to handle whitespace) + recipe.ingredients?.forEach(ingredient => { + const unit = ingredient.unit?.toLowerCase().trim() || ""; + if ( + ["g", "kg", "gram", "grams", "kilogram", "kilograms"].includes(unit) + ) { + metricCount++; + } else if ( + ["oz", "lb", "lbs", "ounce", "ounces", "pound", "pounds"].includes(unit) + ) { + imperialCount++; + } + }); + + // Determine predominant system + if (metricCount > 0 && imperialCount === 0) { + return "metric"; + } + if (imperialCount > 0 && metricCount === 0) { + return "imperial"; + } + if (metricCount > imperialCount) { + return "metric"; + } + if (imperialCount > metricCount) { + return "imperial"; + } + return "mixed"; // Equal or unknown + } + + /** + * Convert recipe units to target unit system with brewing-friendly normalization + * + * BeerXML files are always in metric per spec. This function: + * 1. Converts from metric to target system (if imperial) + * 2. Normalizes values to brewing-friendly increments (e.g., 1oz -> 30g) + * + * IMPORTANT: Normalization is applied even when importing as metric, because + * the recipe may have originally been imperial and converted to metric for + * BeerXML export, resulting in odd values like 28.3g (1oz). The backend's + * conversion endpoint rounds these to practical brewing increments (30g) unless normalization argument is explicitly passed as false. + */ + async convertRecipeUnits( + recipe: BeerXMLRecipe, + targetUnitSystem: UnitSystem, + normalize: boolean = true + ): Promise<{ recipe: BeerXMLRecipe; warnings?: string[] }> { + try { + // Call dedicated BeerXML conversion endpoint + const response = await ApiService.beerxml.convertRecipe({ + recipe, + target_system: targetUnitSystem, + normalize, + }); + + const convertedRecipe = response.data.recipe; + const warnings = response.data.warnings ?? []; + + if (!convertedRecipe) { + void UnifiedLogger.warn( + "beerxml", + "No converted recipe returned, using original" + ); + return { recipe, warnings: [] }; + } + + return { + recipe: convertedRecipe as BeerXMLRecipe, + warnings, + }; + } catch (error) { + void UnifiedLogger.error( + "beerxml", + "Error converting recipe units:", + error + ); + // Return original recipe if conversion fails - don't block import + void UnifiedLogger.warn( + "beerxml", + "Unit conversion failed, continuing with original units" + ); + return { recipe, warnings: [] }; + } + } } // Create and export singleton instance diff --git a/src/services/brewing/OfflineMetricsCalculator.ts b/src/services/brewing/OfflineMetricsCalculator.ts index f72d886f..1f18b212 100644 --- a/src/services/brewing/OfflineMetricsCalculator.ts +++ b/src/services/brewing/OfflineMetricsCalculator.ts @@ -5,13 +5,21 @@ * Implements standard brewing formulas for OG, FG, ABV, IBU, and SRM. */ -import { RecipeMetrics, RecipeFormData, RecipeIngredient } from "@src/types"; +import { isDryHopIngredient } from "@/src/utils/recipeUtils"; +import { + RecipeMetrics, + RecipeFormData, + RecipeMetricsInput, + RecipeIngredient, +} from "@src/types"; export class OfflineMetricsCalculator { /** * Calculate recipe metrics offline using standard brewing formulas */ - static calculateMetrics(recipeData: RecipeFormData): RecipeMetrics { + static calculateMetrics( + recipeData: RecipeFormData | RecipeMetricsInput + ): RecipeMetrics { // Validate first const { isValid } = this.validateRecipeData(recipeData); if (!isValid) { @@ -68,7 +76,8 @@ export class OfflineMetricsCalculator { for (const fermentable of fermentables) { // Get potential (extract potential) - default to 35 if not specified - const potential = fermentable.potential ?? 35; + const potential = + "potential" in fermentable ? (fermentable.potential ?? 35) : 35; // Convert amount to pounds based on unit const amountLbs = this.convertToPounds( fermentable.amount ?? 0, @@ -98,8 +107,10 @@ export class OfflineMetricsCalculator { let attenuationCount = 0; for (const yeast of yeasts) { - if (yeast.attenuation !== undefined && yeast.attenuation >= 0) { - totalAttenuation += yeast.attenuation; + const attenuation = + "attenuation" in yeast ? yeast.attenuation : undefined; + if (attenuation !== undefined && attenuation >= 0) { + totalAttenuation += attenuation; attenuationCount++; } } @@ -145,16 +156,11 @@ export class OfflineMetricsCalculator { ? (hop.time ?? 0) // only force 0 when time is missing : (hop.time ?? boilTime); // default to boil time for boil additions - // Skip non-bittering additions - if ( - use === "dry-hop" || - use === "dry hop" || - use === "dryhop" || - hopTime <= 0 - ) { + // Skip non-bittering additions (dry hops or hops with no boil time) + if (isDryHopIngredient(hop) || hopTime <= 0) { continue; } - const alphaAcid = hop.alpha_acid ?? 5; // Default 5% AA (allow 0) + const alphaAcid = "alpha_acid" in hop ? (hop.alpha_acid ?? 5) : 5; // Default 5% AA (allow 0) // Convert hop amount to ounces for IBU calculation const amountOz = this.convertToOunces(hop.amount ?? 0, hop.unit); @@ -196,7 +202,7 @@ export class OfflineMetricsCalculator { let totalMCU = 0; // Malt Color Units for (const grain of grains) { - const colorLovibond = grain.color ?? 2; // Default to 2L if not specified + const colorLovibond = "color" in grain ? (grain.color ?? 2) : 2; // Default to 2L if not specified // Convert grain amount to pounds for SRM calculation const amountLbs = this.convertToPounds(grain.amount ?? 0, grain.unit); @@ -213,7 +219,7 @@ export class OfflineMetricsCalculator { /** * Validate recipe data for calculations */ - static validateRecipeData(recipeData: RecipeFormData): { + static validateRecipeData(recipeData: RecipeFormData | RecipeMetricsInput): { isValid: boolean; errors: string[]; } { @@ -254,17 +260,19 @@ export class OfflineMetricsCalculator { if (ing.amount !== undefined && ing.amount < 0) { errors.push(`${ing.name || "Ingredient"} amount must be >= 0`); } + const alphaAcid = "alpha_acid" in ing ? ing.alpha_acid : undefined; if ( ing.type === "hop" && - ing.alpha_acid !== undefined && - (ing.alpha_acid < 0 || ing.alpha_acid > 30) + alphaAcid !== undefined && + (alphaAcid < 0 || alphaAcid > 30) ) { errors.push("Hop alpha acid must be between 0 and 30"); } + const attenuation = "attenuation" in ing ? ing.attenuation : undefined; if ( ing.type === "yeast" && - ing.attenuation !== undefined && - (ing.attenuation < 0 || ing.attenuation > 100) + attenuation !== undefined && + (attenuation < 0 || attenuation > 100) ) { errors.push("Yeast attenuation must be between 0 and 100"); } diff --git a/src/services/calculators/HydrometerCorrectionCalculator.ts b/src/services/calculators/HydrometerCorrectionCalculator.ts index d939e884..36031362 100644 --- a/src/services/calculators/HydrometerCorrectionCalculator.ts +++ b/src/services/calculators/HydrometerCorrectionCalculator.ts @@ -4,6 +4,7 @@ * Most hydrometers are calibrated at 60°F (15.5°C) */ +import { TemperatureUnit } from "@/src/types/common"; import { UnitConverter } from "./UnitConverter"; export interface CorrectionResult { @@ -28,7 +29,7 @@ export class HydrometerCorrectionCalculator { measuredGravity: number, wortTemp: number, calibrationTemp: number, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): CorrectionResult { // Validate inputs this.validateInputs(measuredGravity, wortTemp, calibrationTemp, tempUnit); @@ -37,12 +38,12 @@ export class HydrometerCorrectionCalculator { let wortTempF = wortTemp; let calibrationTempF = calibrationTemp; - if (tempUnit === "c") { - wortTempF = UnitConverter.convertTemperature(wortTemp, "c", "f"); + if (tempUnit === "C") { + wortTempF = UnitConverter.convertTemperature(wortTemp, "C", "F"); calibrationTempF = UnitConverter.convertTemperature( calibrationTemp, - "c", - "f" + "C", + "F" ); } @@ -83,10 +84,10 @@ export class HydrometerCorrectionCalculator { public static calculateCorrectionDefault( measuredGravity: number, wortTemp: number, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): CorrectionResult { const defaultCalibrationTemp = - tempUnit === "f" + tempUnit === "F" ? this.DEFAULT_CALIBRATION_TEMP_F : this.DEFAULT_CALIBRATION_TEMP_C; @@ -107,11 +108,11 @@ export class HydrometerCorrectionCalculator { measuredGravity: number, targetGravity: number, calibrationTemp: number, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): number { // Set initial bounds based on physical temperature limits let low: number, high: number; - if (tempUnit === "f") { + if (tempUnit === "F") { low = 32; // Freezing point of water in Fahrenheit high = 212; // Boiling point of water in Fahrenheit } else { @@ -153,14 +154,21 @@ export class HydrometerCorrectionCalculator { /** * Get correction table for common temperatures + * + * @param measuredGravity - The specific gravity reading from the hydrometer + * @param calibrationTemp - The calibration temperature of the hydrometer (default: 60°F) + * Note: Default of 60 is meaningful only when tempUnit="F". + * If using tempUnit="C", explicitly pass 15.5 for standard calibration. + * @param tempUnit - Temperature unit ("F" or "C", default: "F") + * @returns Array of temperature-correction pairs for display */ public static getCorrectionTable( measuredGravity: number, calibrationTemp: number = 60, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): { temp: number; correctedGravity: number; correction: number }[] { const temps = - tempUnit === "f" + tempUnit === "F" ? [50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100, 110, 120] : [10, 13, 15.5, 18, 21, 24, 27, 29, 32, 35, 38, 43, 49]; @@ -186,7 +194,7 @@ export class HydrometerCorrectionCalculator { public static isCorrectionSignificant( wortTemp: number, calibrationTemp: number, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): boolean { // Calculate correction for a reference gravity of 1.050 const result = this.calculateCorrection( @@ -205,7 +213,7 @@ export class HydrometerCorrectionCalculator { measuredGravity: number, wortTemp: number, calibrationTemp: number, - tempUnit: "f" | "c" + tempUnit: TemperatureUnit ): void { // Validate specific gravity if (measuredGravity < 0.99 || measuredGravity > 1.2) { @@ -213,7 +221,7 @@ export class HydrometerCorrectionCalculator { } // Validate temperatures based on unit - if (tempUnit === "f") { + if (tempUnit === "F") { if (wortTemp < 32 || wortTemp > 212) { throw new Error("Wort temperature must be between 32°F and 212°F"); } @@ -239,13 +247,13 @@ export class HydrometerCorrectionCalculator { */ public static getCalibrationTemperatures(): Record< string, - { f: number; c: number } + { F: number; C: number } > { return { - Standard: { f: 60, c: 15.5 }, - European: { f: 68, c: 20 }, - Precision: { f: 68, c: 20 }, - Digital: { f: 68, c: 20 }, + Standard: { F: 60, C: 15.5 }, + European: { F: 68, C: 20 }, + Precision: { F: 68, C: 20 }, + Digital: { F: 68, C: 20 }, }; } } diff --git a/src/services/calculators/PrimingSugarCalculator.ts b/src/services/calculators/PrimingSugarCalculator.ts index c8498b9e..64c883b0 100644 --- a/src/services/calculators/PrimingSugarCalculator.ts +++ b/src/services/calculators/PrimingSugarCalculator.ts @@ -3,6 +3,7 @@ * Calculates amount of priming sugar needed for carbonation */ +import { TemperatureUnit } from "@/src/types/common"; import { UnitConverter } from "./UnitConverter"; export interface PrimingSugarResult { @@ -94,11 +95,11 @@ export class PrimingSugarCalculator { */ public static estimateResidualCO2( fermentationTemp: number, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): number { let tempF = fermentationTemp; - if (tempUnit === "c") { - tempF = UnitConverter.convertTemperature(fermentationTemp, "c", "f"); + if (tempUnit === "C") { + tempF = UnitConverter.convertTemperature(fermentationTemp, "C", "F"); } // Find closest temperature in lookup table diff --git a/src/services/calculators/StrikeWaterCalculator.ts b/src/services/calculators/StrikeWaterCalculator.ts index 5a761672..8fe806fc 100644 --- a/src/services/calculators/StrikeWaterCalculator.ts +++ b/src/services/calculators/StrikeWaterCalculator.ts @@ -3,6 +3,7 @@ * Calculates the temperature of water needed to achieve target mash temperature */ +import { TemperatureUnit } from "@/src/types/common"; import { UnitConverter } from "./UnitConverter"; export interface StrikeWaterResult { @@ -32,7 +33,7 @@ export class StrikeWaterCalculator { grainTemp: number, targetMashTemp: number, waterToGrainRatio: number = 1.25, - tempUnit: "f" | "c" = "f", + tempUnit: TemperatureUnit = "F", tunWeight: number = 10 // pounds of tun material ): StrikeWaterResult { // Convert grain weight to pounds for calculations @@ -46,12 +47,12 @@ export class StrikeWaterCalculator { let grainTempF = grainTemp; let targetMashTempF = targetMashTemp; - if (tempUnit === "c") { - grainTempF = UnitConverter.convertTemperature(grainTemp, "c", "f"); + if (tempUnit === "C") { + grainTempF = UnitConverter.convertTemperature(grainTemp, "C", "F"); targetMashTempF = UnitConverter.convertTemperature( targetMashTemp, - "c", - "f" + "C", + "F" ); } @@ -74,8 +75,8 @@ export class StrikeWaterCalculator { // Convert results back to requested unit let strikeTemp = strikeWaterTempF; - if (tempUnit === "c") { - strikeTemp = UnitConverter.convertTemperature(strikeWaterTempF, "f", "c"); + if (tempUnit === "C") { + strikeTemp = UnitConverter.convertTemperature(strikeWaterTempF, "F", "C"); } return { @@ -93,24 +94,24 @@ export class StrikeWaterCalculator { targetMashTemp: number, currentMashVolume: number, // quarts infusionWaterTemp: number, - tempUnit: "f" | "c" = "f" + tempUnit: TemperatureUnit = "F" ): InfusionResult { // Convert temperatures to Fahrenheit for calculations let currentTempF = currentMashTemp; let targetTempF = targetMashTemp; let infusionTempF = infusionWaterTemp; - if (tempUnit === "c") { + if (tempUnit === "C") { currentTempF = UnitConverter.convertTemperature( currentMashTemp, - "c", - "f" + "C", + "F" ); - targetTempF = UnitConverter.convertTemperature(targetMashTemp, "c", "f"); + targetTempF = UnitConverter.convertTemperature(targetMashTemp, "C", "F"); infusionTempF = UnitConverter.convertTemperature( infusionWaterTemp, - "c", - "f" + "C", + "F" ); } @@ -131,9 +132,9 @@ export class StrikeWaterCalculator { // Convert temperatures back to requested unit let targetTemp = targetTempF; let infusionTemp = infusionTempF; - if (tempUnit === "c") { - targetTemp = UnitConverter.convertTemperature(targetTempF, "f", "c"); - infusionTemp = UnitConverter.convertTemperature(infusionTempF, "f", "c"); + if (tempUnit === "C") { + targetTemp = UnitConverter.convertTemperature(targetTempF, "F", "C"); + infusionTemp = UnitConverter.convertTemperature(infusionTempF, "F", "C"); } return { @@ -178,7 +179,7 @@ export class StrikeWaterCalculator { grainTemp: number, targetMashTemp: number, waterToGrainRatio: number, - tempUnit: "f" | "c" + tempUnit: TemperatureUnit ): void { if (grainWeight <= 0) { throw new Error("Grain weight must be greater than 0"); @@ -189,7 +190,7 @@ export class StrikeWaterCalculator { } // Temperature validation based on unit - if (tempUnit === "f") { + if (tempUnit === "F") { if (grainTemp < 32 || grainTemp > 120) { throw new Error("Grain temperature must be between 32°F and 120°F"); } diff --git a/src/services/calculators/UnitConverter.ts b/src/services/calculators/UnitConverter.ts index 57966588..25429360 100644 --- a/src/services/calculators/UnitConverter.ts +++ b/src/services/calculators/UnitConverter.ts @@ -47,51 +47,48 @@ export class UnitConverter { tbsp: 0.0147868, }; + /** + * Normalize temperature unit to canonical form ("C" or "F") + * Only supports Celsius and Fahrenheit (Kelvin is not used in brewing) + * @private + */ + private static normalizeTemperatureUnit(unit: string): "C" | "F" { + const normalized = unit.toLowerCase(); + switch (normalized) { + case "f": + case "fahrenheit": + return "F"; + case "c": + case "celsius": + return "C"; + default: + throw new Error( + `Unsupported temperature unit: ${unit}. Only Celsius (C) and Fahrenheit (F) are supported for brewing.` + ); + } + } + // Temperature conversion functions public static convertTemperature( value: number, fromUnit: string, toUnit: string ): number { - const from = fromUnit.toLowerCase(); - const to = toUnit.toLowerCase(); + // Normalize units to canonical form + const from = this.normalizeTemperatureUnit(fromUnit); + const to = this.normalizeTemperatureUnit(toUnit); if (from === to) { return value; } - // Convert to Celsius first - let celsius: number; - switch (from) { - case "f": - case "fahrenheit": - celsius = (value - 32) * (5 / 9); - break; - case "c": - case "celsius": - celsius = value; - break; - case "k": - case "kelvin": - celsius = value - 273.15; - break; - default: - throw new Error(`Unknown temperature unit: ${fromUnit}`); - } - - // Convert from Celsius to target unit - switch (to) { - case "f": - case "fahrenheit": - return celsius * (9 / 5) + 32; - case "c": - case "celsius": - return celsius; - case "k": - case "kelvin": - return celsius + 273.15; - default: - throw new Error(`Unknown temperature unit: ${toUnit}`); + // Direct conversion between C and F + if (from === "F") { + // F to C + return (value - 32) * (5 / 9); + } else { + // C to F + return value * (9 / 5) + 32; } } @@ -190,7 +187,7 @@ export class UnitConverter { } public static getTemperatureUnits(): string[] { - return ["f", "c", "k", "fahrenheit", "celsius", "kelvin"]; + return ["F", "C"]; // Only Celsius and Fahrenheit are used in brewing } // Validation methods @@ -203,8 +200,12 @@ export class UnitConverter { } public static isValidTemperatureUnit(unit: string): boolean { - const validUnits = ["f", "c", "k", "fahrenheit", "celsius", "kelvin"]; - return validUnits.includes(unit.toLowerCase()); + try { + this.normalizeTemperatureUnit(unit); + return true; + } catch { + return false; + } } // Formatting methods for display @@ -219,6 +220,7 @@ export class UnitConverter { } public static formatTemperature(temp: number, unit: string): string { - return `${temp.toFixed(1)}°${unit.toUpperCase()}`; + const normalizedUnit = this.normalizeTemperatureUnit(unit); + return `${temp.toFixed(1)}°${normalizedUnit}`; } } diff --git a/src/services/config.ts b/src/services/config.ts index 46fac297..d7792be5 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -166,6 +166,7 @@ export const ENDPOINTS = { PARSE: "/beerxml/parse", MATCH_INGREDIENTS: "/beerxml/match-ingredients", CREATE_INGREDIENTS: "/beerxml/create-ingredients", + CONVERT_RECIPE: "/beerxml/convert-recipe", }, // AI diff --git a/src/services/offlineV2/LegacyMigrationService.ts b/src/services/offlineV2/LegacyMigrationService.ts index e153ec6c..03a21844 100644 --- a/src/services/offlineV2/LegacyMigrationService.ts +++ b/src/services/offlineV2/LegacyMigrationService.ts @@ -7,10 +7,11 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { UserCacheService } from "./UserCacheService"; -import { Recipe } from "@src/types"; +import { Recipe, UnitSystem } from "@src/types"; import { OfflineRecipe } from "@src/types/offline"; import { STORAGE_KEYS } from "@services/config"; import { generateUniqueId } from "@utils/keyUtils"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; export interface MigrationResult { migrated: number; @@ -25,7 +26,7 @@ export class LegacyMigrationService { */ static async migrateLegacyRecipesToV2( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { const result: MigrationResult = { migrated: 0, @@ -35,16 +36,18 @@ export class LegacyMigrationService { }; try { - console.log(`[LegacyMigration] Starting migration for user: ${userId}`); - // Get legacy recipes from old storage const legacyRecipes = await this.getLegacyRecipes(userId); - console.log( - `[LegacyMigration] Found ${legacyRecipes.length} legacy recipes` + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `Found ${legacyRecipes.length} legacy recipes` ); if (legacyRecipes.length === 0) { - console.log(`[LegacyMigration] No legacy recipes to migrate`); + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `No legacy recipes to migrate` + ); return result; } @@ -55,8 +58,9 @@ export class LegacyMigrationService { ); const existingIds = new Set(existingV2Recipes.map(r => r.id)); - console.log( - `[LegacyMigration] Found ${existingV2Recipes.length} existing V2 recipes` + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `Found ${existingV2Recipes.length} existing V2 recipes` ); // Migrate each legacy recipe @@ -64,8 +68,9 @@ export class LegacyMigrationService { try { // Skip if already exists in V2 cache if (existingIds.has(legacyRecipe.id)) { - console.log( - `[LegacyMigration] Skipping duplicate recipe: ${legacyRecipe.id}` + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `Skipping duplicate recipe: ${legacyRecipe.id}` ); result.skipped++; continue; @@ -88,13 +93,15 @@ export class LegacyMigrationService { tempId: legacyRecipe.tempId, }); - console.log( - `[LegacyMigration] Migrated recipe: ${legacyRecipe.name} (${legacyRecipe.id})` + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `Migrated recipe: ${legacyRecipe.name} (${legacyRecipe.id})` ); result.migrated++; } catch (error) { - console.error( - `[LegacyMigration] Failed to migrate recipe ${legacyRecipe.id}:`, + void UnifiedLogger.error( + "LegacyMigration.migrateLegacyRecipesToV2", + `Failed to migrate recipe ${legacyRecipe.id}:`, error ); result.errors++; @@ -104,18 +111,24 @@ export class LegacyMigrationService { } } - console.log(`[LegacyMigration] Migration completed:`, result); + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `Migration completed:`, + result + ); // Clear legacy recipes after successful migration if (result.migrated > 0) { try { await this.clearLegacyRecipes(userId); - console.log( - `[LegacyMigration] Successfully cleared legacy recipes after migration` + void UnifiedLogger.info( + "LegacyMigration.migrateLegacyRecipesToV2", + `Successfully cleared legacy recipes after migration` ); } catch (clearError) { - console.error( - `[LegacyMigration] Failed to clear legacy recipes after migration:`, + void UnifiedLogger.error( + "LegacyMigration.migrateLegacyRecipesToV2", + `Failed to clear legacy recipes after migration:`, clearError ); // Don't fail the entire migration if cleanup fails @@ -124,7 +137,11 @@ export class LegacyMigrationService { return result; } catch (error) { - console.error(`[LegacyMigration] Migration failed:`, error); + void UnifiedLogger.error( + "LegacyMigration.migrateLegacyRecipesToV2", + `Migration failed:`, + error + ); result.errors++; result.errorDetails.push( `Migration failed: ${error instanceof Error ? error.message : "Unknown error"}` @@ -155,7 +172,11 @@ export class LegacyMigrationService { recipe => recipe.user_id === userId && !recipe.isDeleted ); } catch (error) { - console.error(`[LegacyMigration] Failed to load legacy recipes:`, error); + void UnifiedLogger.error( + "LegacyMigration.migrateLegacyRecipesToV2", + `Failed to load legacy recipes:`, + error + ); return []; } } @@ -166,7 +187,7 @@ export class LegacyMigrationService { private static convertLegacyToV2Recipe( legacyRecipe: OfflineRecipe, userId: string, - userUnitSystem: "imperial" | "metric" + userUnitSystem: UnitSystem ): Recipe { // Create base recipe from legacy data const v2Recipe: Recipe = { @@ -193,10 +214,16 @@ export class LegacyMigrationService { parent_recipe_id: legacyRecipe.parent_recipe_id, original_author: legacyRecipe.original_author, // Add missing required fields based on user's unit system - batch_size_unit: userUnitSystem === "metric" ? "l" : "gal", - unit_system: userUnitSystem, - mash_temperature: userUnitSystem === "metric" ? 67 : 152, // 67°C ≈ 152°F - mash_temp_unit: userUnitSystem === "metric" ? "C" : "F", + batch_size_unit: + legacyRecipe.batch_size_unit || + (userUnitSystem === "metric" ? "l" : "gal"), + unit_system: legacyRecipe.unit_system || userUnitSystem, + mash_temperature: + legacyRecipe.mash_temperature || + (userUnitSystem === "metric" ? 67 : 152), // Default mash temp values + mash_temp_unit: + legacyRecipe.mash_temp_unit || + (userUnitSystem === "metric" ? "C" : "F"), notes: "", // Default empty notes }; @@ -225,8 +252,9 @@ export class LegacyMigrationService { */ static async clearLegacyRecipes(userId: string): Promise { try { - console.log( - `[LegacyMigration] Clearing legacy recipes for user: ${userId}` + void UnifiedLogger.info( + "LegacyMigration.clearLegacyRecipes", + `Clearing legacy recipes for user: ${userId}` ); const offlineData = await AsyncStorage.getItem( @@ -255,11 +283,16 @@ export class LegacyMigrationService { JSON.stringify(updatedData) ); - console.log( - `[LegacyMigration] Cleared legacy recipes for user ${userId}` + void UnifiedLogger.info( + "LegacyMigration.clearLegacyRecipes", + `Cleared legacy recipes for user: ${userId}` ); } catch (error) { - console.error(`[LegacyMigration] Failed to clear legacy recipes:`, error); + void UnifiedLogger.error( + "LegacyMigration.clearLegacyRecipes", + `Failed to clear legacy recipes:`, + error + ); throw error; } } diff --git a/src/services/offlineV2/StartupHydrationService.ts b/src/services/offlineV2/StartupHydrationService.ts index 144b485f..83eade59 100644 --- a/src/services/offlineV2/StartupHydrationService.ts +++ b/src/services/offlineV2/StartupHydrationService.ts @@ -7,6 +7,8 @@ import { UserCacheService } from "./UserCacheService"; import { StaticDataService } from "./StaticDataService"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; +import type { UnitSystem } from "@/src/types"; export class StartupHydrationService { private static isHydrating = false; @@ -17,32 +19,45 @@ export class StartupHydrationService { */ static async hydrateOnStartup( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { // Prevent multiple concurrent hydrations if (this.isHydrating || this.hasHydrated) { - console.log( - `[StartupHydrationService] Hydration already in progress or completed` - ); return; } this.isHydrating = true; - console.log( - `[StartupHydrationService] Starting hydration for user: "${userId}"` - ); try { // Hydrate in parallel for better performance - await Promise.allSettled([ + const results = await Promise.allSettled([ this.hydrateUserData(userId, userUnitSystem), this.hydrateStaticData(), ]); - this.hasHydrated = true; - console.log(`[StartupHydrationService] Hydration completed successfully`); + // Only mark as hydrated if both succeeded + const hasFailure = results.some(r => r.status === "rejected"); + if (!hasFailure) { + this.hasHydrated = true; + } else { + void UnifiedLogger.warn( + "offline-hydration", + "[StartupHydrationService] Partial hydration failure - will retry on next startup", + { + results: results.map((r, i) => ({ + type: i === 0 ? "userData" : "staticData", + status: r.status, + reason: r.status === "rejected" ? r.reason : undefined, + })), + } + ); + } } catch (error) { - console.error(`[StartupHydrationService] Hydration failed:`, error); + void UnifiedLogger.error( + "offline-hydration", + "[StartupHydrationService] Hydration failed:", + error + ); // Don't throw - app should still work even if hydration fails } finally { this.isHydrating = false; @@ -54,11 +69,9 @@ export class StartupHydrationService { */ private static async hydrateUserData( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { try { - console.log(`[StartupHydrationService] Hydrating user data...`); - // Check if user already has cached recipes const existingRecipes = await UserCacheService.getRecipes( userId, @@ -66,23 +79,15 @@ export class StartupHydrationService { ); if (existingRecipes.length === 0) { - console.log( - `[StartupHydrationService] No cached recipes found, will hydrate from server` - ); // The UserCacheService.getRecipes() method will automatically hydrate // So we don't need to do anything special here - } else { - console.log( - `[StartupHydrationService] User already has ${existingRecipes.length} cached recipes` - ); } // TODO: Add brew sessions hydration when implemented // await this.hydrateBrewSessions(userId); - - console.log(`[StartupHydrationService] User data hydration completed`); } catch (error) { - console.warn( + void UnifiedLogger.warn( + "offline-hydration", `[StartupHydrationService] User data hydration failed:`, error ); @@ -95,22 +100,15 @@ export class StartupHydrationService { */ private static async hydrateStaticData(): Promise { try { - console.log(`[StartupHydrationService] Hydrating static data...`); - - // Check and update ingredients cache - const ingredientsStats = await StaticDataService.getCacheStats(); - if (!ingredientsStats.ingredients.cached) { - console.log( - `[StartupHydrationService] No cached ingredients found, fetching...` - ); + // Check and update ingredients and beer styles cache + const cacheStats = await StaticDataService.getCacheStats(); + if (!cacheStats.ingredients.cached) { await StaticDataService.getIngredients(); // This will cache automatically } else { - console.log( - `[StartupHydrationService] Ingredients already cached (${ingredientsStats.ingredients.record_count} items)` - ); // Check for updates in background StaticDataService.updateIngredientsCache().catch(error => { - console.warn( + void UnifiedLogger.warn( + "offline-hydration", `[StartupHydrationService] Background ingredients update failed:`, error ); @@ -118,27 +116,21 @@ export class StartupHydrationService { } // Check and update beer styles cache - if (!ingredientsStats.beerStyles.cached) { - console.log( - `[StartupHydrationService] No cached beer styles found, fetching...` - ); + if (!cacheStats.beerStyles.cached) { await StaticDataService.getBeerStyles(); // This will cache automatically } else { - console.log( - `[StartupHydrationService] Beer styles already cached (${ingredientsStats.beerStyles.record_count} items)` - ); // Check for updates in background StaticDataService.updateBeerStylesCache().catch(error => { - console.warn( + void UnifiedLogger.warn( + "offline-hydration", `[StartupHydrationService] Background beer styles update failed:`, error ); }); } - - console.log(`[StartupHydrationService] Static data hydration completed`); } catch (error) { - console.warn( + void UnifiedLogger.warn( + "offline-hydration", `[StartupHydrationService] Static data hydration failed:`, error ); @@ -151,7 +143,6 @@ export class StartupHydrationService { static resetHydrationState(): void { this.isHydrating = false; this.hasHydrated = false; - console.log(`[StartupHydrationService] Hydration state reset`); } /** diff --git a/src/services/offlineV2/StaticDataService.ts b/src/services/offlineV2/StaticDataService.ts index d8daad06..b621fb7b 100644 --- a/src/services/offlineV2/StaticDataService.ts +++ b/src/services/offlineV2/StaticDataService.ts @@ -14,6 +14,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import ApiService from "@services/api/apiService"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; import { CachedStaticData, StaticDataCacheStats, @@ -72,7 +73,11 @@ export class StaticDataService { const freshData = await this.fetchAndCacheIngredients(); return this.applyIngredientFilters(freshData, filters); } catch (error) { - console.error("Error getting ingredients:", error); + await UnifiedLogger.error( + "offline-static", + "Error getting ingredients:", + error + ); throw new OfflineError( "Failed to get ingredients data", "INGREDIENTS_ERROR", @@ -106,7 +111,11 @@ export class StaticDataService { const freshData = await this.fetchAndCacheBeerStyles(); return this.applyBeerStyleFilters(freshData, filters); } catch (error) { - console.error("Error getting beer styles:", error); + await UnifiedLogger.error( + "offline-static", + "Error getting beer styles:", + error + ); throw new OfflineError( "Failed to get beer styles data", "BEER_STYLES_ERROR", @@ -147,7 +156,11 @@ export class StaticDataService { String(cachedBeerStyles) !== String(beerStylesVersion.version), }; } catch (error) { - console.warn("Failed to check for updates:", error); + await UnifiedLogger.warn( + "offline-static", + "Failed to check for updates:", + error + ); // Return false for both if check fails return { ingredients: false, beerStyles: false }; } @@ -160,7 +173,11 @@ export class StaticDataService { try { await this.fetchAndCacheIngredients(); } catch (error) { - console.error("Failed to update ingredients cache:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to update ingredients cache:", + error + ); throw new VersionError( "Failed to update ingredients cache", "ingredients" @@ -175,7 +192,11 @@ export class StaticDataService { try { await this.fetchAndCacheBeerStyles(); } catch (error) { - console.error("Failed to update beer styles cache:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to update beer styles cache:", + error + ); throw new VersionError( "Failed to update beer styles cache", "beer_styles" @@ -192,7 +213,8 @@ export class StaticDataService { try { await this.fetchAndCacheIngredients(); } catch (error) { - console.warn( + await UnifiedLogger.warn( + "offline-static", "Failed to refresh ingredients after authentication:", error ); @@ -216,7 +238,11 @@ export class StaticDataService { AsyncStorage.removeItem(STORAGE_KEYS_V2.BEER_STYLES_VERSION), ]); } catch (error) { - console.error("Failed to clear cache:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to clear cache:", + error + ); throw new OfflineError( "Failed to clear cache", "CACHE_CLEAR_ERROR", @@ -264,7 +290,11 @@ export class StaticDataService { }, }; } catch (error) { - console.error("Failed to get cache stats:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to get cache stats:", + error + ); // Return empty stats on error return { ingredients: { @@ -304,7 +334,8 @@ export class StaticDataService { // In this case, we can't fetch ingredients, so return empty array // but still cache the version info for when user logs in if (authError?.status === 401 || authError?.status === 403) { - console.warn( + void UnifiedLogger.warn( + "offline-static", "Cannot fetch ingredients: user not authenticated. Ingredients will be available after login." ); @@ -356,7 +387,11 @@ export class StaticDataService { return ingredients; } catch (error) { - console.error("Failed to fetch ingredients:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to fetch ingredients:", + error + ); throw new OfflineError( "Failed to fetch ingredients", "FETCH_ERROR", @@ -371,7 +406,8 @@ export class StaticDataService { private static async fetchAndCacheBeerStyles(): Promise { try { if (__DEV__) { - console.log( + void UnifiedLogger.debug( + "offline-static", `[StaticDataService.fetchAndCacheBeerStyles] Starting fetch...` ); } @@ -383,7 +419,8 @@ export class StaticDataService { ]); if (__DEV__) { - console.log( + void UnifiedLogger.debug( + "offline-static", `[StaticDataService.fetchAndCacheBeerStyles] API responses received - version: ${versionResponse?.data?.version}` ); } @@ -395,9 +432,12 @@ export class StaticDataService { if (Array.isArray(beerStylesData)) { // If it's already an array, process normally - console.log( - `[StaticDataService.fetchAndCacheBeerStyles] Processing array format with ${beerStylesData.length} items` - ); + if (__DEV__) { + void UnifiedLogger.debug( + "offline-static", + `[StaticDataService.fetchAndCacheBeerStyles] Processing array format with ${beerStylesData.length} items` + ); + } beerStylesData.forEach((item: any) => { if (Array.isArray(item?.styles)) { // Item is a category with styles array @@ -416,7 +456,8 @@ export class StaticDataService { ) { // If it's an object with numeric keys (like "1", "2", etc.), convert to array if (__DEV__) { - console.log( + void UnifiedLogger.debug( + "offline-static", `[StaticDataService.fetchAndCacheBeerStyles] Processing object format with keys: ${Object.keys(beerStylesData).length}` ); } @@ -432,10 +473,10 @@ export class StaticDataService { } }); } else { - console.error( + await UnifiedLogger.error( + "offline-static", `[StaticDataService.fetchAndCacheBeerStyles] Unexpected data format:`, - typeof beerStylesData, - beerStylesData + { type: typeof beerStylesData, data: beerStylesData } ); throw new OfflineError( "Beer styles data is not in expected format", @@ -459,7 +500,11 @@ export class StaticDataService { return allStyles; } catch (error) { - console.error("Failed to fetch beer styles:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to fetch beer styles:", + error + ); throw new OfflineError( "Failed to fetch beer styles", "FETCH_ERROR", @@ -478,7 +523,11 @@ export class StaticDataService { ); return cached ? JSON.parse(cached) : null; } catch (error) { - console.error("Failed to get cached ingredients:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to get cached ingredients:", + error + ); return null; } } @@ -493,7 +542,11 @@ export class StaticDataService { ); return cached ? JSON.parse(cached) : null; } catch (error) { - console.error("Failed to get cached beer styles:", error); + await UnifiedLogger.error( + "offline-static", + "Failed to get cached beer styles:", + error + ); return null; } } @@ -512,7 +565,11 @@ export class StaticDataService { return await AsyncStorage.getItem(key); } catch (error) { - console.error(`Failed to get cached version for ${dataType}:`, error); + await UnifiedLogger.error( + "offline-static", + `Failed to get cached version for ${dataType}:`, + error + ); return null; } } @@ -559,7 +616,8 @@ export class StaticDataService { dataType === "ingredients" && (error?.status === 401 || error?.status === 403) ) { - console.warn( + void UnifiedLogger.warn( + "offline-static", "Background ingredients update failed: authentication required" ); } else { @@ -569,7 +627,11 @@ export class StaticDataService { } } catch (error) { // Silent fail for background checks - console.warn(`Background version check failed for ${dataType}:`, error); + void UnifiedLogger.warn( + "offline-static", + `Background version check failed for ${dataType}:`, + error + ); } finally { this.versionCheckInProgress[dataType] = false; } diff --git a/src/services/offlineV2/UserCacheService.ts b/src/services/offlineV2/UserCacheService.ts index 8fc3d9fa..73531740 100644 --- a/src/services/offlineV2/UserCacheService.ts +++ b/src/services/offlineV2/UserCacheService.ts @@ -14,7 +14,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { UserValidationService } from "@utils/userValidation"; -import UnifiedLogger from "@services/logger/UnifiedLogger"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; import { isTempId } from "@utils/recipeUtils"; import { TempIdMapping, @@ -26,6 +26,7 @@ import { STORAGE_KEYS_V2, Recipe, BrewSession, + UnitSystem, } from "@src/types"; // Simple per-key queue (no external deps) with race condition protection @@ -44,7 +45,8 @@ async function withKeyQueue(key: string, fn: () => Promise): Promise { // Log warning if too many concurrent calls for the same key if (currentCount > 10) { - console.warn( + await UnifiedLogger.warn( + "offline-cache", `[withKeyQueue] High concurrent call count for key "${key}": ${currentCount} calls` ); } @@ -52,7 +54,8 @@ async function withKeyQueue(key: string, fn: () => Promise): Promise { // Break infinite loops by limiting max concurrent calls per key if (currentCount > 50) { queueDebugCounters.set(key, 0); // Reset counter - console.error( + await UnifiedLogger.error( + "offline-cache", `[withKeyQueue] Breaking potential infinite loop for key "${key}" after ${currentCount} calls` ); // Execute directly to break the loop @@ -109,7 +112,8 @@ export class UserCacheService { try { // Require userId for security - prevent cross-user data access if (!userId) { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService.getRecipeById] User ID is required for security` ); return null; @@ -134,7 +138,11 @@ export class UserCacheService { return recipeItem.data; } catch (error) { - console.error(`[UserCacheService.getRecipeById] Error:`, error); + void UnifiedLogger.error( + "offline-cache", + `[UserCacheService.getRecipeById] Error:`, + error + ); return null; } } @@ -171,7 +179,8 @@ export class UserCacheService { isDeleted: !!recipeItem.isDeleted, }; } catch (error) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.getRecipeByIdIncludingDeleted] Error:`, error ); @@ -184,71 +193,22 @@ export class UserCacheService { */ static async getRecipes( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { try { - await UnifiedLogger.debug( - "UserCacheService.getRecipes", - `Retrieving recipes for user ${userId}`, - { - userId, - unitSystem: userUnitSystem, - } - ); - - console.log( - `[UserCacheService.getRecipes] Getting recipes for user ID: "${userId}"` - ); - const cached = await this.getCachedRecipes(userId); - // Log detailed info about what we found in cache - const deletedCount = cached.filter(item => item.isDeleted).length; - const pendingSyncCount = cached.filter(item => item.needsSync).length; - const deletedPendingSyncCount = cached.filter( - item => item.isDeleted && item.needsSync - ).length; - - await UnifiedLogger.debug( - "UserCacheService.getRecipes", - `Cache contents analysis`, - { - userId, - totalItems: cached.length, - deletedItems: deletedCount, - pendingSyncItems: pendingSyncCount, - deletedPendingSyncItems: deletedPendingSyncCount, - itemDetails: cached.map(item => ({ - id: item.id, - dataId: item.data.id, - name: item.data.name, - isDeleted: item.isDeleted || false, - needsSync: item.needsSync, - syncStatus: item.syncStatus, - })), - } - ); - - console.log( - `[UserCacheService.getRecipes] getCachedRecipes returned ${cached.length} items for user "${userId}"` - ); - // If no cached recipes found, try to hydrate from server if (cached.length === 0) { - console.log( - `[UserCacheService.getRecipes] No cached recipes found, attempting to hydrate from server...` - ); try { await this.hydrateRecipesFromServer(userId, false, userUnitSystem); // Try again after hydration const hydratedCached = await this.getCachedRecipes(userId); - console.log( - `[UserCacheService.getRecipes] After hydration: ${hydratedCached.length} recipes cached` - ); return this.filterAndSortHydrated(hydratedCached); } catch (hydrationError) { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService.getRecipes] Failed to hydrate from server:`, hydrationError ); @@ -258,34 +218,9 @@ export class UserCacheService { // Filter out deleted items and return data const filteredRecipes = cached.filter(item => !item.isDeleted); - console.log( - `[UserCacheService.getRecipes] After filtering out deleted: ${filteredRecipes.length} recipes` - ); - - if (filteredRecipes.length > 0) { - const recipeIds = filteredRecipes.map(item => item.data.id); - console.log( - `[UserCacheService.getRecipes] Recipe IDs: [${recipeIds.join(", ")}]` - ); - } const finalRecipes = this.filterAndSortHydrated(filteredRecipes); - await UnifiedLogger.debug( - "UserCacheService.getRecipes", - `Returning filtered recipes to UI`, - { - userId, - returnedCount: finalRecipes.length, - filteredOutCount: filteredRecipes.length - finalRecipes.length, - returnedRecipes: finalRecipes.map(recipe => ({ - id: recipe.id, - name: recipe.name, - style: recipe.style || "Unknown", - })), - } - ); - return finalRecipes; } catch (error) { await UnifiedLogger.error( @@ -296,7 +231,6 @@ export class UserCacheService { error: error instanceof Error ? error.message : "Unknown error", } ); - console.error("Error getting recipes:", error); throw new OfflineError("Failed to get recipes", "RECIPES_ERROR", true); } } @@ -321,21 +255,6 @@ export class UserCacheService { ); } - await UnifiedLogger.info( - "UserCacheService.createRecipe", - `Starting recipe creation for user ${currentUserId}`, - { - userId: currentUserId, - tempId, - recipeName: recipe.name || "Untitled", - recipeStyle: recipe.style || "Unknown", - hasIngredients: !!( - recipe.ingredients && recipe.ingredients.length > 0 - ), - ingredientCount: recipe.ingredients?.length || 0, - timestamp: new Date().toISOString(), - } - ); const newRecipe: Recipe = { ...recipe, id: tempId, @@ -400,7 +319,11 @@ export class UserCacheService { return newRecipe; } catch (error) { - console.error("Error creating recipe:", error); + void UnifiedLogger.error( + "offline-cache", + "Error creating recipe:", + error + ); throw new OfflineError("Failed to create recipe", "CREATE_ERROR", true); } } @@ -501,7 +424,11 @@ export class UserCacheService { return updatedRecipe; } catch (error) { - console.error("Error updating recipe:", error); + void UnifiedLogger.error( + "offline-cache", + "Error updating recipe:", + error + ); if (error instanceof OfflineError) { throw error; } @@ -550,17 +477,6 @@ export class UserCacheService { ).length, } ); - console.log( - `[UserCacheService.deleteRecipe] Recipe not found. Looking for ID: "${id}"` - ); - console.log( - `[UserCacheService.deleteRecipe] Available recipe IDs:`, - cached.map(item => ({ - id: item.id, - dataId: item.data.id, - tempId: item.tempId, - })) - ); throw new OfflineError("Recipe not found", "NOT_FOUND", false); } @@ -681,7 +597,11 @@ export class UserCacheService { // Trigger background sync this.backgroundSync(); } catch (error) { - console.error("Error deleting recipe:", error); + void UnifiedLogger.error( + "offline-cache", + "Error deleting recipe:", + error + ); if (error instanceof OfflineError) { throw error; } @@ -760,7 +680,7 @@ export class UserCacheService { return clonedRecipe; } catch (error) { - console.error("Error cloning recipe:", error); + void UnifiedLogger.error("offline-cache", "Error cloning recipe:", error); if (error instanceof OfflineError) { throw error; } @@ -788,7 +708,8 @@ export class UserCacheService { try { // Require userId for security - prevent cross-user data access if (!userId) { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService.getBrewSessionById] User ID is required for security` ); return null; @@ -840,7 +761,11 @@ export class UserCacheService { return sessionItem.data; } catch (error) { - console.error(`[UserCacheService.getBrewSessionById] Error:`, error); + void UnifiedLogger.error( + "offline-cache", + `[UserCacheService.getBrewSessionById] Error:`, + error + ); return null; } } @@ -850,60 +775,13 @@ export class UserCacheService { */ static async getBrewSessions( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { try { - await UnifiedLogger.debug( - "UserCacheService.getBrewSessions", - `Retrieving brew sessions for user ${userId}`, - { - userId, - unitSystem: userUnitSystem, - } - ); - - console.log( - `[UserCacheService.getBrewSessions] Getting brew sessions for user ID: "${userId}"` - ); - const cached = await this.getCachedBrewSessions(userId); - // Log detailed info about what we found in cache - const deletedCount = cached.filter(item => item.isDeleted).length; - const pendingSyncCount = cached.filter(item => item.needsSync).length; - const deletedPendingSyncCount = cached.filter( - item => item.isDeleted && item.needsSync - ).length; - - await UnifiedLogger.debug( - "UserCacheService.getBrewSessions", - `Cache contents analysis`, - { - userId, - totalItems: cached.length, - deletedItems: deletedCount, - pendingSyncItems: pendingSyncCount, - deletedPendingSyncItems: deletedPendingSyncCount, - itemDetails: cached.map(item => ({ - id: item.id, - dataId: item.data.id, - name: item.data.name, - isDeleted: item.isDeleted || false, - needsSync: item.needsSync, - syncStatus: item.syncStatus, - })), - } - ); - - console.log( - `[UserCacheService.getBrewSessions] getCachedBrewSessions returned ${cached.length} items for user "${userId}"` - ); - // If no cached sessions found, try to hydrate from server if (cached.length === 0) { - console.log( - `[UserCacheService.getBrewSessions] No cached sessions found, attempting to hydrate from server...` - ); try { await this.hydrateBrewSessionsFromServer( userId, @@ -912,13 +790,11 @@ export class UserCacheService { ); // Try again after hydration const hydratedCached = await this.getCachedBrewSessions(userId); - console.log( - `[UserCacheService.getBrewSessions] After hydration: ${hydratedCached.length} sessions cached` - ); return this.filterAndSortHydrated(hydratedCached); } catch (hydrationError) { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService.getBrewSessions] Failed to hydrate from server:`, hydrationError ); @@ -928,34 +804,9 @@ export class UserCacheService { // Filter out deleted items and return data const filteredSessions = cached.filter(item => !item.isDeleted); - console.log( - `[UserCacheService.getBrewSessions] After filtering out deleted: ${filteredSessions.length} sessions` - ); - - if (filteredSessions.length > 0) { - const sessionIds = filteredSessions.map(item => item.data.id); - console.log( - `[UserCacheService.getBrewSessions] Session IDs: [${sessionIds.join(", ")}]` - ); - } const finalSessions = this.filterAndSortHydrated(filteredSessions); - await UnifiedLogger.debug( - "UserCacheService.getBrewSessions", - `Returning filtered sessions to UI`, - { - userId, - returnedCount: finalSessions.length, - filteredOutCount: filteredSessions.length - finalSessions.length, - returnedSessions: finalSessions.map(session => ({ - id: session.id, - name: session.name, - status: session.status || "Unknown", - })), - } - ); - return finalSessions; } catch (error) { await UnifiedLogger.error( @@ -966,7 +817,6 @@ export class UserCacheService { error: error instanceof Error ? error.message : "Unknown error", } ); - console.error("Error getting brew sessions:", error); throw new OfflineError( "Failed to get brew sessions", "SESSIONS_ERROR", @@ -1080,7 +930,11 @@ export class UserCacheService { return newSession; } catch (error) { - console.error("Error creating brew session:", error); + void UnifiedLogger.error( + "offline-cache", + "Error creating brew session:", + error + ); throw new OfflineError( "Failed to create brew session", "CREATE_ERROR", @@ -1185,7 +1039,11 @@ export class UserCacheService { return updatedSession; } catch (error) { - console.error("Error updating brew session:", error); + void UnifiedLogger.error( + "offline-cache", + "Error updating brew session:", + error + ); if (error instanceof OfflineError) { throw error; } @@ -1355,7 +1213,11 @@ export class UserCacheService { // Trigger background sync this.backgroundSync(); } catch (error) { - console.error("Error deleting brew session:", error); + void UnifiedLogger.error( + "offline-cache", + "Error deleting brew session:", + error + ); if (error instanceof OfflineError) { throw error; } @@ -1461,7 +1323,11 @@ export class UserCacheService { return updatedSessionData; } catch (error) { - console.error("Error adding fermentation entry:", error); + void UnifiedLogger.error( + "offline-cache", + "Error adding fermentation entry:", + error + ); throw new OfflineError( "Failed to add fermentation entry", "CREATE_ERROR", @@ -1565,7 +1431,11 @@ export class UserCacheService { return updatedSessionData; } catch (error) { - console.error("Error updating fermentation entry:", error); + void UnifiedLogger.error( + "offline-cache", + "Error updating fermentation entry:", + error + ); throw new OfflineError( "Failed to update fermentation entry", "UPDATE_ERROR", @@ -1661,7 +1531,11 @@ export class UserCacheService { return updatedSessionData; } catch (error) { - console.error("Error deleting fermentation entry:", error); + void UnifiedLogger.error( + "offline-cache", + "Error deleting fermentation entry:", + error + ); throw new OfflineError( "Failed to delete fermentation entry", "DELETE_ERROR", @@ -1713,16 +1587,6 @@ export class UserCacheService { // IMPORTANT: Use session.id (real ID) not sessionId parameter (could be temp ID) const realSessionId = session.id; - await UnifiedLogger.debug( - "UserCacheService.addDryHopFromRecipe", - `Using session ID for operation`, - { - paramSessionId: sessionId, - realSessionId, - sessionName: session.name, - } - ); - // Create new dry-hop addition with automatic timestamp const newDryHop: import("@src/types").DryHopAddition = { addition_date: @@ -1736,16 +1600,6 @@ export class UserCacheService { recipe_instance_id: dryHopData.recipe_instance_id, // CRITICAL: Preserve instance ID for uniqueness }; - await UnifiedLogger.debug( - "UserCacheService.addDryHopFromRecipe", - `Created dry-hop addition object`, - { - newDryHop, - hasInstanceId: !!newDryHop.recipe_instance_id, - instanceId: newDryHop.recipe_instance_id, - } - ); - // Update dry-hop additions array locally (offline-first) const updatedDryHops = [...(session.dry_hop_additions || []), newDryHop]; @@ -1797,7 +1651,7 @@ export class UserCacheService { return updatedSessionData; } catch (error) { - console.error("Error adding dry-hop:", error); + void UnifiedLogger.error("offline-cache", "Error adding dry-hop:", error); throw new OfflineError("Failed to add dry-hop", "CREATE_ERROR", true); } } @@ -1892,7 +1746,11 @@ export class UserCacheService { return updatedSessionData; } catch (error) { - console.error("Error removing dry-hop:", error); + void UnifiedLogger.error( + "offline-cache", + "Error removing dry-hop:", + error + ); throw new OfflineError("Failed to remove dry-hop", "UPDATE_ERROR", true); } } @@ -1978,7 +1836,11 @@ export class UserCacheService { return updatedSession; } catch (error) { - console.error("Error deleting dry-hop:", error); + void UnifiedLogger.error( + "offline-cache", + "Error deleting dry-hop:", + error + ); throw new OfflineError("Failed to delete dry-hop", "DELETE_ERROR", true); } } @@ -2003,7 +1865,7 @@ export class UserCacheService { if (this.syncInProgress && this.syncStartTime) { const elapsed = Date.now() - this.syncStartTime; if (elapsed > this.SYNC_TIMEOUT_MS) { - console.warn("Resetting stuck sync flag"); + void UnifiedLogger.warn("offline-cache", "Resetting stuck sync flag"); this.syncInProgress = false; this.syncStartTime = undefined; } @@ -2031,9 +1893,6 @@ export class UserCacheService { try { let operations = await this.getPendingOperations(); if (operations.length > 0) { - console.log( - `[UserCacheService] Starting sync of ${operations.length} pending operations` - ); } // Process operations one at a time, reloading after each to catch any updates @@ -2071,13 +1930,6 @@ export class UserCacheService { // CRITICAL: Reload operations after ID mapping to get updated recipe_id references // This ensures subsequent operations (like brew sessions) use the new real IDs operations = await this.getPendingOperations(); - await UnifiedLogger.debug( - "UserCacheService.syncPendingOperations", - `Reloaded pending operations after ID mapping`, - { - remainingOperations: operations.length, - } - ); continue; // Skip to next iteration with fresh operations list } else if (operation.type === "update") { // For update operations, mark the item as synced @@ -2093,7 +1945,8 @@ export class UserCacheService { } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService] Failed to process operation ${operation.id}:`, errorMessage ); @@ -2121,17 +1974,6 @@ export class UserCacheService { if (isOfflineError) { // Offline error - don't increment retry count, just stop syncing // We'll try again next time (when network is back or next background sync) - await UnifiedLogger.debug( - "UserCacheService.syncPendingOperations", - `Stopping sync due to offline error - will retry later`, - { - operationId: operation.id, - operationType: operation.type, - entityType: operation.entityType, - error: errorMessage, - remainingOperations: operations.length, - } - ); // Break out of the while loop - don't keep retrying offline operations break; } else { @@ -2186,17 +2028,13 @@ export class UserCacheService { `Sync failed: ${errorMessage}`, { error: errorMessage } ); - console.error("Sync failed:", errorMessage); + void UnifiedLogger.error("offline-cache", "Sync failed:", errorMessage); result.success = false; result.errors.push(`Sync process failed: ${errorMessage}`); return result; } finally { this.syncInProgress = false; this.syncStartTime = undefined; - await UnifiedLogger.debug( - "UserCacheService.syncPendingOperations", - "Sync process completed, flags reset" - ); } } @@ -2215,7 +2053,11 @@ export class UserCacheService { const operations = await this.getPendingOperations(); return operations.length; } catch (error) { - console.error("Error getting pending operations count:", error); + void UnifiedLogger.error( + "offline-cache", + "Error getting pending operations count:", + error + ); return 0; } } @@ -2227,7 +2069,11 @@ export class UserCacheService { try { await AsyncStorage.removeItem(STORAGE_KEYS_V2.PENDING_OPERATIONS); } catch (error) { - console.error("Error clearing sync queue:", error); + void UnifiedLogger.error( + "offline-cache", + "Error clearing sync queue:", + error + ); throw new OfflineError("Failed to clear sync queue", "CLEAR_ERROR", true); } } @@ -2280,7 +2126,11 @@ export class UserCacheService { `Failed to reset retry counts: ${errorMessage}`, { error: errorMessage } ); - console.error("Error resetting retry counts:", error); + void UnifiedLogger.error( + "offline-cache", + "Error resetting retry counts:", + error + ); return 0; } }); @@ -2312,18 +2162,13 @@ export class UserCacheService { return hasTempId && (!hasNeedSync || !hasPendingOp); }); - console.log( - `[UserCacheService] Found ${stuckRecipes.length} stuck recipes with temp IDs` - ); - stuckRecipes.forEach(recipe => { - console.log( - `[UserCacheService] Stuck recipe: ID="${recipe.id}", tempId="${recipe.tempId}", needsSync="${recipe.needsSync}", syncStatus="${recipe.syncStatus}"` - ); - }); - return { stuckRecipes, pendingOperations: pendingOps }; } catch (error) { - console.error("[UserCacheService] Error finding stuck recipes:", error); + void UnifiedLogger.error( + "offline-cache", + "[UserCacheService] Error finding stuck recipes:", + error + ); return { stuckRecipes: [], pendingOperations: [] }; } } @@ -2446,7 +2291,11 @@ export class UserCacheService { syncStatus, }; } catch (error) { - console.error("[UserCacheService] Error getting debug info:", error); + void UnifiedLogger.error( + "offline-cache", + "[UserCacheService] Error getting debug info:", + error + ); return { recipe: null, pendingOperations: [], syncStatus: "error" }; } } @@ -2458,8 +2307,6 @@ export class UserCacheService { recipeId: string ): Promise<{ success: boolean; error?: string }> { try { - console.log(`[UserCacheService] Force syncing recipe: ${recipeId}`); - const debugInfo = await this.getRecipeDebugInfo(recipeId); if (debugInfo.pendingOperations.length === 0) { @@ -2478,7 +2325,8 @@ export class UserCacheService { }; } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error"; - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService] Error force syncing recipe ${recipeId}:`, errorMsg ); @@ -2499,10 +2347,6 @@ export class UserCacheService { for (const recipe of stuckRecipes) { try { - console.log( - `[UserCacheService] Attempting to fix stuck recipe: ${recipe.id}` - ); - // Reset sync status and recreate pending operation recipe.needsSync = true; recipe.syncStatus = "pending"; @@ -2526,21 +2370,24 @@ export class UserCacheService { // Add the pending operation await this.addPendingOperation(operation); - console.log(`[UserCacheService] Fixed stuck recipe: ${recipe.id}`); fixed++; } catch (error) { const errorMsg = `Failed to fix recipe ${recipe.id}: ${error instanceof Error ? error.message : "Unknown error"}`; - console.error(`[UserCacheService] ${errorMsg}`); + void UnifiedLogger.error( + "offline-cache", + `[UserCacheService] ${errorMsg}` + ); errors.push(errorMsg); } } - console.log( - `[UserCacheService] Fixed ${fixed} stuck recipes with ${errors.length} errors` - ); return { fixed, errors }; } catch (error) { - console.error("[UserCacheService] Error fixing stuck recipes:", error); + void UnifiedLogger.error( + "offline-cache", + "[UserCacheService] Error fixing stuck recipes:", + error + ); return { fixed: 0, errors: [ @@ -2555,41 +2402,30 @@ export class UserCacheService { */ static async refreshRecipesFromServer( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { try { - console.log( - `[UserCacheService.refreshRecipesFromServer] Refreshing recipes from server for user: "${userId}"` - ); - // Always fetch fresh data from server await this.hydrateRecipesFromServer(userId, true, userUnitSystem); // Return fresh cached data const refreshedRecipes = await this.getRecipes(userId, userUnitSystem); - console.log( - `[UserCacheService.refreshRecipesFromServer] Refresh completed, returning ${refreshedRecipes.length} recipes` - ); return refreshedRecipes; } catch (error) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.refreshRecipesFromServer] Refresh failed:`, error ); // When refresh fails, try to return existing cached data instead of throwing try { - console.log( - `[UserCacheService.refreshRecipesFromServer] Attempting to return cached data after refresh failure` - ); const cachedRecipes = await this.getRecipes(userId, userUnitSystem); - console.log( - `[UserCacheService.refreshRecipesFromServer] Returning ${cachedRecipes.length} cached recipes after refresh failure` - ); return cachedRecipes; } catch (cacheError) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.refreshRecipesFromServer] Failed to get cached data:`, cacheError ); @@ -2605,13 +2441,9 @@ export class UserCacheService { private static async hydrateRecipesFromServer( userId: string, forceRefresh: boolean = false, - userUnitSystem: "imperial" | "metric" = "imperial" + _userUnitSystem: UnitSystem = "metric" ): Promise { try { - console.log( - `[UserCacheService.hydrateRecipesFromServer] Fetching recipes from server for user: "${userId}" (forceRefresh: ${forceRefresh})` - ); - // Import the API service here to avoid circular dependencies const { default: ApiService } = await import("@services/api/apiService"); @@ -2624,10 +2456,6 @@ export class UserCacheService { // If force refresh and we successfully got server data, clear and replace cache if (forceRefresh && serverRecipes.length >= 0) { - console.log( - `[UserCacheService.hydrateRecipesFromServer] Force refresh successful - updating cache for user "${userId}"` - ); - // Get existing offline-created recipes to preserve before clearing const cached = await AsyncStorage.getItem(STORAGE_KEYS_V2.USER_RECIPES); if (cached) { @@ -2655,10 +2483,6 @@ export class UserCacheService { return false; }); - - console.log( - `[UserCacheService.hydrateRecipesFromServer] Found ${offlineCreatedRecipes.length} V2 offline-created recipes to preserve` - ); } // MIGRATION: Also check for legacy offline recipes that need preservation @@ -2670,21 +2494,6 @@ export class UserCacheService { await LegacyMigrationService.getLegacyRecipeCount(userId); if (legacyCount > 0) { - console.log( - `[UserCacheService.hydrateRecipesFromServer] Found ${legacyCount} legacy recipes - migrating to V2 before force refresh` - ); - - // Migrate legacy recipes to V2 before clearing cache - const migrationResult = - await LegacyMigrationService.migrateLegacyRecipesToV2( - userId, - userUnitSystem - ); - console.log( - `[UserCacheService.hydrateRecipesFromServer] Legacy migration result:`, - migrationResult - ); - // Re-check for offline recipes after migration const cachedAfterMigration = await AsyncStorage.getItem( STORAGE_KEYS_V2.USER_RECIPES @@ -2700,24 +2509,17 @@ export class UserCacheService { item.tempId) ); }); - - console.log( - `[UserCacheService.hydrateRecipesFromServer] After migration: ${offlineCreatedRecipes.length} total offline recipes to preserve` - ); } } } catch (migrationError) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.hydrateRecipesFromServer] Legacy migration failed:`, migrationError ); // Continue with force refresh even if migration fails } - console.log( - `[UserCacheService.hydrateRecipesFromServer] Found ${offlineCreatedRecipes.length} offline-created recipes to preserve` - ); - // Clear all recipes for this user await this.clearUserRecipesFromCache(userId); @@ -2725,16 +2527,8 @@ export class UserCacheService { for (const recipe of offlineCreatedRecipes) { await this.addRecipeToCache(recipe); } - - console.log( - `[UserCacheService.hydrateRecipesFromServer] Preserved ${offlineCreatedRecipes.length} offline-created recipes` - ); } - console.log( - `[UserCacheService.hydrateRecipesFromServer] Fetched ${serverRecipes.length} recipes from server` - ); - // Only process and cache server recipes if we have them if (serverRecipes.length > 0) { // Filter out server recipes that are already preserved (to avoid duplicates) @@ -2745,10 +2539,6 @@ export class UserCacheService { recipe => !preservedIds.has(recipe.id) ); - console.log( - `[UserCacheService.hydrateRecipesFromServer] Filtered out ${serverRecipes.length - filteredServerRecipes.length} duplicate server recipes` - ); - // Convert server recipes to syncable items const now = Date.now(); const syncableRecipes = filteredServerRecipes.map(recipe => ({ @@ -2763,18 +2553,12 @@ export class UserCacheService { for (const recipe of syncableRecipes) { await this.addRecipeToCache(recipe); } - - console.log( - `[UserCacheService.hydrateRecipesFromServer] Successfully cached ${syncableRecipes.length} recipes` - ); } else if (!forceRefresh) { // Only log this for non-force refresh (normal hydration) - console.log( - `[UserCacheService.hydrateRecipesFromServer] No server recipes found` - ); } } catch (error) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.hydrateRecipesFromServer] Failed to hydrate from server:`, error ); @@ -2791,20 +2575,20 @@ export class UserCacheService { static async clearUserData(userId?: string): Promise { try { if (userId) { - console.log( - `[UserCacheService.clearUserData] Clearing data for user: "${userId}"` - ); await this.clearUserRecipesFromCache(userId); await this.clearUserBrewSessionsFromCache(userId); await this.clearUserPendingOperations(userId); } else { - console.log(`[UserCacheService.clearUserData] Clearing all data`); await AsyncStorage.removeItem(STORAGE_KEYS_V2.USER_RECIPES); await AsyncStorage.removeItem(STORAGE_KEYS_V2.PENDING_OPERATIONS); await AsyncStorage.removeItem(STORAGE_KEYS_V2.USER_BREW_SESSIONS); } } catch (error) { - console.error(`[UserCacheService.clearUserData] Error:`, error); + void UnifiedLogger.error( + "offline-cache", + `[UserCacheService.clearUserData] Error:`, + error + ); throw error; } } @@ -2831,11 +2615,9 @@ export class UserCacheService { STORAGE_KEYS_V2.PENDING_OPERATIONS, JSON.stringify(filteredOperations) ); - console.log( - `[UserCacheService.clearUserPendingOperations] Cleared pending operations for user "${userId}"` - ); } catch (error) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.clearUserPendingOperations] Error:`, error ); @@ -2863,11 +2645,9 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_RECIPES, JSON.stringify(filteredRecipes) ); - console.log( - `[UserCacheService.clearUserRecipesFromCache] Cleared recipes for user "${userId}", kept ${filteredRecipes.length} recipes for other users` - ); } catch (error) { - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService.clearUserRecipesFromCache] Error:`, error ); @@ -2886,28 +2666,6 @@ export class UserCacheService { private static filterAndSortHydrated< T extends { updated_at?: string; created_at?: string }, >(hydratedCached: SyncableItem[]): T[] { - const beforeFiltering = hydratedCached.length; - const deletedItems = hydratedCached.filter(item => item.isDeleted); - - // Log what's being filtered out - if (__DEV__ && deletedItems.length > 0) { - void UnifiedLogger.debug( - "UserCacheService.filterAndSortHydrated", - `Filtering out ${deletedItems.length} deleted items`, - { - totalItems: beforeFiltering, - deletedItems: deletedItems.length, - deletedItemsDetails: deletedItems.map(item => ({ - id: item.id, - name: (item.data as any)?.name || "Unknown", - isDeleted: item.isDeleted, - needsSync: item.needsSync, - syncStatus: item.syncStatus, - })), - } - ); - } - const filteredItems = hydratedCached .filter(item => !item.isDeleted) .map(item => item.data) @@ -2928,18 +2686,6 @@ export class UserCacheService { return bTime - aTime; // Newest first }); - if (__DEV__) { - void UnifiedLogger.debug( - "UserCacheService.filterAndSortHydrated", - `Filtering and sorting completed`, - { - beforeFiltering, - afterFiltering: filteredItems.length, - filteredOut: beforeFiltering - filteredItems.length, - } - ); - } - return filteredItems; } @@ -2951,49 +2697,23 @@ export class UserCacheService { ): Promise[]> { return await withKeyQueue(STORAGE_KEYS_V2.USER_RECIPES, async () => { try { - console.log( - `[UserCacheService.getCachedRecipes] Loading cache for user ID: "${userId}"` - ); - const cached = await AsyncStorage.getItem(STORAGE_KEYS_V2.USER_RECIPES); if (!cached) { - console.log(`[UserCacheService.getCachedRecipes] No cache found`); return []; } const allRecipes: SyncableItem[] = JSON.parse(cached); - console.log( - `[UserCacheService.getCachedRecipes] Total cached recipes found: ${allRecipes.length}` + const userRecipes = allRecipes.filter( + item => item.data.user_id === userId ); - // Log sample of all cached recipe user IDs for debugging - if (allRecipes.length > 0) { - const sampleUserIds = allRecipes.slice(0, 5).map(item => ({ - id: item.data.id, - user_id: item.data.user_id, - })); - console.log( - `[UserCacheService.getCachedRecipes] Sample cached recipes:`, - sampleUserIds - ); - } - - const userRecipes = allRecipes.filter(item => { - const isMatch = item.data.user_id === userId; - if (!isMatch) { - console.log( - `[UserCacheService.getCachedRecipes] Recipe ${item.data.id} user_id "${item.data.user_id}" != target "${userId}"` - ); - } - return isMatch; - }); - - console.log( - `[UserCacheService.getCachedRecipes] Filtered to ${userRecipes.length} recipes for user "${userId}"` - ); return userRecipes; - } catch (e) { - console.warn("Corrupt USER_RECIPES cache; resetting", e); + } catch (error) { + void UnifiedLogger.warn( + "offline-cache", + "Corrupt USER_RECIPES cache; resetting", + { error: error instanceof Error ? error.message : "Unknown error" } + ); await AsyncStorage.removeItem(STORAGE_KEYS_V2.USER_RECIPES); return []; } @@ -3018,7 +2738,11 @@ export class UserCacheService { JSON.stringify(recipes) ); } catch (error) { - console.error("Error adding recipe to cache:", error); + void UnifiedLogger.error( + "offline-cache", + "Error adding recipe to cache:", + error + ); throw new OfflineError("Failed to cache recipe", "CACHE_ERROR", true); } }); @@ -3053,7 +2777,11 @@ export class UserCacheService { JSON.stringify(recipes) ); } catch (error) { - console.error("Error updating recipe in cache:", error); + void UnifiedLogger.error( + "offline-cache", + "Error updating recipe in cache:", + error + ); throw new OfflineError( "Failed to update cached recipe", "CACHE_ERROR", @@ -3071,62 +2799,18 @@ export class UserCacheService { ): Promise[]> { return await withKeyQueue(STORAGE_KEYS_V2.USER_BREW_SESSIONS, async () => { try { - // Add stack trace in dev mode to track what's calling this - if (__DEV__) { - const stack = new Error().stack; - const caller = stack?.split("\n")[3]?.trim() || "unknown"; - console.log(`[getCachedBrewSessions] Called by: ${caller}`); - } - - await UnifiedLogger.debug( - "UserCacheService.getCachedBrewSessions", - `Loading cache for user ID: "${userId}"` - ); - const cached = await AsyncStorage.getItem( STORAGE_KEYS_V2.USER_BREW_SESSIONS ); if (!cached) { - await UnifiedLogger.debug( - "UserCacheService.getCachedBrewSessions", - "No cache found" - ); return []; } const allSessions: SyncableItem[] = JSON.parse(cached); - await UnifiedLogger.debug( - "UserCacheService.getCachedBrewSessions", - `Total cached sessions found: ${allSessions.length}` + const userSessions = allSessions.filter( + item => item.data.user_id === userId ); - // Log sample of all cached session user IDs for debugging - if (allSessions.length > 0) { - const sampleUserIds = allSessions.slice(0, 5).map(item => ({ - id: item.data.id, - user_id: item.data.user_id, - })); - await UnifiedLogger.debug( - "UserCacheService.getCachedBrewSessions", - "Sample cached sessions", - { sampleUserIds } - ); - } - - const userSessions = allSessions.filter(item => { - const isMatch = item.data.user_id === userId; - if (!isMatch) { - console.log( - `[UserCacheService.getCachedBrewSessions] Session ${item.data.id} user_id "${item.data.user_id}" != target "${userId}"` - ); - } - return isMatch; - }); - - await UnifiedLogger.debug( - "UserCacheService.getCachedBrewSessions", - `Filtered to ${userSessions.length} sessions for user "${userId}"` - ); return userSessions; } catch (e) { await UnifiedLogger.error( @@ -3148,17 +2832,6 @@ export class UserCacheService { ): Promise { return await withKeyQueue(STORAGE_KEYS_V2.USER_BREW_SESSIONS, async () => { try { - await UnifiedLogger.debug( - "UserCacheService.addBrewSessionToCache", - `Adding session to cache`, - { - sessionId: item.id, - sessionName: item.data.name, - userId: item.data.user_id, - syncStatus: item.syncStatus, - } - ); - const cached = await AsyncStorage.getItem( STORAGE_KEYS_V2.USER_BREW_SESSIONS ); @@ -3172,12 +2845,6 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_BREW_SESSIONS, JSON.stringify(sessions) ); - - await UnifiedLogger.debug( - "UserCacheService.addBrewSessionToCache", - `Successfully added session to cache`, - { totalSessions: sessions.length } - ); } catch (error) { await UnifiedLogger.error( "UserCacheService.addBrewSessionToCache", @@ -3201,17 +2868,6 @@ export class UserCacheService { ): Promise { return await withKeyQueue(STORAGE_KEYS_V2.USER_BREW_SESSIONS, async () => { try { - await UnifiedLogger.debug( - "UserCacheService.updateBrewSessionInCache", - `Updating session in cache`, - { - sessionId: updatedItem.id, - sessionName: updatedItem.data.name, - syncStatus: updatedItem.syncStatus, - needsSync: updatedItem.needsSync, - } - ); - const cached = await AsyncStorage.getItem( STORAGE_KEYS_V2.USER_BREW_SESSIONS ); @@ -3226,16 +2882,8 @@ export class UserCacheService { if (index >= 0) { sessions[index] = updatedItem; - await UnifiedLogger.debug( - "UserCacheService.updateBrewSessionInCache", - `Updated existing session at index ${index}` - ); } else { sessions.push(updatedItem); - await UnifiedLogger.debug( - "UserCacheService.updateBrewSessionInCache", - `Added new session to cache` - ); } await AsyncStorage.setItem( @@ -3265,12 +2913,6 @@ export class UserCacheService { ): Promise { return await withKeyQueue(STORAGE_KEYS_V2.USER_BREW_SESSIONS, async () => { try { - await UnifiedLogger.debug( - "UserCacheService.removeBrewSessionFromCache", - `Removing session from cache`, - { entityId } - ); - const cached = await AsyncStorage.getItem( STORAGE_KEYS_V2.USER_BREW_SESSIONS ); @@ -3287,11 +2929,6 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_BREW_SESSIONS, JSON.stringify(filteredSessions) ); - await UnifiedLogger.debug( - "UserCacheService.removeBrewSessionFromCache", - `Successfully removed session from cache`, - { removedCount: sessions.length - filteredSessions.length } - ); } else { await UnifiedLogger.warn( "UserCacheService.removeBrewSessionFromCache", @@ -3336,10 +2973,6 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_BREW_SESSIONS, JSON.stringify(filteredSessions) ); - await UnifiedLogger.debug( - "UserCacheService.clearUserBrewSessionsFromCache", - `Cleared sessions for user "${userId}", kept ${filteredSessions.length} sessions for other users` - ); } catch (error) { await UnifiedLogger.error( "UserCacheService.clearUserBrewSessionsFromCache", @@ -3360,14 +2993,9 @@ export class UserCacheService { private static async hydrateBrewSessionsFromServer( userId: string, forceRefresh: boolean = false, - _userUnitSystem: "imperial" | "metric" = "imperial" + _userUnitSystem: UnitSystem = "metric" ): Promise { try { - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `Fetching sessions from server for user: "${userId}" (forceRefresh: ${forceRefresh})` - ); - // Import the API service here to avoid circular dependencies const { default: ApiService } = await import("@services/api/apiService"); @@ -3375,28 +3003,6 @@ export class UserCacheService { const response = await ApiService.brewSessions.getAll(1, 100); // Get first 100 sessions // Log RAW response before any processing - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `RAW response from API (before any processing)`, - { - hasBrewSessions: "brew_sessions" in (response.data || {}), - sessionCount: response.data?.brew_sessions?.length || 0, - firstSessionDryHops: - response.data?.brew_sessions?.[0]?.dry_hop_additions?.length || 0, - rawFirstSession: response.data?.brew_sessions?.[0] - ? { - id: response.data.brew_sessions[0].id, - name: response.data.brew_sessions[0].name, - hasDryHopField: - "dry_hop_additions" in response.data.brew_sessions[0], - dryHopCount: - response.data.brew_sessions[0].dry_hop_additions?.length || 0, - dryHops: - response.data.brew_sessions[0].dry_hop_additions || "MISSING", - } - : "NO_SESSIONS", - } - ); const serverSessions = response.data?.brew_sessions || []; @@ -3407,11 +3013,6 @@ export class UserCacheService { // If force refresh and we successfully got server data, clear and replace cache if (forceRefresh && serverSessions.length >= 0) { - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `Force refresh successful - updating cache for user "${userId}"` - ); - // Get existing offline-created sessions to preserve before clearing const cached = await AsyncStorage.getItem( STORAGE_KEYS_V2.USER_BREW_SESSIONS @@ -3447,19 +3048,6 @@ export class UserCacheService { // Don't preserve anything else during force refresh return false; }); - - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `Found ${offlineCreatedSessions.length} V2 offline-created sessions (with tempId) to preserve`, - { - tempIdMappings: Array.from(tempIdToRealIdMap.entries()).map( - ([tempId, realId]) => ({ - tempId, - realId, - }) - ), - } - ); } // Clear all sessions for this user @@ -3469,11 +3057,6 @@ export class UserCacheService { for (const session of offlineCreatedSessions) { await this.addBrewSessionToCache(session); } - - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `Preserved ${offlineCreatedSessions.length} offline-created sessions` - ); } await UnifiedLogger.info( @@ -3506,11 +3089,6 @@ export class UserCacheService { session => !preservedIds.has(session.id) ); - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `Filtered out ${serverSessions.length - filteredServerSessions.length} duplicate server sessions` - ); - // Convert server sessions to syncable items const now = Date.now(); const syncableSessions = filteredServerSessions.map(session => { @@ -3555,10 +3133,6 @@ export class UserCacheService { ); } else if (!forceRefresh) { // Only log this for non-force refresh (normal hydration) - await UnifiedLogger.debug( - "UserCacheService.hydrateBrewSessionsFromServer", - `No server sessions found` - ); } } catch (error) { await UnifiedLogger.error( @@ -3578,7 +3152,7 @@ export class UserCacheService { */ static async refreshBrewSessionsFromServer( userId: string, - userUnitSystem: "imperial" | "metric" = "imperial" + userUnitSystem: UnitSystem = "metric" ): Promise { try { await UnifiedLogger.info( @@ -3622,23 +3196,6 @@ export class UserCacheService { ): Partial { const sanitized = { ...updates }; - // Debug logging to understand the data being sanitized - if (__DEV__) { - UnifiedLogger.debug( - "UserCacheService.sanitizeBrewSessionUpdatesForAPI", - "Sanitizing brew session data for API", - { - originalFields: Object.keys(updates), - hasFermentationData: !!( - sanitized.fermentation_data && - sanitized.fermentation_data.length > 0 - ), - fermentationEntryCount: sanitized.fermentation_data?.length || 0, - fermentationDataSample: sanitized.fermentation_data?.[0] || null, - } - ); - } - // Remove fields that shouldn't be updated via API delete sanitized.id; delete sanitized.created_at; @@ -3676,21 +3233,133 @@ export class UserCacheService { Math.floor(Number(sanitized.batch_rating)) || undefined; } - // Debug logging for sanitized result - if (__DEV__) { - UnifiedLogger.debug( - "UserCacheService.sanitizeBrewSessionUpdatesForAPI", - "Sanitization completed", + return sanitized; + } + + /** + * Resolve temporary recipe_id in brew session data + * + * Validates that recipe_id exists and is a non-empty string before processing. + * If recipe_id is a temp ID, resolves it to the real MongoDB ID by looking up + * the synced recipe in the cache. + * + * @param brewSessionData - The brew session data that may contain a temp recipe_id + * @param operation - The pending operation being processed + * @param pathContext - Context indicating if this is a CREATE or UPDATE operation + * @returns Object containing updated brewSessionData (with recipe_id guaranteed to be a string) and flag indicating if temp ID was found + * @throws OfflineError if recipe_id is missing/invalid, userId is missing, or recipe not found + */ + private static async resolveTempRecipeId< + TempBrewSessionData extends Partial, + >( + brewSessionData: TempBrewSessionData, + operation: PendingOperation, + pathContext: "CREATE" | "UPDATE" + ): Promise<{ + brewSessionData: TempBrewSessionData & { recipe_id: string }; + hadTempRecipeId: boolean; + }> { + const updatedData = { ...brewSessionData }; + + // Validate that recipe_id exists and is a non-empty string + if ( + !updatedData.recipe_id || + typeof updatedData.recipe_id !== "string" || + updatedData.recipe_id.trim().length === 0 + ) { + await UnifiedLogger.error( + "UserCacheService.resolveTempRecipeId", + `Brew session ${pathContext} has missing or invalid recipe_id`, { - sanitizedFields: Object.keys(sanitized), - removedFields: Object.keys(updates).filter( - key => !(key in sanitized) - ), + entityId: operation.entityId, + recipeId: updatedData.recipe_id, + pathContext, } ); + throw new OfflineError( + "Invalid brew session - missing or invalid recipe_id", + "DEPENDENCY_ERROR", + false + ); } - return sanitized; + const hasTemporaryRecipeId = updatedData.recipe_id.startsWith("temp_"); + + if (!hasTemporaryRecipeId) { + // recipe_id exists, is valid, and is not a temp ID, so it's already a real ID + return { + brewSessionData: updatedData as TempBrewSessionData & { + recipe_id: string; + }, + hadTempRecipeId: false, + }; + } + + // Validate operation has userId + if (!operation.userId) { + await UnifiedLogger.error( + "UserCacheService.resolveTempRecipeId", + `Cannot resolve temp recipe_id - operation has no userId`, + { entityId: operation.entityId, pathContext } + ); + throw new OfflineError( + "Invalid operation - missing userId", + "DEPENDENCY_ERROR", + false + ); + } + + await UnifiedLogger.info( + "UserCacheService.resolveTempRecipeId", + `Brew session ${pathContext} has temporary recipe_id - looking up real ID`, + { + tempRecipeId: updatedData.recipe_id, + entityId: operation.entityId, + pathContext, + } + ); + + // Look up the real recipe ID from the recipe cache + const recipes = await this.getCachedRecipes(operation.userId); + const matchedRecipe = recipes.find( + r => + r.data.id === updatedData.recipe_id || + r.tempId === updatedData.recipe_id + ); + + if (matchedRecipe) { + const realRecipeId = matchedRecipe.data.id; + await UnifiedLogger.info( + "UserCacheService.resolveTempRecipeId", + `Resolved temp recipe_id to real ID${pathContext === "UPDATE" ? " (UPDATE path)" : ""}`, + { + tempRecipeId: updatedData.recipe_id, + realRecipeId: realRecipeId, + pathContext, + } + ); + updatedData.recipe_id = realRecipeId; + } else { + // Recipe not found - this means the recipe hasn't synced yet + await UnifiedLogger.warn( + "UserCacheService.resolveTempRecipeId", + `Cannot find recipe for temp ID - skipping brew session ${pathContext} sync`, + { + tempRecipeId: updatedData.recipe_id, + entityId: operation.entityId, + pathContext, + } + ); + throw new OfflineError("Recipe not synced yet", "DEPENDENCY_ERROR", true); + } + + // At this point, recipe_id has been resolved to a real string ID + return { + brewSessionData: updatedData as TempBrewSessionData & { + recipe_id: string; + }, + hadTempRecipeId: true, + }; } /** @@ -3703,7 +3372,11 @@ export class UserCacheService { ); return cached ? JSON.parse(cached) : []; } catch (error) { - console.error("Error getting pending operations:", error); + void UnifiedLogger.error( + "offline-cache", + "Error getting pending operations:", + error + ); return []; } } @@ -3726,7 +3399,11 @@ export class UserCacheService { JSON.stringify(operations) ); } catch (error) { - console.error("Error adding pending operation:", error); + void UnifiedLogger.error( + "offline-cache", + "Error adding pending operation:", + error + ); throw new OfflineError( "Failed to queue operation", "QUEUE_ERROR", @@ -3754,7 +3431,11 @@ export class UserCacheService { JSON.stringify(filtered) ); } catch (error) { - console.error("Error removing pending operation:", error); + void UnifiedLogger.error( + "offline-cache", + "Error removing pending operation:", + error + ); } }); } @@ -3780,7 +3461,11 @@ export class UserCacheService { ); } } catch (error) { - console.error("Error updating pending operation:", error); + void UnifiedLogger.error( + "offline-cache", + "Error updating pending operation:", + error + ); } }); } @@ -3876,19 +3561,28 @@ export class UserCacheService { } ); } else if (operation.entityType === "brew_session") { + // CRITICAL FIX: Check if brew session has temp recipe_id and resolve it + const { brewSessionData, hadTempRecipeId } = + await this.resolveTempRecipeId( + { ...operation.data }, + operation, + "CREATE" + ); + await UnifiedLogger.info( "UserCacheService.processPendingOperation", `Executing CREATE API call for brew session`, { entityId: operation.entityId, operationId: operation.id, - sessionName: operation.data?.name || "Unknown", - brewSessionData: operation.data, // Log the actual data being sent + sessionName: brewSessionData?.name || "Unknown", + recipeId: brewSessionData.recipe_id, + hadTempRecipeId: hadTempRecipeId, + brewSessionData: brewSessionData, // Log the actual data being sent } ); - const response = await ApiService.brewSessions.create( - operation.data - ); + const response = + await ApiService.brewSessions.create(brewSessionData); if (response && response.data && response.data.id) { await UnifiedLogger.info( "UserCacheService.processPendingOperation", @@ -3910,24 +3604,23 @@ export class UserCacheService { if (isTempId) { // Convert UPDATE with temp ID to CREATE operation - if (__DEV__) { - console.log( - `[UserCacheService.syncOperation] Converting UPDATE with temp ID ${operation.entityId} to CREATE operation:`, - JSON.stringify(operation.data, null, 2) - ); - } + + await UnifiedLogger.info( + "UserCacheService.processPendingOperation", + `Converting UPDATE with temp ID ${operation.entityId} to CREATE operation for recipe`, + { + entityId: operation.entityId, + recipeName: operation.data?.name || "Unknown", + operationId: operation.id, + } + ); + const response = await ApiService.recipes.create(operation.data); if (response && response.data && response.data.id) { return { realId: response.data.id }; } } else { // Normal UPDATE operation for real MongoDB IDs - if (__DEV__) { - console.log( - `[UserCacheService.syncOperation] Sending UPDATE data to API for recipe ${operation.entityId}:`, - JSON.stringify(operation.data, null, 2) - ); - } await ApiService.recipes.update( operation.entityId, operation.data @@ -4008,6 +3701,14 @@ export class UserCacheService { // Check if this is a temp ID - if so, treat as CREATE instead of UPDATE const isTempId = operation.entityId.startsWith("temp_"); + // CRITICAL FIX: Check if brew session has temp recipe_id and resolve it (for both CREATE and UPDATE paths) + const { brewSessionData, hadTempRecipeId } = + await this.resolveTempRecipeId( + { ...operation.data }, + operation, + "UPDATE" + ); + if (isTempId) { // Convert UPDATE with temp ID to CREATE operation await UnifiedLogger.info( @@ -4015,12 +3716,13 @@ export class UserCacheService { `Converting UPDATE with temp ID ${operation.entityId} to CREATE operation for brew session`, { entityId: operation.entityId, - sessionName: operation.data?.name || "Unknown", + sessionName: brewSessionData?.name || "Unknown", + recipeId: brewSessionData.recipe_id, + hadTempRecipeId: hadTempRecipeId, } ); - const response = await ApiService.brewSessions.create( - operation.data - ); + const response = + await ApiService.brewSessions.create(brewSessionData); if (response && response.data && response.data.id) { return { realId: response.data.id }; } @@ -4031,13 +3733,15 @@ export class UserCacheService { `Executing UPDATE API call for brew session ${operation.entityId}`, { entityId: operation.entityId, - updateFields: Object.keys(operation.data || {}), - sessionName: operation.data?.name || "Unknown", + updateFields: Object.keys(brewSessionData || {}), + sessionName: brewSessionData?.name || "Unknown", + recipeId: brewSessionData.recipe_id, + hadTempRecipeId: hadTempRecipeId, } ); // Filter out embedded document arrays - they have dedicated sync operations - const updateData = { ...operation.data }; + const updateData = { ...brewSessionData }; // Remove fermentation_data - synced via fermentation_entry operations if (updateData.fermentation_data) { @@ -4167,7 +3871,8 @@ export class UserCacheService { } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error( + void UnifiedLogger.error( + "offline-cache", `[UserCacheService] Error processing ${operation.type} operation for ${operation.entityId}:`, errorMessage ); @@ -4205,16 +3910,6 @@ export class UserCacheService { const jitter = Math.floor(base * 0.1 * Math.random()); const delay = base + jitter; - await UnifiedLogger.debug( - "UserCacheService.backgroundSync", - `Background sync scheduled in ${delay}ms (backoff delay)`, - { - delay, - maxRetry, - exponential: exp, - } - ); - // Don't wait for sync to complete setTimeout(async () => { try { @@ -4235,7 +3930,11 @@ export class UserCacheService { `Background sync failed: ${errorMessage}`, { error: errorMessage } ); - console.warn("Background sync failed:", error); + void UnifiedLogger.warn( + "offline-cache", + "Background sync failed:", + error + ); } }, delay); } catch (error) { @@ -4246,7 +3945,11 @@ export class UserCacheService { `Failed to start background sync: ${errorMessage}`, { error: errorMessage } ); - console.warn("Failed to start background sync:", error); + void UnifiedLogger.warn( + "offline-cache", + "Failed to start background sync:", + error + ); } } @@ -4291,7 +3994,8 @@ export class UserCacheService { JSON.stringify(recipes) ); } else { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService] Recipe with temp ID "${tempId}" not found in cache` ); } @@ -4322,17 +4026,9 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_BREW_SESSIONS, JSON.stringify(sessions) ); - await UnifiedLogger.debug( - "UserCacheService.mapTempIdToRealId", - `Mapped brew session temp ID to real ID`, - { - tempId, - realId, - sessionName: sessions[i].data.name, - } - ); } else { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService] Brew session with temp ID "${tempId}" not found in cache` ); } @@ -4359,16 +4055,6 @@ export class UserCacheService { session.syncStatus = "pending"; session.lastModified = Date.now(); updatedCount++; - await UnifiedLogger.debug( - "UserCacheService.mapTempIdToRealId", - `Updated recipe_id reference in brew session`, - { - sessionId: session.id, - sessionName: session.data.name, - oldRecipeId: tempId, - newRecipeId: realId, - } - ); } } @@ -4395,29 +4081,22 @@ export class UserCacheService { STORAGE_KEYS_V2.PENDING_OPERATIONS ); const operations: PendingOperation[] = cached ? JSON.parse(cached) : []; - let updated = false; + let updatedEntityIds = 0; + let updatedBrewSessionRecipeRefs = 0; + let updatedParentIds = 0; + for (const op of operations) { // Update entityId if it matches the temp ID if (op.entityId === tempId) { op.entityId = realId; - updated = true; + updatedEntityIds++; } // Update recipe_id in brew session operation data if it references the temp recipe ID if (op.entityType === "brew_session" && op.data) { const brewSessionData = op.data as Partial; if (brewSessionData.recipe_id === tempId) { brewSessionData.recipe_id = realId; - updated = true; - await UnifiedLogger.debug( - "UserCacheService.mapTempIdToRealId", - `Updated recipe_id in pending brew session operation`, - { - operationId: op.id, - operationType: op.type, - oldRecipeId: tempId, - newRecipeId: realId, - } - ); + updatedBrewSessionRecipeRefs++; } } // Update parentId for fermentation_entry and dry_hop_addition operations @@ -4427,25 +4106,29 @@ export class UserCacheService { op.parentId === tempId ) { op.parentId = realId; - updated = true; - await UnifiedLogger.debug( - "UserCacheService.mapTempIdToRealId", - `Updated parentId in pending ${op.entityType} operation`, - { - operationId: op.id, - operationType: op.type, - entityType: op.entityType, - oldParentId: tempId, - newParentId: realId, - } - ); + updatedParentIds++; } } - if (updated) { + + const totalUpdated = + updatedEntityIds + updatedBrewSessionRecipeRefs + updatedParentIds; + + if (totalUpdated > 0) { await AsyncStorage.setItem( STORAGE_KEYS_V2.PENDING_OPERATIONS, JSON.stringify(operations) ); + await UnifiedLogger.info( + "UserCacheService.mapTempIdToRealId", + `Updated ${totalUpdated} pending operation(s) with new IDs`, + { + tempId, + realId, + updatedEntityIds, + updatedBrewSessionRecipeRefs, + updatedParentIds, + } + ); } }); @@ -4468,7 +4151,8 @@ export class UserCacheService { error: error instanceof Error ? error.message : "Unknown error", } ); - console.error( + void UnifiedLogger.error( + "offline-cache", "[UserCacheService] Error mapping temp ID to real ID:", error ); @@ -4498,13 +4182,9 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_RECIPES, JSON.stringify(recipes) ); - await UnifiedLogger.debug( - "UserCacheService.markItemAsSynced", - `Marked recipe as synced`, - { entityId, recipeName: recipes[i].data.name } - ); } else { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService] Recipe with ID "${entityId}" not found in cache for marking as synced` ); } @@ -4530,13 +4210,9 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_BREW_SESSIONS, JSON.stringify(sessions) ); - await UnifiedLogger.debug( - "UserCacheService.markItemAsSynced", - `Marked brew session as synced`, - { entityId, sessionName: sessions[i].data.name } - ); } else { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService] Brew session with ID "${entityId}" not found in cache for marking as synced` ); } @@ -4574,13 +4250,9 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_RECIPES, JSON.stringify(filteredRecipes) ); - await UnifiedLogger.debug( - "UserCacheService.removeItemFromCache", - `Removed recipe from cache`, - { entityId, removedCount: recipes.length - filteredRecipes.length } - ); } else { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService] Recipe with ID "${entityId}" not found in cache for removal` ); } @@ -4604,16 +4276,9 @@ export class UserCacheService { STORAGE_KEYS_V2.USER_BREW_SESSIONS, JSON.stringify(filteredSessions) ); - await UnifiedLogger.debug( - "UserCacheService.removeItemFromCache", - `Removed brew session from cache`, - { - entityId, - removedCount: sessions.length - filteredSessions.length, - } - ); } else { - console.warn( + void UnifiedLogger.warn( + "offline-cache", `[UserCacheService] Brew session with ID "${entityId}" not found in cache for removal` ); } @@ -4639,24 +4304,6 @@ export class UserCacheService { ): Partial { const sanitized = { ...updates }; - // Debug logging to understand the data being sanitized - if (__DEV__ && sanitized.ingredients) { - console.log( - "[UserCacheService.sanitizeRecipeUpdatesForAPI] Original ingredients:", - JSON.stringify( - sanitized.ingredients.map(ing => ({ - name: ing.name, - id: ing.id, - id_type: typeof ing.id, - ingredient_id: (ing as any).ingredient_id, - ingredient_id_type: typeof (ing as any).ingredient_id, - })), - null, - 2 - ) - ); - } - // Remove fields that shouldn't be updated via API delete sanitized.id; delete sanitized.created_at; @@ -4773,14 +4420,6 @@ export class UserCacheService { }); } - // Debug logging to see the sanitized result - if (__DEV__ && sanitized.ingredients) { - console.log( - "[UserCacheService.sanitizeRecipeUpdatesForAPI] Sanitized ingredients (FULL):", - JSON.stringify(sanitized.ingredients, null, 2) - ); - } - return sanitized; } @@ -4827,18 +4466,6 @@ export class UserCacheService { STORAGE_KEYS_V2.TEMP_ID_MAPPINGS, JSON.stringify(filtered) ); - - await UnifiedLogger.debug( - "UserCacheService.saveTempIdMapping", - `Saved tempId mapping for ${entityType}`, - { - tempId, - realId, - entityType, - userId, - expiresIn: `${ttl / (60 * 60 * 1000)} hours`, - } - ); }); } catch (error) { await UnifiedLogger.error( @@ -4884,17 +4511,6 @@ export class UserCacheService { ); if (mapping) { - await UnifiedLogger.debug( - "UserCacheService.getRealIdFromTempId", - `Found tempId mapping`, - { - tempId, - realId: mapping.realId, - entityType, - userId, - age: `${Math.round((now - mapping.timestamp) / 1000)}s`, - } - ); return mapping.realId; } diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 736a6df9..1d2d6cf9 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -18,6 +18,7 @@ import { File, Directory, Paths } from "expo-file-system"; import { Platform } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { STORAGE_KEYS } from "@services/config"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; /** * Android API levels for permission handling @@ -92,7 +93,11 @@ export class StorageService { return status === "granted"; } } catch (error) { - console.error("Error requesting media permissions:", error); + UnifiedLogger.error( + "storage", + "Error requesting media permissions:", + error + ); return false; } } @@ -343,7 +348,8 @@ export class BeerXMLService { // If user cancelled directory selection, fall back to sharing if (safResult.userCancelled) { } else { - console.warn( + UnifiedLogger.warn( + "storage", "🍺 BeerXML Export - SAF failed, falling back to sharing:", safResult.error ); @@ -423,7 +429,11 @@ export class BeerXMLService { uri: file.uri, }; } catch (error) { - console.error("🍺 BeerXML Export - Directory choice error:", error); + UnifiedLogger.error( + "storage", + "🍺 BeerXML Export - Directory choice error:", + error + ); return { success: false, error: error instanceof Error ? error.message : "File save failed", @@ -446,7 +456,7 @@ export class OfflineStorageService { try { // Validate input if (!Array.isArray(recipes)) { - console.error("Invalid recipes data: expected array"); + UnifiedLogger.error("storage", "Invalid recipes data: expected array"); return false; } @@ -454,7 +464,8 @@ export class OfflineStorageService { const dataSize = JSON.stringify(recipes).length; const MAX_SIZE = 10 * 1024 * 1024; // 10MB if (dataSize > MAX_SIZE) { - console.error( + UnifiedLogger.error( + "storage", `Recipe data too large: ${dataSize} bytes exceeds ${MAX_SIZE} bytes limit` ); return false; @@ -474,7 +485,7 @@ export class OfflineStorageService { return true; } catch (error) { - console.error("Failed to store offline recipes:", error); + UnifiedLogger.error("storage", "Failed to store offline recipes:", error); return false; } } @@ -523,7 +534,10 @@ export class OfflineStorageService { try { // Validate input if (!Array.isArray(ingredients)) { - console.error("Invalid ingredients data: expected array"); + UnifiedLogger.error( + "storage", + "Invalid ingredients data: expected array" + ); return false; } @@ -531,7 +545,8 @@ export class OfflineStorageService { const dataSize = JSON.stringify(ingredients).length; const MAX_SIZE = 5 * 1024 * 1024; // 5MB for ingredients if (dataSize > MAX_SIZE) { - console.error( + UnifiedLogger.error( + "storage", `Ingredients data too large: ${dataSize} bytes exceeds ${MAX_SIZE} bytes limit` ); return false; @@ -551,7 +566,7 @@ export class OfflineStorageService { return true; } catch (error) { - console.error("Failed to cache ingredients:", error); + UnifiedLogger.error("storage", "Failed to cache ingredients:", error); return false; } } @@ -610,7 +625,11 @@ export class OfflineStorageService { await AsyncStorage.setItem(STORAGE_KEYS.LAST_SYNC, timestamp.toString()); return true; } catch (error) { - console.error("Failed to update last sync timestamp:", error); + UnifiedLogger.error( + "storage", + "Failed to update last sync timestamp:", + error + ); return false; } } @@ -627,7 +646,11 @@ export class OfflineStorageService { const value = Number(raw); return Number.isFinite(value) ? value : null; } catch (error) { - console.error("Failed to get last sync timestamp:", error); + UnifiedLogger.error( + "storage", + "Failed to get last sync timestamp:", + error + ); return null; } } @@ -645,7 +668,7 @@ export class OfflineStorageService { return true; } catch (error) { - console.error("Failed to clear offline data:", error); + UnifiedLogger.error("storage", "Failed to clear offline data:", error); return false; } } @@ -684,7 +707,7 @@ export class OfflineStorageService { totalStorageSize: recipesSize + ingredientsSize, }; } catch (error) { - console.error("Failed to get offline stats:", error); + UnifiedLogger.error("storage", "Failed to get offline stats:", error); return { recipesCount: 0, ingredientsCount: 0, diff --git a/src/styles/components/beerxml/unitConversionModalStyles.ts b/src/styles/components/beerxml/unitConversionModalStyles.ts new file mode 100644 index 00000000..bcd0db8f --- /dev/null +++ b/src/styles/components/beerxml/unitConversionModalStyles.ts @@ -0,0 +1,100 @@ +/** + * Unit Conversion Choice Modal Styles + * + * Styles for the unit conversion choice modal component. + */ + +import { StyleSheet } from "react-native"; + +export const unitConversionModalStyles = StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + }, + modalContent: { + borderRadius: 12, + padding: 24, + width: "100%", + maxWidth: 400, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: 16, + gap: 12, + }, + title: { + fontSize: 20, + fontWeight: "600", + flex: 1, + }, + messageContainer: { + marginBottom: 24, + gap: 12, + }, + recipeName: { + fontSize: 18, + fontWeight: "600", + marginBottom: 8, + }, + message: { + fontSize: 16, + lineHeight: 24, + }, + unitHighlight: { + fontWeight: "600", + }, + subMessage: { + fontSize: 14, + lineHeight: 20, + }, + buttonContainer: { + gap: 12, + }, + primaryButton: { + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 8, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + minHeight: 48, // Touch target accessibility + }, + secondaryButton: { + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 8, + borderWidth: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + minHeight: 48, // Touch target accessibility + }, + buttonText: { + fontSize: 16, + fontWeight: "600", + }, + buttonSubtext: { + fontSize: 12, + fontWeight: "400", + marginTop: 4, + opacity: 0.9, + }, + buttonIcon: { + marginRight: 8, + }, + buttonSpinner: { + marginRight: 8, + }, +}); diff --git a/src/styles/modals/createRecipeStyles.ts b/src/styles/modals/createRecipeStyles.ts index 11b2874d..a9fe66a4 100644 --- a/src/styles/modals/createRecipeStyles.ts +++ b/src/styles/modals/createRecipeStyles.ts @@ -883,9 +883,6 @@ export const createRecipeStyles = (theme: ThemeContextValue) => }, ingredientSummary: { marginTop: 8, - flexDirection: "row", - flexWrap: "wrap", - gap: 8, }, ingredientTypeCount: { fontSize: 12, @@ -1023,6 +1020,7 @@ export const createRecipeStyles = (theme: ThemeContextValue) => // Basic BeerXML styles section: { marginBottom: 24, + padding: 16, }, button: { flexDirection: "row" as const, diff --git a/src/types/ai.ts b/src/types/ai.ts index 0b22e246..8c7c283e 100644 --- a/src/types/ai.ts +++ b/src/types/ai.ts @@ -13,6 +13,7 @@ * @module types/ai */ +import { UnitSystem } from "./common"; import { Recipe, RecipeMetrics } from "./recipe"; /** @@ -21,14 +22,15 @@ import { Recipe, RecipeMetrics } from "./recipe"; * Sends complete recipe data to backend for analysis and optimization */ export interface AIAnalysisRequest { - /** Complete recipe object with all fields (ingredients, parameters, etc.) */ - complete_recipe: Recipe; + /** Complete recipe object with all fields (ingredients, parameters, etc.) + * Note: For unit_conversion workflow, can be a partial recipe */ + complete_recipe: Recipe | Partial; /** Optional beer style guide ID for style-specific analysis */ style_id?: string; /** Unit system preference for analysis results */ - unit_system?: "metric" | "imperial"; + unit_system?: UnitSystem; /** Optional workflow name for specific optimization strategies */ workflow_name?: string; @@ -53,7 +55,7 @@ export interface AIAnalysisResponse { analysis_timestamp: string; /** Unit system used for the analysis */ - unit_system: "metric" | "imperial"; + unit_system: UnitSystem; /** User preferences used during analysis */ user_preferences: AIUserPreferences; diff --git a/src/types/api.ts b/src/types/api.ts index 86aae2dd..07797e01 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -25,7 +25,12 @@ import { BrewSessionSummary, } from "./brewSession"; import { User, UserSettings } from "./user"; -import { ApiResponse, PaginatedResponse } from "./common"; +import { + ApiResponse, + PaginatedResponse, + TemperatureUnit, + UnitSystem, +} from "./common"; // Authentication API types export interface LoginRequest { @@ -186,7 +191,7 @@ export interface CalculateMetricsPreviewRequest { boil_time: number; ingredients: Recipe["ingredients"]; mash_temperature?: number; - mash_temp_unit?: "F" | "C"; + mash_temp_unit?: TemperatureUnit; } export type CalculateMetricsPreviewResponse = RecipeMetrics; @@ -401,3 +406,15 @@ export interface DashboardData { } export type DashboardResponse = ApiResponse; + +// BeerXML API types +export interface BeerXMLConvertRecipeRequest { + recipe: Partial; + target_system: UnitSystem; + normalize?: boolean; +} + +export interface BeerXMLConvertRecipeResponse { + recipe: Partial; + warnings?: string[]; +} diff --git a/src/types/brewSession.ts b/src/types/brewSession.ts index 4784e906..0cc3662a 100644 --- a/src/types/brewSession.ts +++ b/src/types/brewSession.ts @@ -1,4 +1,4 @@ -import { ID } from "./common"; +import { ID, TemperatureUnit } from "./common"; import { Recipe, HopFormat } from "./recipe"; // Brew session status types @@ -20,9 +20,6 @@ export type FermentationStage = | "bottled" | "kegged"; -// Temperature unit -export type TemperatureUnit = "F" | "C"; - // Gravity reading interface export interface GravityReading { id: ID; @@ -94,7 +91,7 @@ export interface BrewSession { mash_temp?: number; // Additional API fields - temperature_unit?: "C" | "F"; + temperature_unit?: TemperatureUnit; fermentation_data?: FermentationEntry[]; // Backend uses fermentation_data dry_hop_additions?: DryHopAddition[]; style_database_id?: string; // Android-specific field for style reference. Gets stripped out on API calls. diff --git a/src/types/common.ts b/src/types/common.ts index 88c54ddf..cf62e581 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -26,6 +26,8 @@ export interface PaginatedResponse { // Unit system types export type UnitSystem = "metric" | "imperial"; +export type TemperatureUnit = "C" | "F"; + export type MeasurementType = | "weight" | "hop_weight" diff --git a/src/types/recipe.ts b/src/types/recipe.ts index 09b50181..01ef4bfa 100644 --- a/src/types/recipe.ts +++ b/src/types/recipe.ts @@ -17,11 +17,11 @@ * - Optional fields: Many type-specific fields are optional (e.g., alpha_acid for hops) */ -import { ID } from "./common"; +import { ID, TemperatureUnit, UnitSystem } from "./common"; // Recipe types export type IngredientType = "grain" | "hop" | "yeast" | "other"; -export type BatchSizeUnit = "gal" | "l"; +export type BatchSizeUnit = "l" | "gal"; export type IngredientUnit = | "lb" | "kg" @@ -42,7 +42,8 @@ export type HopFormat = "Pellet" | "Leaf" | "Plug" | "Whole" | "Extract"; // Recipe ingredient interface export interface RecipeIngredient { - id: ID; + id?: ID; // Optional - backend generates on creation, present on fetched recipes + ingredient_id?: string; // Reference to ingredient database entry (optional for test data and backward compat) name: string; type: IngredientType; amount: number; @@ -99,11 +100,11 @@ export interface Recipe { description: string; batch_size: number; batch_size_unit: BatchSizeUnit; - unit_system: "imperial" | "metric"; + unit_system: UnitSystem; boil_time: number; efficiency: number; mash_temperature: number; - mash_temp_unit: "F" | "C"; + mash_temp_unit: TemperatureUnit; mash_time?: number; is_public: boolean; notes: string; @@ -140,17 +141,39 @@ export interface RecipeFormData { description: string; batch_size: number; batch_size_unit: BatchSizeUnit; - unit_system: "imperial" | "metric"; + unit_system: UnitSystem; boil_time: number; efficiency: number; mash_temperature: number; - mash_temp_unit: "F" | "C"; + mash_temp_unit: TemperatureUnit; mash_time?: number; is_public: boolean; notes: string; ingredients: RecipeIngredient[]; } +/** + * Minimal data required for metrics calculation + * + * Note: This interface intentionally does NOT include unit_system. + * The metrics calculator uses specific unit fields (batch_size_unit, mash_temp_unit) + * rather than relying on a general unit_system preference, ensuring calculations + * work correctly regardless of user's unit system settings. + * + * RecipeIngredient.id is optional, so this works for both imports and existing recipes. + */ +export interface RecipeMetricsInput { + batch_size: number; + batch_size_unit: BatchSizeUnit; + efficiency: number; + boil_time: number; + mash_temperature?: number; + mash_temp_unit?: TemperatureUnit; + ingredients: RecipeIngredient[]; +} + +// RecipeCreatePayload removed - Partial is sufficient since RecipeIngredient.id is optional + // Recipe search filters export interface RecipeSearchFilters { style?: string; @@ -222,14 +245,5 @@ export interface CreateRecipeIngredientData { notes?: string; } -// Strict ingredient input for BeerXML import with required fields for validation -export interface IngredientInput { - ingredient_id: string; - name: string; - type: IngredientType; - amount: number; - unit: IngredientUnit; - use?: string; - time?: number; - instance_id?: string; // Unique instance identifier for React keys and duplicate ingredient handling (optional - backend generates if missing) -} +// IngredientInput removed - RecipeIngredient now handles both creation and fetched recipes +// with optional id field diff --git a/tests/app/(modals)/(beerxml)/importBeerXML.test.tsx b/tests/app/(modals)/(beerxml)/importBeerXML.test.tsx index 6137aced..e31dcab3 100644 --- a/tests/app/(modals)/(beerxml)/importBeerXML.test.tsx +++ b/tests/app/(modals)/(beerxml)/importBeerXML.test.tsx @@ -10,11 +10,40 @@ import { TEST_IDS } from "@src/constants/testIDs"; // Mock dependencies jest.mock("@contexts/ThemeContext", () => ({ useTheme: () => ({ - colors: { primary: "#007AFF", text: "#000" }, + colors: { + primary: "#007AFF", + text: "#000", + background: "#FFF", + backgroundSecondary: "#F5F5F5", + textSecondary: "#666", + border: "#CCC", + warning: "#F59E0B", + }, fonts: { regular: "System" }, }), })); +jest.mock("@contexts/UnitContext", () => ({ + useUnits: () => ({ + unitSystem: "imperial", + loading: false, + error: null, + updateUnitSystem: jest.fn(), + setError: jest.fn(), + getPreferredUnit: jest.fn(), + convertUnit: jest.fn(), + convertForDisplay: jest.fn(), + formatValue: jest.fn(), + getTemperatureSymbol: jest.fn(), + formatTemperature: jest.fn(), + formatCurrentTemperature: jest.fn(), + convertTemperature: jest.fn(), + getTemperatureAxisConfig: jest.fn(), + getUnitSystemLabel: jest.fn(), + getCommonUnits: jest.fn(), + }), +})); + jest.mock("expo-router", () => ({ router: { back: jest.fn(), push: jest.fn() }, })); @@ -25,10 +54,17 @@ jest.mock("@services/beerxml/BeerXMLService", () => { default: { importBeerXMLFile: jest.fn(), parseBeerXML: jest.fn(), + convertRecipeUnits: jest.fn(recipe => + Promise.resolve({ recipe, warnings: [] }) + ), }, }; }); +jest.mock("@src/components/beerxml/UnitConversionChoiceModal", () => ({ + UnitConversionChoiceModal: () => null, +})); + describe("ImportBeerXMLScreen", () => { beforeEach(() => { jest.clearAllMocks(); @@ -162,7 +198,7 @@ describe("ImportBeerXMLScreen - User Interactions", () => { jest.clearAllMocks(); }); - it("should handle file selection success", async () => { + it("should handle file selection success and navigate through unit choice", async () => { // Mock successful file import and parsing const mockBeerXMLService = require("@services/beerxml/BeerXMLService").default; @@ -177,38 +213,31 @@ describe("ImportBeerXMLScreen - User Interactions", () => { { name: "Test Recipe", style: "IPA", + batch_size: 20, + batch_size_unit: "l", ingredients: [ - { name: "Pale Malt", type: "grain", amount: 10, unit: "lb" }, + { name: "Pale Malt", type: "grain", amount: 4500, unit: "g" }, ], }, ]); - const mockRouter = require("expo-router").router; - - const { getByTestId, getByText } = render(); + const { getByTestId } = render(); const selectButton = getByTestId(TEST_IDS.beerxml.selectFileButton); await act(async () => { fireEvent.press(selectButton); }); - // Wait until parsing is done and recipe preview is shown + // Wait until parsing is done await waitFor(() => { - expect(getByText("Recipe Preview")).toBeTruthy(); - }); - - // Press the Import Recipe button - await act(async () => { - fireEvent.press(getByText("Import Recipe")); + expect(mockBeerXMLService.parseBeerXML).toHaveBeenCalled(); }); expect(mockBeerXMLService.importBeerXMLFile).toHaveBeenCalled(); - expect(mockRouter.push).toHaveBeenCalledWith({ - pathname: "/(modals)/(beerxml)/ingredientMatching", - params: expect.objectContaining({ - filename: "test_recipe.xml", - }), - }); + + // After parsing, the component moves to unit_choice step + // The modal would be shown but is mocked to return null + // We can verify the flow reached this point by checking services were called }); it("should handle file selection error", async () => { @@ -428,3 +457,125 @@ it("should retry file selection after error", async () => { 'Retry Recipe' ); }); + +describe("ImportBeerXMLScreen - Unit Conversion Workflow", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should always show unit choice modal after parsing (BeerXML is always metric)", async () => { + const mockBeerXMLService = + require("@services/beerxml/BeerXMLService").default; + + mockBeerXMLService.importBeerXMLFile = jest.fn().mockResolvedValue({ + success: true, + content: + 'Test Recipe', + filename: "test_recipe.xml", + }); + + mockBeerXMLService.parseBeerXML = jest.fn().mockResolvedValue([ + { + name: "Test Recipe", + style: "IPA", + batch_size: 20, + batch_size_unit: "l", + ingredients: [ + { name: "Pale Malt", type: "grain", amount: 4500, unit: "g" }, + ], + }, + ]); + + const { getByTestId } = render(); + const selectButton = getByTestId(TEST_IDS.beerxml.selectFileButton); + + await act(async () => { + fireEvent.press(selectButton); + }); + + // Wait for parsing to complete + await waitFor(() => { + expect(mockBeerXMLService.parseBeerXML).toHaveBeenCalled(); + }); + + // The modal is mocked, but we can verify the state reached unit_choice step + // by checking that the modal would be rendered with visible=true + expect(mockBeerXMLService.importBeerXMLFile).toHaveBeenCalled(); + }); + + it("should convert recipe to chosen unit system with normalization", async () => { + const mockBeerXMLService = + require("@services/beerxml/BeerXMLService").default; + + const metricRecipe = { + name: "Test Recipe", + style: "IPA", + batch_size: 20, + batch_size_unit: "l", + ingredients: [ + { name: "Pale Malt", type: "grain", amount: 4500, unit: "g" }, + ], + }; + + const convertedRecipe = { + name: "Test Recipe", + style: "IPA", + batch_size: 5.28, + batch_size_unit: "gal", + ingredients: [ + { name: "Pale Malt", type: "grain", amount: 10, unit: "lb" }, // Normalized from 9.92 + ], + }; + + mockBeerXMLService.importBeerXMLFile = jest.fn().mockResolvedValue({ + success: true, + content: '', + filename: "test_recipe.xml", + }); + + mockBeerXMLService.parseBeerXML = jest + .fn() + .mockResolvedValue([metricRecipe]); + + mockBeerXMLService.convertRecipeUnits = jest + .fn() + .mockResolvedValue({ recipe: convertedRecipe, warnings: [] }); + + render(); + + // Note: Since modal is mocked, we can't test the full flow + // but the convertRecipeUnits function is set up correctly + expect(mockBeerXMLService.convertRecipeUnits).toBeDefined(); + }); + + it("should handle conversion errors gracefully", async () => { + const mockBeerXMLService = + require("@services/beerxml/BeerXMLService").default; + + mockBeerXMLService.importBeerXMLFile = jest.fn().mockResolvedValue({ + success: true, + content: '', + filename: "test_recipe.xml", + }); + + mockBeerXMLService.parseBeerXML = jest.fn().mockResolvedValue([ + { + name: "Test Recipe", + style: "IPA", + batch_size: 20, + batch_size_unit: "l", + ingredients: [], + }, + ]); + + mockBeerXMLService.convertRecipeUnits = jest + .fn() + .mockRejectedValue(new Error("Conversion failed")); + + render(); + + // Conversion errors are handled when user chooses a unit system + // Since modal is mocked, we just verify the error handling is set up + expect(mockBeerXMLService.convertRecipeUnits).toBeDefined(); + }); +}); diff --git a/tests/app/(modals)/(beerxml)/importReview.test.tsx b/tests/app/(modals)/(beerxml)/importReview.test.tsx index dcd76fe2..a1805d0a 100644 --- a/tests/app/(modals)/(beerxml)/importReview.test.tsx +++ b/tests/app/(modals)/(beerxml)/importReview.test.tsx @@ -3,18 +3,29 @@ */ import React from "react"; -import { render } from "@testing-library/react-native"; +import { renderWithProviders, testUtils } from "@/tests/testUtils"; import ImportReviewScreen from "../../../../app/(modals)/(beerxml)/importReview"; import { TEST_IDS } from "@src/constants/testIDs"; -// Mock dependencies -jest.mock("@contexts/ThemeContext", () => ({ - useTheme: () => ({ - colors: { primary: "#007AFF", text: "#000", background: "#FFF" }, - fonts: { regular: "System" }, - }), +// Mock Appearance +jest.mock("react-native/Libraries/Utilities/Appearance", () => ({ + getColorScheme: jest.fn(() => "light"), + addChangeListener: jest.fn(), + removeChangeListener: jest.fn(), })); +// Mock dependencies +jest.mock("@contexts/ThemeContext", () => { + const React = require("react"); + return { + useTheme: () => ({ + colors: { primary: "#007AFF", text: "#000", background: "#FFF" }, + fonts: { regular: "System" }, + }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, + }; +}); + jest.mock("expo-router", () => ({ router: { back: jest.fn(), push: jest.fn() }, useLocalSearchParams: jest.fn(() => ({ @@ -29,35 +40,9 @@ jest.mock("expo-router", () => ({ })), })); -jest.mock("@tanstack/react-query", () => { - const actual = jest.requireActual("@tanstack/react-query"); - return { - ...actual, - QueryClient: jest.fn().mockImplementation(() => ({ - invalidateQueries: jest.fn(), - clear: jest.fn(), - defaultOptions: jest.fn(() => ({ queries: { retry: false } })), - getQueryCache: jest.fn(() => ({ - getAll: jest.fn(() => []), - })), - removeQueries: jest.fn(), - })), - useQuery: jest.fn(() => ({ - data: null, - isLoading: false, - error: null, - })), - useMutation: jest.fn(() => ({ - mutate: jest.fn(), - mutateAsync: jest.fn(), - isLoading: false, - error: null, - })), - useQueryClient: jest.fn(() => ({ - invalidateQueries: jest.fn(), - })), - }; -}); +// Note: @tanstack/react-query is already mocked in setupTests.js with a more +// sophisticated implementation that includes Map-backed query storage and cache tracking. +// No need to override it here. jest.mock("@services/api/apiService", () => ({ default: { @@ -68,51 +53,108 @@ jest.mock("@services/api/apiService", () => ({ }, })); +// Mock auth context +let mockAuthState = { + user: { id: "test-user", username: "testuser", email: "test@example.com" }, + isLoading: false, + isAuthenticated: true, + error: null, +}; + +const setMockAuthState = (overrides: Partial) => { + mockAuthState = { ...mockAuthState, ...overrides }; +}; + +jest.mock("@contexts/AuthContext", () => { + return { + useAuth: () => mockAuthState, + AuthProvider: ({ children }: { children: React.ReactNode }) => children, + }; +}); + +// Mock other context providers +jest.mock("@contexts/NetworkContext", () => { + return { + useNetwork: () => ({ isConnected: true, isInternetReachable: true }), + NetworkProvider: ({ children }: { children: React.ReactNode }) => children, + }; +}); + +jest.mock("@contexts/DeveloperContext", () => { + return { + useDeveloper: () => ({ isDeveloperMode: false }), + DeveloperProvider: ({ children }: { children: React.ReactNode }) => + children, + }; +}); + +jest.mock("@contexts/UnitContext", () => { + return { + useUnits: () => ({ unitSystem: "metric", setUnitSystem: jest.fn() }), + UnitProvider: ({ children }: { children: React.ReactNode }) => children, + }; +}); + +jest.mock("@contexts/CalculatorsContext", () => { + return { + useCalculators: () => ({ state: {}, dispatch: jest.fn() }), + CalculatorsProvider: ({ children }: { children: React.ReactNode }) => + children, + }; +}); + describe("ImportReviewScreen", () => { beforeEach(() => { jest.clearAllMocks(); + testUtils.resetCounters(); + setMockAuthState({ + user: { + id: "test-user", + username: "testuser", + email: "test@example.com", + }, + isLoading: false, + isAuthenticated: true, + error: null, + }); }); it("should render without crashing", () => { - expect(() => render()).not.toThrow(); + expect(() => renderWithProviders()).not.toThrow(); }); it("should display import review title", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Import Review")).toBeTruthy(); }); it("should display recipe name", () => { - const { getAllByText } = render(); + const { getAllByText } = renderWithProviders(); expect(getAllByText("Test Recipe").length).toBeGreaterThan(0); }); }); describe("ImportReviewScreen - Additional UI Tests", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it("should display batch size information", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Batch Size:")).toBeTruthy(); // The 5.0 and gal are in the same text node together expect(getByText("5.0 gal")).toBeTruthy(); }); it("should show metrics section", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Calculated Metrics")).toBeTruthy(); expect(getByText("No metrics calculated")).toBeTruthy(); }); it("should display filename from params", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("test.xml")).toBeTruthy(); }); it("should have proper screen structure", () => { - const { getByTestId } = render(); + const { getByTestId } = renderWithProviders(); expect( getByTestId(TEST_IDS.patterns.scrollAction("import-review")) ).toBeTruthy(); @@ -120,12 +162,8 @@ describe("ImportReviewScreen - Additional UI Tests", () => { }); describe("ImportReviewScreen - UI Elements", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it("should display recipe details section", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Recipe Details")).toBeTruthy(); expect(getByText("Name:")).toBeTruthy(); @@ -134,7 +172,9 @@ describe("ImportReviewScreen - UI Elements", () => { }); it("should display import summary section", () => { - const { getByText, getAllByText } = render(); + const { getByText, getAllByText } = renderWithProviders( + + ); expect(getByText("Import Summary")).toBeTruthy(); expect(getByText("Source File")).toBeTruthy(); @@ -144,14 +184,14 @@ describe("ImportReviewScreen - UI Elements", () => { }); it("should display action buttons", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Review Ingredients")).toBeTruthy(); expect(getByText("Create Recipe")).toBeTruthy(); }); it("should show efficiency and boil time", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Efficiency:")).toBeTruthy(); expect(getByText("75%")).toBeTruthy(); // efficiency with % @@ -160,7 +200,9 @@ describe("ImportReviewScreen - UI Elements", () => { }); it("should show ingredients section", () => { - const { getAllByText, getByText } = render(); + const { getAllByText, getByText } = renderWithProviders( + + ); expect(getAllByText("Ingredients").length).toBeGreaterThan(0); expect(getByText("0 ingredients")).toBeTruthy(); // count with text @@ -189,18 +231,14 @@ describe("ImportReviewScreen - coerceIngredientTime Function", () => { createdIngredientsCount: "6", }); - const { getAllByText } = render(); + const { getAllByText } = renderWithProviders(); expect(getAllByText("Time Test Recipe").length).toBeGreaterThan(0); }); }); describe("ImportReviewScreen - Advanced UI Tests", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it("should display recipe with no specified style", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Not specified")).toBeTruthy(); // Style not specified }); @@ -212,24 +250,62 @@ describe("ImportReviewScreen - Advanced UI Tests", () => { batch_size: 5, batch_size_unit: "gal", ingredients: [ - { name: "Hop 1" }, - { name: "Hop 2" }, - { name: "Hop 3" }, - { name: "Hop 4" }, - { name: "Hop 5" }, - { name: "Hop 6" }, + { + ingredient_id: "1", + name: "Hop 1", + type: "hop", + amount: 1, + unit: "oz", + }, + { + ingredient_id: "2", + name: "Hop 2", + type: "hop", + amount: 2, + unit: "oz", + }, + { + ingredient_id: "3", + name: "Hop 3", + type: "hop", + amount: 1, + unit: "oz", + }, + { + ingredient_id: "4", + name: "Hop 4", + type: "hop", + amount: 1, + unit: "oz", + }, + { + ingredient_id: "5", + name: "Hop 5", + type: "hop", + amount: 0.5, + unit: "oz", + }, + { + ingredient_id: "6", + name: "Hop 6", + type: "hop", + amount: 1, + unit: "oz", + }, ], }), filename: "test.xml", createdIngredientsCount: "6", }); - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("6 ingredients")).toBeTruthy(); }); it("should display multiple UI sections", () => { - const { getByText, getAllByText } = render(); + const { getByText, getAllByText } = renderWithProviders( + + ); expect(getByText("Import Summary")).toBeTruthy(); expect(getByText("Recipe Details")).toBeTruthy(); @@ -238,21 +314,23 @@ describe("ImportReviewScreen - Advanced UI Tests", () => { }); it("should show default recipe values", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("60 minutes")).toBeTruthy(); // Default boil time expect(getByText("75%")).toBeTruthy(); // Default efficiency }); it("should display import action buttons", () => { - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Review Ingredients")).toBeTruthy(); expect(getByText("Create Recipe")).toBeTruthy(); }); it("should render all component parts", () => { - const { getByTestId, getAllByRole } = render(); + const { getByTestId, getAllByRole } = renderWithProviders( + + ); expect( getByTestId(TEST_IDS.patterns.scrollAction("import-review")) @@ -268,10 +346,6 @@ describe("ImportReviewScreen - Advanced UI Tests", () => { }); describe("ImportReviewScreen - Recipe Variations", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it("should handle recipe with custom values", () => { const mockParams = jest.requireMock("expo-router").useLocalSearchParams; mockParams.mockReturnValue({ @@ -291,7 +365,9 @@ describe("ImportReviewScreen - Recipe Variations", () => { createdIngredientsCount: "2", }); - const { getAllByText, getByText } = render(); + const { getAllByText, getByText } = renderWithProviders( + + ); expect(getAllByText("Custom Recipe").length).toBeGreaterThan(0); expect(getByText("10.0 L")).toBeTruthy(); // Metric batch size (uppercase L) @@ -315,7 +391,7 @@ describe("ImportReviewScreen - Recipe Variations", () => { error: null, }); - const { getByText } = render(); + const { getByText } = renderWithProviders(); expect(getByText("Calculated Metrics")).toBeTruthy(); // Restore original mock diff --git a/tests/app/(modals)/(calculators)/hydrometerCorrection.test.tsx b/tests/app/(modals)/(calculators)/hydrometerCorrection.test.tsx index cad5bc36..c1541220 100644 --- a/tests/app/(modals)/(calculators)/hydrometerCorrection.test.tsx +++ b/tests/app/(modals)/(calculators)/hydrometerCorrection.test.tsx @@ -45,7 +45,7 @@ const mockState = { measuredGravity: "", wortTemp: "", calibrationTemp: "68", - tempUnit: "f", + tempUnit: "F", result: null as any, }, }; @@ -131,7 +131,7 @@ jest.mock("@components/calculators/UnitToggle", () => { return ( onChange && onChange(value === "f" ? "c" : "f")} + onPress={() => onChange && onChange(value === "F" ? "C" : "F")} > {label} @@ -275,7 +275,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { expect(mockDispatch).toHaveBeenCalledWith({ type: "SET_HYDROMETER_CORRECTION", payload: { - tempUnit: "c", + tempUnit: "C", wortTemp: "23.9", // 75°F to °C calibrationTemp: "20.0", // 68°F to °C }, @@ -289,7 +289,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "68", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -303,7 +303,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { expect(mockDispatch).toHaveBeenCalledWith({ type: "SET_HYDROMETER_CORRECTION", payload: { - tempUnit: "c", + tempUnit: "C", wortTemp: "20.0", calibrationTemp: "20.0", }, @@ -315,7 +315,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "20", calibrationTemp: "20", - tempUnit: "c" as const, + tempUnit: "C" as const, result: null, }; @@ -329,7 +329,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { expect(mockDispatch).toHaveBeenCalledWith({ type: "SET_HYDROMETER_CORRECTION", payload: { - tempUnit: "f", + tempUnit: "F", wortTemp: "68.0", calibrationTemp: "68.0", }, @@ -341,7 +341,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "", wortTemp: "", calibrationTemp: "", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -355,7 +355,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { expect(mockDispatch).toHaveBeenCalledWith({ type: "SET_HYDROMETER_CORRECTION", payload: { - tempUnit: "c", + tempUnit: "C", wortTemp: "", calibrationTemp: "", }, @@ -367,7 +367,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "invalid", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -381,7 +381,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { expect(mockDispatch).toHaveBeenCalledWith({ type: "SET_HYDROMETER_CORRECTION", payload: { - tempUnit: "c", + tempUnit: "C", wortTemp: "invalid", // Should remain unchanged calibrationTemp: "20.0", }, @@ -395,7 +395,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "", wortTemp: "", calibrationTemp: "", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -415,7 +415,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -436,7 +436,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "75", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -448,7 +448,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { 1.05, 75, 68, - "f" + "F" ); expect(mockDispatch).toHaveBeenCalledWith({ @@ -467,7 +467,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "75", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -483,7 +483,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: 1.05, wortTemp: 75, calibrationTemp: 68, - tempUnit: "f", + tempUnit: "F", }, result: { correctedGravity: 1.048, @@ -498,7 +498,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "invalid", wortTemp: "75", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -518,7 +518,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "75", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -550,7 +550,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "75", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: 1.048, }; @@ -566,7 +566,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "75", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: 1.048, }; @@ -590,7 +590,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { }); it("should show Celsius placeholders and units when in Celsius mode", () => { - mockState.hydrometerCorrection.tempUnit = "c"; + mockState.hydrometerCorrection.tempUnit = "C"; renderWithProviders(); // This is indirectly tested through the NumberInput props @@ -603,7 +603,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "0", wortTemp: "0", calibrationTemp: "0", - tempUnit: "c" as const, + tempUnit: "C" as const, result: null, }; @@ -613,7 +613,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { 0, 0, 0, - "c" + "C" ); }); @@ -622,7 +622,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.100", wortTemp: "212", calibrationTemp: "32", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -632,7 +632,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { 1.1, 212, 32, - "f" + "F" ); }); @@ -641,7 +641,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.150", wortTemp: "68", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -651,7 +651,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { 1.15, 68, 68, - "f" + "F" ); }); @@ -660,7 +660,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "68.5", calibrationTemp: "67.8", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -670,7 +670,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { 1.05, 68.5, 67.8, - "f" + "F" ); }); }); @@ -725,7 +725,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { measuredGravity: "1.050", wortTemp: "68", calibrationTemp: "68", - tempUnit: "f" as const, + tempUnit: "F" as const, result: null, }; @@ -740,7 +740,7 @@ describe("HydrometerCorrectionCalculatorScreen", () => { expect(mockDispatch).toHaveBeenCalledWith({ type: "SET_HYDROMETER_CORRECTION", payload: { - tempUnit: "c", + tempUnit: "C", wortTemp: "20.0", // 68°F -> 20.0°C calibrationTemp: "20.0", // 68°F -> 20.0°C }, diff --git a/tests/app/(modals)/(calculators)/strikeWater.test.tsx b/tests/app/(modals)/(calculators)/strikeWater.test.tsx index d853e6b1..f64ca1a4 100644 --- a/tests/app/(modals)/(calculators)/strikeWater.test.tsx +++ b/tests/app/(modals)/(calculators)/strikeWater.test.tsx @@ -34,7 +34,7 @@ const mockCalculatorsState = { grainWeightUnit: "lb", grainTemp: "", targetMashTemp: "", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null as any, }, @@ -123,9 +123,9 @@ jest.mock("@services/calculators/StrikeWaterCalculator", () => ({ // Mock unit converter const mockUnitConverter = { convertTemperature: jest.fn((value, from, to) => { - if (from === "f" && to === "c") { + if (from === "F" && to === "C") { return (value - 32) * (5 / 9); - } else if (from === "c" && to === "f") { + } else if (from === "C" && to === "F") { return (value * 9) / 5 + 32; } return value; @@ -251,7 +251,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "", targetMashTemp: "", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null, }; @@ -272,7 +272,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "70", targetMashTemp: "152", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null, }; @@ -293,7 +293,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "70", targetMashTemp: "152", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null, }; @@ -318,7 +318,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "70", targetMashTemp: "152", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null, }; @@ -347,7 +347,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "70", targetMashTemp: "152", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "", // Missing ratio result: null, }; @@ -378,7 +378,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "70", targetMashTemp: "152", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "1.25", result: null, }; @@ -398,7 +398,7 @@ describe("StrikeWaterCalculatorScreen", () => { grainWeightUnit: "lb", grainTemp: "70", targetMashTemp: "152", - tempUnit: "f", + tempUnit: "F", waterToGrainRatio: "0", result: null, }; diff --git a/tests/services/brewing/OfflineMetricsCalculator.test.ts b/tests/services/brewing/OfflineMetricsCalculator.test.ts new file mode 100644 index 00000000..c6eeaa0d --- /dev/null +++ b/tests/services/brewing/OfflineMetricsCalculator.test.ts @@ -0,0 +1,572 @@ +/** + * Offline Metrics Calculator Tests + * + * Tests for brewing calculations without API dependency + */ + +import { OfflineMetricsCalculator } from "@services/brewing/OfflineMetricsCalculator"; +import { RecipeMetricsInput, RecipeIngredient } from "@src/types"; + +describe("OfflineMetricsCalculator", () => { + describe("validateRecipeData", () => { + const baseRecipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + }, + ], + }; + + it("should validate valid recipe data", () => { + const result = OfflineMetricsCalculator.validateRecipeData(baseRecipe); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject recipe with zero batch size", () => { + const invalidRecipe = { ...baseRecipe, batch_size: 0 }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Batch size must be greater than 0"); + }); + + it("should reject recipe with negative batch size", () => { + const invalidRecipe = { ...baseRecipe, batch_size: -5 }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Batch size must be greater than 0"); + }); + + it("should reject recipe with zero efficiency", () => { + const invalidRecipe = { ...baseRecipe, efficiency: 0 }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Efficiency must be between 1 and 100"); + }); + + it("should reject recipe with efficiency > 100", () => { + const invalidRecipe = { ...baseRecipe, efficiency: 101 }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Efficiency must be between 1 and 100"); + }); + + it("should reject recipe with negative boil time", () => { + const invalidRecipe = { ...baseRecipe, boil_time: -10 }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Boil time must be zero or greater"); + }); + + it("should accept recipe with zero boil time", () => { + const validRecipe = { ...baseRecipe, boil_time: 0 }; + const result = OfflineMetricsCalculator.validateRecipeData(validRecipe); + + expect(result.isValid).toBe(true); + }); + + it("should reject recipe with no ingredients", () => { + const invalidRecipe = { ...baseRecipe, ingredients: [] }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("At least one ingredient is required"); + }); + + it("should reject recipe with no fermentables", () => { + const invalidRecipe: RecipeMetricsInput = { + ...baseRecipe, + ingredients: [ + { + id: "1", + name: "Cascade Hops", + type: "hop", + amount: 1, + unit: "oz", + }, + ] as RecipeIngredient[], + }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "At least one fermentable (grain or sugar/extract) is required" + ); + }); + + it("should reject ingredient with negative amount", () => { + const invalidRecipe: RecipeMetricsInput = { + ...baseRecipe, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: -5, + unit: "lb", + }, + ] as RecipeIngredient[], + }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Pale Malt amount must be >= 0"); + }); + + it("should reject hop with invalid alpha acid", () => { + const invalidRecipe: RecipeMetricsInput = { + ...baseRecipe, + ingredients: [ + ...baseRecipe.ingredients, + { + id: "2", + name: "Cascade", + type: "hop", + amount: 1, + unit: "oz", + alpha_acid: 35, + }, + ] as RecipeIngredient[], + }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Hop alpha acid must be between 0 and 30" + ); + }); + + it("should reject yeast with invalid attenuation", () => { + const invalidRecipe: RecipeMetricsInput = { + ...baseRecipe, + ingredients: [ + ...baseRecipe.ingredients, + { + id: "3", + name: "US-05", + type: "yeast", + amount: 1, + unit: "pkg", + attenuation: 150, + }, + ] as RecipeIngredient[], + }; + const result = OfflineMetricsCalculator.validateRecipeData(invalidRecipe); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Yeast attenuation must be between 0 and 100" + ); + }); + }); + + describe("calculateMetrics", () => { + it("should calculate metrics for simple pale ale recipe", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + potential: 37, + color: 2, + }, + { + id: "2", + name: "Cascade", + type: "hop", + amount: 1, + unit: "oz", + alpha_acid: 5.5, + use: "boil", + time: 60, + }, + { + id: "3", + name: "US-05", + type: "yeast", + amount: 1, + unit: "pkg", + attenuation: 75, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + expect(metrics.og).toBeGreaterThan(1.0); + expect(metrics.og).toBeLessThan(1.1); + expect(metrics.fg).toBeGreaterThan(1.0); + expect(metrics.fg).toBeLessThan(metrics.og); + expect(metrics.abv).toBeGreaterThan(0); + expect(metrics.ibu).toBeGreaterThan(0); + expect(metrics.srm).toBeGreaterThan(0); + }); + + it("should return default metrics for invalid recipe", () => { + const invalidRecipe: RecipeMetricsInput = { + batch_size: 0, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(invalidRecipe); + + expect(metrics).toEqual({ + og: 1.0, + fg: 1.0, + abv: 0.0, + ibu: 0.0, + srm: 0.0, + }); + }); + + it("should handle metric units (liters)", () => { + const recipe: RecipeMetricsInput = { + batch_size: 20, + batch_size_unit: "l", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "C", + mash_temperature: 67, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 4.5, + unit: "kg", + potential: 37, + color: 2, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + expect(metrics.og).toBeGreaterThan(1.0); + expect(metrics.srm).toBeGreaterThan(0); + }); + + it("should calculate correct FG when no yeast is present (0% attenuation)", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + potential: 37, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + // With no yeast, FG should equal OG (0% attenuation) + expect(metrics.fg).toBe(metrics.og); + expect(metrics.abv).toBe(0); + }); + + it("should skip dry hop additions in IBU calculation", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + potential: 37, + }, + { + id: "2", + name: "Cascade", + type: "hop", + amount: 2, + unit: "oz", + alpha_acid: 5.5, + use: "dry-hop", + time: 7, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + // Dry hops shouldn't contribute to IBU + expect(metrics.ibu).toBe(0); + }); + + it("should handle whirlpool/flameout hops with specific time", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + potential: 37, + }, + { + id: "2", + name: "Cascade", + type: "hop", + amount: 1, + unit: "oz", + alpha_acid: 5.5, + use: "whirlpool", + time: 20, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + // Whirlpool hops with time should contribute some IBU + expect(metrics.ibu).toBeGreaterThan(0); + }); + + it("should use default values for missing ingredient properties", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + // Missing potential, color - should use defaults + }, + { + id: "2", + name: "Cascade", + type: "hop", + amount: 1, + unit: "oz", + // Missing alpha_acid - should default to 5% + use: "boil", + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + expect(metrics.og).toBeGreaterThan(1.0); + expect(metrics.ibu).toBeGreaterThan(0); + expect(metrics.srm).toBeGreaterThan(0); + }); + + it("should round metrics to proper decimal places", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + potential: 37, + color: 2, + }, + { + id: "2", + name: "Cascade", + type: "hop", + amount: 1, + unit: "oz", + alpha_acid: 5.5, + use: "boil", + time: 60, + }, + { + id: "3", + name: "US-05", + type: "yeast", + amount: 1, + unit: "pkg", + attenuation: 75, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + // OG/FG should be 3 decimal places (e.g., 1.050) + expect(metrics.og.toString().split(".")[1]?.length).toBeLessThanOrEqual( + 3 + ); + expect(metrics.fg.toString().split(".")[1]?.length).toBeLessThanOrEqual( + 3 + ); + + // ABV, IBU, SRM should be 1 decimal place + expect(metrics.abv.toString().split(".")[1]?.length).toBeLessThanOrEqual( + 1 + ); + expect(metrics.ibu.toString().split(".")[1]?.length).toBeLessThanOrEqual( + 1 + ); + expect(metrics.srm.toString().split(".")[1]?.length).toBeLessThanOrEqual( + 1 + ); + }); + + it("should handle recipes with other fermentables (sugars/extracts)", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "DME", + type: "other", + amount: 5, + unit: "lb", + potential: 42, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + expect(metrics.og).toBeGreaterThan(1.0); + // SRM should be 2 (default for no grains) + expect(metrics.srm).toBe(2); + }); + + it("should clamp SRM to valid range [1, 60]", () => { + const darkRecipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Chocolate Malt", + type: "grain", + amount: 20, + unit: "lb", + potential: 35, + color: 350, // Very dark + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(darkRecipe); + + expect(metrics.srm).toBeLessThanOrEqual(60); + expect(metrics.srm).toBeGreaterThanOrEqual(1); + }); + + it("should average attenuation when multiple yeasts are present", () => { + const recipe: RecipeMetricsInput = { + batch_size: 5, + batch_size_unit: "gal", + efficiency: 75, + boil_time: 60, + mash_temp_unit: "F", + mash_temperature: 152, + ingredients: [ + { + id: "1", + name: "Pale Malt", + type: "grain", + amount: 10, + unit: "lb", + potential: 37, + }, + { + id: "2", + name: "US-05", + type: "yeast", + amount: 1, + unit: "pkg", + attenuation: 75, + }, + { + id: "3", + name: "WLP001", + type: "yeast", + amount: 1, + unit: "pkg", + attenuation: 77, + }, + ], + }; + + const metrics = OfflineMetricsCalculator.calculateMetrics(recipe); + + // Should use average attenuation (75 + 77) / 2 = 76 + expect(metrics.fg).toBeLessThan(metrics.og); + expect(metrics.abv).toBeGreaterThan(0); + }); + }); +}); diff --git a/tests/src/components/recipes/RecipeForm/ParametersForm.test.tsx b/tests/src/components/recipes/RecipeForm/ParametersForm.test.tsx index eb3555c2..1775fb63 100644 --- a/tests/src/components/recipes/RecipeForm/ParametersForm.test.tsx +++ b/tests/src/components/recipes/RecipeForm/ParametersForm.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render, fireEvent, waitFor } from "@testing-library/react-native"; import { ParametersForm } from "@src/components/recipes/RecipeForm/ParametersForm"; -import { RecipeFormData } from "@src/types"; +import { RecipeFormData, UnitSystem } from "@src/types"; import { TEST_IDS } from "@src/constants/testIDs"; // Comprehensive React Native mocking to avoid ES6 module issues @@ -42,7 +42,7 @@ jest.mock("@contexts/ThemeContext", () => ({ // Mock unit context const mockUnitContext = { - unitSystem: "imperial" as "imperial" | "metric", + unitSystem: "imperial" as UnitSystem, setUnitSystem: jest.fn(), }; diff --git a/tests/src/contexts/CalculatorsContext.test.tsx b/tests/src/contexts/CalculatorsContext.test.tsx index 2c9d206c..794c2fa9 100644 --- a/tests/src/contexts/CalculatorsContext.test.tsx +++ b/tests/src/contexts/CalculatorsContext.test.tsx @@ -12,8 +12,6 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { CalculatorsProvider, useCalculators, - CalculatorState, - CalculatorAction, } from "../../../src/contexts/CalculatorsContext"; // Mock AsyncStorage @@ -58,13 +56,13 @@ describe("CalculatorsContext", () => { expect(state.abv.result).toBeNull(); // Test Strike Water calculator defaults - expect(state.strikeWater.tempUnit).toBe("f"); + expect(state.strikeWater.tempUnit).toBe("F"); expect(state.strikeWater.waterToGrainRatio).toBe("1.25"); expect(state.strikeWater.result).toBeNull(); // Test Hydrometer Correction defaults expect(state.hydrometerCorrection.calibrationTemp).toBe("60"); - expect(state.hydrometerCorrection.tempUnit).toBe("f"); + expect(state.hydrometerCorrection.tempUnit).toBe("F"); expect(state.hydrometerCorrection.result).toBeNull(); // Test Boil Timer defaults @@ -142,7 +140,7 @@ describe("CalculatorsContext", () => { grainWeightUnit: "kg", grainTemp: "20", targetMashTemp: "67", - tempUnit: "c", + tempUnit: "C", result: { strikeTemp: 72.5, waterVolume: 12.5 }, }, }); @@ -151,7 +149,7 @@ describe("CalculatorsContext", () => { const { strikeWater } = result.current.state; expect(strikeWater.grainWeight).toBe("10"); expect(strikeWater.grainWeightUnit).toBe("kg"); - expect(strikeWater.tempUnit).toBe("c"); + expect(strikeWater.tempUnit).toBe("C"); expect(strikeWater.result).toEqual({ strikeTemp: 72.5, waterVolume: 12.5, @@ -170,7 +168,7 @@ describe("CalculatorsContext", () => { measuredGravity: "1.050", wortTemp: "80", calibrationTemp: "68", - tempUnit: "f", + tempUnit: "F", result: 1.052, }, }); @@ -458,7 +456,7 @@ describe("CalculatorsContext", () => { }, preferences: { defaultUnits: "metric", - temperatureUnit: "c", + temperatureUnit: "C", saveHistory: true, }, }, @@ -477,7 +475,7 @@ describe("CalculatorsContext", () => { expect(state.preferences.defaultUnits).toBe("metric"); // Should preserve other calculator states - expect(state.strikeWater.tempUnit).toBe("f"); // Default preserved + expect(state.strikeWater.tempUnit).toBe("F"); // Default preserved }); it("should handle partial nested state updates", () => { @@ -562,7 +560,7 @@ describe("CalculatorsContext", () => { }, preferences: { defaultUnits: "metric" as const, - temperatureUnit: "c" as const, + temperatureUnit: "C" as const, saveHistory: false, }, }; @@ -581,7 +579,7 @@ describe("CalculatorsContext", () => { expect(result.current.state.abv.originalGravity).toBe("1.060"); expect(result.current.state.abv.formula).toBe("advanced"); expect(result.current.state.preferences.defaultUnits).toBe("metric"); - expect(result.current.state.preferences.temperatureUnit).toBe("c"); + expect(result.current.state.preferences.temperatureUnit).toBe("C"); }); it("should handle corrupted persisted state gracefully", async () => { @@ -595,7 +593,8 @@ describe("CalculatorsContext", () => { // Should fall back to default state expect(result.current.state.abv.formula).toBe("simple"); - expect(result.current.state.preferences.defaultUnits).toBe("imperial"); + expect(result.current.state.preferences.defaultUnits).toBe("metric"); + expect(result.current.state.preferences.temperatureUnit).toBe("C"); }); it("should persist state changes to AsyncStorage after hydration", async () => { diff --git a/tests/src/contexts/DeveloperContext.test.tsx b/tests/src/contexts/DeveloperContext.test.tsx index 3f02cef4..b7a4d4b5 100644 --- a/tests/src/contexts/DeveloperContext.test.tsx +++ b/tests/src/contexts/DeveloperContext.test.tsx @@ -11,6 +11,7 @@ import { NetworkSimulationMode, } from "@contexts/DeveloperContext"; import { Text, TouchableOpacity } from "react-native"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; // Mock AsyncStorage jest.mock("@react-native-async-storage/async-storage", () => ({ @@ -26,6 +27,16 @@ jest.mock("@services/config", () => ({ }, })); +// Mock UnifiedLogger +jest.mock("@services/logger/UnifiedLogger", () => ({ + UnifiedLogger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + const mockAsyncStorage = AsyncStorage as jest.Mocked; // Test component that uses the developer context @@ -147,8 +158,6 @@ describe("DeveloperContext", () => { mockAsyncStorage.getItem.mockRejectedValueOnce( new Error("Storage error") ); - const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); - const { getByTestId } = render( @@ -157,13 +166,12 @@ describe("DeveloperContext", () => { await waitFor(() => { expect(getByTestId("network-mode")).toHaveTextContent("normal"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.warn).toHaveBeenCalledWith( + "developer", "Failed to load developer settings:", expect.any(Error) ); }); - - consoleSpy.mockRestore(); }); }); @@ -193,8 +201,6 @@ describe("DeveloperContext", () => { mockAsyncStorage.setItem.mockRejectedValueOnce( new Error("Storage error") ); - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const { getByTestId } = render( @@ -206,13 +212,12 @@ describe("DeveloperContext", () => { }); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + expect.any(String), "Failed to set network simulation mode:", expect.any(Error) ); }); - - consoleSpy.mockRestore(); }); }); @@ -309,8 +314,6 @@ describe("DeveloperContext", () => { mockAsyncStorage.removeItem.mockRejectedValueOnce( new Error("Reset error") ); - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); - const { getByTestId } = render( @@ -322,13 +325,12 @@ describe("DeveloperContext", () => { }); await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + expect.any(String), "Failed to reset developer settings:", expect.any(Error) ); }); - - consoleSpy.mockRestore(); }); }); @@ -344,50 +346,4 @@ describe("DeveloperContext", () => { }).toThrow("useDeveloper must be used within a DeveloperProvider"); }); }); - - describe("Console Logging", () => { - it("should log network mode changes", async () => { - const consoleSpy = jest.spyOn(console, "log").mockImplementation(); - - const { getByTestId } = render( - - - - ); - - await act(async () => { - getByTestId("set-slow").props.onPress(); - }); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - 'Developer mode: Network simulation set to "slow"' - ); - }); - - consoleSpy.mockRestore(); - }); - - it("should log settings reset", async () => { - const consoleSpy = jest.spyOn(console, "log").mockImplementation(); - - const { getByTestId } = render( - - - - ); - - await act(async () => { - getByTestId("reset-settings").props.onPress(); - }); - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - "Developer settings reset to defaults" - ); - }); - - consoleSpy.mockRestore(); - }); - }); }); diff --git a/tests/src/contexts/UnitContext.test.tsx b/tests/src/contexts/UnitContext.test.tsx index 83db2753..f48b2966 100644 --- a/tests/src/contexts/UnitContext.test.tsx +++ b/tests/src/contexts/UnitContext.test.tsx @@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { UnitProvider, useUnits } from "@contexts/UnitContext"; import { AuthProvider } from "@contexts/AuthContext"; import { UnitSystem } from "@src/types"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; // Mock AsyncStorage jest.mock("@react-native-async-storage/async-storage", () => ({ @@ -22,6 +23,16 @@ jest.mock("@services/api/apiService", () => ({ }, })); +// Mock UnifiedLogger +jest.mock("@services/logger/UnifiedLogger", () => ({ + UnifiedLogger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + const mockAsyncStorage = AsyncStorage as jest.Mocked; describe("UnitContext", () => { @@ -31,40 +42,39 @@ describe("UnitContext", () => { mockAsyncStorage.setItem.mockResolvedValue(); }); - const createWrapper = - (initialUnitSystem?: UnitSystem, isAuthenticated = false) => - ({ children }: { children: React.ReactNode }) => - React.createElement(AuthProvider, { - initialAuthState: { - user: isAuthenticated - ? { - id: "test", - username: "test", - email: "test@example.com", - email_verified: true, - created_at: "2023-01-01T00:00:00Z", - updated_at: "2023-01-01T00:00:00Z", - is_active: true, - } - : null, - }, - children: React.createElement(UnitProvider, { - initialUnitSystem, - children, - }), - }); + const createWrapper = ( + initialUnitSystem?: UnitSystem, + isAuthenticated = false + ) => + function TestWrapper({ children }: { children: React.ReactNode }) { + const initialAuthState = { + user: isAuthenticated + ? { + id: "test", + username: "test", + email: "test@example.com", + email_verified: true, + created_at: "2023-01-01T00:00:00Z", + updated_at: "2023-01-01T00:00:00Z", + is_active: true, + } + : null, + }; + + return ( + + + {children} + + + ); + }; describe("useUnits hook", () => { it("should throw error when used outside provider", () => { - const consoleSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); - expect(() => { renderHook(() => useUnits()); }).toThrow("useUnits must be used within a UnitProvider"); - - consoleSpy.mockRestore(); }); it("should provide unit context when used within provider", () => { @@ -220,7 +230,7 @@ describe("UnitContext", () => { expect(result.current.getPreferredUnit("weight")).toBe("lb"); expect(result.current.getPreferredUnit("volume")).toBe("gal"); - expect(result.current.getPreferredUnit("temperature")).toBe("f"); + expect(result.current.getPreferredUnit("temperature")).toBe("F"); }); it("should return correct preferred units for metric system", () => { @@ -229,7 +239,7 @@ describe("UnitContext", () => { expect(result.current.getPreferredUnit("weight")).toBe("kg"); expect(result.current.getPreferredUnit("volume")).toBe("l"); - expect(result.current.getPreferredUnit("temperature")).toBe("c"); + expect(result.current.getPreferredUnit("temperature")).toBe("C"); }); }); @@ -296,8 +306,8 @@ describe("UnitContext", () => { expect(result.current.formatValue(0.5, "lb", "weight")).toBe("0.5 lb"); // Temperature should always use 1 decimal place - expect(result.current.formatValue(20.555, "c", "temperature")).toBe( - "20.6 c" + expect(result.current.formatValue(20.555, "C", "temperature")).toBe( + "20.6 C" ); // Small volume should use 2 decimal places when < 1 @@ -423,18 +433,13 @@ describe("UnitContext", () => { const wrapper = createWrapper("metric"); const { result } = renderHook(() => useUnits(), { wrapper }); - const consoleSpy = jest - .spyOn(console, "warn") - .mockImplementation(() => {}); - const result_conv = result.current.convertUnit(100, "unknown", "kg"); expect(result_conv.value).toBe(100); expect(result_conv.unit).toBe("unknown"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.warn).toHaveBeenCalledWith( + "units", "No conversion available from unknown to kg" ); - - consoleSpy.mockRestore(); }); it("should handle invalid string inputs in convertUnit", () => { @@ -502,9 +507,6 @@ describe("UnitContext", () => { }); it("should handle unsupported unit conversions with warning", () => { - const consoleSpy = jest - .spyOn(console, "warn") - .mockImplementation(() => {}); const wrapper = createWrapper("metric"); const { result } = renderHook(() => useUnits(), { wrapper }); @@ -517,11 +519,10 @@ describe("UnitContext", () => { expect(invalidResult.value).toBe(100); expect(invalidResult.unit).toBe("unknown_unit"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.warn).toHaveBeenCalledWith( + "units", "No conversion available from unknown_unit to another_unit" ); - - consoleSpy.mockRestore(); }); }); @@ -738,7 +739,7 @@ describe("UnitContext", () => { const tempUnits = imperialResult.current.getCommonUnits("temperature"); expect(tempUnits.length).toBe(1); - expect(tempUnits[0].value).toBe("f"); + expect(tempUnits[0].value).toBe("F"); expect(tempUnits[0].label).toBe("°F"); const metricWrapper = createWrapper("metric"); @@ -749,7 +750,7 @@ describe("UnitContext", () => { const metricTempUnits = metricResult.current.getCommonUnits("temperature"); expect(metricTempUnits.length).toBe(1); - expect(metricTempUnits[0].value).toBe("c"); + expect(metricTempUnits[0].value).toBe("C"); expect(metricTempUnits[0].label).toBe("°C"); }); @@ -794,8 +795,8 @@ describe("UnitContext", () => { const wrapper = createWrapper("metric"); const { result } = renderHook(() => useUnits(), { wrapper }); - expect(result.current.formatValue(20.555, "c", "temperature")).toBe( - "20.6 c" + expect(result.current.formatValue(20.555, "C", "temperature")).toBe( + "20.6 C" ); }); @@ -953,9 +954,6 @@ describe("UnitContext", () => { it("should handle background fetch errors gracefully", async () => { const ApiService = require("@services/api/apiService").default; - const consoleSpy = jest - .spyOn(console, "warn") - .mockImplementation(() => {}); // Mock cached settings const cachedSettings = { preferred_units: "imperial" }; @@ -977,12 +975,11 @@ describe("UnitContext", () => { // Should still use cached settings despite background error expect(result.current.unitSystem).toBe("imperial"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.warn).toHaveBeenCalledWith( + "units", "Background settings fetch failed:", expect.any(Error) ); - - consoleSpy.mockRestore(); }); }); @@ -1030,9 +1027,6 @@ describe("UnitContext", () => { it("should handle updateUnitSystem error and revert", async () => { const ApiService = require("@services/api/apiService").default; - const consoleSpy = jest - .spyOn(console, "error") - .mockImplementation(() => {}); // Mock AsyncStorage.setItem to fail, which will trigger error for unauthenticated users mockAsyncStorage.setItem.mockRejectedValue(new Error("Storage Error")); @@ -1047,12 +1041,11 @@ describe("UnitContext", () => { // Should revert to original system on error expect(result.current.unitSystem).toBe("imperial"); expect(result.current.error).toBe("Failed to save unit preference"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + expect.any(String), "Failed to update unit system:", expect.any(Error) ); - - consoleSpy.mockRestore(); }); }); @@ -1088,18 +1081,18 @@ describe("UnitContext", () => { const wrapper = createWrapper("metric"); const { result } = renderHook(() => useUnits(), { wrapper }); - const conversion = result.current.convertUnit(32, "f", "c"); + const conversion = result.current.convertUnit(32, "F", "C"); expect(conversion.value).toBeCloseTo(0, 1); - expect(conversion.unit).toBe("c"); + expect(conversion.unit).toBe("C"); }); it("should handle c to f temperature conversion", () => { const wrapper = createWrapper("imperial"); const { result } = renderHook(() => useUnits(), { wrapper }); - const conversion = result.current.convertUnit(0, "c", "f"); + const conversion = result.current.convertUnit(0, "C", "F"); expect(conversion.value).toBeCloseTo(32, 1); - expect(conversion.unit).toBe("f"); + expect(conversion.unit).toBe("F"); }); }); }); diff --git a/tests/src/hooks/useRecipeMetrics.test.ts b/tests/src/hooks/useRecipeMetrics.test.ts index e0232f54..2c0e45e3 100644 --- a/tests/src/hooks/useRecipeMetrics.test.ts +++ b/tests/src/hooks/useRecipeMetrics.test.ts @@ -1,14 +1,12 @@ /* eslint-disable import/first */ -import { renderHook, waitFor, act } from "@testing-library/react-native"; +import { renderHook, waitFor } from "@testing-library/react-native"; import React from "react"; -// Mock the API service before importing anything that uses it -jest.mock("@services/api/apiService", () => ({ - __esModule: true, - default: { - recipes: { - calculateMetricsPreview: jest.fn(), - }, +// Mock the OfflineMetricsCalculator +jest.mock("@services/brewing/OfflineMetricsCalculator", () => ({ + OfflineMetricsCalculator: { + validateRecipeData: jest.fn(), + calculateMetrics: jest.fn(), }, })); @@ -18,18 +16,9 @@ jest.unmock("@tanstack/react-query"); import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useRecipeMetrics } from "@src/hooks/useRecipeMetrics"; import { RecipeFormData, RecipeMetrics, RecipeIngredient } from "@src/types"; -import ApiService from "@services/api/apiService"; - -const mockedApiService = jest.mocked(ApiService); +import { OfflineMetricsCalculator } from "@services/brewing/OfflineMetricsCalculator"; -// Helper to create mock AxiosResponse -const createMockAxiosResponse = (data: T) => ({ - data, - status: 200, - statusText: "OK", - headers: {}, - config: {} as any, -}); +const mockedCalculator = jest.mocked(OfflineMetricsCalculator); const createTestQueryClient = () => new QueryClient({ @@ -109,9 +98,7 @@ describe("useRecipeMetrics - Essential Tests", () => { // Should remain disabled expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); - expect( - mockedApiService.recipes.calculateMetricsPreview - ).not.toHaveBeenCalled(); + expect(mockedCalculator.calculateMetrics).not.toHaveBeenCalled(); }); it("should not enable query when batch_size is zero", () => { @@ -125,9 +112,7 @@ describe("useRecipeMetrics - Essential Tests", () => { expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); - expect( - mockedApiService.recipes.calculateMetricsPreview - ).not.toHaveBeenCalled(); + expect(mockedCalculator.calculateMetrics).not.toHaveBeenCalled(); }); it("should respect explicit enabled parameter", () => { @@ -141,9 +126,7 @@ describe("useRecipeMetrics - Essential Tests", () => { expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); - expect( - mockedApiService.recipes.calculateMetricsPreview - ).not.toHaveBeenCalled(); + expect(mockedCalculator.calculateMetrics).not.toHaveBeenCalled(); }); it("should filter out NaN and invalid numeric values", async () => { @@ -158,9 +141,11 @@ describe("useRecipeMetrics - Essential Tests", () => { } as any, }; - mockedApiService.recipes.calculateMetricsPreview.mockResolvedValue( - createMockAxiosResponse(mockMetricsResponse.data) - ); + mockedCalculator.validateRecipeData.mockReturnValue({ + isValid: true, + errors: [], + }); + mockedCalculator.calculateMetrics.mockReturnValue(mockMetricsResponse.data); const wrapper = createWrapper(queryClient); const { result } = renderHook(() => useRecipeMetrics(mockRecipeData), { @@ -182,13 +167,13 @@ describe("useRecipeMetrics - Essential Tests", () => { it("should handle empty metrics response gracefully", async () => { const mockRecipeData = createMockRecipeData(); - const mockMetricsResponse = { - data: {} as RecipeMetrics, - }; + const mockMetricsResponse = {} as RecipeMetrics; - mockedApiService.recipes.calculateMetricsPreview.mockResolvedValue( - createMockAxiosResponse(mockMetricsResponse.data) - ); + mockedCalculator.validateRecipeData.mockReturnValue({ + isValid: true, + errors: [], + }); + mockedCalculator.calculateMetrics.mockReturnValue(mockMetricsResponse); const wrapper = createWrapper(queryClient); const { result } = renderHook(() => useRecipeMetrics(mockRecipeData), { @@ -240,87 +225,33 @@ describe("useRecipeMetrics - Essential Tests", () => { ], }; - // Mock successful API response - const mockResponse = { - data: { - og: 1.05, - fg: 1.012, - abv: 5.0, - ibu: 30, - srm: 6, - }, - status: 200, - statusText: "OK", - headers: {}, - config: {} as any, - }; - mockedApiService.recipes.calculateMetricsPreview.mockResolvedValue( - mockResponse - ); - - const wrapper = createWrapper(queryClient); - const { result } = renderHook(() => useRecipeMetrics(mockRecipeData), { - wrapper, - }); - - // Test should complete without errors despite null/undefined id/name fields - expect(() => result.current).not.toThrow(); - }); - - it("should handle API validation errors by falling back to offline calculation", async () => { - const mockRecipeData = createMockRecipeData(); - const validationError = { - response: { status: 400 }, - message: "Invalid recipe data", + // Mock successful metrics calculation + const mockMetrics: RecipeMetrics = { + og: 1.05, + fg: 1.012, + abv: 5.0, + ibu: 30, + srm: 6, }; - mockedApiService.recipes.calculateMetricsPreview.mockRejectedValue( - validationError - ); - - const wrapper = createWrapper(queryClient); - const { result } = renderHook(() => useRecipeMetrics(mockRecipeData), { - wrapper, - }); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); + mockedCalculator.validateRecipeData.mockReturnValue({ + isValid: true, + errors: [], }); - - // Should have data from offline calculation fallback - expect(result.current.data).toBeDefined(); - expect(result.current.error).toBeNull(); - // Should only be called once (no retries for 400 errors) - expect( - mockedApiService.recipes.calculateMetricsPreview - ).toHaveBeenCalledTimes(1); - }); - - it("should handle network errors by falling back to offline calculation", async () => { - const mockRecipeData = createMockRecipeData(); - const networkError = { - response: { status: 500 }, - message: "Network error", - }; - mockedApiService.recipes.calculateMetricsPreview.mockRejectedValue( - networkError - ); + mockedCalculator.calculateMetrics.mockReturnValue(mockMetrics); const wrapper = createWrapper(queryClient); const { result } = renderHook(() => useRecipeMetrics(mockRecipeData), { wrapper, }); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - // Should have data from offline calculation fallback - expect(result.current.data).toBeDefined(); - expect(result.current.error).toBeNull(); - // Should only be called once since query function catches the error and falls back - expect( - mockedApiService.recipes.calculateMetricsPreview - ).toHaveBeenCalledTimes(1); + // Test should complete without errors despite null/undefined id/name fields + expect(result.current).toBeDefined(); + expect(result.current.isError).toBe(false); + // Verify the hook returns a valid query result object with expected properties + expect(result.current).toHaveProperty("data"); + expect(result.current).toHaveProperty("isLoading"); + expect(result.current).toHaveProperty("isError"); }); it("should handle complex recipe data with all ingredient types", async () => { @@ -377,9 +308,11 @@ describe("useRecipeMetrics - Essential Tests", () => { } as RecipeMetrics, }; - mockedApiService.recipes.calculateMetricsPreview.mockResolvedValue( - createMockAxiosResponse(mockMetricsResponse.data) - ); + mockedCalculator.validateRecipeData.mockReturnValue({ + isValid: true, + errors: [], + }); + mockedCalculator.calculateMetrics.mockReturnValue(mockMetricsResponse.data); const wrapper = createWrapper(queryClient); const { result } = renderHook(() => useRecipeMetrics(complexRecipeData), { @@ -398,18 +331,10 @@ describe("useRecipeMetrics - Essential Tests", () => { srm: 12, }); - // Verify API was called with correct data structure - expect( - mockedApiService.recipes.calculateMetricsPreview - ).toHaveBeenCalledWith({ - batch_size: complexRecipeData.batch_size, - batch_size_unit: complexRecipeData.batch_size_unit, - efficiency: complexRecipeData.efficiency, - boil_time: complexRecipeData.boil_time, - ingredients: complexRecipeData.ingredients, - mash_temperature: complexRecipeData.mash_temperature, - mash_temp_unit: complexRecipeData.mash_temp_unit, - }); + // Verify calculator was called with correct data + expect(mockedCalculator.calculateMetrics).toHaveBeenCalledWith( + complexRecipeData + ); }); it("should create correct query key with recipe parameters", async () => { @@ -432,19 +357,19 @@ describe("useRecipeMetrics - Essential Tests", () => { ], }); - const mockMetricsResponse = { - data: { - og: 1.055, - fg: 1.012, - abv: 5.6, - ibu: 45, - srm: 8, - } as RecipeMetrics, + const mockMetricsResponse: RecipeMetrics = { + og: 1.055, + fg: 1.012, + abv: 5.6, + ibu: 45, + srm: 8, }; - mockedApiService.recipes.calculateMetricsPreview.mockResolvedValue( - createMockAxiosResponse(mockMetricsResponse.data) - ); + mockedCalculator.validateRecipeData.mockReturnValue({ + isValid: true, + errors: [], + }); + mockedCalculator.calculateMetrics.mockReturnValue(mockMetricsResponse); const wrapper = createWrapper(queryClient); const { result } = renderHook(() => useRecipeMetrics(mockRecipeData), { @@ -463,8 +388,17 @@ describe("useRecipeMetrics - Essential Tests", () => { ); expect(recipeMetricsQueries).toHaveLength(1); - expect(recipeMetricsQueries[0].queryKey[3]).toBe(5.5); // batch_size - expect(recipeMetricsQueries[0].queryKey[4]).toBe("gal"); // batch_size_unit - expect(recipeMetricsQueries[0].queryKey[5]).toBe(72); // efficiency + + // Query key structure from useRecipeMetrics.ts:40-50 + // [0] = "recipeMetrics", [1] = "offline-first", + // [2] = batch_size, [3] = batch_size_unit, [4] = efficiency + const [queryName, mode, batchSize, batchSizeUnit, efficiency] = + recipeMetricsQueries[0].queryKey; + + expect(queryName).toBe("recipeMetrics"); + expect(mode).toBe("offline-first"); + expect(batchSize).toBe(5.5); + expect(batchSizeUnit).toBe("gal"); + expect(efficiency).toBe(72); }); }); diff --git a/tests/src/services/NotificationService.test.ts b/tests/src/services/NotificationService.test.ts index 73fb715f..c099986c 100644 --- a/tests/src/services/NotificationService.test.ts +++ b/tests/src/services/NotificationService.test.ts @@ -11,6 +11,7 @@ import { import * as Notifications from "expo-notifications"; import * as Haptics from "expo-haptics"; import { Platform } from "react-native"; +import { UnifiedLogger } from "@services/logger/UnifiedLogger"; // Mock expo-notifications jest.mock("expo-notifications", () => ({ @@ -52,6 +53,16 @@ jest.mock("react-native", () => ({ }, })); +// Mock UnifiedLogger +jest.mock("@/src/services/logger/UnifiedLogger", () => ({ + UnifiedLogger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + describe("NotificationService", () => { const mockGetPermissions = Notifications.getPermissionsAsync as jest.Mock; const mockRequestPermissions = @@ -114,14 +125,13 @@ describe("NotificationService", () => { mockGetPermissions.mockResolvedValue({ status: "denied" }); mockRequestPermissions.mockResolvedValue({ status: "denied" }); - const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); const result = await NotificationService.initialize(); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.warn).toHaveBeenCalledWith( + "notifications", "Notification permissions not granted" ); - consoleSpy.mockRestore(); }); it("should set up Android notification channel", async () => { @@ -156,16 +166,14 @@ describe("NotificationService", () => { it("should handle initialization errors", async () => { mockGetPermissions.mockRejectedValue(new Error("Permission error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const result = await NotificationService.initialize(); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to initialize notifications:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -221,8 +229,6 @@ describe("NotificationService", () => { it("should handle scheduling errors", async () => { mockGetPermissions.mockResolvedValue({ status: "granted" }); mockScheduleNotification.mockRejectedValue(new Error("Scheduling error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const identifier = await NotificationService.scheduleHopAlert( "Cascade", 1, @@ -231,11 +237,11 @@ describe("NotificationService", () => { ); expect(identifier).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to schedule hop alert:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -372,15 +378,13 @@ describe("NotificationService", () => { it("should handle cancellation errors", async () => { mockCancelAll.mockRejectedValue(new Error("Cancel error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); await NotificationService.cancelAllAlerts(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to cancel notifications:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -402,15 +406,13 @@ describe("NotificationService", () => { it("should handle cancellation errors", async () => { mockCancelSpecific.mockRejectedValue(new Error("Cancel specific error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); await NotificationService.cancelAlert("test-id"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to cancel notification:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -435,15 +437,13 @@ describe("NotificationService", () => { it("should handle haptic feedback errors", async () => { mockHapticImpact.mockRejectedValue(new Error("Haptic error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); await NotificationService.triggerHapticFeedback("medium"); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to trigger haptic feedback:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -514,16 +514,14 @@ describe("NotificationService", () => { it("should handle permission check errors", async () => { mockGetPermissions.mockRejectedValue(new Error("Permission check error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const result = await NotificationService.areNotificationsEnabled(); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to check notification permissions:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -543,16 +541,14 @@ describe("NotificationService", () => { it("should return empty array on error", async () => { mockGetScheduled.mockRejectedValue(new Error("Get scheduled error")); - - const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const result = await NotificationService.getScheduledNotifications(); expect(result).toEqual([]); - expect(consoleSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.error).toHaveBeenCalledWith( + "notifications", "Failed to get scheduled notifications:", expect.any(Error) ); - consoleSpy.mockRestore(); }); }); @@ -613,25 +609,20 @@ describe("NotificationService", () => { expect(result.size).toBe(0); // No successful schedules }); - it("should log debug information in development", async () => { + it("should log a warning in development when skipping hop alerts", async () => { const originalDev = (global as any).__DEV__; (global as any).__DEV__ = true; - const consoleSpy = jest.spyOn(console, "log").mockImplementation(); - const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); - await NotificationService.scheduleHopAlertsForRecipe( [{ time: 3, name: "Very Late Hop", amount: 0.5, unit: "oz" }], 90 // 1.5 minute boil (90s) ); // 90s boil - 3min hop (180s) - 30s = -120s (negative, so skipped with warning) - expect(consoleWarnSpy).toHaveBeenCalledWith( + expect(UnifiedLogger.warn).toHaveBeenCalledWith( + "notifications", expect.stringContaining('⚠️ Skipping hop alert for "Very Late Hop"') ); - - consoleSpy.mockRestore(); - consoleWarnSpy.mockRestore(); (global as any).__DEV__ = originalDev; }); }); diff --git a/tests/src/services/calculators/HydrometerCorrectionCalculator.test.ts b/tests/src/services/calculators/HydrometerCorrectionCalculator.test.ts index c6106aad..d85c8a45 100644 --- a/tests/src/services/calculators/HydrometerCorrectionCalculator.test.ts +++ b/tests/src/services/calculators/HydrometerCorrectionCalculator.test.ts @@ -10,10 +10,10 @@ import { HydrometerCorrectionCalculator } from "@services/calculators/Hydrometer jest.mock("@services/calculators/UnitConverter", () => ({ UnitConverter: { convertTemperature: jest.fn((value, from, to) => { - if (from === "c" && to === "f") { + if (from === "C" && to === "F") { return (value * 9) / 5 + 32; } - if (from === "f" && to === "c") { + if (from === "F" && to === "C") { return ((value - 32) * 5) / 9; } return value; @@ -28,7 +28,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, // measured gravity 60, // wort temp (at calibration) 60, // calibration temp - "f" + "F" ); expect(result.correctedGravity).toBeCloseTo(1.05, 3); @@ -42,7 +42,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, // measured gravity 80, // wort temp (higher than calibration) 60, // calibration temp - "f" + "F" ); expect(result.correctedGravity).toBeGreaterThan(1.05); // Should increase at higher temp @@ -54,7 +54,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, // measured gravity 40, // wort temp (lower than calibration) 60, // calibration temp - "f" + "F" ); expect(result.correctedGravity).toBeLessThan(1.05); // Should decrease at lower temp @@ -66,7 +66,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, 20, // 20°C 15.5, // 15.5°C (standard calibration) - "c" + "C" ); expect(result.correctedGravity).toBeGreaterThan(1.05); @@ -79,7 +79,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05678, 75, 60, - "f" + "F" ); // Check that results are properly rounded @@ -93,31 +93,31 @@ describe("HydrometerCorrectionCalculator", () => { it("should throw error for invalid gravity", () => { expect(() => { - HydrometerCorrectionCalculator.calculateCorrection(0.98, 60, 60, "f"); + HydrometerCorrectionCalculator.calculateCorrection(0.98, 60, 60, "F"); }).toThrow("Measured gravity must be between 0.990 and 1.200"); expect(() => { - HydrometerCorrectionCalculator.calculateCorrection(1.25, 60, 60, "f"); + HydrometerCorrectionCalculator.calculateCorrection(1.25, 60, 60, "F"); }).toThrow("Measured gravity must be between 0.990 and 1.200"); }); it("should throw error for invalid Fahrenheit temperatures", () => { expect(() => { - HydrometerCorrectionCalculator.calculateCorrection(1.05, 30, 60, "f"); // Below freezing + HydrometerCorrectionCalculator.calculateCorrection(1.05, 30, 60, "F"); // Below freezing }).toThrow("Wort temperature must be between 32°F and 212°F"); expect(() => { - HydrometerCorrectionCalculator.calculateCorrection(1.05, 60, 250, "f"); // Above boiling + HydrometerCorrectionCalculator.calculateCorrection(1.05, 60, 250, "F"); // Above boiling }).toThrow("Calibration temperature must be between 32°F and 212°F"); }); it("should throw error for invalid Celsius temperatures", () => { expect(() => { - HydrometerCorrectionCalculator.calculateCorrection(1.05, -5, 20, "c"); // Below freezing + HydrometerCorrectionCalculator.calculateCorrection(1.05, -5, 20, "C"); // Below freezing }).toThrow("Wort temperature must be between 0°C and 100°C"); expect(() => { - HydrometerCorrectionCalculator.calculateCorrection(1.05, 20, 110, "c"); // Above boiling + HydrometerCorrectionCalculator.calculateCorrection(1.05, 20, 110, "C"); // Above boiling }).toThrow("Calibration temperature must be between 0°C and 100°C"); }); }); @@ -127,7 +127,7 @@ describe("HydrometerCorrectionCalculator", () => { const result = HydrometerCorrectionCalculator.calculateCorrectionDefault( 1.05, 70, - "f" + "F" ); expect(result.calibrationTemp).toBe(60); // Default F calibration @@ -138,7 +138,7 @@ describe("HydrometerCorrectionCalculator", () => { const result = HydrometerCorrectionCalculator.calculateCorrectionDefault( 1.05, 20, - "c" + "C" ); expect(result.calibrationTemp).toBe(15.5); // Default C calibration @@ -152,7 +152,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, // measured 1.052, // target (higher) 60, // calibration - "f" + "F" ); expect(targetTemp).toBeGreaterThan(60); // Should be higher temp @@ -163,7 +163,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, targetTemp, 60, - "f" + "F" ); expect(verification.correctedGravity).toBeCloseTo(1.052, 2); }); @@ -173,7 +173,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, 1.048, // target (lower) 15.5, - "c" + "C" ); expect(targetTemp).toBeLessThan(15.5); // Should be lower temp @@ -186,7 +186,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, 2.0, // Impossible target 60, - "f" + "F" ); }).toThrow("Could not converge on solution - check input values"); }); @@ -197,7 +197,7 @@ describe("HydrometerCorrectionCalculator", () => { const table = HydrometerCorrectionCalculator.getCorrectionTable( 1.05, 60, - "f" + "F" ); expect(table.length).toBeGreaterThan(10); @@ -221,7 +221,7 @@ describe("HydrometerCorrectionCalculator", () => { const table = HydrometerCorrectionCalculator.getCorrectionTable( 1.05, 15.5, - "c" + "C" ); expect(table.length).toBeGreaterThan(10); @@ -252,7 +252,7 @@ describe("HydrometerCorrectionCalculator", () => { HydrometerCorrectionCalculator.isCorrectionSignificant( 60, // wort temp 60, // calibration temp - "f" + "F" ); expect(isSignificant).toBe(false); @@ -263,7 +263,7 @@ describe("HydrometerCorrectionCalculator", () => { HydrometerCorrectionCalculator.isCorrectionSignificant( 80, // wort temp (20F higher) 60, // calibration temp - "f" + "F" ); expect(isSignificant).toBe(true); @@ -274,7 +274,7 @@ describe("HydrometerCorrectionCalculator", () => { HydrometerCorrectionCalculator.isCorrectionSignificant( 62, // wort temp (small difference) 60, // calibration temp - "f" + "F" ); expect(isSignificant).toBe(false); @@ -285,7 +285,7 @@ describe("HydrometerCorrectionCalculator", () => { HydrometerCorrectionCalculator.isCorrectionSignificant( 25, // 10C higher 15.5, - "c" + "C" ); expect(isSignificant).toBe(true); @@ -304,17 +304,17 @@ describe("HydrometerCorrectionCalculator", () => { // Check Standard hydrometer calibration const standard = calibrationTemps["Standard"]; - expect(standard.f).toBe(60); - expect(standard.c).toBe(15.5); + expect(standard.F).toBe(60); + expect(standard.C).toBe(15.5); // Check that all entries have both F and C values Object.values(calibrationTemps).forEach(temp => { - expect(temp).toHaveProperty("f"); - expect(temp).toHaveProperty("c"); - expect(temp.f).toBeGreaterThan(30); - expect(temp.f).toBeLessThan(80); - expect(temp.c).toBeGreaterThan(10); - expect(temp.c).toBeLessThan(30); + expect(temp).toHaveProperty("F"); + expect(temp).toHaveProperty("C"); + expect(temp.F).toBeGreaterThan(30); + expect(temp.F).toBeLessThan(80); + expect(temp.C).toBeGreaterThan(10); + expect(temp.C).toBeLessThan(30); }); }); }); @@ -325,7 +325,7 @@ describe("HydrometerCorrectionCalculator", () => { 0.995, 70, 60, - "f" + "F" ); expect(lowGravity.correctedGravity).toBeGreaterThan(0.995); @@ -333,7 +333,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.15, 70, 60, - "f" + "F" ); expect(highGravity.correctedGravity).toBeGreaterThan(1.15); }); @@ -343,7 +343,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, 200, // Very hot 60, - "f" + "F" ); expect(extremeHot.correctedGravity).toBeGreaterThan(1.05); @@ -351,7 +351,7 @@ describe("HydrometerCorrectionCalculator", () => { 1.05, 35, // Very cold 60, - "f" + "F" ); expect(extremeCold.correctedGravity).toBeLessThan(1.05); }); diff --git a/tests/src/services/calculators/PrimingSugarCalculator.test.ts b/tests/src/services/calculators/PrimingSugarCalculator.test.ts index e94eccc8..15183642 100644 --- a/tests/src/services/calculators/PrimingSugarCalculator.test.ts +++ b/tests/src/services/calculators/PrimingSugarCalculator.test.ts @@ -27,7 +27,7 @@ jest.mock("@services/calculators/UnitConverter", () => ({ return value; }), convertTemperature: jest.fn((value, from, to) => { - if (from === "c" && to === "f") { + if (from === "C" && to === "F") { return (value * 9) / 5 + 32; } return value; @@ -146,8 +146,8 @@ describe("PrimingSugarCalculator", () => { describe("estimateResidualCO2", () => { it("should estimate residual CO2 for common temperatures", () => { - const co2At65F = PrimingSugarCalculator.estimateResidualCO2(65, "f"); - const co2At70F = PrimingSugarCalculator.estimateResidualCO2(70, "f"); + const co2At65F = PrimingSugarCalculator.estimateResidualCO2(65, "F"); + const co2At70F = PrimingSugarCalculator.estimateResidualCO2(70, "F"); expect(co2At65F).toBe(0.9); expect(co2At70F).toBe(0.8); @@ -155,21 +155,21 @@ describe("PrimingSugarCalculator", () => { }); it("should handle Celsius temperatures", () => { - const co2 = PrimingSugarCalculator.estimateResidualCO2(18, "c"); // ~65F + const co2 = PrimingSugarCalculator.estimateResidualCO2(18, "C"); // ~65F expect(co2).toBeGreaterThan(0); expect(co2).toBeLessThan(2); }); it("should find closest temperature in lookup table", () => { - const co2At67F = PrimingSugarCalculator.estimateResidualCO2(67, "f"); // Between 65 and 70 + const co2At67F = PrimingSugarCalculator.estimateResidualCO2(67, "F"); // Between 65 and 70 // Should pick the closest value (65F = 0.9, 70F = 0.8, so 67 should be 0.9) expect(co2At67F).toBe(0.9); }); it("should handle extreme temperatures", () => { - const co2Cold = PrimingSugarCalculator.estimateResidualCO2(20, "f"); // Very cold - const co2Hot = PrimingSugarCalculator.estimateResidualCO2(90, "f"); // Very hot + const co2Cold = PrimingSugarCalculator.estimateResidualCO2(20, "F"); // Very cold + const co2Hot = PrimingSugarCalculator.estimateResidualCO2(90, "F"); // Very hot expect(co2Cold).toBeGreaterThan(0); expect(co2Hot).toBeGreaterThan(0); diff --git a/tests/src/services/calculators/StrikeWaterCalculator.test.ts b/tests/src/services/calculators/StrikeWaterCalculator.test.ts index b16b1f3f..0747da92 100644 --- a/tests/src/services/calculators/StrikeWaterCalculator.test.ts +++ b/tests/src/services/calculators/StrikeWaterCalculator.test.ts @@ -33,10 +33,10 @@ jest.mock("@services/calculators/UnitConverter", () => ({ return value; }), convertTemperature: jest.fn((value, from, to) => { - if (from === "c" && to === "f") { + if (from === "C" && to === "F") { return (value * 9) / 5 + 32; } - if (from === "f" && to === "c") { + if (from === "F" && to === "C") { return ((value - 32) * 5) / 9; } return value; @@ -53,7 +53,7 @@ describe("StrikeWaterCalculator", () => { 70, // grain temp (F) 152, // target mash temp (F) 1.25, // water-to-grain ratio - "f", // temp unit + "F", // temp unit 10 // tun weight ); @@ -70,7 +70,7 @@ describe("StrikeWaterCalculator", () => { 50, 152, 1.25, - "f" + "F" ); const warmGrain = StrikeWaterCalculator.calculateStrikeWater( 10, @@ -78,7 +78,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.25, - "f" + "F" ); expect(coolGrain.strikeTemp).toBeGreaterThan(warmGrain.strikeTemp); @@ -91,7 +91,7 @@ describe("StrikeWaterCalculator", () => { 20, // grain temp (C) 67, // target mash temp (C) 1.25, // water-to-grain ratio - "c" // temp unit + "C" // temp unit ); expect(result.strikeTemp).toBeGreaterThan(67); // Should be higher than target @@ -106,7 +106,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.0, - "f" + "F" ); const thinMash = StrikeWaterCalculator.calculateStrikeWater( 10, @@ -114,7 +114,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.5, - "f" + "F" ); expect(thickMash.waterVolume).toBe(10); // 10 * 1.0 @@ -129,7 +129,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.25, - "f", + "F", 5 // light tun ); const heavyTun = StrikeWaterCalculator.calculateStrikeWater( @@ -138,7 +138,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.25, - "f", + "F", 20 // heavy tun ); @@ -166,7 +166,7 @@ describe("StrikeWaterCalculator", () => { 70.456, 152.789, 1.25, - "f" + "F" ); expect(result.strikeTemp).toBe(Math.round(result.strikeTemp * 10) / 10); @@ -184,7 +184,7 @@ describe("StrikeWaterCalculator", () => { 158, // target mash temp 12.5, // current mash volume (qt) 180, // infusion water temp - "f" + "F" ); expect(result.infusionVolume).toBeGreaterThan(0); @@ -198,7 +198,7 @@ describe("StrikeWaterCalculator", () => { 70, // target mash temp (C) 12, // current mash volume 85, // infusion water temp (C) - "c" + "C" ); expect(result.infusionVolume).toBeGreaterThan(0); @@ -213,7 +213,7 @@ describe("StrikeWaterCalculator", () => { 158, // target temp 12, // volume 150, // infusion temp (too low - less than target) - "f" + "F" ); }).toThrow( "Infusion water temperature must be higher than target mash temperature" @@ -226,14 +226,14 @@ describe("StrikeWaterCalculator", () => { 155, 12, 180, - "f" + "F" ); const bigJump = StrikeWaterCalculator.calculateInfusion( 150, 165, 12, 180, - "f" + "F" ); expect(bigJump.infusionVolume).toBeGreaterThan(smallJump.infusionVolume); @@ -245,7 +245,7 @@ describe("StrikeWaterCalculator", () => { 158.456, 12.789, 180.321, - "f" + "F" ); expect(result.infusionVolume).toBe( @@ -300,45 +300,45 @@ describe("StrikeWaterCalculator", () => { describe("validateInputs", () => { it("should validate grain weight", () => { expect(() => { - StrikeWaterCalculator.validateInputs(0, 70, 152, 1.25, "f"); + StrikeWaterCalculator.validateInputs(0, 70, 152, 1.25, "F"); }).toThrow("Grain weight must be greater than 0"); expect(() => { - StrikeWaterCalculator.validateInputs(-5, 70, 152, 1.25, "f"); + StrikeWaterCalculator.validateInputs(-5, 70, 152, 1.25, "F"); }).toThrow("Grain weight must be greater than 0"); }); it("should validate water-to-grain ratio", () => { expect(() => { - StrikeWaterCalculator.validateInputs(10, 70, 152, 0, "f"); + StrikeWaterCalculator.validateInputs(10, 70, 152, 0, "F"); }).toThrow("Water to grain ratio must be between 0 and 10"); expect(() => { - StrikeWaterCalculator.validateInputs(10, 70, 152, 15, "f"); + StrikeWaterCalculator.validateInputs(10, 70, 152, 15, "F"); }).toThrow("Water to grain ratio must be between 0 and 10"); }); it("should validate Fahrenheit temperature ranges", () => { expect(() => { - StrikeWaterCalculator.validateInputs(10, 25, 152, 1.25, "f"); // Grain too cold + StrikeWaterCalculator.validateInputs(10, 25, 152, 1.25, "F"); // Grain too cold }).toThrow("Grain temperature must be between 32°F and 120°F"); expect(() => { - StrikeWaterCalculator.validateInputs(10, 70, 130, 1.25, "f"); // Mash too cold + StrikeWaterCalculator.validateInputs(10, 70, 130, 1.25, "F"); // Mash too cold }).toThrow("Target mash temperature must be between 140°F and 170°F"); expect(() => { - StrikeWaterCalculator.validateInputs(10, 70, 180, 1.25, "f"); // Mash too hot + StrikeWaterCalculator.validateInputs(10, 70, 180, 1.25, "F"); // Mash too hot }).toThrow("Target mash temperature must be between 140°F and 170°F"); }); it("should validate Celsius temperature ranges", () => { expect(() => { - StrikeWaterCalculator.validateInputs(10, -5, 67, 1.25, "c"); // Grain too cold + StrikeWaterCalculator.validateInputs(10, -5, 67, 1.25, "C"); // Grain too cold }).toThrow("Grain temperature must be between 0°C and 50°C"); expect(() => { - StrikeWaterCalculator.validateInputs(10, 20, 55, 1.25, "c"); // Mash too cold + StrikeWaterCalculator.validateInputs(10, 20, 55, 1.25, "C"); // Mash too cold }).toThrow("Target mash temperature must be between 60°C and 77°C"); }); @@ -347,11 +347,11 @@ describe("StrikeWaterCalculator", () => { it("should pass validation for valid inputs", () => { expect(() => { - StrikeWaterCalculator.validateInputs(10, 70, 152, 1.25, "f"); + StrikeWaterCalculator.validateInputs(10, 70, 152, 1.25, "F"); }).not.toThrow(); expect(() => { - StrikeWaterCalculator.validateInputs(5, 20, 67, 1.5, "c"); + StrikeWaterCalculator.validateInputs(5, 20, 67, 1.5, "C"); }).not.toThrow(); }); }); @@ -396,7 +396,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.25, - "f" + "F" ); expect(result.strikeTemp).toBeGreaterThan(0); @@ -410,7 +410,7 @@ describe("StrikeWaterCalculator", () => { 70, 152, 1.25, - "f" + "F" ); expect(result.strikeTemp).toBeGreaterThan(0); @@ -424,7 +424,7 @@ describe("StrikeWaterCalculator", () => { 150, 152, 1.25, - "f" // Only 2F difference + "F" // Only 2F difference ); expect(result.strikeTemp).toBeGreaterThan(152); diff --git a/tests/src/services/calculators/UnitConverter.test.ts b/tests/src/services/calculators/UnitConverter.test.ts index d868e8cf..c9355199 100644 --- a/tests/src/services/calculators/UnitConverter.test.ts +++ b/tests/src/services/calculators/UnitConverter.test.ts @@ -60,19 +60,16 @@ describe("UnitConverter", () => { describe("temperature conversions", () => { it("should convert Fahrenheit to Celsius", () => { - const result = UnitConverter.convertTemperature(212, "f", "c"); + const result = UnitConverter.convertTemperature(212, "F", "C"); expect(result).toBe(100); }); it("should convert Celsius to Fahrenheit", () => { - const result = UnitConverter.convertTemperature(0, "c", "f"); + const result = UnitConverter.convertTemperature(0, "C", "F"); expect(result).toBe(32); }); - it("should convert Celsius to Kelvin", () => { - const result = UnitConverter.convertTemperature(25, "c", "k"); - expect(result).toBeCloseTo(298.15, 2); - }); + // Kelvin removed - not used in brewing it("should handle case insensitive temperature units", () => { const result1 = UnitConverter.convertTemperature( @@ -87,8 +84,8 @@ describe("UnitConverter", () => { it("should throw error for unknown temperature units", () => { expect(() => { - UnitConverter.convertTemperature(100, "x", "c"); - }).toThrow("Unknown temperature unit"); + UnitConverter.convertTemperature(100, "x", "C"); + }).toThrow("Unsupported temperature unit"); }); }); @@ -128,7 +125,7 @@ describe("UnitConverter", () => { }); it("should validate temperature units", () => { - expect(UnitConverter.isValidTemperatureUnit("f")).toBe(true); + expect(UnitConverter.isValidTemperatureUnit("F")).toBe(true); expect(UnitConverter.isValidTemperatureUnit("celsius")).toBe(true); expect(UnitConverter.isValidTemperatureUnit("invalid")).toBe(false); }); @@ -146,8 +143,8 @@ describe("UnitConverter", () => { }); it("should format temperature correctly", () => { - expect(UnitConverter.formatTemperature(152.4, "f")).toBe("152.4°F"); - expect(UnitConverter.formatTemperature(67.2, "c")).toBe("67.2°C"); + expect(UnitConverter.formatTemperature(152.4, "F")).toBe("152.4°F"); + expect(UnitConverter.formatTemperature(67.2, "C")).toBe("67.2°C"); }); }); @@ -172,28 +169,20 @@ describe("UnitConverter", () => { describe("temperature errors", () => { it("should throw error for unknown from temperature unit", () => { expect(() => { - UnitConverter.convertTemperature(100, "invalid", "c"); - }).toThrow("Unknown temperature unit: invalid"); + UnitConverter.convertTemperature(100, "invalid", "C"); + }).toThrow("Unsupported temperature unit"); }); it("should throw error for unknown to temperature unit", () => { expect(() => { - UnitConverter.convertTemperature(100, "c", "invalid"); - }).toThrow("Unknown temperature unit: invalid"); - }); - - it("should handle Kelvin conversion", () => { - const result = UnitConverter.convertTemperature(273.15, "k", "c"); - expect(result).toBeCloseTo(0, 2); + UnitConverter.convertTemperature(100, "C", "invalid"); + }).toThrow("Unsupported temperature unit"); }); - it("should handle Fahrenheit to Kelvin conversion", () => { - const result = UnitConverter.convertTemperature(32, "f", "k"); - expect(result).toBeCloseTo(273.15, 2); - }); + // Kelvin tests removed - not used in brewing it("should handle same unit temperature conversion", () => { - const result = UnitConverter.convertTemperature(100, "c", "c"); + const result = UnitConverter.convertTemperature(100, "C", "C"); expect(result).toBe(100); }); }); @@ -258,13 +247,9 @@ describe("UnitConverter", () => { it("should return all temperature units", () => { const units = UnitConverter.getTemperatureUnits(); - expect(units).toContain("f"); - expect(units).toContain("c"); - expect(units).toContain("k"); - expect(units).toContain("fahrenheit"); - expect(units).toContain("celsius"); - expect(units).toContain("kelvin"); - expect(units.length).toBe(6); + expect(units).toContain("F"); + expect(units).toContain("C"); + expect(units.length).toBe(2); // Only C and F (Kelvin not used in brewing) }); }); }); diff --git a/tests/src/services/offlineV2/StartupHydrationService.test.ts b/tests/src/services/offlineV2/StartupHydrationService.test.ts index 5c281eed..f7f4ccf9 100644 --- a/tests/src/services/offlineV2/StartupHydrationService.test.ts +++ b/tests/src/services/offlineV2/StartupHydrationService.test.ts @@ -70,7 +70,7 @@ describe("StartupHydrationService", () => { // Verify user data hydration was attempted expect(mockUserCacheService.getRecipes).toHaveBeenCalledWith( mockUserId, - "imperial" + "metric" ); // Verify static data hydration was attempted @@ -467,12 +467,12 @@ describe("StartupHydrationService", () => { }, }); - // Call without unit system (should default to imperial) + // Call without unit system (should default to metric) await StartupHydrationService.hydrateOnStartup(mockUserId); expect(mockUserCacheService.getRecipes).toHaveBeenCalledWith( mockUserId, - "imperial" + "metric" ); }); }); diff --git a/tests/src/types/brewSession.test.ts b/tests/src/types/brewSession.test.ts index 1ddda85b..0f91dbbd 100644 --- a/tests/src/types/brewSession.test.ts +++ b/tests/src/types/brewSession.test.ts @@ -1,7 +1,6 @@ import { BrewSessionStatus, FermentationStage, - TemperatureUnit, GravityReading, FermentationEntry, BrewSession, @@ -11,7 +10,7 @@ import { FermentationStats, BrewSessionSummary, } from "@src/types/brewSession"; -import { ID } from "@src/types/common"; +import { ID, TemperatureUnit } from "@src/types/common"; import { Recipe } from "@src/types/recipe"; describe("Brew Session Types", () => { diff --git a/tests/src/types/common.test.ts b/tests/src/types/common.test.ts index 32fb4239..1b600473 100644 --- a/tests/src/types/common.test.ts +++ b/tests/src/types/common.test.ts @@ -142,7 +142,7 @@ describe("Common Types", () => { it("should handle first page pagination", () => { const firstPageResponse: PaginatedResponse = { - data: ["a", "b", "c"], + data: ["a", "b", "C"], pagination: { page: 1, pages: 5, diff --git a/tests/testUtils.tsx b/tests/testUtils.tsx index b6568c02..13ab5cb1 100644 --- a/tests/testUtils.tsx +++ b/tests/testUtils.tsx @@ -7,6 +7,7 @@ import { NetworkProvider } from "@contexts/NetworkContext"; import { DeveloperProvider } from "@contexts/DeveloperContext"; import { UnitProvider } from "@contexts/UnitContext"; import { CalculatorsProvider } from "@contexts/CalculatorsContext"; +import { TemperatureUnit } from "@/src/types"; // Note: ScreenDimensionsProvider causes issues with react-native-safe-area-context in tests // import { ScreenDimensionsProvider } from "@contexts/ScreenDimensionsContext"; @@ -20,7 +21,7 @@ interface CustomRenderOptions extends Omit { isConnected?: boolean; }; unitSettings?: { - temperatureUnit?: "F" | "C"; + temperatureUnit?: TemperatureUnit; volumeUnit?: "gal" | "L"; weightUnit?: "lb" | "kg"; }; diff --git a/tests/utils/deviceUtils.test.ts b/tests/utils/deviceUtils.test.ts new file mode 100644 index 00000000..b1f44c38 --- /dev/null +++ b/tests/utils/deviceUtils.test.ts @@ -0,0 +1,282 @@ +/** + * Device Utilities Tests + * + * Tests for device identification and platform detection utilities + * Android-only app + */ + +import * as Device from "expo-device"; +import * as SecureStore from "expo-secure-store"; +import * as Crypto from "expo-crypto"; +import { getDeviceId, getDeviceName, getPlatform } from "@utils/deviceUtils"; +import { STORAGE_KEYS } from "@services/config"; + +// Mutable state for device mock +let mockModelName: string | null | undefined = null; +let mockOsName: string | null | undefined = null; + +// Mock expo modules with dynamic getters +jest.mock("expo-device", () => ({ + get modelName() { + return mockModelName; + }, + get osName() { + return mockOsName; + }, +})); +jest.mock("expo-secure-store"); +jest.mock("expo-crypto"); + +describe("deviceUtils", () => { + let originalCryptoDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + originalCryptoDescriptor = Object.getOwnPropertyDescriptor( + global, + "crypto" + ); + }); + + afterEach(() => { + if (originalCryptoDescriptor) { + Object.defineProperty(global, "crypto", originalCryptoDescriptor); + } else { + // If crypto wasn’t originally defined, clean up any test-added value + delete (global as any).crypto; + } + }); + + describe("getDeviceId", () => { + it("should return existing device ID from SecureStore", async () => { + const existingId = "existing-device-id-123"; + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(existingId); + + const result = await getDeviceId(); + + expect(result).toBe(existingId); + expect(SecureStore.getItemAsync).toHaveBeenCalledWith( + STORAGE_KEYS.BIOMETRIC_DEVICE_ID + ); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + }); + + it("should generate and store new device ID when none exists", async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null); + (SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined); + + // Mock crypto.randomUUID + const mockUUID = "550e8400-e29b-41d4-a716-446655440000"; + Object.defineProperty(global, "crypto", { + value: { + randomUUID: jest.fn(() => mockUUID), + }, + writable: true, + configurable: true, + }); + + const result = await getDeviceId(); + + expect(result).toBe(mockUUID); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + STORAGE_KEYS.BIOMETRIC_DEVICE_ID, + mockUUID + ); + }); + + it("should use expo-crypto fallback when crypto.randomUUID is unavailable", async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null); + (SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined); + + // Remove crypto.randomUUID + Object.defineProperty(global, "crypto", { + value: undefined, + writable: true, + configurable: true, + }); + + // Mock expo-crypto random bytes (16 bytes for UUID) + const mockBytes = new Uint8Array([ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, + ]); + (Crypto.getRandomBytesAsync as jest.Mock).mockResolvedValue(mockBytes); + + const result = await getDeviceId(); + + // Should be a valid UUID format (with version and variant bits set) + expect(result).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + STORAGE_KEYS.BIOMETRIC_DEVICE_ID, + result + ); + }); + + it("should generate temporary ID when SecureStore fails", async () => { + (SecureStore.getItemAsync as jest.Mock).mockRejectedValue( + new Error("SecureStore unavailable") + ); + + const mockUUID = "temp-uuid-123"; + Object.defineProperty(global, "crypto", { + value: { + randomUUID: jest.fn(() => mockUUID), + }, + writable: true, + configurable: true, + }); + + const consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + const result = await getDeviceId(); + + expect(result).toBe(mockUUID); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to get/store device ID"), + expect.any(Error) + ); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it("should handle SecureStore.setItemAsync failure gracefully", async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null); + (SecureStore.setItemAsync as jest.Mock).mockRejectedValue( + new Error("Storage full") + ); + + const mockUUID = "new-uuid-456"; + Object.defineProperty(global, "crypto", { + value: { + randomUUID: jest.fn(() => mockUUID), + }, + writable: true, + configurable: true, + }); + + const consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + const result = await getDeviceId(); + + // Should still return the generated UUID even if storage fails + expect(result).toBe(mockUUID); + expect(SecureStore.setItemAsync).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe("getDeviceName", () => { + beforeEach(() => { + // Reset modelName before each test + mockModelName = null; + }); + + it("should return 'Unknown Device' when modelName is null", async () => { + mockModelName = null; + + const result = await getDeviceName(); + + expect(result).toBe("Unknown Device"); + }); + + it("should return 'Unknown Device' when modelName is undefined", async () => { + mockModelName = undefined; + + const result = await getDeviceName(); + + expect(result).toBe("Unknown Device"); + }); + + it("should return the modelName when it is a valid string", async () => { + mockModelName = "Pixel 6 Pro"; + + const result = await getDeviceName(); + + expect(result).toBe("Pixel 6 Pro"); + }); + }); + + describe("getPlatform", () => { + it("should return 'web' for unknown OS (fallback)", () => { + mockOsName = "Windows"; + + const result = getPlatform(); + + expect(result).toBe("web"); + }); + + it("should return 'web' when osName is null", () => { + mockOsName = null; + + const result = getPlatform(); + + expect(result).toBe("web"); + }); + }); + + describe("UUID generation", () => { + it("should generate RFC4122 v4 compliant UUIDs with expo-crypto", async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null); + (SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined); + + // Remove crypto.randomUUID to force expo-crypto path + Object.defineProperty(global, "crypto", { + value: undefined, + writable: true, + configurable: true, + }); + + // Mock random bytes that will result in specific UUID + const mockBytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + mockBytes[i] = i * 16; + } + (Crypto.getRandomBytesAsync as jest.Mock).mockResolvedValue(mockBytes); + + const result = await getDeviceId(); + + // Verify UUID format + expect(result).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + + // Verify version 4 (4th character of 3rd group should be '4') + const versionChar = result.split("-")[2][0]; + expect(versionChar).toBe("4"); + + // Verify variant (first character of 4th group should be 8, 9, a, or b) + const variantChar = result.split("-")[3][0].toLowerCase(); + expect(["8", "9", "a", "b"]).toContain(variantChar); + }); + + it("should prefer crypto.randomUUID when available", async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null); + (SecureStore.setItemAsync as jest.Mock).mockResolvedValue(undefined); + + const mockUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + const randomUUIDMock = jest.fn(() => mockUUID); + Object.defineProperty(global, "crypto", { + value: { + randomUUID: randomUUIDMock, + }, + writable: true, + configurable: true, + }); + + const result = await getDeviceId(); + + expect(randomUUIDMock).toHaveBeenCalled(); + expect(Crypto.getRandomBytesAsync).not.toHaveBeenCalled(); + expect(result).toBe(mockUUID); + }); + }); +}); diff --git a/tests/utils/unitContextMock.ts b/tests/utils/unitContextMock.ts index 5aa89dd1..98630029 100644 --- a/tests/utils/unitContextMock.ts +++ b/tests/utils/unitContextMock.ts @@ -32,7 +32,7 @@ const defaultMockState = { case "volume": return "gal"; case "temperature": - return "f"; + return "F"; default: return "lb"; } @@ -59,7 +59,7 @@ const defaultMockState = { ? "lb" : measurementType === "volume" ? "gal" - : "f", + : "F", }) ), formatValue: jest.fn(