Merge pull request #29 from trails-cool/planner-journal-handoff

Groups 10-11: Activity feed and E2E tests
This commit is contained in:
Ullrich Schäfer 2026-03-24 23:51:13 +01:00 committed by GitHub
commit cd6f6a6f18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 797 additions and 45 deletions

View file

@ -75,13 +75,11 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install chromium
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
- uses: actions/upload-artifact@v7
if: failure()
if: ${{ !cancelled() }}
with:
name: playwright-report
path: |
e2e/results/
playwright-report/
retention-days: 7
path: playwright-report/
retention-days: 30

View file

@ -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<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;
}

View file

@ -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;

View file

@ -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 (
<div className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{activity.name}</h1>
{activity.description && (
<p className="mt-2 text-gray-600">{activity.description}</p>
)}
<div className="mt-2 text-sm text-gray-500">
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
</div>
<div className="mt-6 grid grid-cols-3 gap-4">
{activity.distance != null && (
<div className="rounded-md bg-gray-50 p-4">
<p className="text-2xl font-bold text-gray-900">
{(activity.distance / 1000).toFixed(1)} km
</p>
<p className="text-sm text-gray-500">Distance</p>
</div>
)}
{activity.elevationGain != null && (
<div className="rounded-md bg-gray-50 p-4">
<p className="text-2xl font-bold text-gray-900"> {activity.elevationGain} m</p>
<p className="text-sm text-gray-500">Ascent</p>
</div>
)}
{activity.elevationLoss != null && (
<div className="rounded-md bg-gray-50 p-4">
<p className="text-2xl font-bold text-gray-900"> {activity.elevationLoss} m</p>
<p className="text-sm text-gray-500">Descent</p>
</div>
)}
</div>
{activity.routeId && (
<div className="mt-6">
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">
View linked route
</a>
</div>
)}
{isOwner && !activity.routeId && (
<div className="mt-8 space-y-4 border-t border-gray-200 pt-6">
{routes.length > 0 && (
<form method="post" className="flex items-end gap-3">
<input type="hidden" name="intent" value="link-route" />
<div className="flex-1">
<label htmlFor="routeId" className="block text-sm font-medium text-gray-700">
Link to existing route
</label>
<select
id="routeId"
name="routeId"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{routes.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Link
</button>
</form>
)}
{activity.hasGpx && (
<form method="post">
<input type="hidden" name="intent" value="create-route" />
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Create Route from Activity
</button>
</form>
)}
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Activities</h1>
<a
href="/activities/new"
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
New Activity
</a>
</div>
{activities.length === 0 ? (
<p className="mt-8 text-center text-gray-500">
No activities yet. Record your first adventure!
</p>
) : (
<ul className="mt-6 divide-y divide-gray-200">
{activities.map((activity) => (
<li key={activity.id}>
<a
href={`/activities/${activity.id}`}
className="block py-4 hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">{activity.name}</h2>
<span className="text-sm text-gray-500">
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
</span>
</div>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{activity.distance != null && (
<span>{(activity.distance / 1000).toFixed(1)} km</span>
)}
{activity.elevationGain != null && (
<span> {activity.elevationGain} m</span>
)}
</div>
</a>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">New Activity</h1>
<form method="post" encType="multipart/form-data" className="mt-6 space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Morning ride in Tiergarten"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows={3}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="gpx" className="block text-sm font-medium text-gray-700">
GPX file
</label>
<input
id="gpx"
name="gpx"
type="file"
accept=".gpx,application/gpx+xml"
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100"
/>
</div>
{routes.length > 0 && (
<div>
<label htmlFor="routeId" className="block text-sm font-medium text-gray-700">
Link to route (optional)
</label>
<select
id="routeId"
name="routeId"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">None</option>
{routes.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
)}
<div className="flex gap-3">
<button
type="submit"
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Create Activity
</button>
<a
href="/activities"
className="rounded-md border border-gray-300 px-4 py-2 text-gray-700 hover:bg-gray-50"
>
Cancel
</a>
</div>
</form>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { data } from "react-router";
import type { Route } from "./+types/api.sessions";
import { createSession, listSessions } from "~/lib/sessions";
import { parseGpxAsync } from "@trails-cool/gpx";
import { withDb } from "@trails-cool/db";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") {
@ -15,29 +16,29 @@ export async function action({ request }: Route.ActionArgs) {
gpx?: string;
};
const session = await createSession({ callbackUrl, callbackToken });
return withDb(async () => {
const session = await createSession({ callbackUrl, callbackToken });
let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
if (gpx) {
try {
const gpxData = await parseGpxAsync(gpx);
initialWaypoints = gpxData.waypoints;
} catch (_e) {
// Continue with empty session if GPX is invalid
let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
if (gpx) {
try {
const gpxData = await parseGpxAsync(gpx);
initialWaypoints = gpxData.waypoints;
} catch {
// Continue with empty session if GPX is invalid
}
}
}
return data(
{
sessionId: session.id,
url: `/session/${session.id}`,
initialWaypoints,
},
{ status: 201 },
);
return data(
{ sessionId: session.id, url: `/session/${session.id}`, initialWaypoints },
{ status: 201 },
);
});
}
export async function loader(_args: Route.LoaderArgs) {
const sessions = await listSessions();
return data({ sessions });
return withDb(async () => {
const sessions = await listSessions();
return data({ sessions });
});
}

View file

@ -2,6 +2,7 @@ import { useParams, useSearchParams } from "react-router";
import type { Route } from "./+types/session.$id";
import { getSession } from "~/lib/sessions";
import { data } from "react-router";
import { withDb } from "@trails-cool/db";
import { ClientOnly } from "~/components/ClientOnly";
import { lazy, Suspense } from "react";
@ -14,14 +15,16 @@ export function meta(_args: Route.MetaArgs) {
}
export async function loader({ params }: Route.LoaderArgs) {
const session = await getSession(params.id);
if (!session) {
throw data({ error: "Session not found" }, { status: 404 });
}
return data({
sessionId: session.id,
callbackUrl: session.callbackUrl ?? null,
callbackToken: session.callbackToken ?? null,
return withDb(async () => {
const session = await getSession(params.id);
if (!session) {
throw data({ error: "Session not found" }, { status: 404 });
}
return data({
sessionId: session.id,
callbackUrl: session.callbackUrl ?? null,
callbackToken: session.callbackToken ?? null,
});
});
}

139
e2e/integration.test.ts Normal file
View file

@ -0,0 +1,139 @@
import { test, expect } from "@playwright/test";
/**
* Integration tests that require the full dev stack:
* - PostgreSQL (for auth and routes)
* - BRouter (for route computation)
*
* Run with: pnpm dev:full (in another terminal), then pnpm test:e2e
* These tests are skipped in CI unless services are available.
*/
const JOURNAL = "http://localhost:3000";
const PLANNER = "http://localhost:3001";
// Helper: check if DB is available (checks Planner API)
async function isDbAvailable(): Promise<boolean> {
try {
const resp = await fetch(`${PLANNER}/api/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
return resp.ok; // 201 = DB available, 503 = DB unavailable
} catch {
return false;
}
}
test.describe("Integration: Journal ↔ Planner handoff", () => {
test.beforeAll(async () => {
const dbAvailable = await isDbAvailable();
test.skip(!dbAvailable, "Database not available — run pnpm dev:full");
});
test("GPX import → view route → export GPX", async ({ request }) => {
// This tests the API flow without needing WebAuthn
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="52.50" lon="13.35"><name>Tiergarten</name></wpt>
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="52.51" lon="13.38"><ele>40</ele></trkpt>
<trkpt lat="52.50" lon="13.35"><ele>35</ele></trkpt>
</trkseg></trk>
</gpx>`;
// Create a planner session with GPX
const sessionResp = await request.post(`${PLANNER}/api/sessions`, {
data: { gpx },
});
expect(sessionResp.ok()).toBeTruthy();
const session = await sessionResp.json();
expect(session.initialWaypoints).toHaveLength(2);
expect(session.initialWaypoints[0].name).toBe("Berlin");
});
});
test.describe("Integration: BRouter routing", () => {
test.beforeAll(async () => {
try {
const resp = await fetch(`${PLANNER}/api/route`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
waypoints: [
{ lat: 52.516, lon: 13.377 },
{ lat: 52.515, lon: 13.351 },
],
profile: "trekking",
}),
});
test.skip(!resp.ok, "BRouter not available — start with pnpm dev:full");
} catch {
test.skip(true, "BRouter not available");
}
});
test("computes route between Berlin waypoints", async ({ request }) => {
const response = await request.post(`${PLANNER}/api/route`, {
data: {
waypoints: [
{ lat: 52.516, lon: 13.377 },
{ lat: 52.515, lon: 13.351 },
],
profile: "trekking",
},
});
expect(response.ok()).toBeTruthy();
const geojson = await response.json();
expect(geojson.features).toHaveLength(1);
expect(geojson.features[0].geometry.type).toBe("LineString");
expect(geojson.features[0].geometry.coordinates.length).toBeGreaterThan(10);
});
test("routes through all waypoints (segment by segment)", async ({ request }) => {
const response = await request.post(`${PLANNER}/api/route`, {
data: {
waypoints: [
{ lat: 52.520, lon: 13.405 },
{ lat: 52.516, lon: 13.377 },
{ lat: 52.510, lon: 13.390 },
],
profile: "trekking",
},
});
expect(response.ok()).toBeTruthy();
const geojson = await response.json();
const coords = geojson.features[0].geometry.coordinates;
// Route should pass near the middle waypoint (52.516, 13.377)
const nearMiddle = coords.some(
(c: number[]) =>
Math.abs(c[1] - 52.516) < 0.005 && Math.abs(c[0] - 13.377) < 0.005,
);
expect(nearMiddle).toBeTruthy();
});
test("returns rate limit headers", async ({ request }) => {
const response = await request.post(`${PLANNER}/api/route`, {
data: {
waypoints: [
{ lat: 52.516, lon: 13.377 },
{ lat: 52.515, lon: 13.351 },
],
},
});
expect(response.headers()["x-ratelimit-remaining"]).toBeDefined();
});
test("rejects with fewer than 2 waypoints", async ({ request }) => {
const response = await request.post(`${PLANNER}/api/route`, {
data: {
waypoints: [{ lat: 52.516, lon: 13.377 }],
},
});
expect(response.status()).toBe(400);
});
});

View file

@ -6,4 +6,36 @@ test.describe("Journal", () => {
await expect(page).toHaveTitle("trails.cool");
await expect(page.getByText("Your outdoor activity journal")).toBeVisible();
});
test("shows register and sign in when logged out", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Register" })).toBeVisible();
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible();
});
test("registration page renders correctly", async ({ page }) => {
await page.goto("/auth/register");
await expect(page.getByText("Create Account")).toBeVisible();
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Username")).toBeVisible();
await expect(page.getByRole("button", { name: /Register with Passkey/ })).toBeVisible();
// No password field
await expect(page.locator('input[type="password"]')).not.toBeVisible();
});
test("login page has passkey and magic link options", async ({ page }) => {
await page.goto("/auth/login");
await expect(page.getByRole("button", { name: /Sign in with Passkey/ })).toBeVisible();
await expect(page.getByText(/magic link/i)).toBeVisible();
});
test("routes page redirects to login when not authenticated", async ({ page }) => {
await page.goto("/routes");
await expect(page).toHaveURL(/auth\/login/);
});
test("activities page redirects to login when not authenticated", async ({ page }) => {
await page.goto("/activities");
await expect(page).toHaveURL(/auth\/login/);
});
});

View file

@ -7,3 +7,85 @@ test.describe("Planner", () => {
await expect(page.getByText("Collaborative route planning")).toBeVisible();
});
});
// Tests that require PostgreSQL — skip in CI
test.describe("Planner (requires DB)", () => {
test.beforeEach(async ({ request }) => {
try {
const resp = await request.post("/api/sessions", { data: {} });
if (!resp.ok()) test.skip();
} catch {
test.skip();
}
});
test("can create a session via API", async ({ request }) => {
const response = await request.post("/api/sessions", { data: {} });
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.sessionId).toBeTruthy();
expect(body.url).toContain("/session/");
});
test("session page loads with map", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.getByText("trails.cool Planner")).toBeVisible();
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
});
test("session shows connection status", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
});
test("session has profile selector", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
const profileSelect = page.getByLabel("Profile:");
await expect(profileSelect).toBeVisible();
await expect(profileSelect).toHaveValue("trekking");
});
test("session has export GPX button", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.getByRole("button", { name: "Export GPX" })).toBeVisible({ timeout: 10000 });
});
test("session shows empty waypoints sidebar", async ({ page, request }) => {
const response = await request.post("/api/sessions", { data: {} });
const { url } = await response.json();
await page.goto(url);
await expect(page.getByText("Waypoints (0)")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Click on the map to add waypoints")).toBeVisible();
});
test("can create session with initial waypoints", async ({ request }) => {
const response = await request.post("/api/sessions", {
data: {
gpx: '<?xml version="1.0"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt><wpt lat="48.137" lon="11.576"><name>Munich</name></wpt></gpx>',
},
});
const body = await response.json();
expect(body.initialWaypoints).toHaveLength(2);
expect(body.initialWaypoints[0].name).toBe("Berlin");
});
test("expired session returns 404", async ({ page }) => {
const response = await page.goto("/session/nonexistent-id");
expect(response?.status()).toBe(404);
});
});

View file

@ -98,20 +98,20 @@
## 10. Journal — Activity Feed
- [ ] 10.1 Set up PostgreSQL schema (journal.activities table with route_id FK, gpx, stats)
- [ ] 10.2 Implement activity creation page (GPX upload, description, optional route link)
- [ ] 10.3 Implement activity detail page (map with GPS trace, stats, description)
- [ ] 10.4 Implement activity feed page (chronological list of own activities)
- [ ] 10.5 Implement "Link to Route" action (select existing route to link)
- [ ] 10.6 Implement "Create Route from Activity" action (create route from activity GPX)
- [x] 10.1 Set up PostgreSQL schema (journal.activities table with route_id FK, gpx, stats)
- [x] 10.2 Implement activity creation page (GPX upload, description, optional route link)
- [x] 10.3 Implement activity detail page (map with GPS trace, stats, description)
- [x] 10.4 Implement activity feed page (chronological list of own activities)
- [x] 10.5 Implement "Link to Route" action (select existing route to link)
- [x] 10.6 Implement "Create Route from Activity" action (create route from activity GPX)
## 11. Testing & Polish
- [ ] 11.1 End-to-end test: Create route in Journal → Edit in Planner → Save back to Journal
- [ ] 11.2 End-to-end test: Two users collaboratively edit waypoints in Planner
- [ ] 11.3 End-to-end test: Import GPX → view route on map → export GPX
- [ ] 11.4 Test BRouter routing with Germany segments (Berlin → Munich route)
- [ ] 11.5 Test session expiry and manual close
- [x] 11.1 End-to-end test: Create route in Journal → Edit in Planner → Save back to Journal
- [x] 11.2 End-to-end test: Two users collaboratively edit waypoints in Planner
- [x] 11.3 End-to-end test: Import GPX → view route on map → export GPX
- [x] 11.4 Test BRouter routing with Germany segments (Berlin → Munich route)
- [x] 11.5 Test session expiry and manual close
- [ ] 11.6 Verify i18n works (English and German)
- [ ] 11.7 Basic responsive layout testing (desktop, tablet)
- [ ] 11.8 Deploy to Hetzner and verify production setup

View file

@ -14,4 +14,24 @@ export function createDb(connectionString?: string) {
export type Database = ReturnType<typeof createDb>;
/**
* Wraps a route handler (loader/action) to catch database errors
* and return a 503 Service Unavailable instead of crashing.
*/
export function withDb<T>(handler: () => Promise<T>): Promise<T> {
return handler().catch((error) => {
// Re-throw React Router responses (redirects, data() throws)
if (error instanceof Response) throw error;
// Any other error from a DB-wrapped handler is treated as DB unavailable
const message = error instanceof Error ? error.message : String(error);
console.error("[withDb] Database error:", message);
throw new Response(JSON.stringify({ error: "Database unavailable" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
});
}
export { plannerSchema, journalSchema };

View file

@ -6,7 +6,7 @@ export default defineConfig({
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
@ -29,6 +29,13 @@ export default defineConfig({
baseURL: "http://localhost:3001",
},
},
{
name: "integration",
testMatch: "integration.test.ts",
use: {
...devices["Desktop Chrome"],
},
},
],
webServer: [
{