trails/apps/journal/app/lib/activities.server.ts
Ullrich Schäfer a9c99844c6
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) <noreply@anthropic.com>
2026-03-24 23:20:04 +01:00

103 lines
2.8 KiB
TypeScript

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<string | null> {
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;
}