Skip to content
Open
131 changes: 131 additions & 0 deletions apps/expo/features/packs/utils/__tests__/computeCategories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Pack, PackItem } from 'expo-app/features/packs/types';
import { describe, expect, it, vi } from 'vitest';
import { computeCategorySummaries } from '../computeCategories';

vi.mock('expo-app/features/auth/store', () => ({
userStore: {
preferredWeightUnit: {
peek: vi.fn().mockReturnValue('g'),
},
},
}));

function makeItem(
overrides: Partial<PackItem> & Pick<PackItem, 'weight' | 'weightUnit'>,
): PackItem {
return {
id: 'item-1',
name: 'Test Item',
quantity: 1,
category: 'Shelter',
consumable: false,
worn: false,
packId: 'pack-1',
deleted: false,
isAIGenerated: false,
...overrides,
};
}

function makePack(items: PackItem[]): Pack {
return {
id: 'pack-1',
name: 'Test Pack',
category: 'hiking',
isPublic: false,
deleted: false,
items,
baseWeight: 0,
totalWeight: 0,
};
}

describe('computeCategorySummaries', () => {
it('returns empty array for a pack with no items', () => {
expect(computeCategorySummaries(makePack([]))).toEqual([]);
});

it('groups items under the correct category name', () => {
const items = [
makeItem({ id: 'i1', weight: 200, weightUnit: 'g', category: 'Shelter' }),
makeItem({ id: 'i2', weight: 300, weightUnit: 'g', category: 'Food' }),
];
const result = computeCategorySummaries(makePack(items));
expect(result).toHaveLength(2);
const names = result.map((c) => c.name);
expect(names).toContain('Shelter');
expect(names).toContain('Food');
});

it('falls back to "Other" for empty category string', () => {
const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: '' })];
const result = computeCategorySummaries(makePack(items));
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));
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));
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));
expect(result[0]?.weight).toBe(1000);
});

it('multiplies weight by quantity', () => {
const items = [makeItem({ weight: 100, weightUnit: 'g', quantity: 3, category: 'Food' })];
const result = computeCategorySummaries(makePack(items));
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));
expect(result[0]?.percentage).toBe(100);
});

it('splits percentage evenly across equal-weight categories', () => {
const items = [
makeItem({ id: 'i1', weight: 500, weightUnit: 'g', category: 'Shelter' }),
makeItem({ id: 'i2', weight: 500, weightUnit: 'g', category: 'Food' }),
];
const result = computeCategorySummaries(makePack(items));
for (const cat of result) {
expect(cat.percentage).toBe(50);
}
});

it('counts item rows (not total quantity) in each category', () => {
const items = [
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));
expect(result[0]?.items).toBe(2);
});

it('merges multiple items in the same category', () => {
const items = [
makeItem({ id: 'i1', weight: 300, weightUnit: 'g', category: 'Shelter' }),
makeItem({ id: 'i2', weight: 200, weightUnit: 'g', category: 'Shelter' }),
];
const result = computeCategorySummaries(makePack(items));
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));
expect(result[0]?.percentage).toBe(0);
});
});
15 changes: 12 additions & 3 deletions apps/expo/features/trips/store/trips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import { apiClient } from 'expo-app/lib/api/packrat';
import { persistPlugin } from 'expo-app/lib/persist-plugin';
import type { TripInStore } from '../types';

// The mobile `Trip` / `TripInStore` interface (apps/expo/features/trips/types.ts)
// pre-dates the @packrat/schemas TripSchema and drifts from it (description is
// not-nullable locally, nullable in schema). Aligning the two is part of PR 3
// of the client-uuid-split (docs/design/client-uuid-split.md §9). For now, the
// schema-parsed return is `unknown`-cast through `TripInStore[]` to keep
// syncedCrud's generic happy.
const listTrips = async () => {
const { data, error } = await apiClient.trips.get({ query: { includePublic: 0 } });
if (error) throw new Error(`Failed to list trips: ${error.value}`);
return TripSchema.array().parse(data);
// safe-cast: mobile TripInStore drifts from TripSchema (description nullability); aligned in PR 3.
return TripSchema.array().parse(data) as unknown as TripInStore[];
};
Comment on lines +19 to 21
Comment on lines 17 to 21

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 | 🟠 Major | ⚡ Quick win

Guard for !data before parsing API responses.

In Line 17/27/45 flows, the code checks error but still uses data/result without null checks. Add error || !data (and !result) guards before TripSchema.parse(...) to avoid undefined payload crashes.

Suggested diff
 const listTrips = async () => {
   const { data, error } = await apiClient.trips.get({ query: { includePublic: 0 } });
-  if (error) throw new Error(`Failed to list trips: ${error.value}`);
+  if (error || !data) throw new Error(`Failed to list trips: ${error?.value ?? 'empty response'}`);
   return TripSchema.array().parse(data) as unknown as TripInStore[];
 };
@@
   const { data, error } = await apiClient.trips.post({
@@
   });
-  if (error) throw new Error(`Failed to create trip: ${error.value}`);
+  if (error || !data) throw new Error(`Failed to create trip: ${error?.value ?? 'empty response'}`);
   return TripSchema.parse(data) as unknown as TripInStore;
 };
@@
   const { data: result, error } = await apiClient.trips({ tripId: String(id) }).put({
@@
   });
-  if (error) throw new Error(`Failed to update trip: ${error.value}`);
+  if (error || !result) throw new Error(`Failed to update trip: ${error?.value ?? 'empty response'}`);
   return TripSchema.parse(result) as unknown as TripInStore;
 };

As per coding guidelines, "Check for error || !data before using response data from the API client — responses are { data, error, status }".

Also applies to: 39-42, 55-58

🤖 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/trips/store/trips.ts` around lines 17 - 21, The API
response parsing currently assumes data/result is present after calls like
apiClient.trips.get and then calls TripSchema.array().parse(...) (and similar
parse calls at the other flows); update each flow to guard for both error and
missing payload by checking if (error || !data) (or (error || !result)) before
parsing, and throw a descriptive Error (e.g., `Failed to list trips:
${error?.value ?? 'no data'}`) so parsing is only invoked when data/result is
defined; adjust the branches around apiClient.trips.get, any other trips.*
calls, and the TripSchema.parse usages accordingly.

Comment on lines +20 to 21

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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify schema/store drift and all unsafe double-casts for trips.
set -euo pipefail

echo "== TripInStore type definition =="
fd -i "types.ts" apps/expo/features/trips --exec sed -n '1,220p' {}

echo
echo "== TripSchema definition =="
fd -i "trips.ts" packages/schemas/src --exec sed -n '1,220p' {}

echo
echo "== Unsafe casts in trips store =="
rg -n --type=ts "as unknown as TripInStore|as unknown as TripInStore\\[\\]" apps/expo/features/trips/store/trips.ts

Repository: PackRat-AI/PackRat

Length of output: 3372


🏁 Script executed:

cat -n apps/expo/features/trips/store/trips.ts

Repository: PackRat-AI/PackRat

Length of output: 4750


Replace as unknown as TripInStore with an explicit mapper function.

The schema parse returns objects with clientUuid and nullable fields that don't exist in TripInStore. The double-cast silently drops clientUuid and masks nullability mismatches (e.g., description can be null in schema but not in the store type). Create a small normalizer to make this transformation explicit:

const normalizeTripFromSchema = (trip: z.infer<typeof TripSchema>): TripInStore => {
  const { clientUuid, ...rest } = trip;
  return rest as TripInStore;
};

Then use it at lines 20, 41, and 57 instead of the double-cast. This improves type safety and makes the implicit drift auditable.

🤖 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/trips/store/trips.ts` around lines 20 - 21, The current
double-cast "as unknown as TripInStore" after TripSchema.parse hides mismatches
(extra clientUuid and nullable fields) — add a small mapper
normalizeTripFromSchema(trip: z.infer<typeof TripSchema>): TripInStore that
strips clientUuid and enforces non-nullable fields (e.g., map/validate
description) and return the shaped object as TripInStore; then replace the three
uses of the double-cast (the parse results where TripSchema.array().parse(...)
and the other two single-item parses) with results.map(normalizeTripFromSchema)
or normalizeTripFromSchema(parsed) to make the transformation explicit and
type-safe.


const createTrip = async (tripData: TripInStore) => {
Expand All @@ -30,7 +37,8 @@ const createTrip = async (tripData: TripInStore) => {
localUpdatedAt: tripData.localUpdatedAt ?? new Date().toISOString(),
});
if (error) throw new Error(`Failed to create trip: ${error.value}`);
return TripSchema.parse(data);
// safe-cast: mobile TripInStore drifts from TripSchema; aligned in PR 3.
return TripSchema.parse(data) as unknown as TripInStore;
};

const updateTrip = async ({ id, ...data }: Partial<TripInStore>) => {
Expand All @@ -45,7 +53,8 @@ const updateTrip = async ({ id, ...data }: Partial<TripInStore>) => {
...(data.localUpdatedAt ? { localUpdatedAt: data.localUpdatedAt } : {}),
});
if (error) throw new Error(`Failed to update trip: ${error.value}`);
return TripSchema.parse(result);
// safe-cast: mobile TripInStore drifts from TripSchema; aligned in PR 3.
return TripSchema.parse(result) as unknown as TripInStore;
};

// Observable trips store
Expand Down
2 changes: 2 additions & 0 deletions apps/expo/lib/utils/__tests__/compute-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makePackItem(
): PackItem {
return {
id: 'item-1',
clientUuid: 'item-1',
name: 'Test Item',
description: null,
quantity: overrides.quantity ?? 1,
Expand All @@ -36,6 +37,7 @@ function makePackItem(
function makePack(items: PackItem[] = [], overrides: Partial<PackWithItems> = {}): PackWithItems {
return {
id: 'pack-1',
clientUuid: 'pack-1',
name: 'Test Pack',
description: null,
category: 'hiking',
Expand Down
92 changes: 92 additions & 0 deletions apps/expo/lib/utils/__tests__/dateUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { formatLocalDate, parseLocalDate } from '../dateUtils';

describe('parseLocalDate', () => {
it('returns null for undefined', () => {
expect(parseLocalDate(undefined)).toBeNull();
});

it('returns null for empty string', () => {
expect(parseLocalDate('')).toBeNull();
});

it('parses YYYY-MM-DD as a local date with correct year, month, and day', () => {
const result = parseLocalDate('2024-01-15');
expect(result).not.toBeNull();
expect(result?.getFullYear()).toBe(2024);
expect(result?.getMonth()).toBe(0); // January
expect(result?.getDate()).toBe(15);
});

it('parses end-of-year date correctly', () => {
const result = parseLocalDate('2023-12-31');
expect(result).not.toBeNull();
expect(result?.getFullYear()).toBe(2023);
expect(result?.getMonth()).toBe(11); // December
expect(result?.getDate()).toBe(31);
});

it('returns null for an invalid YYYY-MM-DD date (month 13)', () => {
expect(parseLocalDate('2024-13-01')).toBeNull();
});

it('returns null for an invalid YYYY-MM-DD date (day 32)', () => {
expect(parseLocalDate('2024-01-32')).toBeNull();
});

it('parses ISO datetime strings', () => {
const result = parseLocalDate('2024-06-15T10:30:00Z');
expect(result).not.toBeNull();
expect(result?.getUTCFullYear()).toBe(2024);
expect(result?.getUTCMonth()).toBe(5); // June
});

it('returns null for completely invalid input', () => {
expect(parseLocalDate('not-a-date')).toBeNull();
});

it('returns null for a non-standard pattern that looks date-like', () => {
expect(parseLocalDate('foo-bar-baz')).toBeNull();
});

it('YYYY-MM-DD parses as local time (not UTC)', () => {
const result = parseLocalDate('2024-03-10');
expect(result).not.toBeNull();
// date-fns parse() with 'yyyy-MM-dd' sets hours to 0 in local time
expect(result?.getHours()).toBe(0);
expect(result?.getMinutes()).toBe(0);
});
});

describe('formatLocalDate', () => {
it('returns em dash for undefined', () => {
expect(formatLocalDate(undefined)).toBe('—');
});

it('returns em dash for empty string', () => {
expect(formatLocalDate('')).toBe('—');
});

it('returns a non-empty locale string for a valid YYYY-MM-DD date', () => {
const result = formatLocalDate('2024-01-15');
expect(result).not.toBe('—');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});

it('returns em dash for a completely invalid date string', () => {
expect(formatLocalDate('not-a-date')).toBe('—');
});

it('returns a formatted string for ISO datetime', () => {
const result = formatLocalDate('2024-06-15T10:30:00Z');
expect(result).not.toBe('—');
expect(typeof result).toBe('string');
});

it('returns a formatted string for end-of-year date', () => {
const result = formatLocalDate('2023-12-31');
expect(result).not.toBe('—');
expect(typeof result).toBe('string');
});
});
Loading