Skip to content

feat: Trail Condition Reporting System (MVP)#1905

Draft
Copilot wants to merge 7 commits intomainfrom
copilot/add-trail-condition-reporting
Draft

feat: Trail Condition Reporting System (MVP)#1905
Copilot wants to merge 7 commits intomainfrom
copilot/add-trail-condition-reporting

Conversation

Copy link
Contributor

Copilot AI commented Mar 9, 2026

  • Previous CodeRabbit feedback addressed
  • Thread 1 & 5: Add offline data from local store as fallback in useTrailConditionReports
  • Thread 2: Add accessibility metadata (role, state, labels) to chip/stepper Pressables in form
  • Thread 3: Localize formatRelativeTime and clamp future dates
  • Thread 4: Translate surface, waterCrossingDifficulty, hazards values in card
  • Thread 6: Forward lastSync cursor to /mine API endpoint
  • Thread 7: Make id required (not optional) in updateReport
  • Thread 8: Gate trail condition prompt to completed trips (endDate in the past)
  • Thread 9: Add NOT NULL constraints to hazards, photos, waterCrossings in SQL migration and schema
  • Thread 10: Add .max(20) to waterCrossings in API schema
  • Thread 11: Make POST / idempotent — handle duplicate-key constraint as successful replay
  • Code review + CodeQL scan — no issues found

Security Summary: CodeQL found no alerts. No new vulnerabilities introduced.

Original prompt

This section details on the original issue you should resolve

<issue_title>Feature: Trail Condition Reporting System</issue_title>
<issue_description>## Summary

Crowdsourced trail condition reports from hikers including surface conditions, hazards, and water crossings.

Why

  • Real-time trail conditions help other hikers plan
  • Current condition data is often outdated or unavailable
  • Community-driven value similar to crowdsourced reviews
  • Differentiates PackRat from competitors

Technical Approach

Post-Hike Prompt

  • After completing a hike, prompt user for condition report
  • Simple UI: surface, hazards, water sources, photos
  • Low friction submission

Condition Categories

  • Trail surface: paved, gravel, dirt, rocky, snow, mud
  • Water crossings: count, depth, difficulty
  • Hazards: fallen trees, wildlife, erosion, closures
  • Photos: geotagged, optional

Trust System

  • First reports have lower trust score
  • Verified users (track history) have higher trust
  • Photo evidence increases credibility
  • Other users can verify reports

Offline First

  • Submit reports offline, sync when back in range
  • View recent conditions without connectivity
  • Cached for offline access

Deliverable

MVP that demonstrates:

  • Post-hike condition prompt
  • Simple condition submission
  • View recent conditions on trail page
  • Offline storage and sync</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Summary by CodeRabbit

  • New Features
    • Added Trail Conditions feature enabling users to view recent trail condition reports and submit reports about trail surfaces, hazards, water crossings, and overall conditions
    • Integrated condition reporting workflow into Trip Detail screen with a dedicated modal submission form
    • Reports display with condition badges, region information, hazards, and user notes
    • Added internationalization support for trail conditions content

@andrew-bierman
Copy link
Collaborator

@copilot

Copilot AI and others added 2 commits March 9, 2026 11:33
- Add trailConditionReports table to DB schema with surface, condition, hazards, water crossings fields
- Create DB migration SQL (0033_trail_conditions.sql)
- Create API routes for trail conditions (list, create, list-mine, delete)
- Register trail conditions routes in API index
- Add enableTrailConditions feature flag to config.ts
- Create trail-conditions feature module with types, store, hooks, components
- Offline-first store using Legend State + SQLite persistence
- SubmitConditionReportForm, TrailConditionReportCard, ConditionBadge components
- Replace static mock data in trail-conditions screen with real API data
- Add submit report modal to trail conditions screen
- Add post-hike condition report prompt to trip detail screen
- Add i18n translations for trail conditions

Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
- Use c.req.valid('json') instead of c.req.json() in createReport handler
- Add PUT /api/trail-conditions/:reportId endpoint for updates
- Log errors in SubmitConditionReportForm catch block
- Use @ts-expect-error instead of @ts-ignore in useSubmitTrailConditionReport
- Validate date string in formatRelativeTime to handle Invalid Date

Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
Copilot AI changed the title [WIP] Add trail condition reporting system for hikers feat: Trail Condition Reporting System (MVP) Mar 9, 2026
@andrew-bierman
Copy link
Collaborator

@coderabbitai Please review this PR for code quality, best practices, and potential issues.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get the list of files changed in the PR
gh pr view 1905 --repo PackRat-AI/PackRat --json files --jq '.files[].path'

Length of output: 1027


🏁 Script executed:

#!/bin/bash
# Read backend files
echo "===== packages/api/drizzle/0033_trail_conditions.sql ====="
cat packages/api/drizzle/0033_trail_conditions.sql

echo "===== packages/api/src/db/schema.ts (tail) ====="
cat packages/api/src/db/schema.ts | tail -100

echo "===== packages/api/src/routes/trailConditions/reports.ts ====="
cat packages/api/src/routes/trailConditions/reports.ts

echo "===== packages/api/src/routes/trailConditions/index.ts ====="
cat packages/api/src/routes/trailConditions/index.ts

echo "===== packages/api/src/routes/index.ts (tail) ====="
cat packages/api/src/routes/index.ts | tail -30

Length of output: 18346


🏁 Script executed:

#!/bin/bash
echo "===== apps/expo/features/trail-conditions/store/trailConditionReports.ts ====="
cat apps/expo/features/trail-conditions/store/trailConditionReports.ts

echo "===== apps/expo/features/trail-conditions/types.ts ====="
cat apps/expo/features/trail-conditions/types.ts

echo "===== apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts ====="
cat apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts

echo "===== apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts ====="
cat apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts

Length of output: 5530


🏁 Script executed:

#!/bin/bash
echo "===== apps/expo/features/trail-conditions/components/ConditionBadge.tsx ====="
cat apps/expo/features/trail-conditions/components/ConditionBadge.tsx

echo "===== apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx ====="
cat apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx

echo "===== apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx ====="
cat apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx

echo "===== apps/expo/app/(app)/trail-conditions.tsx ====="
cat "apps/expo/app/(app)/trail-conditions.tsx"

echo "===== apps/expo/config.ts (relevant section) ====="
cat apps/expo/config.ts

echo "===== apps/expo/features/trips/screens/TripDetailScreen.tsx (last 100 lines) ====="
tail -100 apps/expo/features/trips/screens/TripDetailScreen.tsx

Length of output: 22901


🏁 Script executed:

#!/bin/bash
echo "===== apps/expo/lib/i18n/locales/en.json (trailConditions section) ====="
python3 -c "
import json
with open('apps/expo/lib/i18n/locales/en.json') as f:
    data = json.load(f)
tc = data.get('trailConditions', {})
print(json.dumps({'trailConditions': tc}, indent=2))
"

echo "===== packages/api/src/db/schema.ts (trailConditionReports table) ====="
grep -n "trailConditionReports" packages/api/src/db/schema.ts | head -5
grep -n -A 40 "export const trailConditionReports" packages/api/src/db/schema.ts

echo "===== Check for missing 404 in updateReport and deleteReport ====="
grep -n "404\|not found\|findFirst" packages/api/src/routes/trailConditions/reports.ts

echo "===== Check for missing index on user_id or trail_name in migration ====="
cat packages/api/drizzle/0033_trail_conditions.sql

echo "===== Check listMyReports for soft-delete filter ====="
grep -n "deleted" packages/api/src/routes/trailConditions/reports.ts

echo "===== Check trailConditionReports.ts store for delete function ====="
grep -n "delete\|Delete" apps/expo/features/trail-conditions/store/trailConditionReports.ts

echo "===== Check for duplicate route conflict (mine vs :id) ====="
grep -n "path:" packages/api/src/routes/trailConditions/reports.ts

echo "===== Check apps/expo/features/trail-conditions/components/index.ts ====="
cat apps/expo/features/trail-conditions/components/index.ts

echo "===== Check apps/expo/features/trail-conditions/index.ts ====="
cat apps/expo/features/trail-conditions/index.ts

echo "===== Check apps/expo/features/trail-conditions/hooks/index.ts ====="
cat apps/expo/features/trail-conditions/hooks/index.ts

echo "===== Check packages/api/drizzle/meta/_journal.json tail ====="
tail -20 packages/api/drizzle/meta/_journal.json

Length of output: 7446


@andrew-bierman — here's my review of PR #1905. Overall this is a solid MVP implementation with good offline-first architecture and OpenAPI documentation. I found several issues worth addressing before merging.


🔴 Critical Issues

1. GET /mine does not filter soft-deleted reports

File: packages/api/src/routes/trailConditions/reports.ts

The /mine handler queries all records for the user without excluding soft-deleted ones. This means deleted reports are sent back to the client and re-appear in the offline store after a sync.

// Current (line ~274)
const reports = await db
  .select()
  .from(trailConditionReports)
  .where(eq(trailConditionReports.userId, auth.userId))  // ← missing deleted filter
  .orderBy(desc(trailConditionReports.createdAt));

Fix:

.where(
  and(
    eq(trailConditionReports.userId, auth.userId),
    eq(trailConditionReports.deleted, false),
  ),
)

2. updatedAt query param on /mine is declared but never used

File: packages/api/src/routes/trailConditions/reports.ts

The listMyReportsRoute schema declares an updatedAt filter (used for incremental sync — "only return reports updated after this timestamp"), but the handler body never reads or applies it. The offline store uses changesSince: 'last-sync', so this completely breaks incremental sync.

// Route schema declares:
updatedAt: z.string().datetime().optional()

// But handler does:
trailConditionRoutes.openapi(listMyReportsRoute, async (c) => {
  const auth = c.get('user');
  const db = createDb(c);
  // `c.req.valid('query')` is never called — updatedAt is ignored

Fix:

const { updatedAt } = c.req.valid('query');
const conditions: SQL[] = [eq(trailConditionReports.userId, auth.userId)];
if (updatedAt) {
  conditions.push(gte(trailConditionReports.updatedAt, new Date(updatedAt)));
}

3. /mine route registered after /{reportId} — routing hazard

File: packages/api/src/routes/trailConditions/reports.ts

The /mine GET route (line 263) is registered after the PUT /{reportId} (line 180) and DELETE /{reportId} (line 312). While there's no conflict today (different HTTP methods), Hono resolves routes in registration order. If a GET /{reportId} is ever added, /mine will silently match as a reportId and break. Best practice is to register static paths before parametric ones.

Suggested order:

  1. GET / — list
  2. POST / — create
  3. GET /mine — list mine ← move up
  4. PUT /{reportId} — update
  5. DELETE /{reportId} — delete

🟠 High Priority

4. Missing database indexes

File: packages/api/drizzle/0033_trail_conditions.sql

The migration creates no indexes. The three most queried patterns all lack index support:

Query pattern Missing index
WHERE user_id = ? (sync endpoint) user_id
WHERE deleted = false ORDER BY created_at (list endpoint) (deleted, created_at)
WHERE trail_name = ? (trail filter) trail_name
-- Add to migration
CREATE INDEX "trail_condition_reports_user_id_idx" ON "trail_condition_reports" ("user_id");
CREATE INDEX "trail_condition_reports_active_created_idx" ON "trail_condition_reports" ("deleted", "created_at" DESC);
CREATE INDEX "trail_condition_reports_trail_name_idx" ON "trail_condition_reports" ("trail_name");

And in schema.ts:

export const trailConditionReports = pgTable(
  'trail_condition_reports',
  { /* existing columns */ },
  (table) => ({
    userIdIdx: index('trail_condition_reports_user_id_idx').on(table.userId),
    activeCreatedIdx: index('trail_condition_reports_active_created_idx').on(table.deleted, table.createdAt),
    trailNameIdx: index('trail_condition_reports_trail_name_idx').on(table.trailName),
  }),
);

5. Hard-coded strings bypass i18n in trail-conditions.tsx

File: apps/expo/app/(app)/trail-conditions.tsx

Despite the i18n namespace being defined in en.json, the screen has numerous hard-coded English strings:

title="Trail Conditions"                              // should be t('trailConditions.title')
"+ Report"                                            // no i18n key
"Report Trail Conditions"                             // t('trailConditions.reportConditionsTitle')
"Cancel"                                              // t('common.cancel')
"Current trail conditions from recent hiker reports"  // no i18n key
"No trail condition reports yet. Be the first to report!" // t('trailConditions.noReports') exists
"Submit a Report"                                     // t('trailConditions.submitReport') exists

formatRelativeTime in TrailConditionReportCard.tsx is also fully hard-coded English ("Today", "1 day ago", etc.) with no i18n support.


6. isSubmitting state is misleading — errors from store sync are silent

File: apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx

submitReport() from useSubmitTrailConditionReport is synchronous — it writes to the Legend-State observable and returns immediately. The try/catch in handleSubmit will never catch a network error (those happen asynchronously in the sync layer). As a result:

  • The success Alert always shows, even if the report will fail to sync.
  • isSubmitting flips to true and back to false within the same synchronous tick.

The submit UX should reflect the offline-first nature of the store — the report is saved locally and will sync in the background:

const handleSubmit = () => {  // no async needed
  if (!trailName.trim()) {
    Alert.alert(t('common.error'), t('trailConditions.trailNameRequired'));
    return;
  }
  submitReport({ ... });
  // Report is queued for sync — just dismiss
  onSuccess?.();
};

🟡 Medium Priority

7. Double DB round-trip in updateReport

File: packages/api/src/routes/trailConditions/reports.ts

The handler calls UPDATE then a separate findFirst to get the updated row. Use .returning() instead:

// Replace:
await db.update(trailConditionReports).set(updateData).where(...);
const updated = await db.query.trailConditionReports.findFirst(...);
return c.json(updated!, 200);

// With:
const [updated] = await db
  .update(trailConditionReports)
  .set(updateData)
  .where(and(eq(trailConditionReports.id, reportId), eq(trailConditionReports.userId, auth.userId)))
  .returning();
if (!updated) return c.json({ error: 'Report not found or unauthorized' }, 403);
return c.json(updated, 200);

This also removes the non-null assertion (updated!).


8. updateData typed as Record<string, unknown> bypasses type safety

File: packages/api/src/routes/trailConditions/reports.ts

const updateData: Record<string, unknown> = { updatedAt: new Date() };

This should use the Drizzle inferred type:

import type { NewTrailConditionReport } from '@packrat/api/db/schema';

const updateData: Partial<NewTrailConditionReport> & { updatedAt: Date } = {
  updatedAt: new Date(),
};

9. Redundant id check in createReport

File: packages/api/src/routes/trailConditions/reports.ts

const data = c.req.valid('json');  // Zod already requires id
if (!data.id) return c.json({ error: 'Report ID is required' }, 400); // unreachable

Since CreateReportRequestSchema has id: z.string() (non-optional), this check is unreachable and can be removed.


10. No upper bound on water crossings stepper

File: apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx

The increment button has no cap:

onPress={() => setWaterCrossings(waterCrossings + 1)}

A reasonable upper bound (e.g., 20) would prevent nonsensical data:

onPress={() => setWaterCrossings(Math.min(20, waterCrossings + 1))}

11. No enum constraints at the DB level

File: packages/api/src/db/schema.ts

surface and overallCondition are plain text columns — invalid values can be written directly to the DB (bypassing the API). Consider using Postgres enums or a check constraint:

surface: text('surface')
  .$type<TrailSurface>()
  .notNull(),
// + add a check constraint in the migration

12. Community reports not cached offline

File: apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts

useTrailConditionReports uses React Query with a 5-minute staleTime but has no persistence layer. Community trail condition data is unavailable when offline. The PR description mentions "Cached for offline access" as a requirement — consider persisting the React Query cache or reading from the local store as a fallback.


🔵 Minor / Nits

  • @ts-expect-error in useSubmitTrailConditionReport.ts: Consider using trailConditionReportsStore.set({ [id]: newReport }) or the appropriate Legend-State typed API to avoid suppressing TypeScript errors.
  • TrailConditionReportInStore omits userId but the type comment doesn't explain why. A brief JSDoc would help future maintainers.
  • Photos field: photos always submits [] — the schema and API support it but the UI never allows photo capture. This should at minimum be tracked as a follow-up issue.
  • Accessibility: Pressable elements in SubmitConditionReportForm and TrailConditionsScreen lack accessibilityLabel and accessibilityRole props.
  • Migration timestamp: The journal entry for 0033_trail_conditions has "when": 1741514518951 (March 2025), which is earlier than 0032 (1760175950793 — October 2025). This may cause ordering issues in migration tooling.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Caution

Review failed

The head commit changed during the review from 4eecda4 to 7d0fa22.

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive trail condition reporting system to the PackRat application. It adds frontend components and hooks for submitting and viewing trail condition reports, a backend API with full CRUD operations, a reactive client-side store with SQLite persistence and sync, database schema and migrations, and localization support. The feature is gated behind a flag and integrates into existing screens.

Changes

Cohort / File(s) Summary
Types & Schema
apps/expo/features/trail-conditions/types.ts, packages/api/src/db/schema.ts, packages/api/drizzle/0033_trail_conditions.sql, packages/api/drizzle/meta/_journal.json
Introduces union types for trail surface, condition, water crossing difficulty; defines TrailConditionReport interface; adds ORM table definition with foreign keys, constraints, and indices; migration adds trail_condition_reports table with proper schema.
UI Components
apps/expo/features/trail-conditions/components/ConditionBadge.tsx, apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx, apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx, apps/expo/features/trail-conditions/components/index.ts
New pill-shaped condition badge component; comprehensive form component for submitting trail reports with field validation, hazard selection, and water crossing controls; report card component displaying trail reports with relative timestamps and conditional field rendering; barrel export.
Data Fetching Hooks
apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts, apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts, apps/expo/features/trail-conditions/hooks/index.ts
Hook for fetching trail condition reports from API with optional trail name filter, 5-minute stale time, and authentication gating; hook for submitting reports to in-memory store with nanoid-generated IDs and ISO timestamps; barrel export.
State Management
apps/expo/features/trail-conditions/store/trailConditionReports.ts, apps/expo/features/trail-conditions/index.ts
Observable-based in-memory store with SQLite persistence, synced CRUD operations, infinite exponential backoff retry logic up to 30s, and authentication gating; feature module barrel export consolidating components, hooks, and types.
Screens & Integration
apps/expo/app/(app)/trail-conditions.tsx, apps/expo/features/trips/screens/TripDetailScreen.tsx, apps/expo/config.ts
Trail conditions screen refactored to fetch data dynamically via hook, show loading/error states, render report cards, and open modal for submitting new reports; TripDetailScreen adds feature-flagged prompt to submit condition reports; enableTrailConditions flag added to config.
API Routes & Backend
packages/api/src/routes/trailConditions/reports.ts, packages/api/src/routes/trailConditions/index.ts, packages/api/src/routes/index.ts
Full CRUD endpoints (GET all reports with trail name filter, POST create, GET mine, PUT update by ID with ownership check, DELETE soft-delete by ID); OpenAPI documented; integrated into main route hierarchy via protectedRoutes.
Localization
apps/expo/lib/i18n/locales/en.json
New trailConditions translation block with 22+ keys for UI labels, form fields, prompts, error messages, and call-to-action text.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Client as Expo Client
    participant Store as Observable Store
    participant API as Backend API
    participant DB as Database

    rect rgba(100, 150, 200, 0.5)
    Note over User,DB: Submitting a Trail Condition Report
    User->>Client: Opens form and submits report
    Client->>Store: Calls submitReport() via hook
    Store->>Store: Generate ID with nanoid
    Store->>Store: Create TrailConditionReportInStore
    Store->>Store: Update observable store
    Client->>Client: Show success alert
    Note over Store: Sync triggered in background
    Store->>API: POST /api/trail-conditions
    API->>DB: Insert trail_condition_reports row
    DB-->>API: Confirm insertion
    API-->>Store: Return created report
    Store->>Store: Update local store with server response
    end

    rect rgba(150, 100, 150, 0.5)
    Note over User,DB: Fetching Trail Condition Reports
    User->>Client: Opens trail conditions screen
    Client->>Client: useTrailConditionReports hook triggers
    Client->>API: GET /api/trail-conditions?trailName=...
    API->>DB: Query trail_condition_reports (active reports)
    DB-->>API: Return matching reports
    API-->>Client: Return TrailConditionReport array
    Client->>Client: Update React query state
    Client->>Client: Render TrailConditionReportCard components
    Client-->>User: Display reports with badges and details
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Hops through trails with joy and care,
Reporting conditions far and fair,
Offline syncs and badges bright,
Real-time hikers' pure delight!
From form to store to API's might,
Trail wisdom shared, forever right! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Trail Condition Reporting System (MVP)' accurately summarizes the main feature addition: an MVP for trail condition reporting that encompasses forms, data display, backend routes, database schema, and i18n support.
Linked Issues check ✅ Passed The PR substantially implements the linked issue #1812 MVP objectives: post-hike reporting prompt, condition categories (surface, hazards, water crossings), offline-first store with sync, viewing recent conditions, and comprehensive backend/frontend infrastructure. Minor gap: offline caching for community reports via useTrailConditionReports uses React Query without persistence strategy.
Out of Scope Changes check ✅ Passed All changes directly support trail condition reporting: new schema, API routes, store with offline sync, UI components/forms, feature flag, i18n keys, and integration into TripDetailScreen. No unrelated refactoring, dependency upgrades, or out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch copilot/add-trail-condition-reporting

Comment @coderabbitai help to get the list of available commands and usage tips.

@andrew-bierman
Copy link
Collaborator

@copilot CodeRabbit has provided review feedback on this PR. Please address the comments and suggestions. Once you've made the necessary changes, I'll ask CodeRabbit to re-review.

Copilot AI and others added 2 commits March 9, 2026 12:41
- Move /mine route before /{reportId} to prevent future routing hazard
- Fix /mine to filter soft-deleted reports and apply updatedAt incremental sync param
- Remove unreachable !data.id guard in createReport (Zod enforces id: string)
- Replace double DB round-trip in updateReport with single .returning() call
- Type updateData as Partial<NewTrailConditionReport> instead of Record<string, unknown>
- Replace two-query delete with atomic .returning() check in deleteReport
- Add DB indexes (userId, (deleted, createdAt), trailName) to schema and migration
- Fix migration journal timestamp for 0033_trail_conditions (was Mar 2025, now Mar 2026)
- Replace all hard-coded strings in trail-conditions.tsx with t() calls
- Add missing i18n keys: reportButton, subtitle, loadError, disclaimer
- Add accessibilityLabel/Role to interactive Pressable elements in TrailConditionsScreen
- Make SubmitConditionReportForm.handleSubmit synchronous (offline-first, store is sync)
- Remove misleading isSubmitting state and async try/catch from submit form
- Cap water crossings stepper at 20 to prevent nonsensical values

Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 9, 2026

@copilot CodeRabbit has provided review feedback on this PR. Please address the comments and suggestions. Once you've made the necessary changes, I'll ask CodeRabbit to re-review.

Addressed all actionable CodeRabbit feedback in commits 5365910 and ad82490:

Critical / High

  • /mine route now registered before /{reportId} (static before parametric)
  • /mine handler now filters deleted = false and applies the updatedAt incremental-sync parameter
  • Removed unreachable !data.id guard (Zod enforces id: string)
  • updateReport replaced double DB round-trip with a single .returning() call; updateData typed as Partial<NewTrailConditionReport> instead of Record<string, unknown>
  • deleteReport replaced two-query pattern with atomic .returning() ownership check

Database

  • Added indexes on userId, (deleted, createdAt DESC), and trailName — both in schema.ts and 0033_trail_conditions.sql
  • Fixed migration journal when timestamp (was March 2025, now after 0032)

Mobile

  • All hard-coded strings in trail-conditions.tsx replaced with t() calls; four missing i18n keys added (reportButton, subtitle, loadError, disclaimer)
  • Pressable elements now have accessibilityLabel and accessibilityRole
  • SubmitConditionReportForm.handleSubmit made synchronous — the store write is synchronous (offline-first), so the async try/catch and isSubmitting state were misleading
  • Water crossings stepper capped at 20 via Math.min(20, ...)

@andrew-bierman
Copy link
Collaborator

@copilot CodeRabbit has reviewed this PR and provided feedback. Please address the review comments and push updates. Once you've made the changes, the monitor will ask CodeRabbit to re-review.

@andrew-bierman
Copy link
Collaborator

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (4)
packages/api/src/db/schema.ts (1)

287-288: Consider adding DB-level constraints for surface and overallCondition.

These columns accept free-form text but have a defined set of valid values (paved | gravel | dirt | rocky | snow | mud and excellent | good | fair | poor). While Zod validates at the API layer, DB-level constraints would provide defense-in-depth against invalid data from direct DB access or future code paths.

♻️ Optional: Add pgEnum or CHECK constraints
+const trailSurfaceEnum = pgEnum('trail_surface', ['paved', 'gravel', 'dirt', 'rocky', 'snow', 'mud']);
+const overallConditionEnum = pgEnum('overall_condition', ['excellent', 'good', 'fair', 'poor']);
+
 export const trailConditionReports = pgTable(
   'trail_condition_reports',
   {
     id: text('id').primaryKey(),
     trailName: text('trail_name').notNull(),
     trailRegion: text('trail_region'),
-    surface: text('surface').notNull(), // paved | gravel | dirt | rocky | snow | mud
-    overallCondition: text('overall_condition').notNull(), // excellent | good | fair | poor
+    surface: trailSurfaceEnum('surface').notNull(),
+    overallCondition: overallConditionEnum('overall_condition').notNull(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/db/schema.ts` around lines 287 - 288, Add DB-level
constraints for the surface and overallCondition columns by replacing the
free-form text columns with enum/check-backed columns: define a pgEnum (or SQL
CHECK) for the surface values ("paved","gravel","dirt","rocky","snow","mud") and
another for overallCondition ("excellent","good","fair","poor"), then use those
enums in the table definition where surface and overallCondition are declared
(referencing the surface and overallCondition column symbols) so the schema
enforces valid values at the database level in addition to Zod validation.
apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx (1)

16-27: Separate stored values from localized labels.

The form is still using English display text as domain values for hazards, and the other chips render capitalized raw enum strings. That makes localization brittle and will leak mixed-language values into stored reports once non-English labels are added. Use stable ids in state/API payloads and map them to t(...) labels in the UI.

Also applies to: 118-188, 219-237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx`
around lines 16 - 27, The form currently uses localized display text as stored
domain values (see SURFACE_OPTIONS, CONDITION_OPTIONS, HAZARD_OPTIONS,
WATER_DIFFICULTY_OPTIONS in SubmitConditionReportForm), which will break
localization and leak mixed-language values; replace these arrays with stable
id/value pairs or enum keys (e.g., { id: 'paved', labelKey:
'trail.surface.paved' }) and update all UI chips/controls to display t(labelKey)
while storing/sending only the id; update the code paths that read/write state
and build the API payload (the handlers and submission logic referenced around
the chips and selection code) to use the id values instead of raw strings so
labels are purely presentational.
apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts (1)

19-20: Replace the @ts-expect-error store write with a typed update path.

This suppresses the type break on the main insert path, so any change to the store shape or Legend-State API will bypass compile-time protection here. Please switch to a typed root update instead of indexing through an ignored error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts`
around lines 19 - 20, Replace the unchecked index write that uses "//
`@ts-expect-error`" with a typed root-update on the store: instead of directly
doing "trailConditionReportsStore[id].set(newReport)", call the store's typed
update/root mutation API (e.g., trailConditionReportsStore.updateRoot(root => {
root[id] = newReport }) or the equivalent update(callback) method provided by
Legend-State) and remove the ts-expect-error; this ensures the assignment to
"id" is type-checked against the store's root shape and still sets the newReport
value.
apps/expo/features/trail-conditions/types.ts (1)

5-30: Keep the API report type strict and derive the looser store type separately.

packages/api/src/db/schema.ts exposes this entity as the DB select model, but this interface makes server-owned fields like userId, createdAt, and updatedAt optional. That weakens API consumers and is why downstream code has to guess between server and local timestamps. Consider a strict API DTO plus a separate looser TrailConditionReportInStore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/features/trail-conditions/types.ts` around lines 5 - 30, Change the
exported types so the API DTO is strict and server-owned fields are required:
create/rename the strict server-facing interface (e.g., TrailConditionReport) so
userId, createdAt, updatedAt (and other server-owned fields) are
non-optional/non-nullable, and keep localCreatedAt/localUpdatedAt and any
client-only fields optional; then derive the looser store type
(TrailConditionReportInStore) from that strict DTO (using Omit/Partial as
needed) so downstream code can use the looser shape without weakening the API
contract. Update any references to
TrailConditionReport/TrailConditionReportInStore accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/expo/app/`(app)/trail-conditions.tsx:
- Line 12: The screen uses useTrailConditionReports() (const { data: reports,
isLoading, error } = useTrailConditionReports()) which currently relies on a
non-persistent React Query cache, so reports vanish after restart or offline
launches; update the hook and/or app bootstrap to persist the React Query cache
(e.g., persistQueryClient using AsyncStorage/Expo SecureStore or the app's
storage layer) and ensure the hook reads from the persisted cache as a fallback
when network is unavailable so reports is populated from persisted data while
offline and after app restarts.

In
`@apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx`:
- Around line 119-135: In SubmitConditionReportForm, the Pressable chips (the
one calling setOverallCondition) and the stepper +/- Pressables lack
accessibility metadata; update those Pressable elements (the overall condition
option Pressables and the stepper increment/decrement Pressables) to include
accessibilityRole="button", a computed accessibilityState={{ selected:
overallCondition === cond }} (or { disabled: true } when appropriate), and
explicit accessibilityLabel/accessibilityHint strings that describe the action
and current value (e.g., "Overall condition: Good, double tap to select" for
chips and "Increase hikers count, current 2" / "Decrease hikers count, current
2" for steppers). Ensure labels reflect dynamic state and that the handlers
(setOverallCondition and the stepper increment/decrement functions) are
unchanged except for using these props.

In `@apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx`:
- Around line 11-23: formatRelativeTime currently returns hard-coded English
strings and shows negative values for future dates; update the function to clamp
negative deltas to 0 (treat future timestamps as "Today") and replace hard-coded
text with a localized formatter or translation calls. Specifically, in
formatRelativeTime(dateStr?: string) ensure diffMs/diffDays are floored at 0,
then produce output via a localization mechanism (e.g. Intl.RelativeTimeFormat
or your i18n.t for keys like "today", "one_day_ago", "n_days_ago",
"one_week_ago", "n_weeks_ago") instead of literal strings so the UI is localized
and future dates render as Today.
- Around line 49-75: TrailConditionReportCard currently renders raw domain
values (report.surface, report.waterCrossingDifficulty, report.hazards) which
bypass localization; replace these direct values with translated labels by
mapping domain values to translation keys and calling t(...) before rendering
(e.g. create helper functions or mapping objects like getSurfaceLabel(surface),
getWaterCrossingDifficultyLabel(difficulty), and map hazards array via
getHazardLabel(h) then join), use the helpers inside the JSX where
report.surface, report.waterCrossingDifficulty and report.hazards are used, and
provide a sensible fallback (e.g. the raw value) if a translation key is
missing.

In `@apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts`:
- Around line 20-29: The hook useTrailConditionReports is only using in-memory
react-query cache and never hydrates from the local trail-condition store;
update useTrailConditionReports to read local cached reports first (e.g., call
your local store/read method) and pass them into useQuery as initialData (or
populate the cache via queryClient.setQueryData) so the UI is offline-readable
after restart, and also ensure onSuccess or a sync-complete callback merges
locally queued reports with server results (using fetchTrailConditionReports) so
queued reports show immediately even before sync completes.

In `@apps/expo/features/trail-conditions/store/trailConditionReports.ts`:
- Around line 31-33: The updateReport function currently accepts
Partial<TrailConditionReportInStore> which makes id optional and can produce a
PUT to /api/trail-conditions/undefined; change the parameter type so id is
required and only the other fields are optional (e.g. require an id property and
use Partial/Omit or an intersection like { id: <idType> } &
Partial<Omit<TrailConditionReportInStore, 'id'>>), update the function signature
for updateReport accordingly, and keep the existing body using the supplied id
to build the request path.
- Around line 11-18: The listMyReports callback currently ignores the syncedCrud
lastSync cursor, so incremental sync never runs; update the listMyReports
function to accept (_params, { lastSync }) and forward lastSync as an
updatedAtGte query param to the '/api/trail-conditions/mine' GET request (use
lastSync + 1 when lastSync is not null/undefined, otherwise omit the param),
preserving the existing error handling with handleApiError and the thrown Error
message.

In `@apps/expo/features/trips/screens/TripDetailScreen.tsx`:
- Around line 194-219: The trail condition prompt is currently shown for all
trips when featureFlags.enableTrailConditions is true; change the render guard
in TripDetailScreen to also require the trip be completed (e.g., check
trip.status === 'completed' or trip.isCompleted / trip.completedAt timestamp)
before rendering the Card and Button (which calls setShowConditionReport);
update the conditional around the JSX that uses
featureFlags.enableTrailConditions so it combines the feature flag and a
completed-trip check (or compute a helper like isTripCompleted(trip) and use
that) to ensure only post-hike trips see the prompt.

In `@packages/api/drizzle/0033_trail_conditions.sql`:
- Around line 7-11: The migration leaves columns nullable but the app requires
them non-null; update the trail table migration so "hazards" jsonb DEFAULT '[]'
NOT NULL, "photos" jsonb DEFAULT '[]' NOT NULL, and "water_crossings" integer
DEFAULT 0 NOT NULL (leave "water_crossing_difficulty" and "notes" nullable if
still optional). Ensure the ALTER/CREATE statement sets NOT NULL for those three
columns so inserted rows can never have NULL and match the app's required model
for hazards, photos, and waterCrossings.

In `@packages/api/src/routes/trailConditions/reports.ts`:
- Around line 38-52: The CreateReportRequestSchema currently allows any
non-negative integer for waterCrossings; update the waterCrossings field on
CreateReportRequestSchema (the z.number().int().min(0).optional().default(0)
entry) to also include .max(20) so the schema enforces the 20-crossing cap (the
partial/update schemas that derive from this will then inherit the restriction).
- Around line 144-163: The endpoint is failing permanently on retried offline
writes when a client-generated id already exists; wrap the insert of
trailConditionReports (the db.insert(...).values(...).returning() call) in a
try/catch that catches a unique constraint / duplicate key DB error (e.g.,
Postgres 23505) for the id constraint, and when caught query
trailConditionReports for the existing row by id and auth.userId (to ensure the
same user) and treat that as a successful replay by returning that existing
record instead of bubbling the error; if the duplicate id exists but belongs to
a different user, rethrow or return an appropriate error, and for any other DB
error rethrow as before. Ensure you reference the same symbols:
trailConditionReports, auth.userId, and the insertion flow in reports.ts.

---

Nitpick comments:
In
`@apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx`:
- Around line 16-27: The form currently uses localized display text as stored
domain values (see SURFACE_OPTIONS, CONDITION_OPTIONS, HAZARD_OPTIONS,
WATER_DIFFICULTY_OPTIONS in SubmitConditionReportForm), which will break
localization and leak mixed-language values; replace these arrays with stable
id/value pairs or enum keys (e.g., { id: 'paved', labelKey:
'trail.surface.paved' }) and update all UI chips/controls to display t(labelKey)
while storing/sending only the id; update the code paths that read/write state
and build the API payload (the handlers and submission logic referenced around
the chips and selection code) to use the id values instead of raw strings so
labels are purely presentational.

In `@apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts`:
- Around line 19-20: Replace the unchecked index write that uses "//
`@ts-expect-error`" with a typed root-update on the store: instead of directly
doing "trailConditionReportsStore[id].set(newReport)", call the store's typed
update/root mutation API (e.g., trailConditionReportsStore.updateRoot(root => {
root[id] = newReport }) or the equivalent update(callback) method provided by
Legend-State) and remove the ts-expect-error; this ensures the assignment to
"id" is type-checked against the store's root shape and still sets the newReport
value.

In `@apps/expo/features/trail-conditions/types.ts`:
- Around line 5-30: Change the exported types so the API DTO is strict and
server-owned fields are required: create/rename the strict server-facing
interface (e.g., TrailConditionReport) so userId, createdAt, updatedAt (and
other server-owned fields) are non-optional/non-nullable, and keep
localCreatedAt/localUpdatedAt and any client-only fields optional; then derive
the looser store type (TrailConditionReportInStore) from that strict DTO (using
Omit/Partial as needed) so downstream code can use the looser shape without
weakening the API contract. Update any references to
TrailConditionReport/TrailConditionReportInStore accordingly.

In `@packages/api/src/db/schema.ts`:
- Around line 287-288: Add DB-level constraints for the surface and
overallCondition columns by replacing the free-form text columns with
enum/check-backed columns: define a pgEnum (or SQL CHECK) for the surface values
("paved","gravel","dirt","rocky","snow","mud") and another for overallCondition
("excellent","good","fair","poor"), then use those enums in the table definition
where surface and overallCondition are declared (referencing the surface and
overallCondition column symbols) so the schema enforces valid values at the
database level in addition to Zod validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b56afd1-6340-434a-ac47-5c827cf4f161

📥 Commits

Reviewing files that changed from the base of the PR and between 3f94763 and ad82490.

📒 Files selected for processing (20)
  • apps/expo/app/(app)/trail-conditions.tsx
  • apps/expo/config.ts
  • apps/expo/features/trail-conditions/components/ConditionBadge.tsx
  • apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx
  • apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx
  • apps/expo/features/trail-conditions/components/index.ts
  • apps/expo/features/trail-conditions/hooks/index.ts
  • apps/expo/features/trail-conditions/hooks/useSubmitTrailConditionReport.ts
  • apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts
  • apps/expo/features/trail-conditions/index.ts
  • apps/expo/features/trail-conditions/store/trailConditionReports.ts
  • apps/expo/features/trail-conditions/types.ts
  • apps/expo/features/trips/screens/TripDetailScreen.tsx
  • apps/expo/lib/i18n/locales/en.json
  • packages/api/drizzle/0033_trail_conditions.sql
  • packages/api/drizzle/meta/_journal.json
  • packages/api/src/db/schema.ts
  • packages/api/src/routes/index.ts
  • packages/api/src/routes/trailConditions/index.ts
  • packages/api/src/routes/trailConditions/reports.ts

};
export default function TrailConditionsScreen() {
const [showSubmitForm, setShowSubmitForm] = useState(false);
const { data: reports, isLoading, error } = useTrailConditionReports();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This screen still isn't backed by offline-cached reports.

It renders entirely from useTrailConditionReports(), and the provided hook snippet uses a plain React Query cache with no persistence. After an app restart or offline launch, recent reports disappear, which misses the MVP requirement to cache recent conditions for offline viewing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/app/`(app)/trail-conditions.tsx at line 12, The screen uses
useTrailConditionReports() (const { data: reports, isLoading, error } =
useTrailConditionReports()) which currently relies on a non-persistent React
Query cache, so reports vanish after restart or offline launches; update the
hook and/or app bootstrap to persist the React Query cache (e.g.,
persistQueryClient using AsyncStorage/Expo SecureStore or the app's storage
layer) and ensure the hook reads from the persisted cache as a fallback when
network is unavailable so reports is populated from persisted data while offline
and after app restarts.

Comment on lines +20 to +29
export function useTrailConditionReports(trailName?: string) {
const { isQueryEnabledWithAccessToken } = useAuthenticatedQueryToolkit();

return useQuery({
queryKey: ['trailConditionReports', trailName],
enabled: isQueryEnabledWithAccessToken,
queryFn: () => fetchTrailConditionReports(trailName),
staleTime: 1000 * 60 * 5, // 5 min
refetchOnWindowFocus: false,
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This hook still isn't offline-readable.

useQuery is only caching in memory here and never hydrates from the local trail-condition store, so reports disappear after an app restart offline and locally queued reports can't show up in the list until sync succeeds. That misses the MVP offline-viewing requirement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/features/trail-conditions/hooks/useTrailConditionReports.ts` around
lines 20 - 29, The hook useTrailConditionReports is only using in-memory
react-query cache and never hydrates from the local trail-condition store;
update useTrailConditionReports to read local cached reports first (e.g., call
your local store/read method) and pass them into useQuery as initialData (or
populate the cache via queryClient.setQueryData) so the UI is offline-readable
after restart, and also ensure onSuccess or a sync-complete callback merges
locally queued reports with server results (using fetchTrailConditionReports) so
queued reports show immediately even before sync completes.

@andrew-bierman
Copy link
Collaborator

@copilot Please address the CodeRabbit review feedback on this PR (11 inline comments).

- Thread 1 & 5: useTrailConditionReports now seeds initialData from local
  Legend-State store so reports are visible offline and after restart
- Thread 2: Added accessibilityRole, accessibilityState, accessibilityLabel
  to all chip and stepper Pressables in SubmitConditionReportForm
- Thread 3: formatRelativeTime now clamps negative deltas to 0 and uses
  t() calls with i18n keys (today, oneDayAgo, nDaysAgo, oneWeekAgo, nWeeksAgo)
- Thread 4: TrailConditionReportCard maps surface/difficulty/hazard domain
  values to translation keys before rendering
- Thread 6: listMyReports in store forwards lastSync cursor as updatedAt
  ISO datetime to enable true incremental sync
- Thread 7: updateReport parameter type now requires id (string) and makes
  only the other fields optional
- Thread 8: Trail condition prompt in TripDetailScreen now gated to trips
  with endDate in the past (completed hikes)
- Thread 9: hazards, photos, waterCrossings marked NOT NULL in SQL migration
  and .notNull() in schema.ts
- Thread 10: waterCrossings Zod schema adds .max(20) validation
- Thread 11: POST / createReport handles Postgres 23505 duplicate-key
  constraint as idempotent replay for the same user

Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
@andrew-bierman
Copy link
Collaborator

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

- Improve 409 error message: 'Report ID already in use by another user'
- Normalize hazard keys to lowercase for case-insensitive API value matching
- Document intentional offline seed behavior in useTrailConditionReports
- CodeQL: no new alerts

Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com>
@andrew-bierman
Copy link
Collaborator

@copilot There are 11 CodeRabbit inline comments on this PR. Please address the review feedback.

@andrew-bierman
Copy link
Collaborator

@copilot There are still 11 unresolved CodeRabbit inline comments. Please address the remaining review feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Trail Condition Reporting System

2 participants