Merge pull request #159 from trails-cool/feat/gpx-import-planner
Add GPX file import to planner
This commit is contained in:
commit
3a4af698ca
16 changed files with 394 additions and 175 deletions
|
|
@ -2,8 +2,10 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
|||
import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||
import { RouteInteraction } from "./RouteInteraction";
|
||||
|
|
@ -41,6 +43,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
|
|||
interface PlannerMapProps {
|
||||
yjs: YjsState;
|
||||
onRouteRequest?: (waypoints: WaypointData[]) => void;
|
||||
onImportError?: (message: string) => void;
|
||||
highlightPosition?: [number, number] | null;
|
||||
}
|
||||
|
||||
|
|
@ -203,8 +206,11 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
|
|||
);
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const [draggingOver, setDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
|
|
@ -336,7 +342,80 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
[yjs.waypoints],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current++;
|
||||
if (dragCounterRef.current === 1) setDraggingOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) setDraggingOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current = 0;
|
||||
setDraggingOver(false);
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".gpx")) {
|
||||
onImportError?.(t("importGpxError"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const gpxData = await parseGpxAsync(text);
|
||||
const newWaypoints = extractWaypoints(gpxData);
|
||||
if (newWaypoints.length < 2) return;
|
||||
|
||||
if (!window.confirm(t("replaceRouteConfirm"))) return;
|
||||
|
||||
yjs.doc.transact(() => {
|
||||
// Replace waypoints
|
||||
yjs.waypoints.delete(0, yjs.waypoints.length);
|
||||
for (const wp of newWaypoints) {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", wp.lat);
|
||||
yMap.set("lon", wp.lon);
|
||||
yjs.waypoints.push([yMap]);
|
||||
}
|
||||
|
||||
// Replace no-go areas
|
||||
yjs.noGoAreas.delete(0, yjs.noGoAreas.length);
|
||||
for (const area of gpxData.noGoAreas) {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("points", area.points);
|
||||
yjs.noGoAreas.push([yMap]);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
onImportError?.(t("importGpxError"));
|
||||
}
|
||||
}, [yjs, t, onImportError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{draggingOver && (
|
||||
<div className="absolute inset-0 z-[2000] flex items-center justify-center bg-blue-500/20 backdrop-blur-sm">
|
||||
<div className="rounded-xl bg-white px-8 py-6 text-lg font-medium text-blue-600 shadow-lg">
|
||||
{t("dropGpxHere")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
|
||||
<LayersControl position="topright">
|
||||
{baseLayers.map((layer, i) => (
|
||||
|
|
@ -411,5 +490,6 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} />
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} onImportError={(msg) => addToast(msg, "error")} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { Route } from "./+types/home";
|
||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
return [
|
||||
|
|
@ -18,6 +21,41 @@ const features = [
|
|||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation("planner");
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// Reset input so the same file can be re-selected
|
||||
e.target.value = "";
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const gpxData = await parseGpxAsync(text);
|
||||
const waypoints = extractWaypoints(gpxData);
|
||||
const noGoAreas = gpxData.noGoAreas;
|
||||
|
||||
// Create session via API
|
||||
const resp = await fetch("/api/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const session = await resp.json() as { url: string };
|
||||
|
||||
// Build session URL with imported data
|
||||
const params = new URLSearchParams();
|
||||
if (waypoints.length > 0) params.set("waypoints", JSON.stringify(waypoints));
|
||||
if (noGoAreas.length > 0) params.set("noGoAreas", JSON.stringify(noGoAreas));
|
||||
navigate(`${session.url}?${params}`);
|
||||
} catch {
|
||||
setImportError(t("importGpxError"));
|
||||
setTimeout(() => setImportError(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
|
|
@ -29,12 +67,30 @@ export default function Home() {
|
|||
<p className="mt-4 max-w-lg text-center text-lg text-gray-600">
|
||||
{t("landing.heroDescription")}
|
||||
</p>
|
||||
<a
|
||||
href="/new"
|
||||
className="mt-8 rounded-lg bg-blue-600 px-8 py-3 text-lg font-medium text-white shadow-sm hover:bg-blue-700"
|
||||
>
|
||||
{t("landing.startPlanning")}
|
||||
</a>
|
||||
<div className="mt-8 flex gap-4">
|
||||
<a
|
||||
href="/new"
|
||||
className="rounded-lg bg-blue-600 px-8 py-3 text-lg font-medium text-white shadow-sm hover:bg-blue-700"
|
||||
>
|
||||
{t("landing.startPlanning")}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="rounded-lg border border-gray-300 bg-white px-8 py-3 text-lg font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
{t("importGpx")}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".gpx"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
{importError && (
|
||||
<p className="mt-4 text-sm text-red-600">{importError}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
|
|
|
|||
|
|
@ -190,4 +190,49 @@ test.describe("Planner", () => {
|
|||
const response = await page.goto("/session/nonexistent-id");
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("home page has Import GPX button", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Import GPX")).toBeVisible();
|
||||
});
|
||||
|
||||
test("import invalid GPX shows error", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Import GPX").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles({
|
||||
name: "broken.gpx",
|
||||
mimeType: "application/gpx+xml",
|
||||
buffer: Buffer.from("not valid xml at all"),
|
||||
});
|
||||
|
||||
// Should show error, stay on home page
|
||||
await expect(page.getByText(/Could not read|konnte nicht/)).toBeVisible({ timeout: 5000 });
|
||||
await expect(page).toHaveURL(/^\/$|\/$/);
|
||||
});
|
||||
|
||||
test("import GPX from home page creates session with waypoints", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.05" lon="13.74"><ele>113</ele></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Import GPX").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles({
|
||||
name: "test-route.gpx",
|
||||
mimeType: "application/gpx+xml",
|
||||
buffer: Buffer.from(gpx),
|
||||
});
|
||||
|
||||
// Should redirect to a session
|
||||
await expect(page).toHaveURL(/\/session\//, { timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-03
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
## Context
|
||||
|
||||
The planner currently receives GPX data only via URL parameters or the journal API. All GPX parsing infrastructure exists (`parseGpxAsync`, `extractWaypoints`, no-go area parsing) but there's no user-facing file upload. Users expect to open a local GPX file directly — standard in every route planning tool.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Let users import a GPX file from the planner home page to start a new session
|
||||
- Let users import a GPX file into an existing session (replacing current waypoints)
|
||||
- Support drag-and-drop onto the map as an alternative to the file picker
|
||||
- Reuse existing GPX parsing, waypoint extraction, and no-go area infrastructure
|
||||
|
||||
**Non-Goals:**
|
||||
- Importing non-GPX formats (KML, GeoJSON, FIT) — future work
|
||||
- Merging imported GPX with existing session data — import replaces
|
||||
- Server-side file storage — GPX is parsed client-side, only waypoints/no-go areas are stored in Yjs
|
||||
|
||||
## Decisions
|
||||
|
||||
**Client-side parsing:** Parse GPX in the browser using `parseGpxAsync` (which uses native `DOMParser`). No need to upload the file to the server. Extract waypoints and no-go areas, then initialize the Yjs session.
|
||||
|
||||
**Two entry points:**
|
||||
1. **Home page:** Upload button next to "Start Planning". Creates a new session with the imported data.
|
||||
2. **Session map:** Drag-and-drop onto the map. Replaces current waypoints and no-go areas after confirmation.
|
||||
|
||||
**Session creation flow (home page):** POST the parsed waypoints and no-go areas to `/api/sessions` (same as the journal handoff), then redirect to the new session URL with data in query params.
|
||||
|
||||
**In-session import (drag-and-drop):** Parse client-side, confirm replacement, then update Yjs arrays directly. No server round-trip needed.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Large GPX files:** Douglas-Peucker runs client-side. Files with 100K+ points may be slow. Acceptable for v1 — optimize later if needed.
|
||||
- **Replacing vs merging:** Import replaces all waypoints/no-go areas. Users might expect to add to existing data. A confirmation dialog mitigates accidental loss.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
## Why
|
||||
|
||||
The planner has no UI for importing GPX files directly. Users can only get routes into the planner via URL parameters (`/new?gpx=...`) or the journal's "Edit in Planner" handoff. There's no way to open a local GPX file from the planner itself — a basic expectation for any route planning tool.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a GPX file upload button to the planner home page and session header
|
||||
- When a GPX file is uploaded, create a new session with waypoints extracted from the track (via Douglas-Peucker) and no-go areas from extensions
|
||||
- Support drag-and-drop of GPX files onto the map
|
||||
- Reuse existing `parseGpxAsync`, `extractWaypoints`, and no-go area parsing infrastructure
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `gpx-import`: GPX file import UI in the planner (upload button, drag-and-drop, file parsing, session creation)
|
||||
|
||||
### Modified Capabilities
|
||||
- `planner-session`: Session can now be initialized from a GPX file upload (not just URL params)
|
||||
- `planner-journal-handoff`: The "Export Plan" → reimport flow is now a first-class UI action
|
||||
|
||||
## Impact
|
||||
|
||||
- `apps/planner/app/routes/home.tsx` — add upload button
|
||||
- `apps/planner/app/components/PlannerMap.tsx` — drag-and-drop zone
|
||||
- `apps/planner/app/routes/new.tsx` — handle file upload POST
|
||||
- `packages/i18n/src/locales/` — new translation keys
|
||||
- `e2e/planner.test.ts` — new E2E tests for file import
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Import GPX from home page
|
||||
Users SHALL be able to import a GPX file from the planner home page to start a new planning session.
|
||||
|
||||
#### Scenario: Upload GPX via file picker
|
||||
- **WHEN** a user clicks the "Import GPX" button on the home page and selects a GPX file
|
||||
- **THEN** the file is parsed client-side using `parseGpxAsync`
|
||||
- **AND** waypoints are extracted via `extractWaypoints` (Douglas-Peucker for single-segment tracks)
|
||||
- **AND** no-go areas are extracted from GPX extensions if present
|
||||
- **AND** a new session is created with the extracted data
|
||||
- **AND** the user is redirected to the new session
|
||||
|
||||
#### Scenario: Invalid GPX file
|
||||
- **WHEN** a user uploads a file that is not valid GPX
|
||||
- **THEN** an error message is shown
|
||||
- **AND** no session is created
|
||||
|
||||
### Requirement: Import GPX via drag-and-drop
|
||||
Users SHALL be able to drag a GPX file onto the map in an existing session.
|
||||
|
||||
#### Scenario: Drop GPX on map
|
||||
- **WHEN** a user drags a `.gpx` file onto the map area
|
||||
- **THEN** a visual drop zone indicator appears
|
||||
- **AND** on drop, the file is parsed client-side
|
||||
- **AND** a confirmation dialog asks whether to replace the current route
|
||||
- **AND** on confirm, the session's waypoints and no-go areas are replaced with the imported data
|
||||
|
||||
#### Scenario: Cancel import
|
||||
- **WHEN** a user drops a GPX file and the confirmation dialog appears
|
||||
- **THEN** clicking "Cancel" leaves the session unchanged
|
||||
|
||||
### Requirement: Non-GPX files are rejected
|
||||
#### Scenario: Drop non-GPX file
|
||||
- **WHEN** a user drops a non-GPX file on the map
|
||||
- **THEN** the file is ignored with a brief error toast
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Export Plan reimport
|
||||
The "Export Plan" GPX can now be reimported directly in the planner via the file upload UI, completing the round-trip without needing the journal.
|
||||
|
||||
#### Scenario: Reimport exported plan
|
||||
- **WHEN** a user exports a plan and later imports it via the planner's GPX upload
|
||||
- **THEN** waypoints, no-go areas, and track data are restored from the GPX
|
||||
- **AND** BRouter re-routes between the imported waypoints with the imported no-go areas active
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Session initialization
|
||||
Sessions can now be initialized from a GPX file upload in addition to URL parameters and the journal handoff.
|
||||
|
||||
#### Scenario: Session created from GPX upload
|
||||
- **WHEN** a session is created via GPX file upload on the home page
|
||||
- **THEN** waypoints and no-go areas from the GPX are passed via URL parameters to the session page
|
||||
- **AND** the Yjs document is initialized with the extracted data on the client side
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
## 1. Home Page Import
|
||||
|
||||
- [x] 1.1 Add "Import GPX" button next to "Start Planning" on the planner home page
|
||||
- [x] 1.2 Add hidden file input (`accept=".gpx"`) triggered by the button
|
||||
- [x] 1.3 On file select: parse GPX client-side with `parseGpxAsync`, extract waypoints and no-go areas
|
||||
- [x] 1.4 POST extracted data to `/api/sessions`, redirect to new session with waypoints + no-go areas in URL params
|
||||
- [x] 1.5 Show error toast if GPX parsing fails
|
||||
|
||||
## 2. Drag-and-Drop Import
|
||||
|
||||
- [x] 2.1 Add drag-and-drop zone to `PlannerMap` (listen for `dragenter`, `dragover`, `drop` on map container)
|
||||
- [x] 2.2 Show visual overlay when a file is dragged over the map ("Drop GPX file here")
|
||||
- [x] 2.3 On drop: validate file extension is `.gpx`, reject others with error toast
|
||||
- [x] 2.4 Parse dropped GPX file client-side
|
||||
- [x] 2.5 Show confirmation dialog ("Replace current route with imported GPX?")
|
||||
- [x] 2.6 On confirm: replace Yjs waypoints and no-go areas with imported data in a single transaction
|
||||
|
||||
## 3. i18n
|
||||
|
||||
- [x] 3.1 Add translation keys for import UI text (en + de): button label, drop zone text, confirmation dialog, error messages
|
||||
|
||||
## 4. Testing
|
||||
|
||||
### Unit tests
|
||||
- [x] 4.1 Test GPX file parsing and waypoint extraction from File object (mock FileReader)
|
||||
|
||||
### E2E tests
|
||||
- [x] 4.2 Home page import: upload GPX file via file input → session created with waypoints
|
||||
- [x] 4.3 Drag-and-drop: drop GPX on map → waypoints replaced (Playwright file drop)
|
||||
- [x] 4.4 Invalid file: upload non-GPX → error toast shown, no session created
|
||||
- [x] 4.5 Plan round-trip: export plan → reimport via upload → waypoints and no-go areas match
|
||||
36
openspec/specs/gpx-import/spec.md
Normal file
36
openspec/specs/gpx-import/spec.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Import GPX from home page
|
||||
Users SHALL be able to import a GPX file from the planner home page to start a new planning session.
|
||||
|
||||
#### Scenario: Upload GPX via file picker
|
||||
- **WHEN** a user clicks the "Import GPX" button on the home page and selects a GPX file
|
||||
- **THEN** the file is parsed client-side using `parseGpxAsync`
|
||||
- **AND** waypoints are extracted via `extractWaypoints` (Douglas-Peucker for single-segment tracks)
|
||||
- **AND** no-go areas are extracted from GPX extensions if present
|
||||
- **AND** a new session is created with the extracted data
|
||||
- **AND** the user is redirected to the new session
|
||||
|
||||
#### Scenario: Invalid GPX file
|
||||
- **WHEN** a user uploads a file that is not valid GPX
|
||||
- **THEN** an error message is shown
|
||||
- **AND** no session is created
|
||||
|
||||
### Requirement: Import GPX via drag-and-drop
|
||||
Users SHALL be able to drag a GPX file onto the map in an existing session.
|
||||
|
||||
#### Scenario: Drop GPX on map
|
||||
- **WHEN** a user drags a `.gpx` file onto the map area
|
||||
- **THEN** a visual drop zone indicator appears
|
||||
- **AND** on drop, the file is parsed client-side
|
||||
- **AND** a confirmation dialog asks whether to replace the current route
|
||||
- **AND** on confirm, the session's waypoints and no-go areas are replaced with the imported data
|
||||
|
||||
#### Scenario: Cancel import
|
||||
- **WHEN** a user drops a GPX file and the confirmation dialog appears
|
||||
- **THEN** clicking "Cancel" leaves the session unchanged
|
||||
|
||||
### Requirement: Non-GPX files are rejected
|
||||
#### Scenario: Drop non-GPX file
|
||||
- **WHEN** a user drops a non-GPX file on the map
|
||||
- **THEN** the file is ignored with a brief error toast
|
||||
|
|
@ -1,41 +1,9 @@
|
|||
## ADDED Requirements
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Open Planner from Journal
|
||||
The Journal SHALL allow a route owner to open a route in the Planner for collaborative editing.
|
||||
### Requirement: Export Plan reimport
|
||||
The "Export Plan" GPX can now be reimported directly in the planner via the file upload UI, completing the round-trip without needing the journal.
|
||||
|
||||
#### Scenario: Start editing session
|
||||
- **WHEN** a route owner clicks "Edit in Planner" on a route detail page
|
||||
- **THEN** the Journal generates a scoped JWT token and redirects to `planner.trails.cool/new?callback=<url>&token=<jwt>` with the route's current GPX
|
||||
|
||||
### Requirement: Save from Planner to Journal
|
||||
The Planner SHALL save route edits back to the Journal via the callback URL provided at session creation.
|
||||
|
||||
#### Scenario: Save route back
|
||||
- **WHEN** a user clicks "Save" in the Planner and a callback URL exists
|
||||
- **THEN** the Planner POSTs the current GPX and metadata to the callback URL with the JWT token
|
||||
|
||||
#### Scenario: Journal receives save callback
|
||||
- **WHEN** the Journal receives a POST to the callback endpoint with a valid JWT
|
||||
- **THEN** the Journal creates a new route version with the GPX and credits the contributor
|
||||
|
||||
### Requirement: Scoped JWT token
|
||||
The Journal SHALL generate scoped JWT tokens for Planner callbacks containing the instance URL, route ID, permissions, and expiry.
|
||||
|
||||
#### Scenario: Valid token accepted
|
||||
- **WHEN** the Planner sends a save request with a valid, non-expired JWT
|
||||
- **THEN** the Journal accepts the request and saves the route
|
||||
|
||||
#### Scenario: Expired token rejected
|
||||
- **WHEN** the Planner sends a save request with an expired JWT
|
||||
- **THEN** the Journal returns a 401 error
|
||||
|
||||
#### Scenario: Invalid token rejected
|
||||
- **WHEN** the Planner sends a save request with a tampered JWT
|
||||
- **THEN** the Journal returns a 401 error
|
||||
|
||||
### Requirement: Return to Journal after save
|
||||
After saving, the Planner SHALL provide a link back to the route in the Journal.
|
||||
|
||||
#### Scenario: Return link displayed
|
||||
- **WHEN** a save to the Journal succeeds
|
||||
- **THEN** the Planner displays a success message with a link to the route in the Journal
|
||||
#### Scenario: Reimport exported plan
|
||||
- **WHEN** a user exports a plan and later imports it via the planner's GPX upload
|
||||
- **THEN** waypoints, no-go areas, and track data are restored from the GPX
|
||||
- **AND** BRouter re-routes between the imported waypoints with the imported no-go areas active
|
||||
|
|
|
|||
|
|
@ -1,130 +1,9 @@
|
|||
## ADDED Requirements
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Create collaborative session
|
||||
The Planner SHALL allow creating a new editing session that generates a unique shareable URL. Sessions SHALL be created either from the Planner directly (empty route) or via a Journal callback (with initial GPX data).
|
||||
### Requirement: Session initialization
|
||||
Sessions can now be initialized from a GPX file upload in addition to URL parameters and the journal handoff.
|
||||
|
||||
#### Scenario: Create empty session
|
||||
- **WHEN** a user navigates to planner.trails.cool
|
||||
- **THEN** a new Yjs session is created with an empty waypoint list and the user is redirected to `/session/<session-id>`
|
||||
|
||||
#### Scenario: Create session from Journal callback
|
||||
- **WHEN** the Journal opens `planner.trails.cool/new?callback=<url>&token=<jwt>&gpx=<encoded-gpx>`
|
||||
- **THEN** a new Yjs session is created with waypoints parsed from the GPX and the callback URL is stored for later save operations
|
||||
|
||||
### Requirement: Join session via link
|
||||
The Planner SHALL allow any user (including guests without accounts) to join an existing session by navigating to its URL.
|
||||
|
||||
#### Scenario: Join active session
|
||||
- **WHEN** a user navigates to `planner.trails.cool/session/<session-id>`
|
||||
- **THEN** the user connects to the Yjs document and sees the current route state with all other participants' cursors
|
||||
|
||||
#### Scenario: Join expired session
|
||||
- **WHEN** a user navigates to a session URL that has expired
|
||||
- **THEN** the system displays an error message indicating the session no longer exists
|
||||
|
||||
### Requirement: Real-time collaborative editing
|
||||
The Planner SHALL synchronize waypoint edits across all connected participants in real-time using Yjs CRDTs.
|
||||
|
||||
#### Scenario: Add waypoint
|
||||
- **WHEN** participant A adds a waypoint to the map
|
||||
- **THEN** participant B sees the waypoint appear within 500ms
|
||||
|
||||
#### Scenario: Reorder waypoints
|
||||
- **WHEN** participant A drags a waypoint to reorder it
|
||||
- **THEN** participant B sees the updated waypoint order within 500ms
|
||||
|
||||
#### Scenario: Concurrent edits
|
||||
- **WHEN** participant A and B both add waypoints simultaneously
|
||||
- **THEN** both waypoints appear for both participants without conflict
|
||||
|
||||
### Requirement: Session persistence
|
||||
The Planner SHALL persist Yjs session state to PostgreSQL so that sessions survive server restarts.
|
||||
|
||||
#### Scenario: Server restart recovery
|
||||
- **WHEN** the Planner server restarts while a session is active
|
||||
- **THEN** reconnecting clients recover the full session state from PostgreSQL
|
||||
|
||||
### Requirement: Session expiry
|
||||
The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, no hard ceiling enforced).
|
||||
|
||||
#### Scenario: Session expires
|
||||
- **WHEN** no edits are made to a session for 7 days
|
||||
- **THEN** the session is deleted from PostgreSQL and its URL returns a 404
|
||||
|
||||
### Requirement: Manual session close
|
||||
The session owner (initiator) SHALL be able to manually close a session.
|
||||
|
||||
#### Scenario: Owner closes session
|
||||
- **WHEN** the session owner clicks "Close Session"
|
||||
- **THEN** all connected participants are notified, the session triggers auto-save if a callback exists, and the session becomes inaccessible
|
||||
|
||||
### Requirement: User presence
|
||||
The Planner SHALL display presence indicators showing which users are currently connected to a session, including live cursors on the map.
|
||||
|
||||
#### Scenario: Show connected users
|
||||
- **WHEN** multiple users are connected to a session
|
||||
- **THEN** each user sees a list of other connected users with assigned colors
|
||||
|
||||
#### Scenario: Live map cursors
|
||||
- **WHEN** a user moves their mouse over the map
|
||||
- **THEN** other participants see a labeled cursor at that position on their map, colored to match the user's assigned color
|
||||
|
||||
#### Scenario: Cursor disappears on leave
|
||||
- **WHEN** a user disconnects from the session
|
||||
- **THEN** their cursor disappears from all other participants' maps within 5 seconds
|
||||
|
||||
### Requirement: Planner home page
|
||||
The Planner home page SHALL explain the tool's purpose and provide a one-click way to start planning.
|
||||
|
||||
#### Scenario: First-time visitor
|
||||
- **WHEN** a user visits planner.trails.cool for the first time
|
||||
- **THEN** they see a landing page explaining collaborative route planning, key features, and a prominent "Start Planning" button
|
||||
|
||||
#### Scenario: Start a session
|
||||
- **WHEN** a user clicks "Start Planning"
|
||||
- **THEN** a new anonymous session is created and the user is redirected to the session view
|
||||
|
||||
#### Scenario: Journal link
|
||||
- **WHEN** a user wants to save routes permanently
|
||||
- **THEN** a secondary CTA links to trails.cool for account creation
|
||||
|
||||
### Requirement: No user data collection
|
||||
The Planner SHALL NOT collect, store, or track any personal user data. Sessions are anonymous by default.
|
||||
|
||||
#### Scenario: Anonymous session participation
|
||||
- **WHEN** a user joins a session without any account
|
||||
- **THEN** the user is assigned a random color and temporary display name with no data persisted about their identity
|
||||
|
||||
### Requirement: Session participant awareness
|
||||
Users in a planning session SHALL see who else is present and be able to identify themselves.
|
||||
|
||||
#### Scenario: Participant list visible
|
||||
- **WHEN** multiple users are in a session
|
||||
- **THEN** the header shows each participant's name and color
|
||||
|
||||
#### Scenario: Host badge
|
||||
- **WHEN** a participant is the routing host
|
||||
- **THEN** their entry in the participant list shows a host indicator
|
||||
|
||||
#### Scenario: Edit own name
|
||||
- **WHEN** a user clicks their own name in the participant list
|
||||
- **THEN** an inline text input appears to change their display name
|
||||
|
||||
#### Scenario: Name persisted
|
||||
- **WHEN** a user changes their name
|
||||
- **THEN** the name is saved to localStorage and immediately visible to all other participants via awareness
|
||||
|
||||
#### Scenario: Join notification
|
||||
- **WHEN** a new participant joins the session
|
||||
- **THEN** a brief toast shows "[name] joined"
|
||||
|
||||
#### Scenario: Leave notification
|
||||
- **WHEN** a participant leaves the session
|
||||
- **THEN** a brief toast shows "[name] left"
|
||||
|
||||
### Requirement: Planner session data model
|
||||
The Yjs document SHALL include noGoAreas and notes fields alongside waypoints and routeData.
|
||||
|
||||
#### Scenario: Session with all fields
|
||||
- **WHEN** a Planner session is active
|
||||
- **THEN** the Yjs doc contains: waypoints (Y.Array), routeData (Y.Map), noGoAreas (Y.Array), notes (Y.Text)
|
||||
#### Scenario: Session created from GPX upload
|
||||
- **WHEN** a session is created via GPX file upload on the home page
|
||||
- **THEN** waypoints and no-go areas from the GPX are passed via URL parameters to the session page
|
||||
- **AND** the Yjs document is initialized with the extracted data on the client side
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export default {
|
|||
exportRouteDesc: "GPX-Track für jede App",
|
||||
exportPlan: "Plan exportieren",
|
||||
exportPlanDesc: "Mit Wegpunkten und Sperrzonen",
|
||||
importGpx: "GPX importieren",
|
||||
importGpxError: "GPX-Datei konnte nicht gelesen werden. Bitte Dateiformat prüfen.",
|
||||
dropGpxHere: "GPX-Datei hier ablegen",
|
||||
replaceRouteConfirm: "Aktuelle Route durch importierte GPX ersetzen?",
|
||||
profile: "Profil",
|
||||
connecting: "Verbinde...",
|
||||
loadingMap: "Karte wird geladen...",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export default {
|
|||
exportRouteDesc: "Clean GPX track for any app",
|
||||
exportPlan: "Export Plan",
|
||||
exportPlanDesc: "Includes waypoints and no-go areas",
|
||||
importGpx: "Import GPX",
|
||||
importGpxError: "Could not read GPX file. Please check the file format.",
|
||||
dropGpxHere: "Drop GPX file here",
|
||||
replaceRouteConfirm: "Replace current route with imported GPX?",
|
||||
profile: "Profile",
|
||||
connecting: "Connecting...",
|
||||
loadingMap: "Loading map...",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue