diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 0ec1aef..73d8a86 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -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>): 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([]); + const [draggingOver, setDraggingOver] = useState(false); + const dragCounterRef = useRef(0); const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); const [segmentBoundaries, setSegmentBoundaries] = useState([]); const [surfaces, setSurfaces] = useState([]); @@ -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 ( +
+ {draggingOver && ( +
+
+ {t("dropGpxHere")} +
+
+ )} {baseLayers.map((layer, i) => ( @@ -411,5 +490,6 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa /> )} +
); } diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 16a0a19..0964f79 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -262,7 +262,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, } > - + addToast(msg, "error")} /> diff --git a/apps/planner/app/routes/home.tsx b/apps/planner/app/routes/home.tsx index e9177de..1677b7f 100644 --- a/apps/planner/app/routes/home.tsx +++ b/apps/planner/app/routes/home.tsx @@ -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(null); + const [importError, setImportError] = useState(null); + + const handleFileSelect = async (e: React.ChangeEvent) => { + 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 (
@@ -29,12 +67,30 @@ export default function Home() {

{t("landing.heroDescription")}

- - {t("landing.startPlanning")} - +
+ + {t("landing.startPlanning")} + + + +
+ {importError && ( +

{importError}

+ )} {/* Features */} diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index 759c9ec..fe6ec4d 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -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 = ` + + + 34 + 113 + 519 + +`; + 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 }); + }); }); diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml b/openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml new file mode 100644 index 0000000..c430c5f --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-03 diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/design.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/design.md new file mode 100644 index 0000000..07a4603 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/design.md @@ -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. diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md new file mode 100644 index 0000000..5f7161a --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md new file mode 100644 index 0000000..2666e02 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md new file mode 100644 index 0000000..900a2a0 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md new file mode 100644 index 0000000..9026a2b --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md new file mode 100644 index 0000000..9f9f640 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md @@ -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 diff --git a/openspec/specs/gpx-import/spec.md b/openspec/specs/gpx-import/spec.md new file mode 100644 index 0000000..2666e02 --- /dev/null +++ b/openspec/specs/gpx-import/spec.md @@ -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 diff --git a/openspec/specs/planner-journal-handoff/spec.md b/openspec/specs/planner-journal-handoff/spec.md index 960df69..900a2a0 100644 --- a/openspec/specs/planner-journal-handoff/spec.md +++ b/openspec/specs/planner-journal-handoff/spec.md @@ -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=&token=` 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 diff --git a/openspec/specs/planner-session/spec.md b/openspec/specs/planner-session/spec.md index 414aa90..9026a2b 100644 --- a/openspec/specs/planner-session/spec.md +++ b/openspec/specs/planner-session/spec.md @@ -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/` - -#### Scenario: Create session from Journal callback -- **WHEN** the Journal opens `planner.trails.cool/new?callback=&token=&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/` -- **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 diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 80917ca..9f6fd53 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 534bbb2..8afc420 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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...",