From a9c99844c6fd3ee4a76afe6f18c137d6a7e87780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 24 Mar 2026 21:29:42 +0100 Subject: [PATCH 1/7] Implement Journal activity feed (Group 10) Activity CRUD: - Activity list page (/activities) with date, distance, elevation - Create activity with GPX upload, description, optional route link - Activity detail with stats and date - "Link to Route" action (select from existing routes) - "Create Route from Activity" action (creates route from GPX) Server logic in activities.server.ts with async GPX parsing. Uses ClientDate component for SSR-safe date rendering. All 6 Group 10 tasks complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/activities.server.ts | 103 ++++++++++++ apps/journal/app/routes.ts | 3 + apps/journal/app/routes/activities.$id.tsx | 155 ++++++++++++++++++ apps/journal/app/routes/activities._index.tsx | 77 +++++++++ apps/journal/app/routes/activities.new.tsx | 132 +++++++++++++++ openspec/changes/phase-1-mvp/tasks.md | 12 +- 6 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 apps/journal/app/lib/activities.server.ts create mode 100644 apps/journal/app/routes/activities.$id.tsx create mode 100644 apps/journal/app/routes/activities._index.tsx create mode 100644 apps/journal/app/routes/activities.new.tsx diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts new file mode 100644 index 0000000..eea778d --- /dev/null +++ b/apps/journal/app/lib/activities.server.ts @@ -0,0 +1,103 @@ +import { randomUUID } from "node:crypto"; +import { eq, desc } from "drizzle-orm"; +import { getDb } from "./db"; +import { activities, routes } from "@trails-cool/db/schema/journal"; +import { parseGpxAsync } from "@trails-cool/gpx"; + +export interface ActivityInput { + name: string; + description?: string; + gpx?: string; + routeId?: string; +} + +export async function createActivity(ownerId: string, input: ActivityInput) { + const db = getDb(); + const id = randomUUID(); + + let distance: number | null = null; + let elevationGain: number | null = null; + let elevationLoss: number | null = null; + let startedAt: Date | null = null; + + if (input.gpx) { + try { + const gpxData = await parseGpxAsync(input.gpx); + const profile = gpxData.elevation.profile; + distance = profile.length > 0 ? Math.round(profile[profile.length - 1]!.distance) : null; + elevationGain = gpxData.elevation.gain; + elevationLoss = gpxData.elevation.loss; + + // Try to extract start time from first track point + if (gpxData.tracks[0]?.[0]?.time) { + startedAt = new Date(gpxData.tracks[0][0].time); + } + } catch { + // Continue without stats if GPX parsing fails + } + } + + await db.insert(activities).values({ + id, + ownerId, + routeId: input.routeId ?? null, + name: input.name, + description: input.description ?? "", + gpx: input.gpx, + distance, + elevationGain, + elevationLoss, + startedAt, + }); + + return id; +} + +export async function getActivity(id: string) { + const db = getDb(); + const [activity] = await db.select().from(activities).where(eq(activities.id, id)); + return activity ?? null; +} + +export async function listActivities(ownerId: string) { + const db = getDb(); + return db + .select() + .from(activities) + .where(eq(activities.ownerId, ownerId)) + .orderBy(desc(activities.createdAt)); +} + +export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) { + const db = getDb(); + await db + .update(activities) + .set({ routeId }) + .where(eq(activities.id, activityId)); +} + +export async function createRouteFromActivity(activityId: string, ownerId: string): Promise { + const db = getDb(); + const [activity] = await db.select().from(activities).where(eq(activities.id, activityId)); + if (!activity?.gpx) return null; + + const routeId = randomUUID(); + await db.insert(routes).values({ + id: routeId, + ownerId, + name: `Route from: ${activity.name}`, + description: `Created from activity "${activity.name}"`, + gpx: activity.gpx, + distance: activity.distance, + elevationGain: activity.elevationGain, + elevationLoss: activity.elevationLoss, + }); + + // Link the activity to the new route + await db + .update(activities) + .set({ routeId }) + .where(eq(activities.id, activityId)); + + return routeId; +} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 66acaab..050a85a 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -15,5 +15,8 @@ export default [ route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"), route("api/routes/:id/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"), route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"), + route("activities", "routes/activities._index.tsx"), + route("activities/new", "routes/activities.new.tsx"), + route("activities/:id", "routes/activities.$id.tsx"), route("users/:username", "routes/users.$username.tsx"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx new file mode 100644 index 0000000..4ba9edd --- /dev/null +++ b/apps/journal/app/routes/activities.$id.tsx @@ -0,0 +1,155 @@ +import { data, redirect } from "react-router"; +import type { Route } from "./+types/activities.$id"; +import { getSessionUser } from "~/lib/auth.server"; +import { getActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib/activities.server"; +import { listRoutes } from "~/lib/routes.server"; +import { ClientDate } from "~/components/ClientDate"; + +export async function loader({ params, request }: Route.LoaderArgs) { + const activity = await getActivity(params.id); + if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); + + const user = await getSessionUser(request); + const isOwner = user?.id === activity.ownerId; + + const userRoutes = isOwner && user ? await listRoutes(user.id) : []; + + return data({ + activity: { + id: activity.id, + name: activity.name, + description: activity.description, + distance: activity.distance, + elevationGain: activity.elevationGain, + elevationLoss: activity.elevationLoss, + duration: activity.duration, + routeId: activity.routeId, + hasGpx: !!activity.gpx, + startedAt: activity.startedAt?.toISOString() ?? null, + createdAt: activity.createdAt.toISOString(), + }, + isOwner, + routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), + }); +} + +export async function action({ params, request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "link-route") { + const routeId = formData.get("routeId") as string; + if (routeId) { + await linkActivityToRoute(params.id, routeId, user.id); + } + return redirect(`/activities/${params.id}`); + } + + if (intent === "create-route") { + const routeId = await createRouteFromActivity(params.id, user.id); + if (routeId) return redirect(`/routes/${routeId}`); + return data({ error: "No GPX data to create route from" }, { status: 400 }); + } + + return data({ error: "Unknown action" }, { status: 400 }); +} + +export function meta({ data: loaderData }: Route.MetaArgs) { + const name = (loaderData as { activity: { name: string } })?.activity?.name ?? "Activity"; + return [{ title: `${name} — trails.cool` }]; +} + +export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) { + const { activity, isOwner, routes } = loaderData; + + return ( +
+

{activity.name}

+ {activity.description && ( +

{activity.description}

+ )} + +
+ +
+ +
+ {activity.distance != null && ( +
+

+ {(activity.distance / 1000).toFixed(1)} km +

+

Distance

+
+ )} + {activity.elevationGain != null && ( +
+

↑ {activity.elevationGain} m

+

Ascent

+
+ )} + {activity.elevationLoss != null && ( +
+

↓ {activity.elevationLoss} m

+

Descent

+
+ )} +
+ + {activity.routeId && ( +
+ + View linked route → + +
+ )} + + {isOwner && !activity.routeId && ( +
+ {routes.length > 0 && ( +
+ +
+ + +
+ +
+ )} + + {activity.hasGpx && ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/apps/journal/app/routes/activities._index.tsx b/apps/journal/app/routes/activities._index.tsx new file mode 100644 index 0000000..8ee9cf1 --- /dev/null +++ b/apps/journal/app/routes/activities._index.tsx @@ -0,0 +1,77 @@ +import { data, redirect } from "react-router"; +import type { Route } from "./+types/activities._index"; +import { getSessionUser } from "~/lib/auth.server"; +import { listActivities } from "~/lib/activities.server"; +import { ClientDate } from "~/components/ClientDate"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const userActivities = await listActivities(user.id); + return data({ + activities: userActivities.map((a) => ({ + id: a.id, + name: a.name, + distance: a.distance, + elevationGain: a.elevationGain, + duration: a.duration, + startedAt: a.startedAt?.toISOString() ?? null, + createdAt: a.createdAt.toISOString(), + })), + }); +} + +export function meta(_args: Route.MetaArgs) { + return [{ title: "Activities — trails.cool" }]; +} + +export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) { + const { activities } = loaderData; + + return ( +
+
+

Activities

+ + New Activity + +
+ + {activities.length === 0 ? ( +

+ No activities yet. Record your first adventure! +

+ ) : ( + + )} +
+ ); +} diff --git a/apps/journal/app/routes/activities.new.tsx b/apps/journal/app/routes/activities.new.tsx new file mode 100644 index 0000000..4200010 --- /dev/null +++ b/apps/journal/app/routes/activities.new.tsx @@ -0,0 +1,132 @@ +import { data, redirect } from "react-router"; +import type { Route } from "./+types/activities.new"; +import { getSessionUser } from "~/lib/auth.server"; +import { createActivity } from "~/lib/activities.server"; +import { listRoutes } from "~/lib/routes.server"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const userRoutes = await listRoutes(user.id); + return data({ + routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), + }); +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const formData = await request.formData(); + const name = formData.get("name") as string; + const description = formData.get("description") as string; + const routeId = formData.get("routeId") as string | null; + const gpxFile = formData.get("gpx") as File | null; + + if (!name) return data({ error: "Name is required" }, { status: 400 }); + + let gpx: string | undefined; + if (gpxFile && gpxFile.size > 0) { + gpx = await gpxFile.text(); + } + + const activityId = await createActivity(user.id, { + name, + description, + gpx, + routeId: routeId || undefined, + }); + + return redirect(`/activities/${activityId}`); +} + +export function meta(_args: Route.MetaArgs) { + return [{ title: "New Activity — trails.cool" }]; +} + +export default function NewActivityPage({ loaderData }: Route.ComponentProps) { + const { routes } = loaderData; + + return ( +
+

New Activity

+ +
+
+ + +
+ +
+ +