diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index ecb9b1a..4e48b8c 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { activities, routes, lineStringFromCoords } from "@trails-cool/db/schema/journal"; +import { activities, routes } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; -import type { SQL } from "drizzle-orm"; +import { setGeomFromGpx } from "./routes.server.ts"; export interface ActivityInput { name: string; @@ -20,8 +20,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) { let elevationGain: number | null = null; let elevationLoss: number | null = null; let startedAt: Date | null = null; - let geom: SQL | null = null; - if (input.gpx) { try { const gpxData = await parseGpxAsync(input.gpx); @@ -30,9 +28,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain = gpxData.elevation.gain; elevationLoss = gpxData.elevation.loss; - const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); - if (coords.length >= 2) geom = lineStringFromCoords(coords); - // Try to extract start time from first track point if (gpxData.tracks[0]?.[0]?.time) { startedAt = new Date(gpxData.tracks[0][0].time); @@ -53,9 +48,12 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain, elevationLoss, startedAt, - geom, }); + if (input.gpx) { + await setGeomFromGpx(id, "activities", input.gpx); + } + return id; } @@ -88,17 +86,6 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin if (!activity?.gpx) return null; const routeId = randomUUID(); - let routeGeom: SQL | null = null; - if (activity.gpx) { - try { - const gpxData = await parseGpxAsync(activity.gpx); - const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); - if (coords.length >= 2) routeGeom = lineStringFromCoords(coords); - } catch { - // Continue without geom - } - } - await db.insert(routes).values({ id: routeId, ownerId, @@ -108,9 +95,10 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin distance: activity.distance, elevationGain: activity.elevationGain, elevationLoss: activity.elevationLoss, - geom: routeGeom, }); + await setGeomFromGpx(routeId, "routes", activity.gpx); + // Link the activity to the new route await db .update(activities) diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 2b0efe4..c9f1b45 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { routes, routeVersions, lineStringFromCoords } from "@trails-cool/db/schema/journal"; -import { parseGpx } from "@trails-cool/gpx"; -import type { SQL } from "drizzle-orm"; +import { routes, routeVersions } from "@trails-cool/db/schema/journal"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { sql } from "drizzle-orm"; export interface RouteInput { name: string; @@ -19,14 +19,11 @@ export async function createRoute(ownerId: string, input: RouteInput) { let distance: number | null = null; let elevationGain: number | null = null; let elevationLoss: number | null = null; - let geom: SQL | null = null; - if (input.gpx) { - const stats = computeRouteStats(input.gpx); + const stats = await computeRouteStats(input.gpx); distance = stats.distance; elevationGain = stats.elevationGain; elevationLoss = stats.elevationLoss; - geom = stats.geom; } await db.insert(routes).values({ @@ -39,9 +36,12 @@ export async function createRoute(ownerId: string, input: RouteInput) { distance, elevationGain, elevationLoss, - geom, }); + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } + // Create initial version if GPX provided if (input.gpx) { await db.insert(routeVersions).values({ @@ -99,11 +99,10 @@ export async function updateRoute( if (input.gpx) { updateData.gpx = input.gpx; - const stats = computeRouteStats(input.gpx); + const stats = await computeRouteStats(input.gpx); updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; - updateData.geom = stats.geom; // Get next version number const existingVersions = await db @@ -127,6 +126,10 @@ export async function updateRoute( .update(routes) .set(updateData) .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); + + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } } export async function deleteRoute(id: string, ownerId: string) { @@ -138,10 +141,9 @@ export async function deleteRoute(id: string, ownerId: string) { return result.length > 0; } -function computeRouteStats(gpxString: string) { +async function computeRouteStats(gpxString: string) { try { - const gpxData = parseGpx(gpxString); - const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + const gpxData = await parseGpxAsync(gpxString); return { distance: Math.round( gpxData.elevation.profile.length > 0 @@ -150,9 +152,25 @@ function computeRouteStats(gpxString: string) { ), elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, - geom: coords.length >= 2 ? lineStringFromCoords(coords) : null, }; } catch { - return { distance: null, elevationGain: null, elevationLoss: null, geom: null }; + return { distance: null, elevationGain: null, elevationLoss: null }; } } + +async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxString: string) { + try { + const gpxData = await parseGpxAsync(gpxString); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length < 2) return; + const geojson = JSON.stringify({ type: "LineString", coordinates: coords }); + const db = getDb(); + await db.execute( + sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`, + ); + } catch (e) { + console.error(`Failed to set geom for ${table}/${id}:`, e); + } +} + +export { setGeomFromGpx }; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 0454d2d..788dcad 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -7,7 +7,6 @@ import { jsonb, customType, } from "drizzle-orm/pg-core"; -import { sql, type SQL } from "drizzle-orm"; const bytea = customType<{ data: Buffer }>({ dataType() { @@ -15,27 +14,12 @@ const bytea = customType<{ data: Buffer }>({ }, }); -const lineString = customType<{ data: string | SQL }>({ +const lineString = customType<{ data: string }>({ dataType() { return "geometry(LineString, 4326)"; }, - toDriver(value) { - return value; - }, }); -/** - * Build a SQL expression to create a PostGIS LineString from coordinate pairs. - * Coordinates are [lon, lat] (GeoJSON order). - */ -export function lineStringFromCoords(coords: [number, number][]): SQL { - const geojson = JSON.stringify({ - type: "LineString", - coordinates: coords, - }); - return sql`ST_GeomFromGeoJSON(${geojson})`; -} - export const journalSchema = pgSchema("journal"); export const users = journalSchema.table("users", { diff --git a/packages/gpx/src/geom.test.ts b/packages/gpx/src/geom.test.ts new file mode 100644 index 0000000..a1521ed --- /dev/null +++ b/packages/gpx/src/geom.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { parseGpxAsync } from "./parse.ts"; + +const sampleGpx = ` + + + + 34 + 113 + 519 + + +`; + +describe("GPX to geometry coordinates", () => { + it("extracts [lon, lat] coordinates from track points", async () => { + const gpxData = await parseGpxAsync(sampleGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(3); + // GeoJSON order: [lon, lat] + expect(coords[0]).toEqual([13.405, 52.52]); + expect(coords[1]).toEqual([13.74, 51.05]); + expect(coords[2]).toEqual([11.576, 48.137]); + }); + + it("produces valid GeoJSON LineString", async () => { + const gpxData = await parseGpxAsync(sampleGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + const geojson = { type: "LineString", coordinates: coords }; + + expect(geojson.type).toBe("LineString"); + expect(geojson.coordinates).toHaveLength(3); + // Verify serialization doesn't throw + expect(() => JSON.stringify(geojson)).not.toThrow(); + }); + + it("handles empty GPX gracefully", async () => { + const emptyGpx = ` + + + `; + const gpxData = await parseGpxAsync(emptyGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(0); + }); + + it("handles single point (insufficient for LineString)", async () => { + const singlePointGpx = ` + + + 34 + + `; + const gpxData = await parseGpxAsync(singlePointGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(1); + // Caller should check coords.length >= 2 before creating LineString + }); +});