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
+ });
+});