diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index 3115f8d..46e770c 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -10,12 +10,12 @@ ## 2. Shared Packages -- [ ] 2.1 Implement `@trails-cool/types` — Route, Activity, Waypoint, RouteVersion, RouteMetadata interfaces -- [ ] 2.2 Implement `@trails-cool/gpx` — GPX parser (XML → waypoints/tracks/elevation) and GPX generator (waypoints/tracks → XML) -- [ ] 2.3 Implement `@trails-cool/map` — MapView React component (Leaflet + OSM), RouteLayer component (GeoJSON polyline), layer switcher (OSM/OpenTopoMap/CyclOSM) -- [ ] 2.4 Implement `@trails-cool/ui` — Button, Input, Card, Layout components with Tailwind styling -- [ ] 2.5 Implement `@trails-cool/i18n` — react-i18next config, English + German translation files, LanguageSwitcher component -- [ ] 2.6 Verify all packages are importable from both apps +- [x] 2.1 Implement `@trails-cool/types` — Route, Activity, Waypoint, RouteVersion, RouteMetadata interfaces +- [x] 2.2 Implement `@trails-cool/gpx` — GPX parser (XML → waypoints/tracks/elevation) and GPX generator (waypoints/tracks → XML) +- [x] 2.3 Implement `@trails-cool/map` — MapView React component (Leaflet + OSM), RouteLayer component (GeoJSON polyline), layer switcher (OSM/OpenTopoMap/CyclOSM) +- [x] 2.4 Implement `@trails-cool/ui` — Button, Input, Card, Layout components with Tailwind styling +- [x] 2.5 Implement `@trails-cool/i18n` — react-i18next config, English + German translation files, LanguageSwitcher component +- [x] 2.6 Verify all packages are importable from both apps ## 3. Infrastructure diff --git a/package.json b/package.json index b0e3430..738b560 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,21 @@ "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/leaflet": "^1.9.21", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", + "i18next": "^25.10.4", + "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.0.1", + "leaflet": "^1.9.4", "playwright": "^1.58.2", "prettier": "^3.8.1", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^16.6.1", + "react-leaflet": "^5.0.0", "react-router": "^7.13.1", "tailwindcss": "^4.2.2", "turbo": "^2.8.20", diff --git a/packages/gpx/package.json b/packages/gpx/package.json index 367f9dc..f4a6df1 100644 --- a/packages/gpx/package.json +++ b/packages/gpx/package.json @@ -2,6 +2,12 @@ "name": "@trails-cool/gpx", "version": "0.0.1", "type": "module", + "exports": { + ".": "./src/index.ts" + }, "main": "./src/index.ts", - "types": "./src/index.ts" + "types": "./src/index.ts", + "dependencies": { + "@trails-cool/types": "workspace:*" + } } diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts new file mode 100644 index 0000000..c4e1a5b --- /dev/null +++ b/packages/gpx/src/generate.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { generateGpx } from "./generate"; +import { parseGpx } from "./parse"; + +describe("generateGpx", () => { + it("generates valid GPX with name", () => { + const gpx = generateGpx({ name: "Test Route" }); + expect(gpx).toContain('Test Route"); + expect(gpx).toContain(""); + }); + + it("generates waypoints", () => { + const gpx = generateGpx({ + waypoints: [ + { lat: 52.52, lon: 13.405, name: "Berlin" }, + { lat: 48.137, lon: 11.576 }, + ], + }); + expect(gpx).toContain('wpt lat="52.52" lon="13.405"'); + expect(gpx).toContain("Berlin"); + expect(gpx).toContain('wpt lat="48.137" lon="11.576"'); + }); + + it("generates track points with elevation", () => { + const gpx = generateGpx({ + tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]], + }); + expect(gpx).toContain(""); + expect(gpx).toContain(''); + expect(gpx).toContain("34"); + }); + + it("escapes XML special characters in names", () => { + const gpx = generateGpx({ name: "Route & B" }); + expect(gpx).toContain("Route <A> & B"); + }); + + it("produces round-trippable GPX", () => { + const original = generateGpx({ + name: "Round Trip", + waypoints: [{ lat: 52.52, lon: 13.405, name: "Start" }], + tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]], + }); + const parsed = parseGpx(original); + expect(parsed.name).toBe("Round Trip"); + expect(parsed.waypoints).toHaveLength(1); + expect(parsed.waypoints[0]!.name).toBe("Start"); + expect(parsed.tracks[0]).toHaveLength(2); + }); +}); diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts new file mode 100644 index 0000000..1bfcd8c --- /dev/null +++ b/packages/gpx/src/generate.ts @@ -0,0 +1,60 @@ +import type { Waypoint } from "@trails-cool/types"; +import type { TrackPoint } from "./types"; + +/** + * Generate a GPX XML string from waypoints and track points. + */ +export function generateGpx(options: { + name?: string; + waypoints?: Waypoint[]; + tracks?: TrackPoint[][]; +}): string { + const lines: string[] = [ + '', + '', + ]; + + if (options.name) { + lines.push(" ", ` ${escapeXml(options.name)}`, " "); + } + + if (options.waypoints) { + for (const wpt of options.waypoints) { + lines.push(` `); + if (wpt.name) { + lines.push(` ${escapeXml(wpt.name)}`); + } + lines.push(" "); + } + } + + if (options.tracks) { + for (const track of options.tracks) { + lines.push(" ", " "); + for (const pt of track) { + lines.push(` `); + if (pt.ele !== undefined) { + lines.push(` ${pt.ele}`); + } + if (pt.time) { + lines.push(` `); + } + lines.push(" "); + } + lines.push(" ", " "); + } + } + + lines.push(""); + return lines.join("\n"); +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index 3a76bbc..6dc0a8d 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,4 +1,3 @@ -/** - * GPX parsing, generation, and validation for trails.cool - */ -export {}; +export { parseGpx } from "./parse"; +export { generateGpx } from "./generate"; +export type { GpxData, TrackPoint, ElevationProfile } from "./types"; diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts new file mode 100644 index 0000000..dd31b56 --- /dev/null +++ b/packages/gpx/src/parse.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { parseGpx } from "./parse"; + +const sampleGpx = ` + + Test Route + Berlin + Munich + + + 34 + 113 + 519 + + +`; + +describe("parseGpx", () => { + it("parses route name", () => { + const result = parseGpx(sampleGpx); + expect(result.name).toBe("Test Route"); + }); + + it("parses waypoints with lat, lon, and name", () => { + const result = parseGpx(sampleGpx); + expect(result.waypoints).toHaveLength(2); + expect(result.waypoints[0]).toEqual({ lat: 52.52, lon: 13.405, name: "Berlin" }); + expect(result.waypoints[1]).toEqual({ lat: 48.137, lon: 11.576, name: "Munich" }); + }); + + it("parses track points with elevation", () => { + const result = parseGpx(sampleGpx); + expect(result.tracks).toHaveLength(1); + expect(result.tracks[0]).toHaveLength(3); + expect(result.tracks[0]![0]).toEqual({ lat: 52.52, lon: 13.405, ele: 34, time: undefined }); + }); + + it("computes elevation gain and loss", () => { + const result = parseGpx(sampleGpx); + expect(result.elevation.gain).toBeGreaterThan(0); + expect(result.elevation.loss).toBe(0); // monotonically increasing elevation + expect(result.elevation.gain).toBe(485); // 113-34 + 519-113 + }); + + it("builds elevation profile", () => { + const result = parseGpx(sampleGpx); + expect(result.elevation.profile).toHaveLength(3); + expect(result.elevation.profile[0]!.distance).toBe(0); + expect(result.elevation.profile[0]!.elevation).toBe(34); + expect(result.elevation.profile[2]!.distance).toBeGreaterThan(0); + }); + + it("throws on invalid XML", () => { + expect(() => parseGpx("not xml at all <<<<")).toThrow("Invalid GPX XML"); + }); +}); diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts new file mode 100644 index 0000000..d1069ea --- /dev/null +++ b/packages/gpx/src/parse.ts @@ -0,0 +1,103 @@ +import type { Waypoint } from "@trails-cool/types"; +import type { GpxData, TrackPoint, ElevationProfile } from "./types"; + +/** + * Parse a GPX XML string into structured data. + */ +export function parseGpx(xml: string): GpxData { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, "application/xml"); + + const parserError = doc.querySelector("parsererror"); + if (parserError) { + throw new Error(`Invalid GPX XML: ${parserError.textContent}`); + } + + const name = doc.querySelector("metadata > name")?.textContent ?? undefined; + const waypoints = parseWaypoints(doc); + const tracks = parseTracks(doc); + const elevation = computeElevation(tracks); + + return { name, waypoints, tracks, elevation }; +} + +function parseWaypoints(doc: Document): Waypoint[] { + const wpts = doc.querySelectorAll("wpt"); + return Array.from(wpts).map((wpt) => { + const lat = parseFloat(wpt.getAttribute("lat") ?? "0"); + const lon = parseFloat(wpt.getAttribute("lon") ?? "0"); + const name = wpt.querySelector("name")?.textContent ?? undefined; + return { lat, lon, name }; + }); +} + +function parseTracks(doc: Document): TrackPoint[][] { + const tracks: TrackPoint[][] = []; + const trksegs = doc.querySelectorAll("trk > trkseg"); + + for (const seg of trksegs) { + const points: TrackPoint[] = []; + for (const pt of seg.querySelectorAll("trkpt")) { + const lat = parseFloat(pt.getAttribute("lat") ?? "0"); + const lon = parseFloat(pt.getAttribute("lon") ?? "0"); + const eleText = pt.querySelector("ele")?.textContent; + const time = pt.querySelector("time")?.textContent ?? undefined; + points.push({ + lat, + lon, + ele: eleText ? parseFloat(eleText) : undefined, + time, + }); + } + tracks.push(points); + } + + return tracks; +} + +function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] { + let gain = 0; + let loss = 0; + const profile: ElevationProfile[] = []; + let totalDistance = 0; + + for (const track of tracks) { + for (let i = 0; i < track.length; i++) { + const pt = track[i]!; + + if (i > 0) { + const prev = track[i - 1]!; + totalDistance += haversineDistance(prev.lat, prev.lon, pt.lat, pt.lon); + + if (pt.ele !== undefined && prev.ele !== undefined) { + const diff = pt.ele - prev.ele; + if (diff > 0) gain += diff; + else loss += Math.abs(diff); + } + } + + if (pt.ele !== undefined) { + profile.push({ distance: totalDistance, elevation: pt.ele }); + } + } + } + + return { gain: Math.round(gain), loss: Math.round(loss), profile }; +} + +/** Haversine distance between two points in meters */ +function haversineDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number { + const R = 6371000; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts new file mode 100644 index 0000000..d7861cc --- /dev/null +++ b/packages/gpx/src/types.ts @@ -0,0 +1,26 @@ +import type { Waypoint } from "@trails-cool/types"; + +export interface TrackPoint { + lat: number; + lon: number; + ele?: number; + time?: string; +} + +export interface ElevationProfile { + /** Distance from start in meters */ + distance: number; + /** Elevation in meters */ + elevation: number; +} + +export interface GpxData { + name?: string; + waypoints: Waypoint[]; + tracks: TrackPoint[][]; + elevation: { + gain: number; + loss: number; + profile: ElevationProfile[]; + }; +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 471ff28..f55bf10 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -2,6 +2,15 @@ "name": "@trails-cool/i18n", "version": "0.0.1", "type": "module", + "exports": { + ".": "./src/index.ts", + "./locales/*": "./src/locales/*" + }, "main": "./src/index.ts", - "types": "./src/index.ts" + "types": "./src/index.ts", + "peerDependencies": { + "react": ">=18", + "i18next": ">=23", + "react-i18next": ">=14" + } } diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index bc8a73d..8b069b6 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,5 +1,29 @@ -/** - * Shared i18n configuration and translation strings for trails.cool - * Uses react-i18next. Start with English + German. - */ -export {}; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import en from "./locales/en"; +import de from "./locales/de"; + +export const defaultNS = "common"; + +export const resources = { + en: { common: en.common, planner: en.planner, journal: en.journal }, + de: { common: de.common, planner: de.planner, journal: de.journal }, +} as const; + +export function initI18n() { + return i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + defaultNS, + fallbackLng: "en", + supportedLngs: ["en", "de"], + interpolation: { + escapeValue: false, + }, + }); +} + +export { i18n }; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts new file mode 100644 index 0000000..63d6037 --- /dev/null +++ b/packages/i18n/src/locales/de.ts @@ -0,0 +1,59 @@ +export default { + common: { + save: "Speichern", + cancel: "Abbrechen", + delete: "Löschen", + edit: "Bearbeiten", + close: "Schließen", + loading: "Wird geladen...", + error: "Etwas ist schiefgelaufen", + }, + planner: { + title: "trails.cool Planer", + subtitle: "Gemeinsame Routenplanung", + newSession: "Neue Sitzung", + saveRoute: "Route speichern", + exportGpx: "GPX exportieren", + profile: "Routing-Profil", + profiles: { + trekking: "Wandern", + bike: "Radfahren", + shortest: "Kürzeste", + }, + elevation: { + gain: "Höhenmeter aufwärts", + loss: "Höhenmeter abwärts", + profile: "Höhenprofil", + }, + }, + journal: { + title: "trails.cool", + subtitle: "Dein Outdoor-Aktivitäten-Tagebuch", + routes: { + title: "Routen", + new: "Neue Route", + edit: "Route bearbeiten", + editInPlanner: "Im Planer bearbeiten", + importGpx: "GPX importieren", + exportGpx: "GPX exportieren", + delete: "Route löschen", + distance: "Strecke", + elevationGain: "Höhenmeter", + }, + activities: { + title: "Aktivitäten", + new: "Neue Aktivität", + duration: "Dauer", + linkToRoute: "Mit Route verknüpfen", + createRouteFromActivity: "Route aus Aktivität erstellen", + }, + auth: { + login: "Anmelden", + register: "Registrieren", + logout: "Abmelden", + email: "E-Mail", + password: "Passwort", + username: "Benutzername", + }, + }, +} as const; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts new file mode 100644 index 0000000..bffca1f --- /dev/null +++ b/packages/i18n/src/locales/en.ts @@ -0,0 +1,59 @@ +export default { + common: { + save: "Save", + cancel: "Cancel", + delete: "Delete", + edit: "Edit", + close: "Close", + loading: "Loading...", + error: "Something went wrong", + }, + planner: { + title: "trails.cool Planner", + subtitle: "Collaborative route planning", + newSession: "New Session", + saveRoute: "Save Route", + exportGpx: "Export GPX", + profile: "Routing Profile", + profiles: { + trekking: "Hiking", + bike: "Cycling", + shortest: "Shortest", + }, + elevation: { + gain: "Elevation Gain", + loss: "Elevation Loss", + profile: "Elevation Profile", + }, + }, + journal: { + title: "trails.cool", + subtitle: "Your outdoor activity journal", + routes: { + title: "Routes", + new: "New Route", + edit: "Edit Route", + editInPlanner: "Edit in Planner", + importGpx: "Import GPX", + exportGpx: "Export GPX", + delete: "Delete Route", + distance: "Distance", + elevationGain: "Elevation Gain", + }, + activities: { + title: "Activities", + new: "New Activity", + duration: "Duration", + linkToRoute: "Link to Route", + createRouteFromActivity: "Create Route from Activity", + }, + auth: { + login: "Log In", + register: "Sign Up", + logout: "Log Out", + email: "Email", + password: "Password", + username: "Username", + }, + }, +} as const; diff --git a/packages/map/package.json b/packages/map/package.json index c7b6eb9..ad780b9 100644 --- a/packages/map/package.json +++ b/packages/map/package.json @@ -2,6 +2,15 @@ "name": "@trails-cool/map", "version": "0.0.1", "type": "module", + "exports": { + ".": "./src/index.ts" + }, "main": "./src/index.ts", - "types": "./src/index.ts" + "types": "./src/index.ts", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "leaflet": ">=1.9", + "react-leaflet": ">=5" + } } diff --git a/packages/map/src/MapView.tsx b/packages/map/src/MapView.tsx new file mode 100644 index 0000000..30c3347 --- /dev/null +++ b/packages/map/src/MapView.tsx @@ -0,0 +1,34 @@ +import { MapContainer, TileLayer, LayersControl } from "react-leaflet"; +import { baseLayers } from "./layers"; +import "leaflet/dist/leaflet.css"; + +export interface MapViewProps { + center?: [number, number]; + zoom?: number; + className?: string; + children?: React.ReactNode; +} + +export function MapView({ + center = [50.1, 10.0], + zoom = 6, + className = "h-full w-full", + children, +}: MapViewProps) { + return ( + + + {baseLayers.map((layer, i) => ( + + + + ))} + + {children} + + ); +} diff --git a/packages/map/src/RouteLayer.tsx b/packages/map/src/RouteLayer.tsx new file mode 100644 index 0000000..7c16389 --- /dev/null +++ b/packages/map/src/RouteLayer.tsx @@ -0,0 +1,18 @@ +import { GeoJSON } from "react-leaflet"; +import type { GeoJsonObject } from "geojson"; + +export interface RouteLayerProps { + data: GeoJsonObject; + color?: string; + weight?: number; +} + +export function RouteLayer({ data, color = "#2563eb", weight = 4 }: RouteLayerProps) { + return ( + + ); +} diff --git a/packages/map/src/index.ts b/packages/map/src/index.ts index 1905d55..a297f36 100644 --- a/packages/map/src/index.ts +++ b/packages/map/src/index.ts @@ -1,5 +1,6 @@ -/** - * Map rendering utilities for trails.cool - * Leaflet wrappers, tile layer configs, overlay management - */ -export {}; +export { MapView } from "./MapView"; +export type { MapViewProps } from "./MapView"; +export { RouteLayer } from "./RouteLayer"; +export type { RouteLayerProps } from "./RouteLayer"; +export { baseLayers } from "./layers"; +export type { TileLayerConfig } from "./layers"; diff --git a/packages/map/src/layers.ts b/packages/map/src/layers.ts new file mode 100644 index 0000000..afa6e36 --- /dev/null +++ b/packages/map/src/layers.ts @@ -0,0 +1,29 @@ +export interface TileLayerConfig { + name: string; + url: string; + attribution: string; + maxZoom?: number; +} + +export const baseLayers: TileLayerConfig[] = [ + { + name: "OpenStreetMap", + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: '© OpenStreetMap', + maxZoom: 19, + }, + { + name: "OpenTopoMap", + url: "https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", + attribution: + '© OpenTopoMap (CC-BY-SA)', + maxZoom: 17, + }, + { + name: "CyclOSM", + url: "https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png", + attribution: + '© OpenStreetMap © CyclOSM', + maxZoom: 20, + }, +]; diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx new file mode 100644 index 0000000..0a436b7 --- /dev/null +++ b/packages/ui/src/Button.tsx @@ -0,0 +1,32 @@ +import type { ButtonHTMLAttributes } from "react"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "ghost"; + size?: "sm" | "md" | "lg"; +} + +const variantStyles = { + primary: "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800", + secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 active:bg-gray-300", + ghost: "text-gray-700 hover:bg-gray-100 active:bg-gray-200", +}; + +const sizeStyles = { + sm: "px-2.5 py-1.5 text-sm", + md: "px-4 py-2 text-sm", + lg: "px-6 py-3 text-base", +}; + +export function Button({ + variant = "primary", + size = "md", + className = "", + ...props +}: ButtonProps) { + return ( +