Skip to content
Merged
7 changes: 7 additions & 0 deletions apps/expo/atoms/catalogGroupAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { CatalogItem } from 'expo-app/features/catalog/types';
import { atom } from 'jotai';

// Holds all variants of the catalog group the user tapped in the list.
// Set immediately before pushing to /catalog/[id] so the detail screen can
// show a variants list without any extra API calls.
export const catalogGroupVariantsAtom = atom<CatalogItem[]>([]);
Comment on lines +1 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider relocating this atom into the catalog feature module.

The atom is tightly coupled to the catalog feature and is only consumed by catalog screens. Per the feature module structure guidelines, catalog-specific state should live within apps/expo/features/catalog/ (e.g., features/catalog/atoms.ts or features/catalog/state.ts).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/atoms/catalogGroupAtom.ts` around lines 1 - 7, Move the
catalogGroupVariantsAtom export out of this shared atoms file into the catalog
feature module’s state/atoms file so catalog-specific state lives with the
feature; create or add to the catalog feature’s atoms/state file a exported atom
named catalogGroupVariantsAtom (same type atom<CatalogItem[]>() and initial
value) and update all imports across the app to point to the new location, then
remove the original export from the current file to avoid duplicate exports.

Source: Coding guidelines

10 changes: 9 additions & 1 deletion apps/expo/components/CategoriesFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export function CategoriesFilter({
error,
retry,
className,
contentPaddingX = 0,
}: {
activeFilter: string;
onFilter: (filter: string) => void;
data: string[] | undefined;
error?: Error | null;
retry?: (() => void) | undefined;
className?: string;
contentPaddingX?: number;
}) {
const { t } = useTranslation();

Expand Down Expand Up @@ -57,7 +59,13 @@ export function CategoriesFilter({
</View>
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="py-1">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={
contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined
}
Comment on lines +62 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix falsy check for contentPaddingX.

Line 66 treats contentPaddingX as falsy when it's 0, which is a valid padding value. The current logic contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined will skip applying the style when contentPaddingX={0}.

🐛 Proposed fix
-          contentContainerStyle={
-            contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined
-          }
+          contentContainerStyle={
+            contentPaddingX > 0 ? { paddingHorizontal: contentPaddingX } : undefined
+          }

Or more explicitly:

-          contentContainerStyle={
-            contentPaddingX ? { paddingHorizontal: contentPaddingX } : undefined
-          }
+          contentContainerStyle={
+            typeof contentPaddingX === 'number' && contentPaddingX > 0
+              ? { paddingHorizontal: contentPaddingX }
+              : undefined
+          }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/components/CategoriesFilter.tsx` around lines 62 - 67, The
ScrollView's contentContainerStyle currently treats contentPaddingX as falsy (so
0 is ignored); update the conditional in the component (where ScrollView is
rendered) to check for undefined (or the property's type) instead of a truthy
check—e.g., use contentPaddingX !== undefined or typeof contentPaddingX ===
'number' to return { paddingHorizontal: contentPaddingX } when 0 is intended,
otherwise undefined; this change involves the ScrollView rendering block
referencing contentPaddingX in CategoriesFilter.tsx.

>
{!data
? Array.from({ length: 10 }).map((_, i) => (
<View
Expand Down
2 changes: 2 additions & 0 deletions apps/expo/features/catalog/components/SimilarItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
data={Array(3).fill(null)}
renderItem={() => <LoadingCard />}
keyExtractor={(_, index) => `loading-${index}`}
style={{ marginHorizontal: -16 }}
contentContainerStyle={{ paddingHorizontal: 16 }}
/>
</View>
Expand All @@ -127,6 +128,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
data={data.items}
renderItem={({ item }) => <SimilarItemCard item={item} onPress={handleItemPress} />}
keyExtractor={(item) => item.id.toString()}
style={{ marginHorizontal: -16 }}
contentContainerStyle={{ paddingHorizontal: 16 }}
/>
</View>
Expand Down
25 changes: 25 additions & 0 deletions apps/expo/features/catalog/lib/groupCatalogItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { CatalogItem } from '../types';

export type CatalogItemGroup = {
key: string;
representative: CatalogItem;
variants: CatalogItem[];
};

function groupKey(item: CatalogItem): string {
return `${(item.name ?? '').toLowerCase().trim()}:::${(item.brand ?? '').toLowerCase().trim()}`;
}

export function groupCatalogItems(items: CatalogItem[]): CatalogItemGroup[] {
const map = new Map<string, CatalogItemGroup>();
for (const item of items) {
const key = groupKey(item);
const group = map.get(key);
if (group) {
group.variants.push(item);
} else {
map.set(key, { key, representative: item, variants: [item] });
}
}
return Array.from(map.values());
}
79 changes: 77 additions & 2 deletions apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import { Button, Text } from '@packrat/ui/nativewindui';
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';
Expand All @@ -14,11 +15,66 @@ import { ErrorScreen } from 'expo-app/screens/ErrorScreen';
import { LoadingSpinnerScreen } from 'expo-app/screens/LoadingSpinnerScreen';
import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Linking, Text as RNText, ScrollView, View } from 'react-native';
import { useAtomValue } from 'jotai';
import { Linking, Pressable, Text as RNText, ScrollView, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CatalogItemImage } from '../components/CatalogItemImage';
import { useCatalogItemDetails } from '../hooks';
import { normalizeDescription } from '../lib/normalizeDescription';
import type { CatalogItem } from '../types';

function VariantRow({ variant }: { variant: CatalogItem }) {
const { t } = useTranslation();
const { colors } = useColorScheme();
const label = [variant.size, variant.color].filter(Boolean).join(' · ');
return (
<View className="flex-row items-center justify-between border-b border-border py-3">
<View className="flex-1 gap-0.5">
{label ? <Text className="text-sm font-medium text-foreground">{label}</Text> : null}
<View className="flex-row items-center gap-3">
{variant.price != null && (
<Text className="text-sm text-foreground">
{variant.currency ?? '$'}
{variant.price.toFixed(2)}
</Text>
)}
{variant.weight != null && (
<Text className="text-xs text-muted-foreground">
{variant.weight} {variant.weightUnit}
</Text>
)}
{variant.availability && (
<View className="flex-row items-center gap-0.5">
<Ionicons
name={
variant.availability === 'in_stock'
? 'checkmark-circle-outline'
: 'close-circle-outline'
}
size={12}
color={variant.availability === 'in_stock' ? colors.green : colors.destructive}
/>
<Text className="text-xs text-muted-foreground">
{variant.availability === 'in_stock'
? t('catalog.inStock')
: t('catalog.outOfStock')}
</Text>
</View>
)}
</View>
</View>
{variant.productUrl ? (
<Pressable
onPress={() => Linking.openURL(variant.productUrl as string)}
className="ml-3 flex-row items-center gap-1 rounded-md border border-border px-3 py-1.5"
>
<Text className="text-xs text-foreground">{t('catalog.viewOnRetailerSite')}</Text>
<Icon name="open-in-new" size={11} color={colors.foreground} />
</Pressable>
) : null}
Comment on lines +66 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Remove unnecessary type cast.

Line 67 casts variant.productUrl as string, but according to the CatalogItemSchema, productUrl is already typed as string (non-nullable). The type cast is redundant.

♻️ Proposed fix
-          onPress={() => Linking.openURL(variant.productUrl as string)}
+          onPress={() => Linking.openURL(variant.productUrl)}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{variant.productUrl ? (
<Pressable
onPress={() => Linking.openURL(variant.productUrl as string)}
className="ml-3 flex-row items-center gap-1 rounded-md border border-border px-3 py-1.5"
>
<Text className="text-xs text-foreground">{t('catalog.viewOnRetailerSite')}</Text>
<Icon name="open-in-new" size={11} color="text-foreground" />
</Pressable>
) : null}
{variant.productUrl ? (
<Pressable
onPress={() => Linking.openURL(variant.productUrl)}
className="ml-3 flex-row items-center gap-1 rounded-md border border-border px-3 py-1.5"
>
<Text className="text-xs text-foreground">{t('catalog.viewOnRetailerSite')}</Text>
<Icon name="open-in-new" size={11} color="text-foreground" />
</Pressable>
) : null}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx` around lines
65 - 73, Remove the redundant type assertion on variant.productUrl inside the
Pressable onPress handler: update the onPress={() =>
Linking.openURL(variant.productUrl as string)} usage to call Linking.openURL
with variant.productUrl directly (no "as string" cast) in
CatalogItemDetailScreen so the Pressable and its onPress handler use the
already-typed variant.productUrl value.

</View>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function CatalogItemDetailScreen() {
const router = useRouter();
Expand All @@ -28,6 +84,12 @@ export function CatalogItemDetailScreen() {
const { t } = useTranslation();
const MATERIAL_LENGTH_THRESHOLD = 60;

const groupVariants = useAtomValue(catalogGroupVariantsAtom);
// Show the variants section only when there are multiple variants for this
// group — i.e. when the user navigated from the grouped catalog list.
const otherVariants =
groupVariants.length > 1 ? groupVariants.filter((v) => v.id !== Number(id)) : [];

const handleAddToPack = () => {
router.push({
pathname: '/catalog/add-to-pack',
Expand Down Expand Up @@ -199,16 +261,29 @@ export function CatalogItemDetailScreen() {
onPress={() => Linking.openURL(item.productUrl as string)}
>
<Text className="text-foreground">{t('catalog.viewOnRetailerSite')}</Text>
<Icon name="open-in-new" size={14} color={colors.foreground} />
</Button>
</View>
</View>

{/* Variants Section */}
{otherVariants.length > 0 && (
<View className="mt-8">
<Text variant="callout">{t('catalog.variantsSection')}</Text>
<View>
{otherVariants.map((variant) => (
<VariantRow key={variant.id} variant={variant} />
))}
</View>
</View>
)}

{item.techs && Object.keys(item.techs).length > 0 && (
<View className="mt-8">
<Text variant="callout" className="mb-2">
{t('catalog.specifications')}
</Text>
<View className="rounded-lg p-3 gap-4">
<View className="rounded-lg p-3 py-0 gap-4">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Simplify redundant padding class.

The class string 'p-3 py-0' is redundant because py-0 overrides the vertical padding from p-3, leaving only the horizontal padding. This is effectively px-3.

Consider using 'px-3' for clarity, or if vertical padding is needed elsewhere, restructure the classes.

♻️ Proposed fix
-              <View className="rounded-lg p-3 py-0 gap-4">
+              <View className="rounded-lg px-3 gap-4">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<View className="rounded-lg p-3 py-0 gap-4">
<View className="rounded-lg px-3 gap-4">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx` at line 287,
In CatalogItemDetailScreen locate the View with className "rounded-lg p-3 py-0
gap-4" and simplify the redundant padding by replacing "p-3 py-0" with "px-3"
(so the class becomes "rounded-lg px-3 gap-4"); if vertical padding was intended
elsewhere, instead explicitly add the needed "py-*" value rather than overriding
p-3.

{Object.entries(item.techs).map(([key, value]) => (
<View key={key} className="gap-1">
<Text className="text-xs text-muted-foreground uppercase">{key}</Text>
Expand Down
57 changes: 20 additions & 37 deletions apps/expo/features/catalog/screens/CatalogItemsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LargeTitleHeader, type LargeTitleSearchBarMethods, Text } from '@packrat/ui/nativewindui';
import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom';
import { searchValueAtom } from 'expo-app/atoms/itemListAtoms';
import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix';
import { CategoriesFilter } from 'expo-app/components/CategoriesFilter';
Expand All @@ -11,7 +12,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { testIds } from 'expo-app/lib/testIds';
import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef';
import { useRouter } from 'expo-router';
import { useAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Expand All @@ -27,6 +28,7 @@ import { CatalogItemCard } from '../components/CatalogItemCard';
import { useCatalogItemsInfinite } from '../hooks';
import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories';
import { useVectorSearch } from '../hooks/useVectorSearch';
import { type CatalogItemGroup, groupCatalogItems } from '../lib/groupCatalogItems';
import type { CatalogItem } from '../types';

function CatalogItemsScreen() {
Expand Down Expand Up @@ -75,15 +77,14 @@ function CatalogItemsScreen() {
Boolean(item?.id),
);

const totalItems = paginatedData?.pages[0]?.totalCount ?? 0;
const groupedItems = useMemo(() => groupCatalogItems(paginatedItems), [paginatedItems]);

const totalItemsText = `${Number(totalItems).toLocaleString()} ${
totalItems === 1 ? t('catalog.item') : t('catalog.items')
}`;
const showingText = t('catalog.showingItems', {
current: paginatedItems.length,
total: Number(totalItems).toLocaleString(),
});
const setGroupVariants = useSetAtom(catalogGroupVariantsAtom);

const handleGroupPress = (group: CatalogItemGroup) => {
setGroupVariants(group.variants);
router.push({ pathname: '/catalog/[id]', params: { id: group.representative.id } });
};
Comment on lines +80 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Verify atom cleanup on back navigation.

The atom is set before navigation (line 85) but never explicitly cleared. If the user navigates back from the detail screen and then selects a different group, the atom will be overwritten correctly. However, if the user navigates to a detail screen via deep link or direct URL entry, the atom will be empty, which is the intended behavior per the design.

Consider documenting this behavior in a comment near the atom definition or in the handleGroupPress function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/features/catalog/screens/CatalogItemsScreen.tsx` around lines 80 -
87, The atom catalogGroupVariantsAtom is set in CatalogItemsScreen via
handleGroupPress before routing but isn't explicitly cleared on back/deep-link
navigation; update the code by adding a clear comment near the
catalogGroupVariantsAtom definition (and/or a short inline comment above
handleGroupPress) that documents the intended behavior: the atom is populated
only when a user navigates via the group press and may be empty on
direct/deep-link entry to the detail screen, and if desired implement explicit
cleanup on detail-screen unmount (e.g., reset atom in the detail screen's
useEffect cleanup) to guarantee clearing on back navigation.


const handleItemPress = (item: CatalogItem) => {
router.push({ pathname: '/catalog/[id]', params: { id: item.id } });
Expand Down Expand Up @@ -114,32 +115,12 @@ function CatalogItemsScreen() {
activeFilter={activeFilter}
error={categoriesError}
retry={refetchCategories}
className="px-4 py-2"
className="py-4"
contentPaddingX={16}
/>

<View className="mb-4 px-4">
<View className="flex-row items-center justify-between">
<Text testID={testIds.catalog.totalItemsCount} className="text-muted-foreground">
{totalItemsText}
</Text>
</View>

{paginatedItems.length > 0 && (
<Text className="mt-1 text-xs text-muted-foreground">{showingText}</Text>
)}
</View>
</>
);
}, [
isSearching,
categories,
activeFilter,
categoriesError,
totalItemsText,
paginatedItems.length,
showingText,
refetchCategories,
]);
}, [isSearching, categories, activeFilter, categoriesError, refetchCategories]);

return (
<>
Expand Down Expand Up @@ -220,17 +201,19 @@ function CatalogItemsScreen() {
/>

<FlatList
data={paginatedItems}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<CatalogItemCard item={item} onPress={() => handleItemPress(item)} />
data={groupedItems}
keyExtractor={(group) => group.key}
renderItem={({ item: group }) => (
<View className="px-4">
<CatalogItemCard item={group.representative} onPress={() => handleGroupPress(group)} />
</View>
)}
ItemSeparatorComponent={ItemSeparatorComponent}
ListHeaderComponent={listHeader}
refreshControl={<RefreshControl refreshing={isManualRefresh} onRefresh={handleRefresh} />}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
contentContainerStyle={{ flexGrow: 1, padding: 16 }}
contentContainerStyle={{ flexGrow: 1, paddingBottom: 16 }}
contentInsetAdjustmentBehavior="automatic"
ListFooterComponent={
<>
Expand Down
17 changes: 10 additions & 7 deletions apps/expo/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@
"loginFailed": "Login Failed",
"invalidEmailOrPassword": "Invalid email or password",
"resumeSync": "Resume Sync",
"syncPaused": "Sync paused \u2014 please sign in again."
"syncPaused": "Sync paused please sign in again."
},
"profile": {
"profile": "Profile",
Expand Down Expand Up @@ -249,10 +249,10 @@
"lastUpdatedShort": "Last updated",
"itemsCount": "{{count}} items",
"sharingBenefits": "Sharing Benefits",
"distributeGroupGear": "\u2022 Distribute group gear among members to reduce individual pack weight",
"sharingBenefit1": "\u2022 Coordinate who brings shared items like tent, stove, and water filter",
"sharingBenefit2": "\u2022 See real-time updates when members modify the pack",
"sharingBenefit3": "\u2022 Plan together and ensure nothing essential is forgotten",
"distributeGroupGear": " Distribute group gear among members to reduce individual pack weight",
"sharingBenefit1": " Coordinate who brings shared items like tent, stove, and water filter",
"sharingBenefit2": " See real-time updates when members modify the pack",
"sharingBenefit3": " Plan together and ensure nothing essential is forgotten",
"itemsInInventory": "{{count}} items in your inventory",
"all": "All",
"byCategory": "By Category",
Expand Down Expand Up @@ -514,7 +514,7 @@
"outOfStock": "Out of Stock",
"preorder": "Pre-order",
"specifications": "Specifications",
"viewOnRetailerSite": "View on Retailer Site",
"viewOnRetailerSite": "Visit Site",
"addToPack": "Add to Pack",
"errorFetchingItem": "Error fetching item",
"pleaseTryAgain": "Please try again.",
Expand Down Expand Up @@ -562,7 +562,10 @@
"recentlyUsed": "Recently Used",
"selectedItemsQuantity": "Selected Items ({{count}})",
"noRecentlyUsedItems": "No recently used items yet",
"quantityFor": "Qty for {{name}}"
"quantityFor": "Qty for {{name}}",
"variantsSection": "Variants",
"variantSize": "Size",
"variantColor": "Color"
},
"welcome": {
"features": {
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/services/catalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,29 @@ export class CatalogService {
let orderBy = [desc(sql`COALESCE(pack_item_counts.count, 0)`), desc(catalogItems.id)]; // default ordering by usage
if (sort) {
const { field, order } = sort;
// All branches include `desc(catalogItems.id)` as a tiebreaker so that
// LIMIT/OFFSET pagination is stable. Without it, rows sharing the same
// sort-key value (e.g. all ETL-imported items have identical created_at)
// are ordered non-deterministically by the planner, causing the same item
// to appear on multiple pages and other items to be skipped entirely.
if (field === 'category') {
orderBy = [
order === 'desc'
? desc(sql`jsonb_array_elements_text(${catalogItems.categories})[0]`)
: asc(sql`jsonb_array_elements_text(${catalogItems.categories})[0]`),
desc(catalogItems.id),
];
} else if (field === 'usage') {
orderBy = [
order === 'desc'
? desc(sql`COALESCE(pack_item_counts.count, 0)`)
: asc(sql`COALESCE(pack_item_counts.count, 0)`),
desc(catalogItems.id),
];
} else {
const sortColumn = catalogItems[field];
if (sortColumn) {
orderBy = [order === 'desc' ? desc(sortColumn) : asc(sortColumn)];
orderBy = [order === 'desc' ? desc(sortColumn) : asc(sortColumn), desc(catalogItems.id)];
}
}
}
Expand Down
Loading