Skip to content
529 changes: 413 additions & 116 deletions apps/expo/app/(app)/trail-conditions.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/expo/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export const featureFlags = {
enableShoppingList: false,
enableSharedPacks: false,
enablePackTemplates: true,
enableTrailConditions: true,
};
3 changes: 3 additions & 0 deletions apps/expo/features/trail-conditions/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './useCreateTrailConditionReport';
export * from './useTrailConditions';
export * from './useVerifyTrailConditionReport';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axiosInstance, { handleApiError } from 'expo-app/lib/api/client';
import type { TrailCondition, TrailConditionInput } from '../types';

const createTrailConditionReport = async (input: TrailConditionInput): Promise<TrailCondition> => {
try {
const res = await axiosInstance.post('/api/trail-conditions', input);
return res.data;
} catch (error) {
const { message } = handleApiError(error);
console.error('Failed to create trail condition report:', error);
throw new Error(message);
}
};

export function useCreateTrailConditionReport() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: createTrailConditionReport,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trailConditions'] });
},
});
}
27 changes: 27 additions & 0 deletions apps/expo/features/trail-conditions/hooks/useTrailConditions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import axiosInstance, { handleApiError } from 'expo-app/lib/api/client';
import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit';
import type { TrailCondition } from '../types';

export const fetchTrailConditions = async (): Promise<TrailCondition[]> => {
try {
const res = await axiosInstance.get('/api/trail-conditions');
return res.data?.items ?? res.data ?? [];
} catch (error) {
const { message } = handleApiError(error);
console.error('Failed to fetch trail conditions:', error);
throw new Error(message);
}
};

export function useTrailConditions() {
const { isQueryEnabledWithAccessToken } = useAuthenticatedQueryToolkit();

return useQuery({
queryKey: ['trailConditions'],
enabled: isQueryEnabledWithAccessToken,
queryFn: fetchTrailConditions,
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axiosInstance, { handleApiError } from 'expo-app/lib/api/client';
import type { TrailCondition } from '../types';

const verifyTrailConditionReport = async (reportId: string): Promise<TrailCondition> => {
try {
const res = await axiosInstance.post(`/api/trail-conditions/${reportId}/verify`);
return res.data;
} catch (error) {
const { message } = handleApiError(error);
console.error('Failed to verify trail condition report:', error);
throw new Error(message);
}
};

export function useVerifyTrailConditionReport() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: verifyTrailConditionReport,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trailConditions'] });
},
});
}
2 changes: 2 additions & 0 deletions apps/expo/features/trail-conditions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './hooks';
export * from './types';
29 changes: 29 additions & 0 deletions apps/expo/features/trail-conditions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type TrailConditionValue = 'excellent' | 'good' | 'fair' | 'poor' | 'closed';

export interface TrailConditionLocation {
latitude: number;
longitude: number;
name?: string;
}

export interface TrailCondition {
id: string;
userId: number;
trailName: string;
location?: TrailConditionLocation | null;
condition: TrailConditionValue;
details: string;
photos?: string[] | null;
trustScore: number;
verifiedCount: number;
helpfulCount: number;
createdAt: string;
updatedAt: string;
}

export type TrailConditionInput = Omit<
TrailCondition,
'id' | 'userId' | 'trustScore' | 'verifiedCount' | 'helpfulCount' | 'createdAt' | 'updatedAt'
> & {
id: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function TrailConditionsTile() {
router.push('/trail-conditions');
};

if (!featureFlags.enableTrips) return null;
if (!featureFlags.enableTrips || !featureFlags.enableTrailConditions) return null;

return (
<>
Expand Down
4 changes: 3 additions & 1 deletion apps/expo/lib/utils/getRelativeTime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export function getRelativeTime(dateString: string): string {
const diff = (Date.now() - new Date(dateString).getTime()) / 1000;
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return 'Unknown';
const diff = (Date.now() - date.getTime()) / 1000;
const units = [
{ label: 'month', seconds: 2592000 },
{ label: 'week', seconds: 604800 },
Expand Down
19 changes: 19 additions & 0 deletions packages/api/drizzle/0034_trail_conditions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
CREATE TYPE "trail_condition" AS ENUM ('excellent', 'good', 'fair', 'poor', 'closed');--> statement-breakpoint
CREATE TABLE "trail_conditions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"trail_name" text NOT NULL,
"location" jsonb,
"condition" "trail_condition" NOT NULL,
"details" text NOT NULL,
"photos" jsonb DEFAULT '[]',
"trust_score" real DEFAULT 0.5 NOT NULL,
"verified_count" integer DEFAULT 0 NOT NULL,
"helpful_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "trail_conditions" ADD CONSTRAINT "trail_conditions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "trail_conditions_user_id_idx" ON "trail_conditions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "trail_conditions_created_at_idx" ON "trail_conditions" USING btree ("created_at");
7 changes: 7 additions & 0 deletions packages/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@
"when": 1773066716880,
"tag": "0033_stormy_next_avengers",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1773076068676,
"tag": "0034_trail_conditions",
"breakpoints": true
}
]
}
44 changes: 44 additions & 0 deletions packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,47 @@ export const catalogItemEtlJobsRelations = relations(catalogItemEtlJobs, ({ one
}),
}));

// Trail condition enum
const trailConditionEnum = pgEnum('trail_condition', [
'excellent',
'good',
'fair',
'poor',
'closed',
]);

// Trail conditions table
export const trailConditions = pgTable(
'trail_conditions',
{
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
trailName: text('trail_name').notNull(),
location: jsonb('location').$type<{ latitude: number; longitude: number; name?: string }>(),
condition: trailConditionEnum('condition').notNull(),
details: text('details').notNull(),
photos: jsonb('photos').$type<string[]>().default([]),
trustScore: real('trust_score').notNull().default(0.5),
verifiedCount: integer('verified_count').notNull().default(0),
helpfulCount: integer('helpful_count').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('trail_conditions_user_id_idx').on(table.userId),
createdAtIdx: index('trail_conditions_created_at_idx').on(table.createdAt),
}),
);
Comment on lines +472 to +504
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

No Drizzle migration file was generated for the new trail_conditions table and trail_condition enum. The schema changes in this file won't take effect in the database without a corresponding migration under packages/api/drizzle/. You need to run bunx drizzle-kit generate (or the equivalent command for this project) to produce the migration SQL file.

Copilot uses AI. Check for mistakes.

export const trailConditionsRelations = relations(trailConditions, ({ one }) => ({
user: one(users, {
fields: [trailConditions.userId],
references: [users.id],
}),
}));

// Infer models from tables
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;
Expand Down Expand Up @@ -509,3 +550,6 @@ export type NewTrip = InferInsertModel<typeof trips>;
export type PackTemplateWithItems = PackTemplate & {
items: PackTemplateItem[];
};

export type TrailCondition = InferSelectModel<typeof trailConditions>;
export type NewTrailCondition = InferInsertModel<typeof trailConditions>;
2 changes: 2 additions & 0 deletions packages/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { guidesRoutes } from './guides';
import { packsRoutes } from './packs';
import { packTemplatesRoutes } from './packTemplates';
import { seasonSuggestionsRoutes } from './seasonSuggestions';
import { trailConditionsRoutes } from './trailConditions';
import { tripsRoutes } from './trips';
import { uploadRoutes } from './upload';
import { userRoutes } from './user';
Expand Down Expand Up @@ -35,6 +36,7 @@ protectedRoutes.route('/pack-templates', packTemplatesRoutes);
protectedRoutes.route('/season-suggestions', seasonSuggestionsRoutes);
protectedRoutes.route('/user', userRoutes);
protectedRoutes.route('/upload', uploadRoutes);
protectedRoutes.route('/trail-conditions', trailConditionsRoutes);

const routes = new OpenAPIHono();

Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/routes/trailConditions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { OpenAPIHono } from '@hono/zod-openapi';
import type { Env } from '@packrat/api/types/env';
import type { Variables } from '@packrat/api/types/variables';
import { trailConditionListRoutes } from './list';
import { trailConditionRoutes } from './report';

const trailConditionsRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>();

trailConditionsRoutes.route('/', trailConditionListRoutes);
trailConditionsRoutes.route('/', trailConditionRoutes);

export { trailConditionsRoutes };
139 changes: 139 additions & 0 deletions packages/api/src/routes/trailConditions/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import { createDb } from '@packrat/api/db';
import { trailConditions } from '@packrat/api/db/schema';
import { ErrorResponseSchema } from '@packrat/api/schemas/catalog';
import {
CreateTrailConditionRequestSchema,
TrailConditionListResponseSchema,
TrailConditionSchema,
} from '@packrat/api/schemas/trailConditions';
import type { Env } from '@packrat/api/types/env';
import type { Variables } from '@packrat/api/types/variables';
import { count, desc, eq, sql } from 'drizzle-orm';

const trailConditionListRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>();

// ------------------------------
// List Trail Conditions Route
// ------------------------------
const listTrailConditionsRoute = createRoute({
method: 'get',
path: '/',
tags: ['Trail Conditions'],
summary: 'List trail conditions',
description: 'Get trail condition reports ordered by most recent',
security: [{ bearerAuth: [] }],
request: {
query: z.object({
limit: z.coerce.number().int().positive().max(100).default(100).optional(),
offset: z.coerce.number().int().min(0).default(0).optional(),
}),
},
responses: {
200: {
description: 'Trail conditions retrieved successfully',
content: { 'application/json': { schema: TrailConditionListResponseSchema } },
},
500: {
description: 'Internal server error',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
},
});

trailConditionListRoutes.openapi(listTrailConditionsRoute, async (c) => {
try {
const db = createDb(c);
const { limit = 100, offset = 0 } = c.req.valid('query');
const [items, countResult] = await Promise.all([
db
.select()
.from(trailConditions)
.orderBy(desc(trailConditions.createdAt))
.limit(limit)
.offset(offset),
db.select({ total: count() }).from(trailConditions),
]);
const total = countResult[0]?.total ?? 0;

return c.json({ items, total }, 200);
} catch (error) {
console.error('Error fetching trail conditions:', error);
return c.json({ error: 'Failed to fetch trail conditions' }, 500);
}
});

// ------------------------------
// Create Trail Condition Route
// ------------------------------
const createTrailConditionRoute = createRoute({
method: 'post',
path: '/',
tags: ['Trail Conditions'],
summary: 'Create a trail condition report',
description: 'Submit a new trail condition report with optional photos and GPS location',
security: [{ bearerAuth: [] }],
request: {
body: {
content: { 'application/json': { schema: CreateTrailConditionRequestSchema } },
required: true,
},
},
responses: {
200: {
description: 'Trail condition report created successfully',
content: { 'application/json': { schema: TrailConditionSchema } },
},
400: {
description: 'Bad request',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
500: {
description: 'Internal server error',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
},
});

trailConditionListRoutes.openapi(createTrailConditionRoute, async (c) => {
const auth = c.get('user');
const db = createDb(c);
const data = c.req.valid('json');

try {
// Compute initial trust score based on reporter history
const countRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(trailConditions)
.where(eq(trailConditions.userId, auth.userId));
const reportCount = countRows[0]?.count ?? 0;

// Trust score starts at 0.5 for new reporters, increasing with more reports
const baseScore = Math.min(0.5 + reportCount * 0.05, 0.9);

const [newReport] = await db
.insert(trailConditions)
.values({
id: data.id,
userId: auth.userId,
trailName: data.trailName,
location: data.location ?? null,
condition: data.condition,
details: data.details,
photos: data.photos ?? [],
trustScore: baseScore,
verifiedCount: 0,
helpfulCount: 0,
})
.returning();

if (!newReport) return c.json({ error: 'Failed to create trail condition report' }, 400);

return c.json(newReport, 200);
} catch (error) {
console.error('Error creating trail condition:', error);
return c.json({ error: 'Failed to create trail condition report' }, 500);
}
});

export { trailConditionListRoutes };
Loading
Loading