fix(swift): resolve seven reported iOS defects across weather, packs, trips, and catalog search - #2697
Conversation
- #2693 Weather: omit the search-state row when empty. The row rendered unconditionally, reserving inset-grouped height and padding, which showed as a large gap between the search field and Saved Locations. - #2690 Pack Details: read weight totals from `currentPack` rather than the captured `pack`. The latter is a snapshot from push time, so the summary kept showing weights for items the live pack no longer had. - #2692 Pack Item Details: make Similar Gear cards tappable, presenting CatalogItemDetailView via the same contentShape/onTapGesture/sheet pattern CatalogItemRow already uses. The cards previously had no action at all. - #2691 Packs list: drop the List selection binding on compact iOS, where navigation is a NavigationLink push. Tracking selection there left the tapped row rendered gray after popping back. Split-view keeps selection. - #2661 Pack Item Details: re-read the item from the view model instead of rendering the construction-time snapshot, so the weight shown right after Save reflects the edit. Edit re-open now prefills from the live item too. - #2660 Trip edit: encode `packId` unconditionally on update, as explicit null when "None" is chosen. Synthesised Encodable omits nil keys, and the route keys off `if ('packId' in data)`, so unassigning never persisted.
Searching "Tent" surfaced leggings, tote bags and repair kits above actual
tents. Two independent causes:
1. Unanchored `ilike '%q%'` matched the letters anywhere, including inside
unrelated words — "consistent", "content", "intent", "patented", "latent"
are common in gear marketing copy, and `description` was matched with the
same weight as `name`. Free-text fields (name, description, categories)
now require a POSIX word-boundary match, so incidental hits inside longer
words no longer qualify. Brand and model keep substring matching, where
typing a prefix is the normal way to search.
2. Nothing ranked by the query at all — ordering was purely by how often an
item had been added to packs, so a popular incidental match outranked an
exact-name tent. Matches are now tiered by where they hit (exact name >
name prefix > name word > category > brand/model > description-only),
with popularity kept as a tiebreaker within a tier.
The query is interpolated into a regex operator, so metacharacters in `q`
are escaped — otherwise a stray "(" would error the statement.
An explicit caller-supplied `sort` still overrides relevance, and the
`desc(id)` pagination tiebreaker is preserved on every branch.
Verified against Postgres with fixtures drawn from the issue: the leggings
and tote-bag decoys drop out of the result set entirely, and the remaining
matches order Tent > Tent Footprint > Copper Spur UL2 Tent > Fabric Repair
Kit (description-only mention, last).
WalkthroughThe PR updates Swift pack, weather, and trip request behavior. It also changes catalog search matching and relevance ordering, with regression tests for search boundaries, ranking, brand/model matching, and literal regex characters. ChangesSwift application updates
Catalog search updates
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Coverage Report for packages/units (./packages/units)
File CoverageNo changed files found. |
Coverage Report for packages/utils (./packages/utils)
File CoverageNo changed files found. |
Coverage Report for packages/overpass (./packages/overpass)
File CoverageNo changed files found. |
Coverage Report for packages/analytics (./packages/analytics)
File CoverageNo changed files found. |
Coverage Report for packages/mcp (./packages/mcp)
File CoverageNo changed files found. |
Coverage Report for apps/expo (./apps/expo)
File CoverageNo changed files found. |
Coverage Report for packages/api (./packages/api)
File CoverageNo changed files found. |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with 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.
Inline comments:
In `@apps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift`:
- Around line 315-323: Replace the onTapGesture interaction in the similar-item
card’s body with a Button that sets showingDetail to true, preserve the card
content and accessibility configuration, and apply .buttonStyle(.plain) to
retain the current visual appearance.
In `@apps/swift/Sources/PackRat/Models/Trip.swift`:
- Around line 41-57: Update Trip and TripService.updateTrip to represent packId
with distinct omitted, assigned, and explicitly cleared states instead of a
plain String?. Adjust encode(to:) to omit packId for the omitted state while
encoding a value or null for the other states, and add serialization tests
covering all three cases.
In `@packages/api/src/services/catalogService.ts`:
- Around line 134-152: Update the search flow around q and term so
whitespace-only input is ignored: trim q before the conditional branch, and only
construct the regex, ILIKE predicates, and searchCondition when the trimmed term
is non-empty. Preserve the no-query result behavior for empty or whitespace-only
terms.
In `@packages/api/test/catalog.test.ts`:
- Around line 166-189: Extend the “still matches brand and model by substring”
test with a fixture whose model, not brand, contains the query substring, and
assert that it is returned. Update the regex-metacharacter test to query an
encoded word-ending value such as “t.ent”, seed both a literal “T.ent” item and
a “Tent” item, and assert the literal item matches while the non-literal item
does not, while preserving the successful response assertion.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 30df1b57-f34f-4ff4-a96e-e6fa5b10f359
📒 Files selected for processing (7)
apps/swift/Sources/PackRat/Features/Packs/PackDetailView.swiftapps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swiftapps/swift/Sources/PackRat/Features/Packs/PacksListView.swiftapps/swift/Sources/PackRat/Features/Weather/WeatherView.swiftapps/swift/Sources/PackRat/Models/Trip.swiftpackages/api/src/services/catalogService.tspackages/api/test/catalog.test.ts
| var body: some View { | ||
| cardContent | ||
| .contentShape(Rectangle()) | ||
| .onTapGesture { showingDetail = true } | ||
| .accessibilityIdentifier("pack_item_similar_card_\(item.id)") | ||
| .accessibilityAddTraits(.isButton) | ||
| .sheet(isPresented: $showingDetail) { | ||
| CatalogItemDetailView(item: item, packsViewModel: packsViewModel) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
file="apps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift"
printf '%s\n' '--- target and file references ---'
rg -n "SimilarItemCard|onTapGesture|accessibilityAddTraits|CatalogItemDetailView|platform|macOS|iOS" \
apps/swift/Sources apps/swift/Package.swift apps/swift/*.xcodeproj 2>/dev/null || true
printf '%s\n' '--- relevant source ---'
sed -n '270,340p' "$file"
printf '%s\n' '--- package/project platform declarations ---'
rg -n -i "macos|ios|deploymentTarget|supportedplatform|swift-tools-version" \
apps/swift/Package.swift apps/swift/*.pbxproj apps/swift/*.xcodeproj/project.pbxproj 2>/dev/null || true
printf '%s\n' '--- related interaction patterns ---'
rg -n -g '*.swift' "Button\s*\{|\.onTapGesture|accessibilityAddTraits\(\.isButton\)|\.buttonStyle\(\.plain\)" apps/swift/Sources apps/swift/Tests 2>/dev/null | head -200Repository: PackRat-AI/PackRat
Length of output: 50374
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
root = Path("apps/swift")
for path in root.rglob("*.swift"):
text = path.read_text(errors="replace")
if "SimilarItemCard" not in text:
continue
print(f"FILE {path}")
for match in re.finditer(r"SimilarItemCard", text):
start = max(0, text.rfind("\n", 0, match.start()-250))
end = text.find("\n", match.end()+400)
end = len(text) if end < 0 else end
print(text[start+1:end+1])
PYRepository: PackRat-AI/PackRat
Length of output: 1641
🌐 Web query:
Apple SwiftUI documentation onTapGesture Button keyboard activation accessibility macOS iOS
💡 Result:
In SwiftUI, using a Button is the recommended practice for interactive elements because it provides essential accessibility and keyboard activation capabilities out of the box [1][2][3][4]. Using.onTapGesture on non-button views (like Text or Image) is generally considered a significant accessibility anti-pattern because these views lack the necessary traits and behaviors required for assistive technologies and keyboard navigation [2][4][5]. Key differences include: 1. Keyboard Activation and Focus: Native SwiftUI Button elements are keyboard-accessible by default [6]. On macOS and platforms supporting Full Keyboard Access, a Button automatically receives focus and can be activated using standard keyboard inputs (such as the Space or Enter/Return keys) [6][7]. Conversely, views using.onTapGesture do not automatically receive keyboard focus and cannot be activated via standard keyboard navigation [6][2]. 2. Accessibility Traits: A Button is automatically identified by VoiceOver as a "button," informing users that the element is interactive [8][2][5]. Views with.onTapGesture do not carry this trait by default; to assistive technologies, they appear as static content [2][5]. If you must use.onTapGesture for a custom interactive view, you are required to manually add the accessibility trait using.accessibilityAddTraits(.isButton) and provide an appropriate accessibility label [6][8][2]. 3. User Experience: Button provides automatic visual feedback (such as a pressed state) that helps users understand when an interaction has been recognized [9][4]. It also handles platform-specific interaction models—like clicks on macOS or taps on iOS—consistently [9][7]. Apple explicitly advises that if you are creating a control functionally equivalent to a button, you should use the Button component, utilizing ButtonStyle to achieve custom designs rather than applying gestures to arbitrary views [9]. [6][8][1][2][9][4][7][5]
Citations:
- 1: https://forums.swift.org/t/text-with-ontapgesture-instead-of-button/58629
- 2: https://swiftcrafted.dev/article/swiftui-accessibility-complete-guide-voiceover-dynamic-type-inclusive-design
- 3: https://david.y4ng.fr/tap-gesture-vs-button/
- 4: https://www.swiftwithvincent.com/blog/be-careful-when-using-ontapgesture
- 5: https://blog.lars-richter.dev/multiple-buttons-in-a-swiftui-list-element/
- 6: https://github.com/pasqualevittoriosi/swift-accessibility-skill/blob/main/swift-accessibility-skill/references/motor-input.md
- 7: https://apple-docs.everest.mt/docs/swiftui/button/
- 8: https://realm.github.io/SwiftLint/accessibility_trait_for_button.html
- 9: https://stackoverflow.com/questions/61604277/difference-between-buttons-and-ontapgesture
Use Button for the similar-item card.
onTapGesture does not provide native keyboard activation or button control behavior. Replace it with a Button and retain .buttonStyle(.plain).
🤖 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/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift` around
lines 315 - 323, Replace the onTapGesture interaction in the similar-item card’s
body with a Button that sets showingDetail to true, preserve the card content
and accessibility configuration, and apply .buttonStyle(.plain) to retain the
current visual appearance.
| /// `packId` is encoded unconditionally — as an explicit `null` when the user | ||
| /// picks "None" — because the update route distinguishes the two cases with | ||
| /// `if ('packId' in data)`. Synthesised `Encodable` omits nil keys entirely, | ||
| /// which the server read as "leave unchanged", so unassigning a pack never | ||
| /// saved. Every other field keeps omit-when-nil so a partial update does not | ||
| /// clobber fields the form did not touch. | ||
| func encode(to encoder: any Encoder) throws { | ||
| var container = encoder.container(keyedBy: CodingKeys.self) | ||
| try container.encodeIfPresent(name, forKey: .name) | ||
| try container.encodeIfPresent(description, forKey: .description) | ||
| try container.encodeIfPresent(location, forKey: .location) | ||
| try container.encodeIfPresent(startDate, forKey: .startDate) | ||
| try container.encodeIfPresent(endDate, forKey: .endDate) | ||
| try container.encodeIfPresent(notes, forKey: .notes) | ||
| try container.encode(packId, forKey: .packId) | ||
| try container.encode(localUpdatedAt, forKey: .localUpdatedAt) | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
rg -n -C 6 'UpdateTripRequest|\.updateTrip\s*\(' \
apps/swift/Sources/PackRat --glob '*.swift'Repository: PackRat-AI/PackRat
Length of output: 6578
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- Trip.swift ---'
sed -n '1,90p' apps/swift/Sources/PackRat/Models/Trip.swift
printf '%s\n' '--- TripService.swift ---'
sed -n '35,75p' apps/swift/Sources/PackRat/Services/TripService.swift
printf '%s\n' '--- TripFormView.swift ---'
sed -n '175,215p' apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift
printf '%s\n' '--- TripsViewModel.swift ---'
sed -n '205,255p' apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift
printf '%s\n' '--- OutboxService.swift ---'
sed -n '315,355p' apps/swift/Sources/PackRat/Services/OutboxService.swift
printf '%s\n' '--- API route references ---'
rg -n -C 8 "packId|UpdateTrip|updateTrip" packages/api/src/routes/trips/index.tsRepository: PackRat-AI/PackRat
Length of output: 12084
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- all updateTrip call sites ---'
rg -n -C 5 '\bupdateTrip\s*\(' apps/swift --glob '*.swift'
printf '%s\n' '--- form pack selection state and initialization ---'
rg -n -C 8 'selectedPackId|existingTrip|TripFormView' \
apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift
printf '%s\n' '--- view-model update method ---'
rg -n -A 95 -B 8 'func updateTrip\s*\(' \
apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift
printf '%s\n' '--- mutation payload definition and uses ---'
rg -n -C 8 'struct TripMutationPayload|TripMutationPayload' \
apps/swift/Sources/PackRat --glob '*.swift'
printf '%s\n' '--- update schema ---'
rg -n -C 12 'UpdateTripBodySchema' packages --glob '*.{ts,tsx}'Repository: PackRat-AI/PackRat
Length of output: 28711
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
trip = Path("apps/swift/Sources/PackRat/Models/Trip.swift").read_text()
service = Path("apps/swift/Sources/PackRat/Services/TripService.swift").read_text()
form = Path("apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift").read_text()
route = Path("packages/api/src/routes/trips/index.ts").read_text()
assert "let packId: String?" in trip
assert "try container.encode(packId, forKey: .packId)" in trip
assert "packId: String? = nil" in service
assert "packId: selectedPackId" in form
assert "if ('packId' in data) updateData.packId = data.packId ?? null;" in route
# Model the two relevant API states produced by this request type.
def encoded_pack_id(pack_id):
return {"packId": pack_id} # encode(Optional.none) writes JSON null
for value, expected in [("pack-1", "set"), (None, "explicit-clear")]:
payload = encoded_pack_id(value)
route_action = "update" if "packId" in payload else "leave-unchanged"
if value is None:
assert route_action == "update"
assert expected == "explicit-clear"
# There is no third value in String? that can represent omission.
assert encoded_pack_id(None) == {"packId": None}
print("UpdateTripRequest has no representable omitted packId state.")
print("nil serializes as packId:null, and the API treats that as unassignment.")
PY
printf '%s\n' '--- relevant tests ---'
rg -n -i 'UpdateTripRequest|packId.*null|unassign|TripService.*update' \
apps/swift --glob '*Test*.swift' --glob '*.swift' || trueRepository: PackRat-AI/PackRat
Length of output: 865
Preserve omitted and explicit-clear states for packId.
TripService.updateTrip defaults packId to nil. The custom encoder then sends "packId": null, and the API treats that value as unassignment. A String? cannot represent both omitted and explicit “None” states. Use a three-state representation and add serialization tests for omitted, set, and clear.
🤖 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/swift/Sources/PackRat/Models/Trip.swift` around lines 41 - 57, Update
Trip and TripService.updateTrip to represent packId with distinct omitted,
assigned, and explicitly cleared states instead of a plain String?. Adjust
encode(to:) to omit packId for the omitted state while encoding a value or null
for the other states, and add serialization tests covering all three cases.
| if (q) { | ||
| const term = q.trim(); | ||
| // Escape regex metacharacters: `q` is user input and reaches a POSIX | ||
| // regex operator below, where an unescaped `(` or `*` would either error | ||
| // or silently change the match semantics. | ||
| const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| // `\m` / `\M` are POSIX word-boundary escapes (start/end of word). | ||
| const wordBoundary = `\\m${escaped}\\M`; | ||
| const nameWord = sql`${catalogItems.name} ~* ${wordBoundary}`; | ||
| const descriptionWord = sql`COALESCE(${catalogItems.description}, '') ~* ${wordBoundary}`; | ||
| const categoriesWord = sql`${catalogItems.categories}::text ~* ${wordBoundary}`; | ||
|
|
||
| const searchCondition = or( | ||
| ilike(catalogItems.name, `%${q}%`), | ||
| ilike(catalogItems.description, `%${q}%`), | ||
| ilike(catalogItems.brand, `%${q}%`), | ||
| ilike(catalogItems.model, `%${q}%`), | ||
| ilike(sql`${catalogItems.categories}::text`, `%${q}%`), | ||
| nameWord, | ||
| descriptionWord, | ||
| categoriesWord, | ||
| ilike(catalogItems.brand, `%${term}%`), | ||
| ilike(catalogItems.model, `%${term}%`), | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Ignore whitespace-only search terms.
Line 134 enters the search branch for q=' '. Line 135 then creates an empty term. The brand and model predicates become ILIKE '%%', so this request applies an unintended filter and relevance ordering instead of using the no-query result set.
Trim before the branch. Only build search conditions when the trimmed term is non-empty.
Proposed fix
- if (q) {
- const term = q.trim();
+ const term = q?.trim();
+ if (term) {📝 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.
| if (q) { | |
| const term = q.trim(); | |
| // Escape regex metacharacters: `q` is user input and reaches a POSIX | |
| // regex operator below, where an unescaped `(` or `*` would either error | |
| // or silently change the match semantics. | |
| const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| // `\m` / `\M` are POSIX word-boundary escapes (start/end of word). | |
| const wordBoundary = `\\m${escaped}\\M`; | |
| const nameWord = sql`${catalogItems.name} ~* ${wordBoundary}`; | |
| const descriptionWord = sql`COALESCE(${catalogItems.description}, '') ~* ${wordBoundary}`; | |
| const categoriesWord = sql`${catalogItems.categories}::text ~* ${wordBoundary}`; | |
| const searchCondition = or( | |
| ilike(catalogItems.name, `%${q}%`), | |
| ilike(catalogItems.description, `%${q}%`), | |
| ilike(catalogItems.brand, `%${q}%`), | |
| ilike(catalogItems.model, `%${q}%`), | |
| ilike(sql`${catalogItems.categories}::text`, `%${q}%`), | |
| nameWord, | |
| descriptionWord, | |
| categoriesWord, | |
| ilike(catalogItems.brand, `%${term}%`), | |
| ilike(catalogItems.model, `%${term}%`), | |
| ); | |
| const term = q?.trim(); | |
| if (term) { | |
| // Escape regex metacharacters: `q` is user input and reaches a POSIX | |
| // regex operator below, where an unescaped `(` or `*` would either error | |
| // or silently change the match semantics. | |
| const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| // `\m` / `\M` are POSIX word-boundary escapes (start/end of word). | |
| const wordBoundary = `\\m${escaped}\\M`; | |
| const nameWord = sql`${catalogItems.name} ~* ${wordBoundary}`; | |
| const descriptionWord = sql`COALESCE(${catalogItems.description}, '') ~* ${wordBoundary}`; | |
| const categoriesWord = sql`${catalogItems.categories}::text ~* ${wordBoundary}`; | |
| const searchCondition = or( | |
| nameWord, | |
| descriptionWord, | |
| categoriesWord, | |
| ilike(catalogItems.brand, `%${term}%`), | |
| ilike(catalogItems.model, `%${term}%`), | |
| ); |
🤖 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 `@packages/api/src/services/catalogService.ts` around lines 134 - 152, Update
the search flow around q and term so whitespace-only input is ignored: trim q
before the conditional branch, and only construct the regex, ILIKE predicates,
and searchCondition when the trimmed term is non-empty. Preserve the no-query
result behavior for empty or whitespace-only terms.
| it('still matches brand and model by substring', async () => { | ||
| const item = await seedCatalogItem({ | ||
| name: 'Ultralight Shelter', | ||
| brand: 'Hilleberg', | ||
| description: 'No matching word in here.', | ||
| categories: ['Shelter'], | ||
| }); | ||
|
|
||
| const res = await apiWithAuth('/catalog?q=Hille&limit=100'); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| const data = await expectJsonResponse(res, ['items']); | ||
| const ids = data.items.map((catalogItem: { id: number }) => catalogItem.id); | ||
| expect(ids).toContain(item.id); | ||
| }); | ||
|
|
||
| it('treats regex metacharacters in the query as literal text', async () => { | ||
| const res = await apiWithAuth(`/catalog?q=${encodeURIComponent('tent(')}&limit=10`); | ||
|
|
||
| // An unescaped "(" would be an invalid POSIX regex and error the query. | ||
| expect(res.status).toBe(200); | ||
| await expectJsonResponse(res); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Test the model branch and literal matching behavior.
Line 166 describes brand and model coverage, but the fixture only matches brand. Add a fixture where only model contains the query substring.
Lines 182-189 only verify that the request succeeds. Keep this error-regression test, but add a literal-match assertion. Use a metacharacter inside a word-ending query, such as t.ent, and assert that a literal T.ent item matches while a Tent item does not.
As per path instructions, tests must “test observable behaviour.”
🤖 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 `@packages/api/test/catalog.test.ts` around lines 166 - 189, Extend the “still
matches brand and model by substring” test with a fixture whose model, not
brand, contains the query substring, and assert that it is returned. Update the
regex-metacharacter test to query an encoded word-ending value such as “t.ent”,
seed both a literal “T.ent” item and a “Tent” item, and assert the literal item
matches while the non-literal item does not, while preserving the successful
response assertion.
Source: Path instructions
Description
Fixes seven reported defects in the Swift app. Six are client-side; one (#2662) is a server-side search-ranking bug that surfaces in the app's Catalog screen.
Closes #2693
Closes #2692
Closes #2691
Closes #2690
Closes #2662
Closes #2661
Closes #2660
Listunconditionally, so it still reserved inset-grouped row height and padding while emptyweightSummaryread the capturedlet pack(a snapshot from push time) while the item list read the livecurrentPackcurrentPack, like the rest of the view already didSimilarItemCardwas a plainVStack— no button, link, or gestureCatalogItemDetailView, reusing thecontentShape/onTapGesture/sheetpatternCatalogItemRowalready usesNavigationLinkand theListhad aselection:binding, so the tapped row stayed selected after popping backnilselection on compact; split-view keeps selection for its detail panePackItemsnapshot it was constructed withEncodableomits nil keys, so nopackIdwas sent; the route keys offif ('packId' in data)and read the absence as "leave unchanged"packIdunconditionally, as an explicitnullwhen "None" is chosenOn #2662
Searching "Tent" returned leggings, tote bags and repair kits above actual tents, for two independent reasons:
ilike '%q%'matched the letters anywhere, including inside unrelated words. "consistent", "content", "intent", "patented" and "latent" are everywhere in gear marketing copy, anddescriptionwas weighted the same asname. Free-text fields (name, description, categories) now require a POSIX word-boundary match. Brand and model keep substring matching, since typing a prefix is the normal way to search those.qis interpolated into a regex operator, so metacharacters are escaped — otherwise a stray(errors the statement. An explicit caller-suppliedsortstill overrides relevance, and thedesc(id)pagination tiebreaker is preserved on every branch.Verified directly against Postgres using fixtures drawn from the issue:
Both decoys drop out of the result set entirely, and the genuine description-only match (repair kit) correctly sorts last.
Type of change
Area(s) affected
apps/expo)packages/api)apps/landing)apps/guides).github/)apps/swift) — not in the template listTesting
packages/api/test/catalog.test.tscovering word-boundary matching, name-over-description ranking, exact-over-partial ranking, brand substring, and regex-metacharacter safetycurlor Postman)What was actually run:
xcodebuildBUILD SUCCEEDED for bothPackRat-iOSandPackRat-macOS(macOS matters here: the iOS Mobile – Pack Card Remains Highlighted After Returning to Packs Screen #2691 fix routes throughisCompact, hardcodedfalsethere)bunx tsc --noEmit— exit 0bunx biome check— clean across 44 filesscripts/lint/no-unprojected-fat-table-queries.ts— clean, no new violationsbun test:swift:scripts— 76/76 passImportant
The five new API tests have never executed, and the reviewer should know why. The
packages/apiintegration harness is broken ondevelopment, independently of this branch: migration0033_social_feed_tables.sqlcreatescomment_likeswithIF NOT EXISTS, then0037_big_archangel.sqlrecreates it without the guard, andtest/vitest.global-setup.tsapplies every.sqlfile blindly. Startup therefore fails withrelation "comment_likes" already existson a clean volume. I confirmed this against pristinedbc84ca0ebefore concluding it wasn't mine.I did not fix it: those files are Drizzle-generated and
CLAUDE.mdforbids hand-editing migrations, so the repair needs a regeneration decision outside this PR's scope. That is why #2662 was validated by running the SQL directly against Postgres instead. The new tests will run once the harness is repaired — worth tracking separately.Screenshots / recordings
None. The six client-side fixes were verified by root-cause analysis and compilation rather than by running the app; each is a small, targeted change at an identified defect. The reproduction steps in each issue are the fastest way to confirm on-device.
Pre-merge checklist
bun format && bun lintpasses with no errorsbun check-typespasses with no errorsfeat:,fix:,chore:, etc.)Summary by CodeRabbit
New Features
Bug Fixes
Tests