diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 191605bdf3..c1be74e548 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -131,12 +131,7 @@ export default function AppLayout() { animation: 'slide_from_bottom', }} /> - + initLocalModel(isAuthenticated)); }, [isAuthenticated]); + const { unit: weightUnit } = useWeightUnit(); + const { unit: temperatureUnit } = useTemperatureUnit(); + const { unit: speedUnit } = useSpeedUnit(); + // Keep a ref for context body values so the transport closure stays fresh const contextRef = React.useRef(context); contextRef.current = context; const isAuthenticatedRef = React.useRef(isAuthenticated); isAuthenticatedRef.current = isAuthenticated; + const weightUnitRef = React.useRef(weightUnit); + weightUnitRef.current = weightUnit; + const temperatureUnitRef = React.useRef(temperatureUnit); + temperatureUnitRef.current = temperatureUnit; + const speedUnitRef = React.useRef(speedUnit); + speedUnitRef.current = speedUnit; // Build the right transport based on current AI mode. // Recreated when aiMode or modelStatus changes (modelStatus drives local readiness). @@ -188,7 +201,10 @@ export default function AIChat() { Context: - User id is ${userId} - - Current date is ${new Date().toLocaleString()}`; + - Current date is ${new Date().toLocaleString()} + - User's preferred weight unit is ${weightUnitRef.current} (always display weights in this unit) + - User's preferred temperature unit is °${temperatureUnitRef.current} (always display temperatures in this unit) + - User's preferred wind/distance unit is ${speedUnitRef.current === 'mph' ? 'mph / miles' : 'km/h / km'} (always display wind speed and distances in this unit)`; if (contextRef.current.contextType === 'pack' && contextRef.current.packId) { systemPrompt += `\n- You are currently helping with a pack with ID: ${contextRef.current.packId}.`; @@ -241,6 +257,9 @@ export default function AIChat() { packId: contextRef.current.packId, location: locationRef.current, date: new Date().toLocaleString(), + weightUnit: weightUnitRef.current, + temperatureUnit: temperatureUnitRef.current, + speedUnit: speedUnitRef.current, }), }), transportKey: 'remote', diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index 548f6d0f12..b87ce7c684 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -1,6 +1,7 @@ import { Avatar, AvatarFallback, AvatarImage, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; -import { userStore } from 'expo-app/features/auth/store'; +import { parseWeightUnit } from '@packrat/units'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import type { PackItem } from 'expo-app/features/packs/types'; import { type CategorySummary, computeCategorySummaries } from 'expo-app/features/packs/utils'; @@ -22,13 +23,14 @@ function WeightCard({ weight: number; className?: string; }) { + const { unit, convertWeight } = useWeightUnit(); return ( {title} - {weight} g + {convertWeight({ weight, fromUnit: 'g' })} {unit} ); @@ -54,6 +56,7 @@ function CustomList({ function CategoryItem({ category, index }: { category: CategorySummary; index: number }) { const { colors } = useColorScheme(); const { t } = useTranslation(); + const { unit: weightUnit } = useWeightUnit(); const itemLabel = category.items === 1 ? t('packs.item') : t('packs.items'); return ( @@ -66,8 +69,7 @@ function CategoryItem({ category, index }: { category: CategorySummary; index: n {category.name} - {category.weight} {userStore.preferredWeightUnit.peek() ?? 'g'} • {category.items}{' '} - {itemLabel} + {category.weight} {weightUnit} • {category.items} {itemLabel} )} - {item.weight} {item.weightUnit} + {convertWeight({ + weight: item.weight, + fromUnit: parseWeightUnit({ value: item.weightUnit }), + })}{' '} + {unit} @@ -126,7 +133,8 @@ export default function CurrentPackScreen() { const { t } = useTranslation(); const pack = usePackDetailsFromStore(params.id as string); - const uniqueCategories = computeCategorySummaries(pack); + const { unit: weightUnit } = useWeightUnit(); + const uniqueCategories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); return ( diff --git a/apps/expo/app/(app)/pack-categories/[id].tsx b/apps/expo/app/(app)/pack-categories/[id].tsx index eef4ee3aef..f0dc3154ee 100644 --- a/apps/expo/app/(app)/pack-categories/[id].tsx +++ b/apps/expo/app/(app)/pack-categories/[id].tsx @@ -1,7 +1,7 @@ import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; -import { userStore } from 'expo-app/features/auth/store'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { computeCategorySummaries } from 'expo-app/features/packs/utils'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; @@ -11,6 +11,7 @@ import { ScrollView, View } from 'react-native'; function CategoryCard({ category, + weightUnit, }: { category: { name: string; @@ -19,6 +20,7 @@ function CategoryCard({ percentage: number; icon?: MaterialIconName; }; + weightUnit: string; }) { const { colors } = useColorScheme(); const { t } = useTranslation(); @@ -45,7 +47,7 @@ function CategoryCard({ - {category.weight} {userStore.preferredWeightUnit.peek() ?? 'g'} + {category.weight} {weightUnit} @@ -60,7 +62,8 @@ export default function PackCategoriesScreen() { const pack = usePackDetailsFromStore(params.id as string); const { t } = useTranslation(); - const categories = computeCategorySummaries(pack); + const { unit: weightUnit } = useWeightUnit(); + const categories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); return ( <> @@ -75,7 +78,7 @@ export default function PackCategoriesScreen() { {categories.map((category) => ( - + ))} diff --git a/apps/expo/app/(app)/pack-stats/[id].tsx b/apps/expo/app/(app)/pack-stats/[id].tsx index 35cf84af06..6f23eab0d2 100644 --- a/apps/expo/app/(app)/pack-stats/[id].tsx +++ b/apps/expo/app/(app)/pack-stats/[id].tsx @@ -1,7 +1,7 @@ import { Button, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { featureFlags } from 'expo-app/config'; -import { userStore } from 'expo-app/features/auth/store'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { usePackWeightHistory } from 'expo-app/features/packs/hooks/usePackWeightHistory'; import { computeCategorySummaries } from 'expo-app/features/packs/utils'; @@ -18,8 +18,9 @@ export default function PackStatsScreen() { const pack = usePackDetailsFromStore(packId); const weightHistory = usePackWeightHistory(packId); + const { unit: weightUnit, convertWeight } = useWeightUnit(); - const categories = computeCategorySummaries(pack); + const categories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); const CATEGORY_DISTRIBUTION = categories.map((category) => ({ name: category.name, weight: category.weight, @@ -63,7 +64,7 @@ export default function PackStatsScreen() { {item.month} - {item.weight.toFixed(1)} g + {convertWeight({ weight: item.weight, fromUnit: 'g' })} {weightUnit} ); @@ -106,8 +107,7 @@ export default function PackStatsScreen() { {item.name} - {item.weight.toFixed(1)} {userStore.preferredWeightUnit.peek() ?? 'g'}( - {item.percentage}%) + {item.weight.toFixed(1)} {weightUnit}({item.percentage}%) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 86b3c654c7..d0c2daa25e 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -1,4 +1,4 @@ -import { ActivityIndicator, Text } from '@packrat/ui/nativewindui'; +import { ActivityIndicator, SegmentedControl, Text } from '@packrat/ui/nativewindui'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Burnt from 'burnt'; import { appAlert } from 'expo-app/app/_layout'; @@ -17,9 +17,13 @@ import { } from 'expo-app/features/ai/lib/localModelManager'; import { DeleteAccountButton } from 'expo-app/features/auth/components/DeleteAccountButton'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; +import { useSpeedUnit } from 'expo-app/features/auth/hooks/useSpeedUnit'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useSeasonSuggestionsPrefs } from 'expo-app/features/packs/atoms/seasonSuggestionsAtoms'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { testIds } from 'expo-app/lib/testIds'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; @@ -37,6 +41,9 @@ export default function SettingsScreen() { const router = useRouter(); const { announcementSeen, setAnnouncementSeen, opened, setOpened } = useSeasonSuggestionsPrefs(); + const { unit: weightUnit, setWeightUnit } = useWeightUnit(); + const { unit: temperatureUnit, setTemperatureUnit } = useTemperatureUnit(); + const { unit: speedUnit, setSpeedUnit } = useSpeedUnit(); const isApple = isAppleIntelligenceAvailable(); const isDownloading = modelStatus === 'downloading'; @@ -100,6 +107,64 @@ export default function SettingsScreen() { style={Platform.OS === 'ios' ? 'light' : colorScheme === 'dark' ? 'light' : 'dark'} /> + + + {t('settings.displayUnits')} + + + + + Weight + + {t('settings.weightSubtitle')} + + + + setWeightUnit(index === 0 ? 'kg' : 'lb')} + /> + + + + + + Temperature + + {t('settings.temperatureSubtitle')} + + + + setTemperatureUnit(index === 0 ? 'C' : 'F')} + /> + + + + + + Wind & Distance + + {t('settings.windDistanceSubtitle')} + + + + setSpeedUnit(index === 0 ? 'kmh' : 'mph')} + /> + + + + + {t('ai.modelManagement')} diff --git a/apps/expo/app/(app)/weight-analysis/[id].tsx b/apps/expo/app/(app)/weight-analysis/[id].tsx index b6a149125d..d0cf934fbd 100644 --- a/apps/expo/app/(app)/weight-analysis/[id].tsx +++ b/apps/expo/app/(app)/weight-analysis/[id].tsx @@ -2,7 +2,7 @@ import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; -import { userStore } from 'expo-app/features/auth/store'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackWeightAnalysis } from 'expo-app/features/packs/hooks/usePackWeightAnalysis'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -43,9 +43,8 @@ export default function WeightAnalysisScreen() { const packId = params.id; const { t } = useTranslation(); - const { data, items } = usePackWeightAnalysis(packId as string); - - const preferredWeightUnit = userStore.preferredWeightUnit.peek() ?? 'g'; + const { data, items, preferredUnit } = usePackWeightAnalysis(packId as string); + const { convertWeight } = useWeightUnit(); return ( @@ -59,22 +58,22 @@ export default function WeightAnalysisScreen() { @@ -97,7 +96,7 @@ export default function WeightAnalysisScreen() { {category.name} - {category.weight} {preferredWeightUnit} + {category.weight} {preferredUnit} @@ -123,7 +122,8 @@ export default function WeightAnalysisScreen() { )} - {item.weight} {item.weightUnit} + {convertWeight({ weight: item.weight, fromUnit: item.weightUnit || 'g' })}{' '} + {preferredUnit} ))} diff --git a/apps/expo/components/initial/WeightBadge.tsx b/apps/expo/components/initial/WeightBadge.tsx index d4cd1c2d17..3de2ea3e75 100644 --- a/apps/expo/components/initial/WeightBadge.tsx +++ b/apps/expo/components/initial/WeightBadge.tsx @@ -1,7 +1,7 @@ import type { WeightUnit } from '@packrat/constants'; -import { isString } from '@packrat/guards'; +import { parseWeightUnit } from '@packrat/units'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { cn } from 'expo-app/lib/cn'; -import { formatWeight } from 'expo-app/utils/weight'; import { Text, View } from 'react-native'; type WeightBadgeProps = { @@ -19,6 +19,8 @@ export function WeightBadge({ containerClassName, textClassName, }: WeightBadgeProps) { + const { convertWeight, unit: displayUnit } = useWeightUnit(); + const getColorClass = () => { switch (type) { case 'base': @@ -31,13 +33,13 @@ export function WeightBadge({ }; const safeWeight = Number(weight) || 0; - const safeUnit = isString(unit) ? unit : 'g'; - const formattedWeight = formatWeight({ weight: safeWeight, unit: safeUnit }); + const safeUnit: WeightUnit = parseWeightUnit({ value: unit }); + const converted = convertWeight({ weight: safeWeight, fromUnit: safeUnit }); return ( - {formattedWeight} + {`${converted} ${displayUnit}`} ); diff --git a/apps/expo/features/ai/components/LocationContext.tsx b/apps/expo/features/ai/components/LocationContext.tsx index 397d4f4e93..74581021a0 100644 --- a/apps/expo/features/ai/components/LocationContext.tsx +++ b/apps/expo/features/ai/components/LocationContext.tsx @@ -1,5 +1,6 @@ import { Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { LocationPicker } from 'expo-app/features/weather/components/LocationPicker'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -17,6 +18,7 @@ export function LocationContext({ location, onSetLocation }: LocationContextProp const { colors } = useColorScheme(); const { t } = useTranslation(); const [showLocationPicker, setShowLocationPicker] = useState(false); + const { displayTemperature } = useTemperatureUnit(); if (!location) { return ( @@ -42,7 +44,9 @@ export function LocationContext({ location, onSetLocation }: LocationContextProp > {location.name} - {location.temperature}° + + {displayTemperature(location.temperature)} + diff --git a/apps/expo/features/ai/components/WeatherGenerativeUI.tsx b/apps/expo/features/ai/components/WeatherGenerativeUI.tsx index 17cf0b7da5..b26fa4c9dd 100644 --- a/apps/expo/features/ai/components/WeatherGenerativeUI.tsx +++ b/apps/expo/features/ai/components/WeatherGenerativeUI.tsx @@ -1,6 +1,7 @@ import { Text } from '@packrat/ui/nativewindui'; import * as Sentry from '@sentry/react-native'; import { Icon } from 'expo-app/components/Icon'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { getWeatherIconByCondition } from 'expo-app/features/weather/lib/weatherIcons'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -44,6 +45,7 @@ interface WeatherGenerativeUIProps { export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps) { const { colors } = useColorScheme(); const { t } = useTranslation(); + const { displayTemperature } = useTemperatureUnit(); useEffect(() => { const { toolCallId } = toolInvocation; @@ -151,7 +153,7 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps - {toolInvocation.output.data.temperature}° + {displayTemperature(toolInvocation.output.data.temperature)} {toolInvocation.output.data.condition || '—'} @@ -214,9 +216,10 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps } } -const getTemperatureColor = (temp: number) => { - if (temp >= 80) return 'text-red-600'; - if (temp >= 60) return 'text-orange-500'; - if (temp >= 40) return 'text-blue-500'; +// Thresholds in Celsius: hot ≥27°C, mild ≥15°C, cool ≥4°C +const getTemperatureColor = (tempC: number) => { + if (tempC >= 27) return 'text-red-600'; + if (tempC >= 15) return 'text-orange-500'; + if (tempC >= 4) return 'text-blue-500'; return 'text-blue-700'; }; diff --git a/apps/expo/features/auth/hooks/useSpeedUnit.ts b/apps/expo/features/auth/hooks/useSpeedUnit.ts new file mode 100644 index 0000000000..bfc07c5f24 --- /dev/null +++ b/apps/expo/features/auth/hooks/useSpeedUnit.ts @@ -0,0 +1,26 @@ +import { use$ } from '@legendapp/state/react'; +import { preferencesStore } from 'expo-app/features/auth/store/preferences'; +import { getDefaultSpeedUnit } from 'expo-app/lib/unitDefaults'; + +export function useSpeedUnit() { + const stored = use$(preferencesStore.speedUnit); + const unit: 'mph' | 'kmh' = stored ?? getDefaultSpeedUnit(); + + const setSpeedUnit = (value: 'mph' | 'kmh') => { + preferencesStore.speedUnit.set(value); + }; + + // Takes a km/h value, returns a formatted string in the user's preferred unit + const displayWindSpeed = (kph: number): string => { + if (unit === 'mph') return `${Math.round(kph * 0.621371)} mph`; + return `${Math.round(kph)} km/h`; + }; + + // Takes a km value, returns a formatted string in the user's preferred unit + const displayVisibility = (km: number): string => { + if (unit === 'mph') return `${Math.round(km * 0.621371)} mi`; + return `${Math.round(km)} km`; + }; + + return { unit, setSpeedUnit, displayWindSpeed, displayVisibility }; +} diff --git a/apps/expo/features/auth/hooks/useTemperatureUnit.ts b/apps/expo/features/auth/hooks/useTemperatureUnit.ts new file mode 100644 index 0000000000..8adca64fae --- /dev/null +++ b/apps/expo/features/auth/hooks/useTemperatureUnit.ts @@ -0,0 +1,28 @@ +import { use$ } from '@legendapp/state/react'; +import { preferencesStore } from 'expo-app/features/auth/store/preferences'; +import { getDefaultTemperatureUnit } from 'expo-app/lib/unitDefaults'; + +export function useTemperatureUnit() { + const stored = use$(preferencesStore.temperatureUnit); + const unit: 'C' | 'F' = stored ?? getDefaultTemperatureUnit(); + + const setTemperatureUnit = (value: 'C' | 'F') => { + preferencesStore.temperatureUnit.set(value); + }; + + // Takes a Celsius value and returns a formatted string in the user's preferred unit + const displayTemperature = (celsius: number): string => { + if (unit === 'F') { + return `${Math.round(celsius * 1.8 + 32)}°F`; + } + return `${Math.round(celsius)}°C`; + }; + + // Returns the raw numeric value in the preferred unit (no unit suffix) + const toPreferred = (celsius: number): number => { + if (unit === 'F') return Math.round(celsius * 1.8 + 32); + return Math.round(celsius); + }; + + return { unit, setTemperatureUnit, displayTemperature, toPreferred }; +} diff --git a/apps/expo/features/auth/hooks/useWeightUnit.ts b/apps/expo/features/auth/hooks/useWeightUnit.ts new file mode 100644 index 0000000000..f7673d71bc --- /dev/null +++ b/apps/expo/features/auth/hooks/useWeightUnit.ts @@ -0,0 +1,27 @@ +import { use$ } from '@legendapp/state/react'; +import type { WeightUnit } from '@packrat/units'; +import { displayWeight, normalize } from '@packrat/units'; +import { preferencesStore } from 'expo-app/features/auth/store/preferences'; +import { getDefaultWeightUnit } from 'expo-app/lib/unitDefaults'; + +export function useWeightUnit() { + const stored = use$(preferencesStore.weightUnit); + const unit: 'kg' | 'lb' = stored ?? getDefaultWeightUnit(); + + const setWeightUnit = (value: 'kg' | 'lb') => { + preferencesStore.weightUnit.set(value); + }; + + const convertWeight = ({ + weight, + fromUnit, + }: { + weight: number; + fromUnit: WeightUnit; + }): number => { + const grams = normalize({ weight, unit: fromUnit }); + return displayWeight({ grams, unit }); + }; + + return { unit, setWeightUnit, convertWeight }; +} diff --git a/apps/expo/features/catalog/components/CatalogItemCard.tsx b/apps/expo/features/catalog/components/CatalogItemCard.tsx index d5d13cd4c2..9e8fcfd178 100644 --- a/apps/expo/features/catalog/components/CatalogItemCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemCard.tsx @@ -8,6 +8,7 @@ import { Text, } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; @@ -24,6 +25,7 @@ type CatalogItemCardProps = { export function CatalogItemCard({ item, onPress }: CatalogItemCardProps) { const { colors } = useColorScheme(); const { t } = useTranslation(); + const { unit, convertWeight } = useWeightUnit(); return ( - {item.weight} {item.weightUnit} + {item.weight != null + ? `${convertWeight({ weight: item.weight, fromUnit: item.weightUnit ?? 'g' })} ${unit}` + : ''} {item.usageCount && item.usageCount > 0 && ( diff --git a/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx b/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx index 22ba617f64..7202afa6a0 100644 --- a/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx @@ -8,6 +8,7 @@ import { Text, } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { testIds } from 'expo-app/lib/testIds'; @@ -23,6 +24,7 @@ type CatalogItemSelectCardProps = { export function CatalogItemSelectCard({ item, isSelected, onToggle }: CatalogItemSelectCardProps) { const { colors } = useColorScheme(); + const { unit, convertWeight } = useWeightUnit(); return ( @@ -77,7 +79,9 @@ export function CatalogItemSelectCard({ item, isSelected, onToggle }: CatalogIte - {item.weight} {item.weightUnit} + {item.weight != null + ? `${convertWeight({ weight: item.weight, fromUnit: item.weightUnit ?? 'g' })} ${unit}` + : ''} {item.usageCount && item.usageCount > 0 && ( diff --git a/apps/expo/features/catalog/components/SimilarItems.tsx b/apps/expo/features/catalog/components/SimilarItems.tsx index 1aad2afa18..a65c14a1f0 100644 --- a/apps/expo/features/catalog/components/SimilarItems.tsx +++ b/apps/expo/features/catalog/components/SimilarItems.tsx @@ -1,4 +1,5 @@ import { Text } from '@packrat/ui/nativewindui'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { type SimilarItem, useSimilarCatalogItems } from 'expo-app/features/catalog/hooks'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useRouter } from 'expo-router'; @@ -20,6 +21,7 @@ interface SimilarItemCardProps { const SimilarItemCard: React.FC = ({ item, onPress }) => { const { t } = useTranslation(); + const { unit, convertWeight } = useWeightUnit(); return ( = ({ item, onPress }) => { - {item.weight} {item.weightUnit} + {item.weight != null + ? `${convertWeight({ weight: item.weight, fromUnit: item.weightUnit ?? 'g' })} ${unit}` + : ''} diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 75b64040f3..4156cd97f6 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -1,10 +1,11 @@ -import { assertDefined, fromZod } from '@packrat/guards'; -import { WeightUnitSchema } from '@packrat/schemas/constants'; +import { assertDefined } from '@packrat/guards'; import { Button, Text } from '@packrat/ui/nativewindui'; +import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; import { useQueryClient } from '@tanstack/react-query'; import * as Burnt from 'burnt'; import { Icon } from 'expo-app/components/Icon'; import { TextInput } from 'expo-app/components/TextInput'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useCreatePackItem, usePackDetailsFromStore } from 'expo-app/features/packs'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -40,6 +41,7 @@ export function AddCatalogItemDetailsScreen() { const pack = usePackDetailsFromStore(packId as string); const createItem = useCreatePackItem(); const queryClient = useQueryClient(); + const { unit: preferredWeightUnit, convertWeight } = useWeightUnit(); const fadeAnim = useState(new Animated.Value(0))[0]; const [isAdding, setIsAdding] = useState(false); const { t } = useTranslation(); @@ -83,8 +85,14 @@ export function AddCatalogItemDetailsScreen() { itemData: { name: catalogItem.name, description: catalogItem.description ?? undefined, - weight: catalogItem.weight || 0, - weightUnit: fromZod(WeightUnitSchema)(catalogItem.weightUnit) ?? 'g', + weight: displayWeight({ + grams: normalize({ + weight: catalogItem.weight || 0, + unit: parseWeightUnit({ value: catalogItem.weightUnit }), + }), + unit: preferredWeightUnit, + }), + weightUnit: preferredWeightUnit, quantity: Number.parseInt(quantity, 10) || 1, category, consumable: isConsumable, @@ -159,7 +167,9 @@ export function AddCatalogItemDetailsScreen() { - {catalogItem.weight} {catalogItem.weightUnit} + {catalogItem.weight != null + ? `${convertWeight({ weight: catalogItem.weight, fromUnit: catalogItem.weightUnit ?? 'g' })} ${preferredWeightUnit}` + : ''} {catalogItem.brand && ( <> diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index 7305775405..4a66204466 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -4,6 +4,7 @@ import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom'; import { Icon } from 'expo-app/components/Icon'; import { Chip } from 'expo-app/components/initial/Chip'; import { ExpandableText } from 'expo-app/components/initial/ExpandableText'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { ItemLinks } from 'expo-app/features/catalog/components/ItemLinks'; import { ItemReviews } from 'expo-app/features/catalog/components/ItemReviews'; import { SimilarItems } from 'expo-app/features/catalog/components/SimilarItems'; @@ -35,6 +36,7 @@ import type { CatalogItem } from '../types'; function VariantRow({ variant }: { variant: CatalogItem }) { const { t } = useTranslation(); const { colors } = useColorScheme(); + const { unit, convertWeight } = useWeightUnit(); const label = [variant.size, variant.color].filter(Boolean).join(' · '); return ( @@ -54,7 +56,8 @@ function VariantRow({ variant }: { variant: CatalogItem }) { )} {variant.weight != null && ( - {variant.weight} {variant.weightUnit} + {convertWeight({ weight: variant.weight ?? 0, fromUnit: variant.weightUnit ?? 'g' })}{' '} + {unit} )} {variant.availability && ( @@ -96,6 +99,7 @@ export function CatalogItemDetailScreen() { const { data: item, isLoading, isError, refetch } = useCatalogItemDetails(id as string); const { colors } = useColorScheme(); const { t } = useTranslation(); + const { unit, convertWeight } = useWeightUnit(); const MATERIAL_LENGTH_THRESHOLD = 60; const [viewerIndex, setViewerIndex] = useState(null); @@ -252,8 +256,8 @@ export function CatalogItemDetailScreen() { - {item.weight !== undefined && item.weightUnit - ? `${item.weight} ${item.weightUnit}` + {item.weight != null + ? `${convertWeight({ weight: item.weight, fromUnit: item.weightUnit ?? 'g' })} ${unit}` : t('catalog.notSpecified')} diff --git a/apps/expo/features/packs/components/CurrentPackTile.tsx b/apps/expo/features/packs/components/CurrentPackTile.tsx index 6895e65342..56e5addeca 100644 --- a/apps/expo/features/packs/components/CurrentPackTile.tsx +++ b/apps/expo/features/packs/components/CurrentPackTile.tsx @@ -1,5 +1,6 @@ import { Avatar, AvatarFallback, AvatarImage, ListItem, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useRouter } from 'expo-router'; @@ -11,6 +12,7 @@ const _LOGO_SOURCE = require('expo-app/assets/packrat-app-icon-gradient.png'); export function CurrentPackTile() { const { t } = useTranslation(); const currentPack = useCurrentPack(); + const { unit, convertWeight } = useWeightUnit(); const router = useRouter(); @@ -40,7 +42,9 @@ export function CurrentPackTile() { rightView={ - {currentPack ? `${currentPack.totalWeight} g` : ''} + {currentPack + ? `${convertWeight({ weight: currentPack.totalWeight, fromUnit: 'g' })} ${unit}` + : ''} diff --git a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx index c3438a0165..867a86adef 100644 --- a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx +++ b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx @@ -1,5 +1,7 @@ import { Text } from '@packrat/ui/nativewindui'; +import { parseWeightUnit } from '@packrat/units'; import { Icon } from 'expo-app/components/Icon'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { CatalogItemImage } from 'expo-app/features/catalog/components/CatalogItemImage'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; @@ -35,11 +37,6 @@ const formatPrice = ({ price, currency }: { price?: number | null; currency?: st return `${currency || '$'}${price.toFixed(2)}`; }; -const formatWeight = ({ weight, unit }: { weight?: number | null; unit?: string | null }) => { - if (!weight) return ''; - return `${weight}${unit || 'g'}`; -}; - function ScalePress({ onPress, hitSlop, @@ -75,6 +72,7 @@ function ScalePress({ export function HorizontalCatalogItemCard({ item, ...restProps }: HorizontalCatalogItemCardProps) { const { colors } = useColorScheme(); + const { unit: displayUnit, convertWeight } = useWeightUnit(); const handleCardPress = () => { if ('onSelect' in restProps) { @@ -115,7 +113,11 @@ export function HorizontalCatalogItemCard({ item, ...restProps }: HorizontalCata )} {!!item.weight && ( - {formatWeight({ weight: item.weight, unit: item.weightUnit })} + {convertWeight({ + weight: item.weight, + fromUnit: parseWeightUnit({ value: item.weightUnit }), + })}{' '} + {displayUnit} )} {!!item.ratingValue && ( diff --git a/apps/expo/features/packs/components/WeightAnalysisTile.tsx b/apps/expo/features/packs/components/WeightAnalysisTile.tsx index d1060dee14..27fb1965d9 100644 --- a/apps/expo/features/packs/components/WeightAnalysisTile.tsx +++ b/apps/expo/features/packs/components/WeightAnalysisTile.tsx @@ -1,6 +1,7 @@ import type { AlertMethods } from '@packrat/ui/nativewindui'; import { Alert, ListItem, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { type Href, useRouter } from 'expo-router'; @@ -12,9 +13,10 @@ export function WeightAnalysisTile() { const { t } = useTranslation(); const router = useRouter(); const currentPack = useCurrentPack(); + const { unit, convertWeight } = useWeightUnit(); const alertRef = useRef(null); - const packWeight = currentPack?.totalWeight ?? 0; + const packWeight = convertWeight({ weight: currentPack?.totalWeight ?? 0, fromUnit: 'g' }); const route: Href | null = currentPack ? `/weight-analysis/${currentPack.id}` : null; const handlePress = () => { @@ -39,7 +41,7 @@ export function WeightAnalysisTile() { } rightView={ - {`Base: ${packWeight} g`} + {`Base: ${packWeight} ${unit}`} } diff --git a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts index 1e34c77982..0e50fd20f5 100644 --- a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts +++ b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts @@ -1,42 +1,37 @@ -import { userStore } from 'expo-app/features/auth/store'; -import { computeCategorySummaries, convertFromGrams, convertToGrams } from '../utils'; +import { use$ } from '@legendapp/state/react'; +import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; +import { preferencesStore } from 'expo-app/features/auth/store/preferences'; +import { getDefaultWeightUnit } from 'expo-app/lib/unitDefaults'; +import { computeCategorySummaries } from '../utils'; import { usePackDetailsFromStore } from './usePackDetailsFromStore'; export function usePackWeightAnalysis(packId: string) { const pack = usePackDetailsFromStore(packId); + const storedUnit = use$(preferencesStore.weightUnit); + const preferredUnit = storedUnit ?? getDefaultWeightUnit(); - const consumableWeightInGrams = pack.items - .filter((item) => item.consumable) - .reduce((sum, item) => { - const unit = item.weightUnit || 'g'; - const weight = item.weight || 0; - return sum + convertToGrams({ weight: weight * item.quantity, unit: unit }); - }, 0); + const toGrams = (item: { weight: number; weightUnit?: string | null; quantity: number }) => + normalize({ + weight: (item.weight || 0) * item.quantity, + unit: parseWeightUnit({ value: item.weightUnit }), + }); - const wornWeightInGrams = pack.items - .filter((item) => item.worn) - .reduce((sum, item) => { - const unit = item.weightUnit || 'g'; - const weight = item.weight || 0; - return sum + convertToGrams({ weight: weight * item.quantity, unit: unit }); - }, 0); + const consumableGrams = pack.items + .filter((i) => i.consumable) + .reduce((s, i) => s + toGrams(i), 0); + const wornGrams = pack.items.filter((i) => i.worn).reduce((s, i) => s + toGrams(i), 0); - const categorySummaries = computeCategorySummaries(pack); + const categorySummaries = computeCategorySummaries({ pack, preferredUnit }); return { data: { - baseWeight: pack.baseWeight, - consumableWeight: convertFromGrams({ - grams: consumableWeightInGrams, - unit: userStore.preferredWeightUnit.peek() ?? 'g', - }), - wornWeight: convertFromGrams({ - grams: wornWeightInGrams, - unit: userStore.preferredWeightUnit.peek() ?? 'g', - }), - totalWeight: pack.totalWeight, + baseWeight: displayWeight({ grams: pack.baseWeight, unit: preferredUnit }), + consumableWeight: displayWeight({ grams: consumableGrams, unit: preferredUnit }), + wornWeight: displayWeight({ grams: wornGrams, unit: preferredUnit }), + totalWeight: displayWeight({ grams: pack.totalWeight, unit: preferredUnit }), categories: categorySummaries, }, items: pack.items, + preferredUnit, }; } diff --git a/apps/expo/features/packs/screens/CreatePackItemForm.tsx b/apps/expo/features/packs/screens/CreatePackItemForm.tsx index 81aa62c093..ca6246dd1b 100644 --- a/apps/expo/features/packs/screens/CreatePackItemForm.tsx +++ b/apps/expo/features/packs/screens/CreatePackItemForm.tsx @@ -5,6 +5,7 @@ import { Form, FormItem, FormSection, SegmentedControl, TextField } from '@packr import * as Sentry from '@sentry/react-native'; import { useForm } from '@tanstack/react-form'; import { Icon } from 'expo-app/components/Icon'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; @@ -47,6 +48,7 @@ export const CreatePackItemForm = ({ const router = useRouter(); const { colors } = useColorScheme(); const { showActionSheetWithOptions } = useActionSheet(); + const { unit: preferredWeightUnit } = useWeightUnit(); const createPackItem = useCreatePackItem(); const updatePackItem = useUpdatePackItem(); const insets = useSafeAreaInsets(); @@ -97,7 +99,7 @@ export const CreatePackItemForm = ({ name: '', description: '', weight: 0, - weightUnit: 'g', + weightUnit: preferredWeightUnit, quantity: 1, category: '', consumable: false, diff --git a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts index 471639df78..8ee642a789 100644 --- a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts +++ b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts @@ -1,15 +1,7 @@ import type { Pack, PackItem } from 'expo-app/features/packs/types'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { computeCategorySummaries } from '../computeCategories'; -vi.mock('expo-app/features/auth/store', () => ({ - userStore: { - preferredWeightUnit: { - peek: vi.fn().mockReturnValue('g'), - }, - }, -})); - function makeItem( overrides: Partial & Pick, ): PackItem { @@ -42,7 +34,7 @@ function makePack(items: PackItem[]): Pack { describe('computeCategorySummaries', () => { it('returns empty array for a pack with no items', () => { - expect(computeCategorySummaries(makePack([]))).toEqual([]); + expect(computeCategorySummaries({ pack: makePack([]), preferredUnit: 'g' })).toEqual([]); }); it('groups items under the correct category name', () => { @@ -50,7 +42,7 @@ describe('computeCategorySummaries', () => { makeItem({ id: 'i1', weight: 200, weightUnit: 'g', category: 'Shelter' }), makeItem({ id: 'i2', weight: 300, weightUnit: 'g', category: 'Food' }), ]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result).toHaveLength(2); const names = result.map((c) => c.name); expect(names).toContain('Shelter'); @@ -59,37 +51,49 @@ describe('computeCategorySummaries', () => { it('falls back to "Other" for empty category string', () => { const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: '' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.name).toBe('Other'); }); it('falls back to "Other" for whitespace-only category', () => { const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: ' ' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.name).toBe('Other'); }); it('computes weight in preferred unit (grams)', () => { const items = [makeItem({ weight: 500, weightUnit: 'g', category: 'Pack' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.weight).toBe(500); }); it('converts weight units before computing (kg → g)', () => { const items = [makeItem({ weight: 1, weightUnit: 'kg', category: 'Pack' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.weight).toBe(1000); }); + it('outputs weight in kg when preferredUnit is kg', () => { + const items = [makeItem({ weight: 1000, weightUnit: 'g', category: 'Pack' })]; + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'kg' }); + expect(result[0]?.weight).toBe(1); + }); + + it('outputs weight in lb when preferredUnit is lb', () => { + const items = [makeItem({ weight: 453.592, weightUnit: 'g', category: 'Pack' })]; + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'lb' }); + expect(result[0]?.weight).toBeCloseTo(1, 1); + }); + it('multiplies weight by quantity', () => { const items = [makeItem({ weight: 100, weightUnit: 'g', quantity: 3, category: 'Food' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.weight).toBe(300); }); it('sets percentage to 100 for a single-category pack', () => { const items = [makeItem({ weight: 300, weightUnit: 'g', category: 'Electronics' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.percentage).toBe(100); }); @@ -98,7 +102,7 @@ describe('computeCategorySummaries', () => { makeItem({ id: 'i1', weight: 500, weightUnit: 'g', category: 'Shelter' }), makeItem({ id: 'i2', weight: 500, weightUnit: 'g', category: 'Food' }), ]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); for (const cat of result) { expect(cat.percentage).toBe(50); } @@ -109,7 +113,7 @@ describe('computeCategorySummaries', () => { makeItem({ id: 'i1', weight: 100, weightUnit: 'g', quantity: 5, category: 'Food' }), makeItem({ id: 'i2', weight: 200, weightUnit: 'g', quantity: 2, category: 'Food' }), ]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.items).toBe(2); }); @@ -118,14 +122,14 @@ describe('computeCategorySummaries', () => { makeItem({ id: 'i1', weight: 300, weightUnit: 'g', category: 'Shelter' }), makeItem({ id: 'i2', weight: 200, weightUnit: 'g', category: 'Shelter' }), ]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result).toHaveLength(1); expect(result[0]?.weight).toBe(500); }); it('sets percentage to 0 when total weight is zero', () => { const items = [makeItem({ weight: 0, weightUnit: 'g', category: 'Empty' })]; - const result = computeCategorySummaries(makePack(items)); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.percentage).toBe(0); }); }); diff --git a/apps/expo/features/packs/utils/computeCategories.ts b/apps/expo/features/packs/utils/computeCategories.ts index 362c0f05bc..c59fd85882 100644 --- a/apps/expo/features/packs/utils/computeCategories.ts +++ b/apps/expo/features/packs/utils/computeCategories.ts @@ -1,6 +1,6 @@ import { assertDefined } from '@packrat/guards'; +import type { WeightUnit } from '@packrat/units'; import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; -import { userStore } from 'expo-app/features/auth/store'; import type { Pack } from '../types'; export type CategorySummary = { @@ -10,11 +10,13 @@ export type CategorySummary = { percentage: number; }; -export function computeCategorySummaries(pack: Pack): CategorySummary[] { - const preferredUnit = parseWeightUnit({ - value: userStore.preferredWeightUnit.peek(), - fallback: 'g', - }); +export function computeCategorySummaries({ + pack, + preferredUnit, +}: { + pack: Pack; + preferredUnit: WeightUnit; +}): CategorySummary[] { const categoryMap: Record = {}; let totalWeightGrams = 0; diff --git a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx index b465001774..58a3fd2768 100644 --- a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx +++ b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx @@ -1,6 +1,7 @@ import type { WeatherAPIForecastResponse } from '@packrat/schemas/weather'; import * as Sentry from '@sentry/react-native'; import { Icon } from 'expo-app/components/Icon'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { WeatherForecast } from 'expo-app/features/weather/components/WeatherForecast'; import { getWeatherBackgroundColors, @@ -93,6 +94,7 @@ export default function TripWeatherDetailsScreen() { const location = weather.location; const current = weather.current; + const { displayTemperature } = useTemperatureUnit(); // Use the trip's location name if provided, otherwise fall back to weather API location const displayLocationName = tripLocationName || location.name; @@ -130,13 +132,13 @@ export default function TripWeatherDetailsScreen() { {displayLocationName} - {current.temp_c}° + {displayTemperature(current.temp_c)} {current.condition.text} - H:{weather.forecast.forecastday[0]?.day.maxtemp_c}° L: - {weather.forecast.forecastday[0]?.day.mintemp_c}° + H:{displayTemperature(weather.forecast.forecastday[0]?.day.maxtemp_c ?? 0)} L: + {displayTemperature(weather.forecast.forecastday[0]?.day.mintemp_c ?? 0)} diff --git a/apps/expo/features/weather/components/LocationCard.tsx b/apps/expo/features/weather/components/LocationCard.tsx index 3a83c9eb6e..395a3781ac 100644 --- a/apps/expo/features/weather/components/LocationCard.tsx +++ b/apps/expo/features/weather/components/LocationCard.tsx @@ -1,5 +1,6 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { Text } from '@packrat/ui/nativewindui'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -19,6 +20,7 @@ export function LocationCard({ location, onPress, onSetActive, onRemove }: Locat const { showActionSheetWithOptions } = useActionSheet(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const { displayTemperature } = useTemperatureUnit(); const handleLongPress = () => { const options = location.isActive @@ -41,7 +43,7 @@ export function LocationCard({ location, onPress, onSetActive, onRemove }: Locat cancelButtonIndex, destructiveButtonIndex, title: location.name, - message: `${location.temperature}° - ${location.condition}`, + message: `${displayTemperature(location.temperature)} - ${location.condition}`, containerStyle: { backgroundColor: colorScheme === 'dark' ? colors.card : 'white', paddingBottom: insets.bottom, @@ -107,9 +109,11 @@ export function LocationCard({ location, onPress, onSetActive, onRemove }: Locat )} - {location.temperature}° + + {displayTemperature(location.temperature)} + - H:{location.highTemp}° L:{location.lowTemp}° + H:{displayTemperature(location.highTemp)} L:{displayTemperature(location.lowTemp)} diff --git a/apps/expo/features/weather/components/LocationPicker.tsx b/apps/expo/features/weather/components/LocationPicker.tsx index b0f250245d..8861f10e10 100644 --- a/apps/expo/features/weather/components/LocationPicker.tsx +++ b/apps/expo/features/weather/components/LocationPicker.tsx @@ -1,6 +1,7 @@ import { assertNonNull } from '@packrat/guards'; import { Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -38,6 +39,7 @@ export function LocationPicker({ const { locationsState } = useLocations(); const { activeLocation } = useActiveLocation(); const [selectedLocation, setSelectedLocation] = useState(activeLocation); + const { displayTemperature } = useTemperatureUnit(); // Use translations for default values const displayTitle = title ?? t('location.selectLocation'); @@ -101,7 +103,7 @@ export function LocationPicker({ )} - {location.temperature}° + {displayTemperature(location.temperature)} ))} diff --git a/apps/expo/features/weather/components/WeatherForecast.tsx b/apps/expo/features/weather/components/WeatherForecast.tsx index 54337d0225..0b798c689a 100644 --- a/apps/expo/features/weather/components/WeatherForecast.tsx +++ b/apps/expo/features/weather/components/WeatherForecast.tsx @@ -1,5 +1,7 @@ import { Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useSpeedUnit } from 'expo-app/features/auth/hooks/useSpeedUnit'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { ScrollView, View } from 'react-native'; @@ -41,6 +43,8 @@ export function WeatherForecast({ temperature, }: WeatherForecastProps) { const { t } = useTranslation(); + const { displayTemperature } = useTemperatureUnit(); + const { displayWindSpeed, displayVisibility } = useSpeedUnit(); return ( <> @@ -57,7 +61,7 @@ export function WeatherForecast({ size={24} className="my-2" /> - {hour.temp}° + {displayTemperature(hour.temp)} )) ) : ( @@ -90,14 +94,18 @@ export function WeatherForecast({ - {day.low}° - {day.high}° + + {displayTemperature(day.low)} + + + {displayTemperature(day.high)} + )) ) : ( @@ -111,7 +119,13 @@ export function WeatherForecast({ {t('weather.feelsLike')} - {details?.feelsLike || temperature}° + + {details?.feelsLike != null + ? displayTemperature(details.feelsLike) + : temperature != null + ? displayTemperature(temperature) + : '—'} + {t('weather.humidity')} @@ -119,7 +133,9 @@ export function WeatherForecast({ {t('weather.visibility')} - {details?.visibility || '10'} mi + + {details?.visibility != null ? displayVisibility(details.visibility) : '—'} + {t('weather.uvIndex')} @@ -130,7 +146,9 @@ export function WeatherForecast({ {t('weather.wind')} - {details?.windSpeed || '5'} mph + + {details?.windSpeed != null ? displayWindSpeed(details.windSpeed) : '—'} + diff --git a/apps/expo/features/weather/components/WeatherTile.tsx b/apps/expo/features/weather/components/WeatherTile.tsx index 510736bdd5..f0badbd6b2 100644 --- a/apps/expo/features/weather/components/WeatherTile.tsx +++ b/apps/expo/features/weather/components/WeatherTile.tsx @@ -1,5 +1,6 @@ import { ListItem, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { router } from 'expo-router'; @@ -11,6 +12,7 @@ export function WeatherTile() { const { activeLocation } = useActiveLocation(); const { colors } = useColorScheme(); const { t } = useTranslation(); + const { displayTemperature } = useTemperatureUnit(); const handlePress = () => { router.push('/weather'); @@ -38,7 +40,7 @@ export function WeatherTile() { {activeLocation && ( - {activeLocation.temperature}° • {activeLocation.condition} + {displayTemperature(activeLocation.temperature)} • {activeLocation.condition} )} diff --git a/apps/expo/features/weather/lib/weatherService.ts b/apps/expo/features/weather/lib/weatherService.ts index 3e2f6e1369..2a325f195b 100644 --- a/apps/expo/features/weather/lib/weatherService.ts +++ b/apps/expo/features/weather/lib/weatherService.ts @@ -111,13 +111,13 @@ export function formatWeatherData(data: WeatherAPIForecastResponse) { const todayForecast = forecast.forecastday[0]; assertDefined(todayForecast); - // Format hourly forecast + // Format hourly forecast — temps stored in Celsius; display layer converts const hourlyForecast = todayForecast.hour .filter((hour) => { const hourTime = new Date(hour.time); return hourTime > localTime; }) - .slice(0, 24) // Get next 24 hours + .slice(0, 24) .map((hour) => { const hourTime = new Date(hour.time); return { @@ -125,21 +125,21 @@ export function formatWeatherData(data: WeatherAPIForecastResponse) { hour: 'numeric', hour12: true, }), - temp: Math.round(hour.temp_f), + temp: Math.round(hour.temp_c), icon: getIconNameFromCode({ code: hour.condition.code, isDay: hour.is_day }) as string, weatherCode: hour.condition.code, isDay: hour.is_day, }; }); - // Format daily forecast + // Format daily forecast — temps in Celsius const dailyForecast = forecast.forecastday.map((day) => { const date = new Date(day.date); return { day: date.toLocaleDateString('en-US', { weekday: 'short' }), - high: Math.round(day.day.maxtemp_f), - low: Math.round(day.day.mintemp_f), - icon: getIconNameFromCode({ code: day.day.condition.code, isDay: 1 }) as string, // Always use day icon for daily forecast + high: Math.round(day.day.maxtemp_c), + low: Math.round(day.day.mintemp_c), + icon: getIconNameFromCode({ code: day.day.condition.code, isDay: 1 }) as string, weatherCode: day.day.condition.code, }; }); @@ -153,20 +153,20 @@ export function formatWeatherData(data: WeatherAPIForecastResponse) { return { id: location.id, name: location.name, - temperature: Math.round(current.temp_f), + temperature: Math.round(current.temp_c), condition: current.condition.text, time: formattedTime, - highTemp: Math.round(todayForecast.day.maxtemp_f), - lowTemp: Math.round(todayForecast.day.mintemp_f), + highTemp: Math.round(todayForecast.day.maxtemp_c), + lowTemp: Math.round(todayForecast.day.mintemp_c), alerts: alertText, lat: location.lat, lon: location.lon, details: { - feelsLike: Math.round(current.feelslike_f), + feelsLike: Math.round(current.feelslike_c), humidity: current.humidity, - visibility: Math.round(current.vis_miles), + visibility: Math.round(current.vis_km), uvIndex: current.uv, - windSpeed: Math.round(current.wind_mph), + windSpeed: Math.round(current.wind_kph), weatherCode: current.condition.code, isDay: current.is_day, }, diff --git a/apps/expo/features/weather/screens/LocationDetailScreen.tsx b/apps/expo/features/weather/screens/LocationDetailScreen.tsx index 2e046aa543..c23e5947b2 100644 --- a/apps/expo/features/weather/screens/LocationDetailScreen.tsx +++ b/apps/expo/features/weather/screens/LocationDetailScreen.tsx @@ -1,6 +1,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { getWeatherBackgroundColors } from 'expo-app/features/weather/lib/weatherService'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -28,6 +29,7 @@ export default function LocationDetailScreen() { ]); const { showActionSheetWithOptions } = useActionSheet(); const { removeLocation } = useLocations(); + const { displayTemperature } = useTemperatureUnit(); const locationId = parseInt(String(id), 10); // Get the locations array safely @@ -95,7 +97,7 @@ export default function LocationDetailScreen() { cancelButtonIndex, destructiveButtonIndex, title: location.name, - message: `${location.temperature}° - ${location.condition}`, + message: `${displayTemperature(location.temperature)} - ${location.condition}`, containerStyle: { backgroundColor: colorScheme === 'dark' ? colors.card : 'white', paddingBottom: insets.bottom, @@ -232,11 +234,12 @@ export default function LocationDetailScreen() { {location.time} - {location.temperature}° + {displayTemperature(location.temperature)} {location.condition} - H:{location.highTemp}° L:{location.lowTemp}° + H:{displayTemperature(location.highTemp)} L: + {displayTemperature(location.lowTemp)} {!location.isActive && ( diff --git a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx index 264b66a00e..6a4e393c2b 100644 --- a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx +++ b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx @@ -1,5 +1,7 @@ import { Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { useSpeedUnit } from 'expo-app/features/auth/hooks/useSpeedUnit'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { formatWeatherData, getWeatherBackgroundColors, @@ -7,15 +9,14 @@ import { } from 'expo-app/features/weather/lib/weatherService'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -// import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { LinearGradient } from 'expo-linear-gradient'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, + Platform, ScrollView, - StatusBar, TouchableOpacity, View, } from 'react-native'; @@ -27,8 +28,9 @@ import type { WeatherLocation } from '../types'; export default function LocationPreviewScreen() { const params = useLocalSearchParams(); const { t } = useTranslation(); - // const { colors, colorScheme } = useColorScheme(); const insets = useSafeAreaInsets(); + const { displayTemperature } = useTemperatureUnit(); + const { displayWindSpeed, displayVisibility } = useSpeedUnit(); const { addLocation } = useLocations(); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -40,13 +42,9 @@ export default function LocationPreviewScreen() { '#192f6a', ]); - // Extract location data from params const _latitude = Number.parseFloat(params.lat as string); const _longitude = Number.parseFloat(params.lon as string); const locationId = Number.parseInt(String(params.id), 10); - // const locationName = params.name as string; - // const region = params.region as string; - // const country = params.country as string; const loadWeatherData = async () => { setIsLoading(true); @@ -59,7 +57,6 @@ export default function LocationPreviewScreen() { // safe-cast: formattedData is shaped by weatherService which guarantees WeatherLocation structure setWeatherData(formattedData as unknown as WeatherLocation); - // Update gradient colors based on weather condition if (formattedData.details) { const weatherCode = formattedData.details.weatherCode || 1000; const isNight = formattedData.details.isDay === 0; @@ -76,7 +73,6 @@ export default function LocationPreviewScreen() { } }; - // Load weather data on initial render useEffect(() => { loadWeatherData(); }, []); @@ -111,50 +107,47 @@ export default function LocationPreviewScreen() { } }; - // Determine if we should use light or dark status bar based on gradient colors - const _isDarkGradient = - gradientColors[0].toLowerCase().startsWith('#4') || - gradientColors[0].toLowerCase().startsWith('#3') || - gradientColors[0].toLowerCase().startsWith('#2') || - gradientColors[0].toLowerCase().startsWith('#1'); - return ( - - - {/* Status bar with matching style */} - + <> + ( + router.back()} hitSlop={8}> + + + ) + : undefined, + headerRight: + !isLoading && !error && weatherData + ? () => ( + + {isSaving ? ( + + ) : ( + {t('weather.saveLocation')} + )} + + ) + : undefined, + }} + /> - {/* Fixed header buttons */} - - router.back()}> - - - - - - {!isLoading && !error && weatherData && ( - - {isSaving ? ( - - ) : ( - {t('weather.saveLocation')} - )} - - )} - - @@ -169,7 +162,7 @@ export default function LocationPreviewScreen() { {error} {t('weather.tryAgain')} @@ -184,16 +177,17 @@ export default function LocationPreviewScreen() { {weatherData.time} - {weatherData.temperature}° + {displayTemperature(weatherData.temperature)} {weatherData.condition} - H:{weatherData.highTemp}° L:{weatherData.lowTemp}° + H:{displayTemperature(weatherData.highTemp)} L: + {displayTemperature(weatherData.lowTemp)} {/* Refresh button */} @@ -220,7 +214,7 @@ export default function LocationPreviewScreen() { size={24} className="my-2" /> - {hour.temp}° + {displayTemperature(hour.temp)} )) ) : ( @@ -237,7 +231,9 @@ export default function LocationPreviewScreen() { {weatherData.dailyForecast - ? t('weather.dayForecast', { count: weatherData.dailyForecast.length }) + ? t('weather.dayForecast', { + count: weatherData.dailyForecast.length, + }) : t('weather.dailyForecast')} {weatherData.dailyForecast ? ( @@ -257,14 +253,18 @@ export default function LocationPreviewScreen() { - {day.low}° - {day.high}° + + {displayTemperature(day.low)} + + + {displayTemperature(day.high)} + )) ) : ( @@ -283,7 +283,9 @@ export default function LocationPreviewScreen() { {t('weather.feelsLike')} - {weatherData.details?.feelsLike || weatherData.temperature}° + {displayTemperature( + weatherData.details?.feelsLike ?? weatherData.temperature, + )} @@ -295,7 +297,9 @@ export default function LocationPreviewScreen() { {t('weather.visibility')} - {weatherData.details?.visibility || '10'} mi + {weatherData.details?.visibility != null + ? displayVisibility(weatherData.details.visibility) + : '—'} @@ -310,7 +314,9 @@ export default function LocationPreviewScreen() { {t('weather.wind')} - {weatherData.details?.windSpeed || '5'} mph + {weatherData.details?.windSpeed != null + ? displayWindSpeed(weatherData.details.windSpeed) + : '—'} @@ -320,6 +326,6 @@ export default function LocationPreviewScreen() { - + ); } diff --git a/apps/expo/features/weather/screens/LocationSearchScreen.tsx b/apps/expo/features/weather/screens/LocationSearchScreen.tsx index c7bbad5872..8c0e5cd992 100644 --- a/apps/expo/features/weather/screens/LocationSearchScreen.tsx +++ b/apps/expo/features/weather/screens/LocationSearchScreen.tsx @@ -405,7 +405,7 @@ export default function LocationSearchScreen() { return ( {/* Search Input */} - + - `${weight}${unit}`; + `${parseFloat(weight.toFixed(2))}${unit}`; export const calculateBaseWeight = ({ items, diff --git a/packages/api/src/routes/chat.ts b/packages/api/src/routes/chat.ts index af2c843b04..b3b74b2c07 100644 --- a/packages/api/src/routes/chat.ts +++ b/packages/api/src/routes/chat.ts @@ -41,9 +41,22 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) packId?: string; location?: string; date: string; + weightUnit?: 'kg' | 'lb'; + temperatureUnit?: 'C' | 'F'; + speedUnit?: 'kmh' | 'mph'; }; - const { messages, contextType, itemId, packId, location, date } = typedBody; + const { + messages, + contextType, + itemId, + packId, + location, + date, + weightUnit, + temperatureUnit, + speedUnit, + } = typedBody; const tools = createTools(user.userId); const schemaInfo = await getSchemaInfo(); @@ -65,7 +78,10 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) Context: - User id is ${user.userId} - - Current date is ${date}`; + - Current date is ${date} + - User's preferred weight unit is ${weightUnit ?? 'kg'} (always display weights in this unit) + - User's preferred temperature unit is °${temperatureUnit ?? 'C'} (always display temperatures in this unit) + - User's preferred wind/distance unit is ${speedUnit === 'mph' ? 'mph / miles' : 'km/h / km'} (always display wind speed and distances in this unit)`; if (contextType === 'pack' && packId) { systemPrompt += `\n- You are currently helping with a pack with ID: ${packId}. Use the getPackDetails tool to fetch its contents.`; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 42ab143c25..2fa782ac26 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -31,6 +31,12 @@ export type ItemCategory = (typeof ITEM_CATEGORIES)[number]; export const WEIGHT_UNITS = Object.freeze(['g', 'oz', 'kg', 'lb'] as const); export type WeightUnit = (typeof WEIGHT_UNITS)[number]; +export const TEMPERATURE_UNITS = Object.freeze(['C', 'F'] as const); +export type TemperatureUnit = (typeof TEMPERATURE_UNITS)[number]; + +export const SPEED_UNITS = Object.freeze(['kmh', 'mph'] as const); +export type SpeedUnit = (typeof SPEED_UNITS)[number]; + export const AVAILABILITY_VALUES = Object.freeze(['in_stock', 'out_of_stock', 'preorder'] as const); export type Availability = (typeof AVAILABILITY_VALUES)[number]; diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index 87e91026ed..cea50240c4 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -8,6 +8,9 @@ export const UserPreferencesSchema = z.object({ opened: z.boolean().default(false), }) .optional(), + weightUnit: z.enum(['kg', 'lb']).optional(), + temperatureUnit: z.enum(['C', 'F']).optional(), + speedUnit: z.enum(['kmh', 'mph']).optional(), }); export type UserPreferences = z.infer;