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