Merge pull request #29 from trails-cool/planner-journal-handoff
Groups 10-11: Activity feed and E2E tests
This commit is contained in:
commit
cd6f6a6f18
14 changed files with 797 additions and 45 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
103
apps/journal/app/lib/activities.server.ts
Normal file
103
apps/journal/app/lib/activities.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
155
apps/journal/app/routes/activities.$id.tsx
Normal file
155
apps/journal/app/routes/activities.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/journal/app/routes/activities._index.tsx
Normal file
77
apps/journal/app/routes/activities._index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/journal/app/routes/activities.new.tsx
Normal file
132
apps/journal/app/routes/activities.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
139
e2e/integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue