From edd714fbb12fb56f700c7b818704ffa27403c669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 22:25:10 +0100 Subject: [PATCH] Implement Journal route management (Group 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route CRUD: - Create route with name, description, optional GPX upload - Route detail page with stats (distance, elevation), version history - Edit route (name, description, upload new GPX → creates new version) - Delete route with confirmation dialog - Route list page sorted by last updated GPX handling: - Import: parse GPX, compute distance/elevation stats on save - Export: download route GPX as file - Version history: new version created on each GPX update Server logic in routes.server.ts with full Drizzle ORM queries. All 10 Group 8 tasks complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/routes.server.ts | 151 ++++++++++++++++ apps/journal/app/routes.ts | 4 + apps/journal/app/routes/routes.$id.edit.tsx | 108 ++++++++++++ apps/journal/app/routes/routes.$id.tsx | 181 ++++++++++++++++++++ apps/journal/app/routes/routes._index.tsx | 74 ++++++++ apps/journal/app/routes/routes.new.tsx | 99 +++++++++++ openspec/changes/phase-1-mvp/tasks.md | 20 +-- 7 files changed, 627 insertions(+), 10 deletions(-) create mode 100644 apps/journal/app/lib/routes.server.ts create mode 100644 apps/journal/app/routes/routes.$id.edit.tsx create mode 100644 apps/journal/app/routes/routes.$id.tsx create mode 100644 apps/journal/app/routes/routes._index.tsx create mode 100644 apps/journal/app/routes/routes.new.tsx diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts new file mode 100644 index 0000000..8ab80e8 --- /dev/null +++ b/apps/journal/app/lib/routes.server.ts @@ -0,0 +1,151 @@ +import { randomUUID } from "node:crypto"; +import { eq, desc, and } from "drizzle-orm"; +import { getDb } from "./db"; +import { routes, routeVersions } from "@trails-cool/db/schema/journal"; +import { parseGpx } from "@trails-cool/gpx"; + +export interface RouteInput { + name: string; + description?: string; + gpx?: string; + routingProfile?: string; +} + +export async function createRoute(ownerId: string, input: RouteInput) { + const db = getDb(); + const id = randomUUID(); + + let distance: number | null = null; + let elevationGain: number | null = null; + let elevationLoss: number | null = null; + + if (input.gpx) { + const stats = computeRouteStats(input.gpx); + distance = stats.distance; + elevationGain = stats.elevationGain; + elevationLoss = stats.elevationLoss; + } + + await db.insert(routes).values({ + id, + ownerId, + name: input.name, + description: input.description ?? "", + gpx: input.gpx, + routingProfile: input.routingProfile, + distance, + elevationGain, + elevationLoss, + }); + + // Create initial version if GPX provided + if (input.gpx) { + await db.insert(routeVersions).values({ + id: randomUUID(), + routeId: id, + version: 1, + gpx: input.gpx, + createdBy: ownerId, + changeDescription: "Initial version", + }); + } + + return id; +} + +export async function getRoute(id: string) { + const db = getDb(); + const [route] = await db.select().from(routes).where(eq(routes.id, id)); + return route ?? null; +} + +export async function getRouteWithVersions(id: string) { + const db = getDb(); + const [route] = await db.select().from(routes).where(eq(routes.id, id)); + if (!route) return null; + + const versions = await db + .select() + .from(routeVersions) + .where(eq(routeVersions.routeId, id)) + .orderBy(desc(routeVersions.version)); + + return { ...route, versions }; +} + +export async function listRoutes(ownerId: string) { + const db = getDb(); + return db + .select() + .from(routes) + .where(eq(routes.ownerId, ownerId)) + .orderBy(desc(routes.updatedAt)); +} + +export async function updateRoute( + id: string, + ownerId: string, + input: Partial, +) { + const db = getDb(); + + const updateData: Record = { updatedAt: new Date() }; + if (input.name !== undefined) updateData.name = input.name; + if (input.description !== undefined) updateData.description = input.description; + + if (input.gpx) { + updateData.gpx = input.gpx; + const stats = computeRouteStats(input.gpx); + updateData.distance = stats.distance; + updateData.elevationGain = stats.elevationGain; + updateData.elevationLoss = stats.elevationLoss; + + // Get next version number + const existingVersions = await db + .select() + .from(routeVersions) + .where(eq(routeVersions.routeId, id)) + .orderBy(desc(routeVersions.version)); + + const nextVersion = (existingVersions[0]?.version ?? 0) + 1; + + await db.insert(routeVersions).values({ + id: randomUUID(), + routeId: id, + version: nextVersion, + gpx: input.gpx, + createdBy: ownerId, + }); + } + + await db + .update(routes) + .set(updateData) + .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); +} + +export async function deleteRoute(id: string, ownerId: string) { + const db = getDb(); + const result = await db + .delete(routes) + .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))) + .returning({ id: routes.id }); + return result.length > 0; +} + +function computeRouteStats(gpxString: string) { + try { + const gpxData = parseGpx(gpxString); + return { + distance: Math.round( + gpxData.elevation.profile.length > 0 + ? gpxData.elevation.profile[gpxData.elevation.profile.length - 1]!.distance + : 0, + ), + elevationGain: gpxData.elevation.gain, + elevationLoss: gpxData.elevation.loss, + }; + } catch { + return { distance: null, elevationGain: null, elevationLoss: null }; + } +} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index eaf2f3f..8f69788 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -8,5 +8,9 @@ export default [ route("auth/logout", "routes/auth.logout.tsx"), route("api/auth/register", "routes/api.auth.register.ts"), route("api/auth/login", "routes/api.auth.login.ts"), + route("routes", "routes/routes._index.tsx"), + route("routes/new", "routes/routes.new.tsx"), + route("routes/:id", "routes/routes.$id.tsx"), + route("routes/:id/edit", "routes/routes.$id.edit.tsx"), route("users/:username", "routes/users.$username.tsx"), ] satisfies RouteConfig; diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx new file mode 100644 index 0000000..9dce0ca --- /dev/null +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -0,0 +1,108 @@ +import { data, redirect } from "react-router"; +import type { Route } from "./+types/routes.$id.edit"; +import { getSessionUser } from "~/lib/auth.server"; +import { getRoute, updateRoute } from "~/lib/routes.server"; + +export async function loader({ params, request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const route = await getRoute(params.id); + if (!route) throw data({ error: "Route not found" }, { status: 404 }); + if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 }); + + return data({ + route: { id: route.id, name: route.name, description: route.description }, + }); +} + +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 name = formData.get("name") as string; + const description = formData.get("description") as string; + const gpxFile = formData.get("gpx") as File | null; + + const input: { name?: string; description?: string; gpx?: string } = {}; + if (name) input.name = name; + if (description !== null) input.description = description; + if (gpxFile && gpxFile.size > 0) { + input.gpx = await gpxFile.text(); + } + + await updateRoute(params.id, user.id, input); + return redirect(`/routes/${params.id}`); +} + +export function meta(_args: Route.MetaArgs) { + return [{ title: "Edit Route — trails.cool" }]; +} + +export default function EditRoutePage({ loaderData }: Route.ComponentProps) { + const { route } = loaderData; + + return ( +
+

Edit Route

+ +
+
+ + +
+ +
+ +