From c3a40614f0afbc5790372b360b39e0f889c38fab Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:21:35 +0100 Subject: [PATCH 01/14] feat(settings): add weight and temperature unit preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds user-configurable unit preferences for weight (kg/lb) and temperature (°C/°F) with locale-based defaults, persisted via the existing Legend State preferences store (SQLite + API sync). Key changes: - Extend UserPreferencesSchema with weightUnit and temperatureUnit fields - Add TEMPERATURE_UNITS constant to @packrat/constants - New useWeightUnit and useTemperatureUnit hooks read from preferencesStore with locale-based fallback (expo-localization) - WeightBadge now converts source weight to the preferred unit before display - formatWeatherData stores all temperatures in Celsius; display layer converts to the preferred unit via useTemperatureUnit - Fix computeCategorySummaries and usePackWeightAnalysis to use preferences store instead of the broken userStore.preferredWeightUnit reference - Settings screen gains a "Display Units" section with kg/lb and °C/°F toggles - All weight and temperature display sites updated for consistency --- apps/expo/app/(app)/current-pack/[id].tsx | 9 ++- apps/expo/app/(app)/pack-categories/[id].tsx | 11 ++- apps/expo/app/(app)/pack-stats/[id].tsx | 8 +- apps/expo/app/(app)/settings/index.tsx | 77 +++++++++++++++++++ apps/expo/app/(app)/weight-analysis/[id].tsx | 15 ++-- apps/expo/components/initial/WeightBadge.tsx | 11 ++- .../ai/components/LocationContext.tsx | 6 +- .../ai/components/WeatherGenerativeUI.tsx | 13 ++-- .../features/auth/hooks/useTemperatureUnit.ts | 28 +++++++ .../expo/features/auth/hooks/useWeightUnit.ts | 22 ++++++ .../packs/hooks/usePackWeightAnalysis.ts | 25 +++--- .../utils/__tests__/computeCategories.test.ts | 10 +-- .../features/packs/utils/computeCategories.ts | 11 ++- .../screens/TripWeatherDetailsScreen.tsx | 10 ++- .../weather/components/LocationCard.tsx | 10 ++- .../weather/components/LocationPicker.tsx | 4 +- .../weather/components/WeatherForecast.tsx | 20 +++-- .../weather/components/WeatherTile.tsx | 4 +- .../features/weather/lib/weatherService.ts | 26 +++---- .../weather/screens/LocationDetailScreen.tsx | 8 +- .../weather/screens/LocationPreviewScreen.tsx | 12 ++- apps/expo/lib/unitDefaults.ts | 17 ++++ packages/constants/src/index.ts | 3 + packages/schemas/src/users.ts | 2 + 24 files changed, 271 insertions(+), 91 deletions(-) create mode 100644 apps/expo/features/auth/hooks/useTemperatureUnit.ts create mode 100644 apps/expo/features/auth/hooks/useWeightUnit.ts create mode 100644 apps/expo/lib/unitDefaults.ts diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index 548f6d0f12..c56e13b31c 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -1,6 +1,6 @@ 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 { 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'; @@ -54,6 +54,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 +67,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} diff --git a/apps/expo/app/(app)/pack-categories/[id].tsx b/apps/expo/app/(app)/pack-categories/[id].tsx index eef4ee3aef..2bd268b697 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, 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..6cd6efdb94 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 } = useWeightUnit(); - const categories = computeCategorySummaries(pack); + const categories = computeCategorySummaries(pack, weightUnit); const CATEGORY_DISTRIBUTION = categories.map((category) => ({ name: category.name, weight: category.weight, @@ -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..71bb41a340 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -17,6 +17,8 @@ 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 { 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'; @@ -37,6 +39,8 @@ export default function SettingsScreen() { const router = useRouter(); const { announcementSeen, setAnnouncementSeen, opened, setOpened } = useSeasonSuggestionsPrefs(); + const { unit: weightUnit, setWeightUnit } = useWeightUnit(); + const { unit: temperatureUnit, setTemperatureUnit } = useTemperatureUnit(); const isApple = isAppleIntelligenceAvailable(); const isDownloading = modelStatus === 'downloading'; @@ -100,6 +104,79 @@ export default function SettingsScreen() { style={Platform.OS === 'ios' ? 'light' : colorScheme === 'dark' ? 'light' : 'dark'} /> + + + Display Units + + + + + Weight + + Shown on pack totals and items + + + + setWeightUnit('kg')} + > + + kg + + + setWeightUnit('lb')} + > + + lb + + + + + + + + Temperature + + Shown in weather forecasts + + + + setTemperatureUnit('C')} + > + + °C + + + setTemperatureUnit('F')} + > + + °F + + + + + + + {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..8f8d9a9f4b 100644 --- a/apps/expo/app/(app)/weight-analysis/[id].tsx +++ b/apps/expo/app/(app)/weight-analysis/[id].tsx @@ -2,7 +2,6 @@ import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; -import { userStore } from 'expo-app/features/auth/store'; 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 +42,7 @@ 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); return ( @@ -59,22 +56,22 @@ export default function WeightAnalysisScreen() { @@ -97,7 +94,7 @@ export default function WeightAnalysisScreen() { {category.name} - {category.weight} {preferredWeightUnit} + {category.weight} {preferredUnit} diff --git a/apps/expo/components/initial/WeightBadge.tsx b/apps/expo/components/initial/WeightBadge.tsx index d4cd1c2d17..9b0835faac 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 { 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,14 @@ export function WeightBadge({ }; const safeWeight = Number(weight) || 0; - const safeUnit = isString(unit) ? unit : 'g'; - const formattedWeight = formatWeight({ weight: safeWeight, unit: safeUnit }); + const safeUnit: WeightUnit = isString(unit) ? (unit as WeightUnit) : 'g'; + const converted = convertWeight(safeWeight, 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/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..320f67a6c7 --- /dev/null +++ b/apps/expo/features/auth/hooks/useWeightUnit.ts @@ -0,0 +1,22 @@ +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); + }; + + // Convert a weight value stored in `fromUnit` to the preferred display unit + const convertWeight = (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/packs/hooks/usePackWeightAnalysis.ts b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts index 1e34c77982..213a4b420a 100644 --- a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts +++ b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts @@ -1,9 +1,13 @@ -import { userStore } from 'expo-app/features/auth/store'; +import { use$ } from '@legendapp/state/react'; +import { preferencesStore } from 'expo-app/features/auth/store/preferences'; +import { getDefaultWeightUnit } from 'expo-app/lib/unitDefaults'; import { computeCategorySummaries, convertFromGrams, convertToGrams } 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) @@ -21,22 +25,23 @@ export function usePackWeightAnalysis(packId: string) { return sum + convertToGrams({ weight: weight * item.quantity, unit: unit }); }, 0); - const categorySummaries = computeCategorySummaries(pack); + const categorySummaries = computeCategorySummaries(pack, preferredUnit); return { data: { - baseWeight: pack.baseWeight, - consumableWeight: convertFromGrams({ - grams: consumableWeightInGrams, - unit: userStore.preferredWeightUnit.peek() ?? 'g', + baseWeight: convertFromGrams({ + grams: convertToGrams({ weight: pack.baseWeight, unit: 'g' }), + unit: preferredUnit, }), - wornWeight: convertFromGrams({ - grams: wornWeightInGrams, - unit: userStore.preferredWeightUnit.peek() ?? 'g', + consumableWeight: convertFromGrams({ grams: consumableWeightInGrams, unit: preferredUnit }), + wornWeight: convertFromGrams({ grams: wornWeightInGrams, unit: preferredUnit }), + totalWeight: convertFromGrams({ + grams: convertToGrams({ weight: pack.totalWeight, unit: 'g' }), + unit: preferredUnit, }), - totalWeight: pack.totalWeight, categories: categorySummaries, }, items: pack.items, + preferredUnit, }; } diff --git a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts index 471639df78..a4a73f9a24 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 { diff --git a/apps/expo/features/packs/utils/computeCategories.ts b/apps/expo/features/packs/utils/computeCategories.ts index 362c0f05bc..f2f37573ff 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,10 @@ export type CategorySummary = { percentage: number; }; -export function computeCategorySummaries(pack: Pack): CategorySummary[] { - const preferredUnit = parseWeightUnit({ - value: userStore.preferredWeightUnit.peek(), - fallback: 'g', - }); +export function computeCategorySummaries( + pack: Pack, + preferredUnit: WeightUnit = 'g', +): 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..37cc98ab69 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, toPreferred } = 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:{toPreferred(weather.forecast.forecastday[0]?.day.maxtemp_c ?? 0)}° L: + {toPreferred(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..d7a6f2dbb0 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, toPreferred } = 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:{toPreferred(location.highTemp)}° L:{toPreferred(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..7b6e77cfa8 100644 --- a/apps/expo/features/weather/components/WeatherForecast.tsx +++ b/apps/expo/features/weather/components/WeatherForecast.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 { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { ScrollView, View } from 'react-native'; @@ -41,6 +42,7 @@ export function WeatherForecast({ temperature, }: WeatherForecastProps) { const { t } = useTranslation(); + const { displayTemperature, toPreferred } = useTemperatureUnit(); return ( <> @@ -57,7 +59,7 @@ export function WeatherForecast({ size={24} className="my-2" /> - {hour.temp}° + {displayTemperature(hour.temp)} )) ) : ( @@ -90,14 +92,14 @@ export function WeatherForecast({ - {day.low}° - {day.high}° + {toPreferred(day.low)}° + {toPreferred(day.high)}° )) ) : ( @@ -111,7 +113,13 @@ export function WeatherForecast({ {t('weather.feelsLike')} - {details?.feelsLike || temperature}° + + {details?.feelsLike != null + ? displayTemperature(details.feelsLike) + : temperature != null + ? displayTemperature(temperature) + : '—'} + {t('weather.humidity')} 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..f9ff5b7dda 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, toPreferred } = 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,11 @@ export default function LocationDetailScreen() { {location.time} - {location.temperature}° + {displayTemperature(location.temperature)} {location.condition} - H:{location.highTemp}° L:{location.lowTemp}° + H:{toPreferred(location.highTemp)}° L:{toPreferred(location.lowTemp)}° {!location.isActive && ( diff --git a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx index 264b66a00e..a13e914188 100644 --- a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx +++ b/apps/expo/features/weather/screens/LocationPreviewScreen.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 { formatWeatherData, getWeatherBackgroundColors, @@ -29,6 +30,7 @@ export default function LocationPreviewScreen() { const { t } = useTranslation(); // const { colors, colorScheme } = useColorScheme(); const insets = useSafeAreaInsets(); + const { displayTemperature, toPreferred } = useTemperatureUnit(); const { addLocation } = useLocations(); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -184,11 +186,11 @@ export default function LocationPreviewScreen() { {weatherData.time} - {weatherData.temperature}° + {displayTemperature(weatherData.temperature)} {weatherData.condition} - H:{weatherData.highTemp}° L:{weatherData.lowTemp}° + H:{toPreferred(weatherData.highTemp)}° L:{toPreferred(weatherData.lowTemp)}° {/* Refresh button */} @@ -220,7 +222,7 @@ export default function LocationPreviewScreen() { size={24} className="my-2" /> - {hour.temp}° + {displayTemperature(hour.temp)} )) ) : ( @@ -283,7 +285,9 @@ export default function LocationPreviewScreen() { {t('weather.feelsLike')} - {weatherData.details?.feelsLike || weatherData.temperature}° + {displayTemperature( + weatherData.details?.feelsLike ?? weatherData.temperature, + )} diff --git a/apps/expo/lib/unitDefaults.ts b/apps/expo/lib/unitDefaults.ts new file mode 100644 index 0000000000..a4b6699eb3 --- /dev/null +++ b/apps/expo/lib/unitDefaults.ts @@ -0,0 +1,17 @@ +import * as Localization from 'expo-localization'; + +// Only three countries still use imperial weight (lbs) as the standard +const IMPERIAL_WEIGHT_REGIONS = new Set(['US', 'LR', 'MM']); + +// Countries/territories that use Fahrenheit for everyday temperature +const FAHRENHEIT_REGIONS = new Set(['US', 'BS', 'BZ', 'KY', 'PW', 'PR', 'GU', 'VI', 'AS', 'MP']); + +export function getDefaultWeightUnit(): 'kg' | 'lb' { + const region = Localization.getLocales()[0]?.regionCode ?? ''; + return IMPERIAL_WEIGHT_REGIONS.has(region) ? 'lb' : 'kg'; +} + +export function getDefaultTemperatureUnit(): 'C' | 'F' { + const region = Localization.getLocales()[0]?.regionCode ?? ''; + return FAHRENHEIT_REGIONS.has(region) ? 'F' : 'C'; +} diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 42ab143c25..da1e57f56b 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -31,6 +31,9 @@ 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 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..28bc7b72eb 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -8,6 +8,8 @@ export const UserPreferencesSchema = z.object({ opened: z.boolean().default(false), }) .optional(), + weightUnit: z.enum(['kg', 'lb']).optional(), + temperatureUnit: z.enum(['C', 'F']).optional(), }); export type UserPreferences = z.infer; From 7b941e91e740cf531563b3dbcf256879b81df9f0 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:22:30 +0100 Subject: [PATCH 02/14] fix(WeightBadge): use parseWeightUnit instead of unsafe type cast --- apps/expo/components/initial/WeightBadge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/components/initial/WeightBadge.tsx b/apps/expo/components/initial/WeightBadge.tsx index 9b0835faac..40b2cb3c3d 100644 --- a/apps/expo/components/initial/WeightBadge.tsx +++ b/apps/expo/components/initial/WeightBadge.tsx @@ -1,5 +1,5 @@ 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 { Text, View } from 'react-native'; @@ -33,7 +33,7 @@ export function WeightBadge({ }; const safeWeight = Number(weight) || 0; - const safeUnit: WeightUnit = isString(unit) ? (unit as WeightUnit) : 'g'; + const safeUnit: WeightUnit = parseWeightUnit({ value: unit }); const converted = convertWeight(safeWeight, safeUnit); return ( From f45c9f57b4207368b8474f8032c75bd4c83fc374 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:30:24 +0100 Subject: [PATCH 03/14] fix(weight): cap display to 2 decimal places across all weight surfaces formatWeight, HorizontalCatalogItemCard, and usePackWeightAnalysis were emitting raw floats (e.g. 0.45359237 lb). Switch them to use parseFloat/ toFixed(2) or displayWeight from @packrat/units, which already rounds. Weight-analysis per-item rows now also convert to the preferred unit. --- apps/expo/app/(app)/weight-analysis/[id].tsx | 4 +- .../components/HorizontalCatalogItemCard.tsx | 2 +- .../packs/hooks/usePackWeightAnalysis.ts | 40 +++++++------------ apps/expo/utils/weight.ts | 2 +- 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/apps/expo/app/(app)/weight-analysis/[id].tsx b/apps/expo/app/(app)/weight-analysis/[id].tsx index 8f8d9a9f4b..89a84b666a 100644 --- a/apps/expo/app/(app)/weight-analysis/[id].tsx +++ b/apps/expo/app/(app)/weight-analysis/[id].tsx @@ -2,6 +2,7 @@ import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +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,6 +44,7 @@ export default function WeightAnalysisScreen() { const { t } = useTranslation(); const { data, items, preferredUnit } = usePackWeightAnalysis(packId as string); + const { convertWeight } = useWeightUnit(); return ( @@ -120,7 +122,7 @@ export default function WeightAnalysisScreen() { )} - {item.weight} {item.weightUnit} + {convertWeight(item.weight, item.weightUnit || 'g')} {preferredUnit} ))} diff --git a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx index c3438a0165..9d0a738dd2 100644 --- a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx +++ b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx @@ -37,7 +37,7 @@ const formatPrice = ({ price, currency }: { price?: number | null; currency?: st const formatWeight = ({ weight, unit }: { weight?: number | null; unit?: string | null }) => { if (!weight) return ''; - return `${weight}${unit || 'g'}`; + return `${parseFloat(weight.toFixed(2))}${unit || 'g'}`; }; function ScalePress({ diff --git a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts index 213a4b420a..f7d57d0e1f 100644 --- a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts +++ b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts @@ -1,7 +1,8 @@ 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, convertFromGrams, convertToGrams } from '../utils'; +import { computeCategorySummaries } from '../utils'; import { usePackDetailsFromStore } from './usePackDetailsFromStore'; export function usePackWeightAnalysis(packId: string) { @@ -9,36 +10,25 @@ export function usePackWeightAnalysis(packId: string) { 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, preferredUnit); return { data: { - baseWeight: convertFromGrams({ - grams: convertToGrams({ weight: pack.baseWeight, unit: 'g' }), - unit: preferredUnit, - }), - consumableWeight: convertFromGrams({ grams: consumableWeightInGrams, unit: preferredUnit }), - wornWeight: convertFromGrams({ grams: wornWeightInGrams, unit: preferredUnit }), - totalWeight: convertFromGrams({ - grams: convertToGrams({ weight: pack.totalWeight, unit: 'g' }), - unit: preferredUnit, - }), + 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, diff --git a/apps/expo/utils/weight.ts b/apps/expo/utils/weight.ts index 916c6020e9..b403aa3984 100644 --- a/apps/expo/utils/weight.ts +++ b/apps/expo/utils/weight.ts @@ -5,7 +5,7 @@ import { convert, displayWeight, normalize, parseWeightUnit } from '@packrat/uni export { convert as convertWeight }; export const formatWeight = ({ weight, unit }: { weight: number; unit: WeightUnit }): string => - `${weight}${unit}`; + `${parseFloat(weight.toFixed(2))}${unit}`; export const calculateBaseWeight = ({ items, From 738a3b4f53d76471be0f184a929acb7b1eb46c1a Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:32:28 +0100 Subject: [PATCH 04/14] fix(weight): convert and round remaining hardcoded-gram displays WeightCard, ItemRow in current-pack, and weight history bars in pack-stats were all displaying raw gram values with a hardcoded 'g' label. Wire them through useWeightUnit so they convert to the preferred unit and round via displayWeight (max 2dp). --- apps/expo/app/(app)/current-pack/[id].tsx | 7 +++++-- apps/expo/app/(app)/pack-stats/[id].tsx | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index c56e13b31c..804ed4616a 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -1,5 +1,6 @@ import { Avatar, AvatarFallback, AvatarImage, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +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'; @@ -22,13 +23,14 @@ function WeightCard({ weight: number; className?: string; }) { + const { unit, convertWeight } = useWeightUnit(); return ( {title} - {weight} g + {convertWeight(weight, 'g')} {unit} ); @@ -84,6 +86,7 @@ function CategoryItem({ category, index }: { category: CategorySummary; index: n function ItemRow({ item, index }: { item: PackItem; index: number }) { const { t } = useTranslation(); + const { unit, convertWeight } = useWeightUnit(); return ( )} - {item.weight} {item.weightUnit} + {convertWeight(item.weight, parseWeightUnit({ value: item.weightUnit }))} {unit} diff --git a/apps/expo/app/(app)/pack-stats/[id].tsx b/apps/expo/app/(app)/pack-stats/[id].tsx index 6cd6efdb94..fdd07a0649 100644 --- a/apps/expo/app/(app)/pack-stats/[id].tsx +++ b/apps/expo/app/(app)/pack-stats/[id].tsx @@ -18,7 +18,7 @@ export default function PackStatsScreen() { const pack = usePackDetailsFromStore(packId); const weightHistory = usePackWeightHistory(packId); - const { unit: weightUnit } = useWeightUnit(); + const { unit: weightUnit, convertWeight } = useWeightUnit(); const categories = computeCategorySummaries(pack, weightUnit); const CATEGORY_DISTRIBUTION = categories.map((category) => ({ @@ -64,7 +64,7 @@ export default function PackStatsScreen() { {item.month} - {item.weight.toFixed(1)} g + {convertWeight(item.weight, 'g')} {weightUnit} ); From ee3115637bb6fd91a1735a86f2ddbb9616b62459 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:41:24 +0100 Subject: [PATCH 05/14] fix(dashboard): convert weight tiles from hardcoded grams to preferred unit --- apps/expo/features/packs/components/CurrentPackTile.tsx | 4 +++- apps/expo/features/packs/components/WeightAnalysisTile.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/expo/features/packs/components/CurrentPackTile.tsx b/apps/expo/features/packs/components/CurrentPackTile.tsx index 6895e65342..64917f0d89 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,7 @@ export function CurrentPackTile() { rightView={ - {currentPack ? `${currentPack.totalWeight} g` : ''} + {currentPack ? `${convertWeight(currentPack.totalWeight, 'g')} ${unit}` : ''} diff --git a/apps/expo/features/packs/components/WeightAnalysisTile.tsx b/apps/expo/features/packs/components/WeightAnalysisTile.tsx index d1060dee14..5266b6c812 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(currentPack?.totalWeight ?? 0, '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}`} } From 09d138744270e28d313a1e73e24535c3b8d272d2 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:44:15 +0100 Subject: [PATCH 06/14] feat(ai): pass weight and temperature unit preferences into AI context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the local model system prompt and the remote API body now include the user's weightUnit and temperatureUnit so the AI can respond in the correct units (e.g. reply in kg/°C vs lb/°F without the user having to ask). --- apps/expo/app/(app)/ai-chat.tsx | 15 ++++++++++++++- packages/api/src/routes/chat.ts | 9 +++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index 0521028793..ae7462ffca 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -29,6 +29,8 @@ import { releaseLocalModel, } from 'expo-app/features/ai/lib/localModelManager'; import { createLocalTools } from 'expo-app/features/ai/lib/tools'; +import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; +import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { getPackItems, packItemsStore } from 'expo-app/features/packs/store/packItems'; import { packsStore } from 'expo-app/features/packs/store/packs'; import { useActiveLocation } from 'expo-app/features/weather/hooks'; @@ -160,11 +162,18 @@ export default function AIChat() { releaseLocalModel().then(() => initLocalModel(isAuthenticated)); }, [isAuthenticated]); + const { unit: weightUnit } = useWeightUnit(); + const { unit: temperatureUnit } = useTemperatureUnit(); + // 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; // Build the right transport based on current AI mode. // Recreated when aiMode or modelStatus changes (modelStatus drives local readiness). @@ -188,7 +197,9 @@ 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)`; if (contextRef.current.contextType === 'pack' && contextRef.current.packId) { systemPrompt += `\n- You are currently helping with a pack with ID: ${contextRef.current.packId}.`; @@ -241,6 +252,8 @@ export default function AIChat() { packId: contextRef.current.packId, location: locationRef.current, date: new Date().toLocaleString(), + weightUnit: weightUnitRef.current, + temperatureUnit: temperatureUnitRef.current, }), }), transportKey: 'remote', diff --git a/packages/api/src/routes/chat.ts b/packages/api/src/routes/chat.ts index af2c843b04..bfc4e419e8 100644 --- a/packages/api/src/routes/chat.ts +++ b/packages/api/src/routes/chat.ts @@ -41,9 +41,12 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) packId?: string; location?: string; date: string; + weightUnit?: 'kg' | 'lb'; + temperatureUnit?: 'C' | 'F'; }; - const { messages, contextType, itemId, packId, location, date } = typedBody; + const { messages, contextType, itemId, packId, location, date, weightUnit, temperatureUnit } = + typedBody; const tools = createTools(user.userId); const schemaInfo = await getSchemaInfo(); @@ -65,7 +68,9 @@ 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)`; if (contextType === 'pack' && packId) { systemPrompt += `\n- You are currently helping with a pack with ID: ${packId}. Use the getPackDetails tool to fetch its contents.`; From c44cfd5f44135949ef1fa7c76f72bcb8ebabf768 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 11:55:24 +0100 Subject: [PATCH 07/14] fix(item-form): default weight unit to user preference instead of hardcoded 'g' --- apps/expo/features/packs/screens/CreatePackItemForm.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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, From 9b60acc05e2da8518a1abdf2fac5ca203451404d Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 12:06:06 +0100 Subject: [PATCH 08/14] feat(catalog): convert weights to preferred unit across all catalog surfaces Catalog item cards, select cards, similar items, detail screen (main weight chip + variant rows) all now display in the user's preferred unit via useWeightUnit/convertWeight. AddCatalogItemDetailsScreen also converts the weight value before saving to pack so the stored item uses the preferred unit. --- .../catalog/components/CatalogItemCard.tsx | 6 +++++- .../components/CatalogItemSelectCard.tsx | 6 +++++- .../catalog/components/SimilarItems.tsx | 6 +++++- .../screens/AddCatalogItemDetailsScreen.tsx | 20 ++++++++++++++----- .../screens/CatalogItemDetailScreen.tsx | 9 ++++++--- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/expo/features/catalog/components/CatalogItemCard.tsx b/apps/expo/features/catalog/components/CatalogItemCard.tsx index d5d13cd4c2..7af8207eb6 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(item.weight, 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..dfecade77a 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(item.weight, 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..4ebd588ad7 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(item.weight, item.weightUnit ?? 'g')} ${unit}` + : ''} diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 75b64040f3..7ce9bda0c5 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(catalogItem.weight, 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..fe79ab627f 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,7 @@ function VariantRow({ variant }: { variant: CatalogItem }) { )} {variant.weight != null && ( - {variant.weight} {variant.weightUnit} + {convertWeight(variant.weight ?? 0, variant.weightUnit ?? 'g')} {unit} )} {variant.availability && ( @@ -96,6 +98,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 +255,8 @@ export function CatalogItemDetailScreen() { - {item.weight !== undefined && item.weightUnit - ? `${item.weight} ${item.weightUnit}` + {item.weight != null + ? `${convertWeight(item.weight, item.weightUnit ?? 'g')} ${unit}` : t('catalog.notSpecified')} From 5216be9f4dae2492d5a58181693eff8fe33c63ec Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 17:09:48 +0100 Subject: [PATCH 09/14] fix(settings): address code review findings + add locale-aware speed/distance unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed: - WeightBadge: add space between converted value and unit label - Temperature H/L displays: replace toPreferred+° with displayTemperature so unit letter (F/C) is always shown alongside the value - WeatherForecast/LocationPreviewScreen: fix wind label mph→km/h and visibility label mi→km (weatherService now stores metric internally) - LocationPreviewScreen: fix daily forecast temp bar thresholds from °F range (40–100) to °C range (4–38) Conventions: - Settings: replace hand-rolled toggle buttons with SegmentedControl - Settings: add testID props (settings:weight-unit, settings:temperature-unit, settings:speed-unit) via testIds registry - Settings: broaden subtitle copy to describe unit scope, not specific screens Oversights: - HorizontalCatalogItemCard: convert weight via useWeightUnit (was the only catalog card still showing raw DB units) Type safety: - computeCategorySummaries: remove default 'g' parameter so callers must pass preferredUnit explicitly; add kg/lb conversion test cases Speed/distance locale preference (new): - SPEED_UNITS / SpeedUnit added to packages/constants - speedUnit added to UserPreferencesSchema (persists via API like weight/temp) - getDefaultSpeedUnit() in unitDefaults.ts reuses FAHRENHEIT_REGIONS (mph correlates with same locales as °F) - useSpeedUnit hook: displayWindSpeed(kph) and displayVisibility(km) - WeatherForecast + LocationPreviewScreen wired to hook - Settings: Wind & Distance row in Display Units card - AI context: speedUnit threaded into local and remote system prompts --- apps/expo/app/(app)/_layout.tsx | 7 +- apps/expo/app/(app)/ai-chat.tsx | 8 +- apps/expo/app/(app)/settings/index.tsx | 86 ++++++++---------- apps/expo/components/initial/WeightBadge.tsx | 3 +- apps/expo/features/auth/hooks/useSpeedUnit.ts | 26 ++++++ .../components/HorizontalCatalogItemCard.tsx | 11 +-- .../utils/__tests__/computeCategories.test.ts | 36 +++++--- .../features/packs/utils/computeCategories.ts | 5 +- .../screens/TripWeatherDetailsScreen.tsx | 6 +- .../weather/components/LocationCard.tsx | 4 +- .../weather/components/WeatherForecast.tsx | 20 +++- .../weather/screens/LocationDetailScreen.tsx | 5 +- .../weather/screens/LocationPreviewScreen.tsx | 91 +++++++++---------- .../weather/screens/LocationSearchScreen.tsx | 2 +- apps/expo/lib/testIds.ts | 3 + apps/expo/lib/unitDefaults.ts | 6 ++ packages/api/src/routes/chat.ts | 17 +++- packages/constants/src/index.ts | 3 + packages/schemas/src/users.ts | 1 + 19 files changed, 198 insertions(+), 142 deletions(-) create mode 100644 apps/expo/features/auth/hooks/useSpeedUnit.ts 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', }} /> - + Weight - Shown on pack totals and items + Shown on all weight displays - - setWeightUnit('kg')} - > - - kg - - - setWeightUnit('lb')} - > - - lb - - + + setWeightUnit(index === 0 ? 'kg' : 'lb')} + /> @@ -146,32 +133,33 @@ export default function SettingsScreen() { Temperature - Shown in weather forecasts + Shown on all temperature displays - - setTemperatureUnit('C')} - > - - °C - - - setTemperatureUnit('F')} - > - - °F - - + + setTemperatureUnit(index === 0 ? 'C' : 'F')} + /> + + + + + + Wind & Distance + + Shown on all speed and distance displays + + + + setSpeedUnit(index === 0 ? 'kmh' : 'mph')} + /> diff --git a/apps/expo/components/initial/WeightBadge.tsx b/apps/expo/components/initial/WeightBadge.tsx index 40b2cb3c3d..3cd84e4f3a 100644 --- a/apps/expo/components/initial/WeightBadge.tsx +++ b/apps/expo/components/initial/WeightBadge.tsx @@ -39,8 +39,7 @@ export function WeightBadge({ return ( - {converted} - {displayUnit} + {`${converted} ${displayUnit}`} ); 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/packs/components/HorizontalCatalogItemCard.tsx b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx index 9d0a738dd2..e51eeadc09 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 `${parseFloat(weight.toFixed(2))}${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,8 @@ export function HorizontalCatalogItemCard({ item, ...restProps }: HorizontalCata )} {!!item.weight && ( - {formatWeight({ weight: item.weight, unit: item.weightUnit })} + {convertWeight(item.weight, parseWeightUnit({ value: item.weightUnit }))}{' '} + {displayUnit} )} {!!item.ratingValue && ( diff --git a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts index a4a73f9a24..b4ddeaabfe 100644 --- a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts +++ b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts @@ -34,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(makePack([]), 'g')).toEqual([]); }); it('groups items under the correct category name', () => { @@ -42,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(makePack(items), 'g'); expect(result).toHaveLength(2); const names = result.map((c) => c.name); expect(names).toContain('Shelter'); @@ -51,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(makePack(items), '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(makePack(items), '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(makePack(items), '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(makePack(items), '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(makePack(items), '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(makePack(items), '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(makePack(items), '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(makePack(items), 'g'); expect(result[0]?.percentage).toBe(100); }); @@ -90,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(makePack(items), 'g'); for (const cat of result) { expect(cat.percentage).toBe(50); } @@ -101,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(makePack(items), 'g'); expect(result[0]?.items).toBe(2); }); @@ -110,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(makePack(items), '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(makePack(items), '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 f2f37573ff..e6a639d5e8 100644 --- a/apps/expo/features/packs/utils/computeCategories.ts +++ b/apps/expo/features/packs/utils/computeCategories.ts @@ -10,10 +10,7 @@ export type CategorySummary = { percentage: number; }; -export function computeCategorySummaries( - pack: Pack, - preferredUnit: WeightUnit = 'g', -): CategorySummary[] { +export function computeCategorySummaries(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 37cc98ab69..58a3fd2768 100644 --- a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx +++ b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx @@ -94,7 +94,7 @@ export default function TripWeatherDetailsScreen() { const location = weather.location; const current = weather.current; - const { displayTemperature, toPreferred } = useTemperatureUnit(); + const { displayTemperature } = useTemperatureUnit(); // Use the trip's location name if provided, otherwise fall back to weather API location const displayLocationName = tripLocationName || location.name; @@ -137,8 +137,8 @@ export default function TripWeatherDetailsScreen() { {current.condition.text} - H:{toPreferred(weather.forecast.forecastday[0]?.day.maxtemp_c ?? 0)}° L: - {toPreferred(weather.forecast.forecastday[0]?.day.mintemp_c ?? 0)}° + H:{displayTemperature(weather.forecast.forecastday[0]?.day.maxtemp_c ?? 0)} L: + {displayTemperature(weather.forecast.forecastday[0]?.day.mintemp_c ?? 0)} { const options = location.isActive @@ -113,7 +113,7 @@ export function LocationCard({ location, onPress, onSetActive, onRemove }: Locat {displayTemperature(location.temperature)} - H:{toPreferred(location.highTemp)}° L:{toPreferred(location.lowTemp)}° + H:{displayTemperature(location.highTemp)} L:{displayTemperature(location.lowTemp)} diff --git a/apps/expo/features/weather/components/WeatherForecast.tsx b/apps/expo/features/weather/components/WeatherForecast.tsx index 7b6e77cfa8..0b798c689a 100644 --- a/apps/expo/features/weather/components/WeatherForecast.tsx +++ b/apps/expo/features/weather/components/WeatherForecast.tsx @@ -1,5 +1,6 @@ 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'; @@ -42,7 +43,8 @@ export function WeatherForecast({ temperature, }: WeatherForecastProps) { const { t } = useTranslation(); - const { displayTemperature, toPreferred } = useTemperatureUnit(); + const { displayTemperature } = useTemperatureUnit(); + const { displayWindSpeed, displayVisibility } = useSpeedUnit(); return ( <> @@ -98,8 +100,12 @@ export function WeatherForecast({ /> - {toPreferred(day.low)}° - {toPreferred(day.high)}° + + {displayTemperature(day.low)} + + + {displayTemperature(day.high)} + )) ) : ( @@ -127,7 +133,9 @@ export function WeatherForecast({ {t('weather.visibility')} - {details?.visibility || '10'} mi + + {details?.visibility != null ? displayVisibility(details.visibility) : '—'} + {t('weather.uvIndex')} @@ -138,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/screens/LocationDetailScreen.tsx b/apps/expo/features/weather/screens/LocationDetailScreen.tsx index f9ff5b7dda..c23e5947b2 100644 --- a/apps/expo/features/weather/screens/LocationDetailScreen.tsx +++ b/apps/expo/features/weather/screens/LocationDetailScreen.tsx @@ -29,7 +29,7 @@ export default function LocationDetailScreen() { ]); const { showActionSheetWithOptions } = useActionSheet(); const { removeLocation } = useLocations(); - const { displayTemperature, toPreferred } = useTemperatureUnit(); + const { displayTemperature } = useTemperatureUnit(); const locationId = parseInt(String(id), 10); // Get the locations array safely @@ -238,7 +238,8 @@ export default function LocationDetailScreen() { {location.condition} - H:{toPreferred(location.highTemp)}° L:{toPreferred(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 a13e914188..e211799ff7 100644 --- a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx +++ b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx @@ -1,5 +1,6 @@ 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, @@ -8,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'; @@ -28,9 +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, toPreferred } = useTemperatureUnit(); + const { displayTemperature } = useTemperatureUnit(); + const { displayWindSpeed, displayVisibility } = useSpeedUnit(); const { addLocation } = useLocations(); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -42,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); @@ -61,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; @@ -78,7 +73,6 @@ export default function LocationPreviewScreen() { } }; - // Load weather data on initial render useEffect(() => { loadWeatherData(); }, []); @@ -113,34 +107,25 @@ 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 */} - - - - {/* Fixed header buttons */} - + <> + + + + + + )} + @@ -171,7 +159,7 @@ export default function LocationPreviewScreen() { {error} {t('weather.tryAgain')} @@ -190,12 +178,13 @@ export default function LocationPreviewScreen() { {weatherData.condition} - H:{toPreferred(weatherData.highTemp)}° L:{toPreferred(weatherData.lowTemp)}° + H:{displayTemperature(weatherData.highTemp)} L: + {displayTemperature(weatherData.lowTemp)} {/* Refresh button */} @@ -239,7 +228,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 ? ( @@ -259,14 +250,18 @@ export default function LocationPreviewScreen() { - {day.low}° - {day.high}° + + {displayTemperature(day.low)} + + + {displayTemperature(day.high)} + )) ) : ( @@ -299,7 +294,9 @@ export default function LocationPreviewScreen() { {t('weather.visibility')} - {weatherData.details?.visibility || '10'} mi + {weatherData.details?.visibility != null + ? displayVisibility(weatherData.details.visibility) + : '—'} @@ -314,7 +311,9 @@ export default function LocationPreviewScreen() { {t('weather.wind')} - {weatherData.details?.windSpeed || '5'} mph + {weatherData.details?.windSpeed != null + ? displayWindSpeed(weatherData.details.windSpeed) + : '—'} @@ -324,6 +323,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..eb886c24c1 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 */} - + ; From 2ed9f6b282e32c92ed1d1e1ced71b1d6f4f1277e Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 17:49:37 +0100 Subject: [PATCH 10/14] fix(settings): improve unit preference subtitles for clarity and brevity --- apps/expo/app/(app)/settings/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 41b69ed535..5cd0197cef 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -116,7 +116,7 @@ export default function SettingsScreen() { Weight - Shown on all weight displays + For gear and pack weights @@ -133,7 +133,7 @@ export default function SettingsScreen() { Temperature - Shown on all temperature displays + For weather and forecasts @@ -150,7 +150,7 @@ export default function SettingsScreen() { Wind & Distance - Shown on all speed and distance displays + For routes and weather data From 7c3162b0333f52fc29c2db65e036d79540481860 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 17:53:57 +0100 Subject: [PATCH 11/14] fix(settings): i18n unit preference section headings and subtitles --- apps/expo/app/(app)/settings/index.tsx | 8 ++++---- apps/expo/lib/i18n/locales/en.json | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 5cd0197cef..2949beffc1 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -109,14 +109,14 @@ export default function SettingsScreen() { - Display Units + {t('settings.displayUnits')} Weight - For gear and pack weights + {t('settings.weightSubtitle')} @@ -133,7 +133,7 @@ export default function SettingsScreen() { Temperature - For weather and forecasts + {t('settings.temperatureSubtitle')} @@ -150,7 +150,7 @@ export default function SettingsScreen() { Wind & Distance - For routes and weather data + {t('settings.windDistanceSubtitle')} diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index 8b78038367..18c9aaab6f 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -715,6 +715,12 @@ "generatedPacks": "Generated Packs", "waitingForSync": "Waiting for packs to sync." }, + "settings": { + "displayUnits": "Display Units", + "weightSubtitle": "For gear and pack weights", + "temperatureSubtitle": "For weather and forecasts", + "windDistanceSubtitle": "For routes and weather data" + }, "weather": { "weather": "Weather", "temperature": "Temperature", From d147a99cd17ceed80846f2f068566c166b0cf4c4 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Wed, 17 Jun 2026 19:16:50 +0100 Subject: [PATCH 12/14] fix(settings): widen wind/distance segmented control to prevent km/h text wrapping on Android Also refactor LocationPreviewScreen header to use Stack.Screen options API and fix LocationSearchScreen search input top spacing. --- apps/expo/app/(app)/settings/index.tsx | 2 +- .../weather/screens/LocationPreviewScreen.tsx | 61 ++++++++++--------- .../weather/screens/LocationSearchScreen.tsx | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 2949beffc1..d0c2daa25e 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -153,7 +153,7 @@ export default function SettingsScreen() { {t('settings.windDistanceSubtitle')} - + - - - @@ -130,7 +134,7 @@ export default function CurrentPackScreen() { const pack = usePackDetailsFromStore(params.id as string); const { unit: weightUnit } = useWeightUnit(); - const uniqueCategories = computeCategorySummaries(pack, weightUnit); // pass unit so computed weights match the badge display + 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 2bd268b697..f0dc3154ee 100644 --- a/apps/expo/app/(app)/pack-categories/[id].tsx +++ b/apps/expo/app/(app)/pack-categories/[id].tsx @@ -63,7 +63,7 @@ export default function PackCategoriesScreen() { const { t } = useTranslation(); const { unit: weightUnit } = useWeightUnit(); - const categories = computeCategorySummaries(pack, weightUnit); + const categories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); return ( <> diff --git a/apps/expo/app/(app)/pack-stats/[id].tsx b/apps/expo/app/(app)/pack-stats/[id].tsx index fdd07a0649..6f23eab0d2 100644 --- a/apps/expo/app/(app)/pack-stats/[id].tsx +++ b/apps/expo/app/(app)/pack-stats/[id].tsx @@ -20,7 +20,7 @@ export default function PackStatsScreen() { const weightHistory = usePackWeightHistory(packId); const { unit: weightUnit, convertWeight } = useWeightUnit(); - const categories = computeCategorySummaries(pack, weightUnit); + const categories = computeCategorySummaries({ pack, preferredUnit: weightUnit }); const CATEGORY_DISTRIBUTION = categories.map((category) => ({ name: category.name, weight: category.weight, @@ -64,7 +64,7 @@ export default function PackStatsScreen() { {item.month} - {convertWeight(item.weight, 'g')} {weightUnit} + {convertWeight({ weight: item.weight, fromUnit: 'g' })} {weightUnit} ); diff --git a/apps/expo/app/(app)/weight-analysis/[id].tsx b/apps/expo/app/(app)/weight-analysis/[id].tsx index 89a84b666a..d0cf934fbd 100644 --- a/apps/expo/app/(app)/weight-analysis/[id].tsx +++ b/apps/expo/app/(app)/weight-analysis/[id].tsx @@ -122,7 +122,8 @@ export default function WeightAnalysisScreen() { )} - {convertWeight(item.weight, item.weightUnit || 'g')} {preferredUnit} + {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 3cd84e4f3a..3de2ea3e75 100644 --- a/apps/expo/components/initial/WeightBadge.tsx +++ b/apps/expo/components/initial/WeightBadge.tsx @@ -34,7 +34,7 @@ export function WeightBadge({ const safeWeight = Number(weight) || 0; const safeUnit: WeightUnit = parseWeightUnit({ value: unit }); - const converted = convertWeight(safeWeight, safeUnit); + const converted = convertWeight({ weight: safeWeight, fromUnit: safeUnit }); return ( diff --git a/apps/expo/features/auth/hooks/useWeightUnit.ts b/apps/expo/features/auth/hooks/useWeightUnit.ts index 320f67a6c7..f7673d71bc 100644 --- a/apps/expo/features/auth/hooks/useWeightUnit.ts +++ b/apps/expo/features/auth/hooks/useWeightUnit.ts @@ -12,8 +12,13 @@ export function useWeightUnit() { preferencesStore.weightUnit.set(value); }; - // Convert a weight value stored in `fromUnit` to the preferred display unit - const convertWeight = (weight: number, fromUnit: WeightUnit): number => { + const convertWeight = ({ + weight, + fromUnit, + }: { + weight: number; + fromUnit: WeightUnit; + }): number => { const grams = normalize({ weight, unit: fromUnit }); return displayWeight({ grams, unit }); }; diff --git a/apps/expo/features/catalog/components/CatalogItemCard.tsx b/apps/expo/features/catalog/components/CatalogItemCard.tsx index 7af8207eb6..9e8fcfd178 100644 --- a/apps/expo/features/catalog/components/CatalogItemCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemCard.tsx @@ -71,7 +71,7 @@ export function CatalogItemCard({ item, onPress }: CatalogItemCardProps) { {item.weight != null - ? `${convertWeight(item.weight, item.weightUnit ?? 'g')} ${unit}` + ? `${convertWeight({ weight: item.weight, fromUnit: item.weightUnit ?? 'g' })} ${unit}` : ''} diff --git a/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx b/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx index dfecade77a..7202afa6a0 100644 --- a/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx @@ -80,7 +80,7 @@ export function CatalogItemSelectCard({ item, isSelected, onToggle }: CatalogIte {item.weight != null - ? `${convertWeight(item.weight, item.weightUnit ?? 'g')} ${unit}` + ? `${convertWeight({ weight: item.weight, fromUnit: item.weightUnit ?? 'g' })} ${unit}` : ''} diff --git a/apps/expo/features/catalog/components/SimilarItems.tsx b/apps/expo/features/catalog/components/SimilarItems.tsx index 4ebd588ad7..a65c14a1f0 100644 --- a/apps/expo/features/catalog/components/SimilarItems.tsx +++ b/apps/expo/features/catalog/components/SimilarItems.tsx @@ -58,7 +58,7 @@ const SimilarItemCard: React.FC = ({ item, onPress }) => { {item.weight != null - ? `${convertWeight(item.weight, item.weightUnit ?? 'g')} ${unit}` + ? `${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 7ce9bda0c5..4156cd97f6 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -168,7 +168,7 @@ export function AddCatalogItemDetailsScreen() { {catalogItem.weight != null - ? `${convertWeight(catalogItem.weight, catalogItem.weightUnit ?? 'g')} ${preferredWeightUnit}` + ? `${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 fe79ab627f..4a66204466 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -56,7 +56,8 @@ function VariantRow({ variant }: { variant: CatalogItem }) { )} {variant.weight != null && ( - {convertWeight(variant.weight ?? 0, variant.weightUnit ?? 'g')} {unit} + {convertWeight({ weight: variant.weight ?? 0, fromUnit: variant.weightUnit ?? 'g' })}{' '} + {unit} )} {variant.availability && ( @@ -256,7 +257,7 @@ export function CatalogItemDetailScreen() { {item.weight != null - ? `${convertWeight(item.weight, item.weightUnit ?? 'g')} ${unit}` + ? `${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 64917f0d89..56e5addeca 100644 --- a/apps/expo/features/packs/components/CurrentPackTile.tsx +++ b/apps/expo/features/packs/components/CurrentPackTile.tsx @@ -42,7 +42,9 @@ export function CurrentPackTile() { rightView={ - {currentPack ? `${convertWeight(currentPack.totalWeight, 'g')} ${unit}` : ''} + {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 e51eeadc09..867a86adef 100644 --- a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx +++ b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx @@ -113,7 +113,10 @@ export function HorizontalCatalogItemCard({ item, ...restProps }: HorizontalCata )} {!!item.weight && ( - {convertWeight(item.weight, parseWeightUnit({ value: item.weightUnit }))}{' '} + {convertWeight({ + weight: item.weight, + fromUnit: parseWeightUnit({ value: item.weightUnit }), + })}{' '} {displayUnit} )} diff --git a/apps/expo/features/packs/components/WeightAnalysisTile.tsx b/apps/expo/features/packs/components/WeightAnalysisTile.tsx index 5266b6c812..27fb1965d9 100644 --- a/apps/expo/features/packs/components/WeightAnalysisTile.tsx +++ b/apps/expo/features/packs/components/WeightAnalysisTile.tsx @@ -16,7 +16,7 @@ export function WeightAnalysisTile() { const { unit, convertWeight } = useWeightUnit(); const alertRef = useRef(null); - const packWeight = convertWeight(currentPack?.totalWeight ?? 0, 'g'); + const packWeight = convertWeight({ weight: currentPack?.totalWeight ?? 0, fromUnit: 'g' }); const route: Href | null = currentPack ? `/weight-analysis/${currentPack.id}` : null; const handlePress = () => { diff --git a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts index f7d57d0e1f..0e50fd20f5 100644 --- a/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts +++ b/apps/expo/features/packs/hooks/usePackWeightAnalysis.ts @@ -21,7 +21,7 @@ export function usePackWeightAnalysis(packId: string) { .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, preferredUnit); + const categorySummaries = computeCategorySummaries({ pack, preferredUnit }); return { data: { diff --git a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts index b4ddeaabfe..8ee642a789 100644 --- a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts +++ b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts @@ -34,7 +34,7 @@ function makePack(items: PackItem[]): Pack { describe('computeCategorySummaries', () => { it('returns empty array for a pack with no items', () => { - expect(computeCategorySummaries(makePack([]), 'g')).toEqual([]); + expect(computeCategorySummaries({ pack: makePack([]), preferredUnit: 'g' })).toEqual([]); }); it('groups items under the correct category name', () => { @@ -42,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), 'g'); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result).toHaveLength(2); const names = result.map((c) => c.name); expect(names).toContain('Shelter'); @@ -51,49 +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), 'g'); + 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), 'g'); + 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), 'g'); + 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), 'g'); + 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(makePack(items), 'kg'); + 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(makePack(items), 'lb'); + 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), 'g'); + 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), 'g'); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.percentage).toBe(100); }); @@ -102,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), 'g'); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); for (const cat of result) { expect(cat.percentage).toBe(50); } @@ -113,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), 'g'); + const result = computeCategorySummaries({ pack: makePack(items), preferredUnit: 'g' }); expect(result[0]?.items).toBe(2); }); @@ -122,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), 'g'); + 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), 'g'); + 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 e6a639d5e8..c59fd85882 100644 --- a/apps/expo/features/packs/utils/computeCategories.ts +++ b/apps/expo/features/packs/utils/computeCategories.ts @@ -10,7 +10,13 @@ export type CategorySummary = { percentage: number; }; -export function computeCategorySummaries(pack: Pack, preferredUnit: WeightUnit): CategorySummary[] { +export function computeCategorySummaries({ + pack, + preferredUnit, +}: { + pack: Pack; + preferredUnit: WeightUnit; +}): CategorySummary[] { const categoryMap: Record = {}; let totalWeightGrams = 0;