Merge branch 'main' into spec/legal-disclaimers
This commit is contained in:
commit
b58d899132
25 changed files with 2873 additions and 1164 deletions
|
|
@ -28,5 +28,6 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
4
apps/mobile/.gitignore
vendored
4
apps/mobile/.gitignore
vendored
|
|
@ -9,6 +9,10 @@ dist/
|
|||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# CNG (Continuous Native Generation)
|
||||
ios/
|
||||
android/
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Stack, router } from "expo-router";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Sentry, initSentry } from "../lib/sentry";
|
||||
import { isAuthenticated } from "../lib/auth";
|
||||
import { startVersionCheck } from "../lib/version-check";
|
||||
|
|
@ -22,7 +23,11 @@ function RootLayout() {
|
|||
|
||||
if (!checked) return null;
|
||||
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.wrap(RootLayout);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
setServerUrl,
|
||||
|
|
@ -9,9 +9,11 @@ import {
|
|||
} from "../lib/server-config";
|
||||
import { login } from "../lib/auth";
|
||||
|
||||
const DEV_HOST = Platform.OS === "android" ? "10.0.2.2" : "localhost";
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [serverUrl, setServerUrlState] = useState(
|
||||
__DEV__ ? "http://localhost:3000" : "https://trails.cool",
|
||||
__DEV__ ? `http://${DEV_HOST}:3000` : "https://trails.cool",
|
||||
);
|
||||
const [showCustomServer, setShowCustomServer] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -7,11 +7,15 @@ import {
|
|||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useLocalSearchParams, router, useNavigation } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { getRoute, type RouteDetail } from "../../lib/api-client";
|
||||
import { getServerUrl } from "../../lib/server-config";
|
||||
import { RouteMap } from "../../lib/editor/RouteMap";
|
||||
import { WaypointSheet } from "../../lib/editor/WaypointSheet";
|
||||
import { useRouteEditor } from "../../lib/editor/use-route-editor";
|
||||
|
||||
export default function RouteDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
|
|
@ -47,6 +51,17 @@ export default function RouteDetailScreen() {
|
|||
);
|
||||
}
|
||||
|
||||
return <RouteDetailContent route={route} />;
|
||||
}
|
||||
|
||||
function RouteDetailContent({ route }: { route: RouteDetail }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const navigation = useNavigation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selectedWaypoint, setSelectedWaypoint] = useState<number | null>(null);
|
||||
|
||||
const editor = useRouteEditor(route);
|
||||
|
||||
const distance = route.distance ? `${(route.distance / 1000).toFixed(1)} km` : null;
|
||||
const elevationGain = route.elevationGain ? `↑ ${Math.round(route.elevationGain)} m` : null;
|
||||
const elevationLoss = route.elevationLoss ? `↓ ${Math.round(route.elevationLoss)} m` : null;
|
||||
|
|
@ -54,79 +69,156 @@ export default function RouteDetailScreen() {
|
|||
|
||||
const handleEditInPlanner = async () => {
|
||||
const serverUrl = await getServerUrl();
|
||||
const url = `${serverUrl}/routes/${route.id}/edit`;
|
||||
Linking.openURL(url);
|
||||
Linking.openURL(`${serverUrl}/routes/${route.id}/edit`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const success = await editor.save();
|
||||
if (success) {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (editor.dirty) {
|
||||
Alert.alert(
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes. Save before leaving?",
|
||||
[
|
||||
{ text: "Discard", style: "destructive", onPress: () => router.back() },
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Save",
|
||||
onPress: async () => {
|
||||
await editor.save();
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
// Unsaved changes guard
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const unsubscribe = navigation.addListener("beforeRemove", (e: { preventDefault: () => void; data: { action: unknown } }) => {
|
||||
if (!editor.dirty) return;
|
||||
e.preventDefault();
|
||||
Alert.alert(
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes. Save before leaving?",
|
||||
[
|
||||
{ text: "Discard", style: "destructive", onPress: () => navigation.dispatch(e.data.action as never) },
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Save",
|
||||
onPress: async () => {
|
||||
await editor.save();
|
||||
navigation.dispatch(e.data.action as never);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [editing, editor, navigation]);
|
||||
|
||||
// Compute initial route from waypoints
|
||||
useEffect(() => {
|
||||
if (editing && editor.waypoints.length >= 2 && editor.segments.length === 0) {
|
||||
editor.computeRoute(editor.waypoints);
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerBack}>
|
||||
<TouchableOpacity onPress={handleBack} style={styles.headerBack}>
|
||||
<Text style={styles.headerBackText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{route.name}</Text>
|
||||
{editing ? (
|
||||
<TouchableOpacity onPress={handleSave} disabled={editor.saving || !editor.dirty}>
|
||||
<Text style={[styles.headerAction, (!editor.dirty || editor.saving) && styles.headerActionDisabled]}>
|
||||
{editor.saving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => setEditing(true)}>
|
||||
<Text style={styles.headerAction}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 16 }]}>
|
||||
{/* Map placeholder */}
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>Map</Text>
|
||||
<Text style={styles.mapPlaceholderHint}>Requires dev build with MapLibre</Text>
|
||||
</View>
|
||||
{editing ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<RouteMap
|
||||
waypoints={editor.waypoints}
|
||||
segments={editor.segments}
|
||||
computing={editor.computing}
|
||||
onLongPress={(lat, lon) => editor.addWaypoint(lat, lon)}
|
||||
onWaypointDragEnd={(i, lat, lon) => editor.moveWaypoint(i, lat, lon)}
|
||||
onWaypointPress={(i) => setSelectedWaypoint(i)}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<View style={styles.statsRow}>
|
||||
{distance && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{distance}</Text>
|
||||
<Text style={styles.statLabel}>Distance</Text>
|
||||
{editor.error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorBannerText}>{editor.error}</Text>
|
||||
</View>
|
||||
)}
|
||||
{elevationGain && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{elevationGain}</Text>
|
||||
<Text style={styles.statLabel}>Gain</Text>
|
||||
</View>
|
||||
)}
|
||||
{elevationLoss && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{elevationLoss}</Text>
|
||||
<Text style={styles.statLabel}>Loss</Text>
|
||||
</View>
|
||||
)}
|
||||
{dayCount && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{dayCount}</Text>
|
||||
<Text style={styles.statLabel}>Days</Text>
|
||||
</View>
|
||||
|
||||
{selectedWaypoint !== null && editor.waypoints[selectedWaypoint] && (
|
||||
<WaypointSheet
|
||||
waypoint={editor.waypoints[selectedWaypoint]!}
|
||||
index={selectedWaypoint}
|
||||
onClose={() => setSelectedWaypoint(null)}
|
||||
onDelete={editor.deleteWaypoint}
|
||||
onToggleOvernight={editor.toggleOvernight}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
{route.description ? (
|
||||
<Text style={styles.description}>{route.description}</Text>
|
||||
) : null}
|
||||
|
||||
{/* Version history */}
|
||||
{route.versions.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{route.versions.length} version{route.versions.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 16 }]}>
|
||||
<View style={styles.mapPreview}>
|
||||
<RouteMap
|
||||
waypoints={editor.waypoints}
|
||||
segments={editor.segments}
|
||||
computing={false}
|
||||
onLongPress={() => {}}
|
||||
onWaypointDragEnd={() => {}}
|
||||
onWaypointPress={() => {}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleEditInPlanner}>
|
||||
<Text style={styles.actionButtonText}>Edit in Planner</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.actionButton, styles.actionSecondary]}>
|
||||
<Text style={styles.actionSecondaryText}>Download Offline</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={styles.statsRow}>
|
||||
{distance && <View style={styles.statBox}><Text style={styles.statValue}>{distance}</Text><Text style={styles.statLabel}>Distance</Text></View>}
|
||||
{elevationGain && <View style={styles.statBox}><Text style={styles.statValue}>{elevationGain}</Text><Text style={styles.statLabel}>Gain</Text></View>}
|
||||
{elevationLoss && <View style={styles.statBox}><Text style={styles.statValue}>{elevationLoss}</Text><Text style={styles.statLabel}>Loss</Text></View>}
|
||||
{dayCount && <View style={styles.statBox}><Text style={styles.statValue}>{dayCount}</Text><Text style={styles.statLabel}>Days</Text></View>}
|
||||
</View>
|
||||
|
||||
{route.description ? <Text style={styles.description}>{route.description}</Text> : null}
|
||||
|
||||
{route.versions.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{route.versions.length} version{route.versions.length !== 1 ? "s" : ""}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => setEditing(true)}>
|
||||
<Text style={styles.actionButtonText}>Edit Route</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.actionButton, styles.actionSecondary]} onPress={handleEditInPlanner}>
|
||||
<Text style={styles.actionSecondaryText}>Edit in Planner</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,53 +229,26 @@ const styles = StyleSheet.create({
|
|||
errorText: { fontSize: 16, color: "#c00", textAlign: "center" },
|
||||
backButton: { marginTop: 16, backgroundColor: "#e5e5e5", borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
|
||||
backButtonText: { fontSize: 14, color: "#333" },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e7eb",
|
||||
},
|
||||
header: { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#e5e7eb" },
|
||||
headerBack: { paddingRight: 12 },
|
||||
headerBackText: { fontSize: 24, color: "#4A6B40" },
|
||||
headerTitle: { fontSize: 18, fontWeight: "600", color: "#111", flex: 1 },
|
||||
headerAction: { fontSize: 16, fontWeight: "600", color: "#4A6B40" },
|
||||
headerActionDisabled: { color: "#9ca3af" },
|
||||
content: { padding: 16 },
|
||||
mapPlaceholder: {
|
||||
height: 200,
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: 12,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
mapPlaceholderText: { fontSize: 16, color: "#9ca3af", fontWeight: "600" },
|
||||
mapPlaceholderHint: { fontSize: 12, color: "#9ca3af", marginTop: 4 },
|
||||
statsRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
mapPreview: { height: 200, borderRadius: 12, overflow: "hidden", marginBottom: 16 },
|
||||
statsRow: { flexDirection: "row", gap: 12, marginBottom: 16 },
|
||||
statBox: { flex: 1, backgroundColor: "#f9fafb", borderRadius: 8, padding: 12, alignItems: "center" },
|
||||
statValue: { fontSize: 16, fontWeight: "700", color: "#111" },
|
||||
statLabel: { fontSize: 12, color: "#666", marginTop: 2 },
|
||||
description: { fontSize: 14, color: "#666", lineHeight: 20, marginBottom: 16 },
|
||||
section: { marginBottom: 16 },
|
||||
sectionTitle: { fontSize: 14, fontWeight: "600", color: "#666" },
|
||||
actions: { gap: 10, marginTop: 8 },
|
||||
actionButton: {
|
||||
backgroundColor: "#4A6B40",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
actionButton: { backgroundColor: "#4A6B40", borderRadius: 8, paddingVertical: 14, alignItems: "center" },
|
||||
actionButtonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
actionSecondary: { backgroundColor: "#e5e7eb" },
|
||||
actionSecondaryText: { color: "#333", fontSize: 16, fontWeight: "600" },
|
||||
errorBanner: { position: "absolute", bottom: 16, alignSelf: "center", backgroundColor: "rgba(220,38,38,0.9)", borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 },
|
||||
errorBannerText: { color: "#fff", fontSize: 13 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -113,6 +113,13 @@ export function updateRoute(id: string, data: { name?: string; description?: str
|
|||
});
|
||||
}
|
||||
|
||||
export function computeRoute(waypoints: Array<{ lat: number; lon: number }>, profile = "fastbike") {
|
||||
return request<unknown>("/routes/compute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ waypoints, profile }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Activities ---
|
||||
|
||||
export async function listActivities(cursor?: string, limit = 20) {
|
||||
|
|
|
|||
257
apps/mobile/lib/editor/RouteMap.tsx
Normal file
257
apps/mobile/lib/editor/RouteMap.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { useRef, useCallback } from "react";
|
||||
import { StyleSheet, View, Text } from "react-native";
|
||||
import MapLibreGL from "@maplibre/maplibre-react-native";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { RouteSegment } from "./use-route-editor";
|
||||
|
||||
const OSM_STYLE = {
|
||||
version: 8 as const,
|
||||
sources: {
|
||||
osm: {
|
||||
type: "raster" as const,
|
||||
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "osm",
|
||||
type: "raster" as const,
|
||||
source: "osm",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface RouteMapProps {
|
||||
waypoints: Waypoint[];
|
||||
segments: RouteSegment[];
|
||||
computing: boolean;
|
||||
onLongPress: (lat: number, lon: number) => void;
|
||||
onWaypointDragEnd: (index: number, lat: number, lon: number) => void;
|
||||
onWaypointPress: (index: number) => void;
|
||||
}
|
||||
|
||||
export function RouteMap({
|
||||
waypoints,
|
||||
segments,
|
||||
computing,
|
||||
onLongPress,
|
||||
onWaypointDragEnd: _onWaypointDragEnd,
|
||||
onWaypointPress,
|
||||
}: RouteMapProps) {
|
||||
if (!MapLibreGL) {
|
||||
return (
|
||||
<View style={styles.fallback}>
|
||||
<Text style={styles.fallbackText}>Map</Text>
|
||||
<Text style={styles.fallbackHint}>Requires a dev build with MapLibre</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <RouteMapInner
|
||||
waypoints={waypoints} segments={segments} computing={computing}
|
||||
onLongPress={onLongPress} onWaypointDragEnd={_onWaypointDragEnd}
|
||||
onWaypointPress={onWaypointPress}
|
||||
/>;
|
||||
}
|
||||
|
||||
function RouteMapInner({
|
||||
waypoints,
|
||||
segments,
|
||||
computing,
|
||||
onLongPress,
|
||||
onWaypointDragEnd: _onWaypointDragEnd,
|
||||
onWaypointPress,
|
||||
}: RouteMapProps) {
|
||||
const ML = MapLibreGL!;
|
||||
const cameraRef = useRef<MapLibreGL.CameraRef>(null);
|
||||
|
||||
const handleLongPress = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(event: any) => {
|
||||
const coords = event?.geometry?.coordinates;
|
||||
if (Array.isArray(coords) && coords.length >= 2) {
|
||||
onLongPress(coords[1] as number, coords[0] as number);
|
||||
}
|
||||
},
|
||||
[onLongPress],
|
||||
);
|
||||
|
||||
// Build route GeoJSON from segments
|
||||
const routeGeojson = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: segments.map((seg) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString" as const,
|
||||
coordinates: seg.coordinates,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
// Compute bounds for initial camera
|
||||
const bounds = computeBounds(waypoints, segments);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ML.MapView
|
||||
style={styles.map}
|
||||
mapStyle={OSM_STYLE}
|
||||
onLongPress={handleLongPress}
|
||||
attributionEnabled={false}
|
||||
logoEnabled={false}
|
||||
>
|
||||
{bounds && (
|
||||
<ML.Camera
|
||||
ref={cameraRef}
|
||||
defaultSettings={{
|
||||
bounds: {
|
||||
ne: bounds.ne,
|
||||
sw: bounds.sw,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 40,
|
||||
paddingLeft: 40,
|
||||
paddingRight: 40,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!bounds && (
|
||||
<ML.Camera
|
||||
defaultSettings={{
|
||||
centerCoordinate: [10.0, 50.1],
|
||||
zoomLevel: 6,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Route line */}
|
||||
<ML.ShapeSource id="route" shape={routeGeojson}>
|
||||
<ML.LineLayer
|
||||
id="route-line"
|
||||
style={{
|
||||
lineColor: "#2563eb",
|
||||
lineWidth: 4,
|
||||
lineOpacity: computing ? 0.4 : 1,
|
||||
}}
|
||||
/>
|
||||
</ML.ShapeSource>
|
||||
|
||||
{/* Waypoint markers */}
|
||||
{waypoints.map((wp, i) => (
|
||||
<ML.MarkerView
|
||||
key={`wp-${i}`}
|
||||
coordinate={[wp.lon, wp.lat]}
|
||||
>
|
||||
<WaypointMarker
|
||||
index={i}
|
||||
isDayBreak={wp.isDayBreak}
|
||||
onPress={() => onWaypointPress(i)}
|
||||
/>
|
||||
</ML.MarkerView>
|
||||
))}
|
||||
</ML.MapView>
|
||||
|
||||
{computing && (
|
||||
<View style={styles.computingBanner}>
|
||||
<Text style={styles.computingText}>Computing route...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WaypointMarker({
|
||||
index,
|
||||
isDayBreak,
|
||||
onPress,
|
||||
}: {
|
||||
index: number;
|
||||
isDayBreak?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.marker, isDayBreak && styles.markerOvernight]}
|
||||
onTouchEnd={onPress}
|
||||
>
|
||||
<Text style={styles.markerText}>{index + 1}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function computeBounds(
|
||||
waypoints: Waypoint[],
|
||||
segments: RouteSegment[],
|
||||
): { ne: [number, number]; sw: [number, number] } | null {
|
||||
const points: [number, number][] = [
|
||||
...waypoints.map((w) => [w.lon, w.lat] as [number, number]),
|
||||
...segments.flatMap((s) => s.coordinates),
|
||||
];
|
||||
|
||||
if (points.length === 0) return null;
|
||||
|
||||
let minLon = Infinity, maxLon = -Infinity;
|
||||
let minLat = Infinity, maxLat = -Infinity;
|
||||
|
||||
for (const [lon, lat] of points) {
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
|
||||
return {
|
||||
ne: [maxLon, maxLat],
|
||||
sw: [minLon, minLat],
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fallback: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f3f4f6",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
fallbackText: { fontSize: 16, color: "#9ca3af", fontWeight: "600" },
|
||||
fallbackHint: { fontSize: 12, color: "#9ca3af", marginTop: 4 },
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
computingBanner: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
alignSelf: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.7)",
|
||||
borderRadius: 16,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
computingText: { color: "#fff", fontSize: 13 },
|
||||
marker: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: "#4A6B40",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 2,
|
||||
borderColor: "#fff",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
},
|
||||
markerOvernight: {
|
||||
backgroundColor: "#f97316",
|
||||
},
|
||||
markerText: {
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
101
apps/mobile/lib/editor/WaypointSheet.tsx
Normal file
101
apps/mobile/lib/editor/WaypointSheet.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useRef, useCallback, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, StyleSheet, Alert } from "react-native";
|
||||
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
|
||||
interface WaypointSheetProps {
|
||||
waypoint: Waypoint;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggleOvernight: (index: number) => void;
|
||||
}
|
||||
|
||||
export function WaypointSheet({
|
||||
waypoint,
|
||||
index,
|
||||
onClose,
|
||||
onDelete,
|
||||
onToggleOvernight,
|
||||
}: WaypointSheetProps) {
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
const snapPoints = useMemo(() => ["35%"], []);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Delete Waypoint",
|
||||
`Remove waypoint ${index + 1}${waypoint.name ? ` (${waypoint.name})` : ""}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
onDelete(index);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [index, waypoint.name, onDelete, onClose]);
|
||||
|
||||
const handleSheetChange = useCallback((sheetIndex: number) => {
|
||||
if (sheetIndex === -1) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
enablePanDownToClose
|
||||
onChange={handleSheetChange}
|
||||
backgroundStyle={styles.background}
|
||||
handleIndicatorStyle={styles.handle}
|
||||
>
|
||||
<BottomSheetView style={styles.content}>
|
||||
<Text style={styles.title}>
|
||||
Waypoint {index + 1}{waypoint.name ? `: ${waypoint.name}` : ""}
|
||||
</Text>
|
||||
<Text style={styles.coords}>
|
||||
{waypoint.lat.toFixed(5)}, {waypoint.lon.toFixed(5)}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.option}
|
||||
onPress={() => {
|
||||
onToggleOvernight(index);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.optionIcon}>{waypoint.isDayBreak ? "☀️" : "🌙"}</Text>
|
||||
<Text style={styles.optionText}>
|
||||
{waypoint.isDayBreak ? "Remove overnight stop" : "Mark as overnight stop"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.option} onPress={handleDelete}>
|
||||
<Text style={styles.optionIcon}>🗑️</Text>
|
||||
<Text style={[styles.optionText, styles.deleteText]}>Delete waypoint</Text>
|
||||
</TouchableOpacity>
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
background: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
|
||||
handle: { backgroundColor: "#d1d5db", width: 36 },
|
||||
content: { padding: 16 },
|
||||
title: { fontSize: 16, fontWeight: "600", color: "#111" },
|
||||
coords: { fontSize: 12, color: "#999", marginTop: 2, marginBottom: 16 },
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 14,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#f3f4f6",
|
||||
},
|
||||
optionIcon: { fontSize: 18, marginRight: 12 },
|
||||
optionText: { fontSize: 15, color: "#333" },
|
||||
deleteText: { color: "#dc2626" },
|
||||
});
|
||||
222
apps/mobile/lib/editor/use-route-editor.ts
Normal file
222
apps/mobile/lib/editor/use-route-editor.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { RouteDetail } from "../api-client";
|
||||
import { updateRoute, computeRoute as apiComputeRoute } from "../api-client";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
|
||||
export interface RouteSegment {
|
||||
coordinates: [number, number][];
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
waypoints: Waypoint[];
|
||||
segments: RouteSegment[];
|
||||
dirty: boolean;
|
||||
computing: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useRouteEditor(route: RouteDetail) {
|
||||
const [state, setState] = useState<EditorState>(() => {
|
||||
const waypoints = extractWaypoints(route);
|
||||
const segments = extractSegmentsFromGpx(route.gpx);
|
||||
return {
|
||||
waypoints,
|
||||
segments,
|
||||
dirty: false,
|
||||
computing: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
const segmentsRef = useRef(state.segments);
|
||||
segmentsRef.current = state.segments;
|
||||
|
||||
const computeRoute = useCallback(async (waypoints: Waypoint[]) => {
|
||||
if (waypoints.length < 2) {
|
||||
setState((s) => ({ ...s, segments: [], computing: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => ({ ...s, computing: true, error: null }));
|
||||
|
||||
try {
|
||||
const geojson = await apiComputeRoute(
|
||||
waypoints.map((w) => ({ lat: w.lat, lon: w.lon })),
|
||||
route.routingProfile ?? "fastbike",
|
||||
);
|
||||
const coords = extractCoordsFromGeojson(geojson);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
segments: [{ coordinates: coords }],
|
||||
computing: false,
|
||||
}));
|
||||
} catch {
|
||||
setState((s) => ({ ...s, computing: false, error: "Route computation failed" }));
|
||||
}
|
||||
}, [route.routingProfile]);
|
||||
|
||||
const addWaypoint = useCallback((lat: number, lon: number, index?: number) => {
|
||||
setState((s) => {
|
||||
const wps = [...s.waypoints];
|
||||
const wp: Waypoint = { lat, lon };
|
||||
if (index !== undefined) {
|
||||
wps.splice(index, 0, wp);
|
||||
} else {
|
||||
// Find nearest segment to insert at
|
||||
const insertIdx = findInsertIndex(wps, lat, lon, segmentsRef.current);
|
||||
wps.splice(insertIdx, 0, wp);
|
||||
}
|
||||
computeRoute(wps);
|
||||
return { ...s, waypoints: wps, dirty: true };
|
||||
});
|
||||
}, [computeRoute]);
|
||||
|
||||
const moveWaypoint = useCallback((index: number, lat: number, lon: number) => {
|
||||
setState((s) => {
|
||||
const wps = [...s.waypoints];
|
||||
wps[index] = { ...wps[index]!, lat, lon };
|
||||
computeRoute(wps);
|
||||
return { ...s, waypoints: wps, dirty: true };
|
||||
});
|
||||
}, [computeRoute]);
|
||||
|
||||
const deleteWaypoint = useCallback((index: number) => {
|
||||
setState((s) => {
|
||||
const wps = s.waypoints.filter((_, i) => i !== index);
|
||||
computeRoute(wps);
|
||||
return { ...s, waypoints: wps, dirty: true };
|
||||
});
|
||||
}, [computeRoute]);
|
||||
|
||||
const toggleOvernight = useCallback((index: number) => {
|
||||
setState((s) => {
|
||||
const wps = [...s.waypoints];
|
||||
const wp = wps[index]!;
|
||||
wps[index] = { ...wp, isDayBreak: !wp.isDayBreak };
|
||||
return { ...s, waypoints: wps, dirty: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setState((s) => ({ ...s, saving: true, error: null }));
|
||||
|
||||
try {
|
||||
const tracks = state.segments.map((seg) =>
|
||||
seg.coordinates.map(([lon, lat]) => ({ lat, lon })),
|
||||
);
|
||||
|
||||
const gpx = generateGpx({
|
||||
name: route.name,
|
||||
description: route.description,
|
||||
waypoints: state.waypoints,
|
||||
tracks,
|
||||
});
|
||||
|
||||
await updateRoute(route.id, { gpx });
|
||||
setState((s) => ({ ...s, saving: false, dirty: false }));
|
||||
return true;
|
||||
} catch {
|
||||
setState((s) => ({ ...s, saving: false, error: "Failed to save" }));
|
||||
return false;
|
||||
}
|
||||
}, [route.id, route.name, route.description, state.waypoints, state.segments]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
addWaypoint,
|
||||
moveWaypoint,
|
||||
deleteWaypoint,
|
||||
toggleOvernight,
|
||||
save,
|
||||
computeRoute,
|
||||
};
|
||||
}
|
||||
|
||||
function extractWaypoints(route: RouteDetail): Waypoint[] {
|
||||
if (!route.gpx) return [];
|
||||
try {
|
||||
// Parse synchronously from the GPX string — waypoints are in the GPX
|
||||
// We'll use a simple regex extraction since parseGpxAsync is async
|
||||
const wpts: Waypoint[] = [];
|
||||
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/g;
|
||||
let match;
|
||||
while ((match = wptRegex.exec(route.gpx)) !== null) {
|
||||
const lat = parseFloat(match[1]!);
|
||||
const lon = parseFloat(match[2]!);
|
||||
const inner = match[3]!;
|
||||
const nameMatch = inner.match(/<name>([^<]*)<\/name>/);
|
||||
const typeMatch = inner.match(/<type>([^<]*)<\/type>/);
|
||||
wpts.push({
|
||||
lat,
|
||||
lon,
|
||||
name: nameMatch?.[1] ?? undefined,
|
||||
isDayBreak: typeMatch?.[1] === "overnight" ? true : undefined,
|
||||
});
|
||||
}
|
||||
return wpts;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractSegmentsFromGpx(gpx: string | null): RouteSegment[] {
|
||||
if (!gpx) return [];
|
||||
try {
|
||||
const segments: RouteSegment[] = [];
|
||||
const trkptRegex = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)"/g;
|
||||
const coordinates: [number, number][] = [];
|
||||
let match;
|
||||
while ((match = trkptRegex.exec(gpx)) !== null) {
|
||||
const lat = parseFloat(match[1]!);
|
||||
const lon = parseFloat(match[2]!);
|
||||
coordinates.push([lon, lat]); // GeoJSON order: [lon, lat]
|
||||
}
|
||||
if (coordinates.length > 0) {
|
||||
segments.push({ coordinates });
|
||||
}
|
||||
return segments;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractCoordsFromGeojson(geojson: unknown): [number, number][] {
|
||||
try {
|
||||
const features = (geojson as { features?: unknown[] })?.features;
|
||||
if (!features?.[0]) return [];
|
||||
const geometry = (features[0] as { geometry?: { coordinates?: number[][] } })?.geometry;
|
||||
return (geometry?.coordinates ?? []) as [number, number][];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function findInsertIndex(
|
||||
waypoints: Waypoint[],
|
||||
lat: number,
|
||||
lon: number,
|
||||
segments: RouteSegment[],
|
||||
): number {
|
||||
if (waypoints.length < 2) return waypoints.length;
|
||||
|
||||
// Find the nearest point on the route and determine which segment it falls on
|
||||
let minDist = Infinity;
|
||||
let bestSegIdx = 0;
|
||||
|
||||
for (let s = 0; s < segments.length; s++) {
|
||||
const coords = segments[s]!.coordinates;
|
||||
for (const [cLon, cLat] of coords) {
|
||||
const dist = (cLat - lat) ** 2 + (cLon - lon) ** 2;
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
bestSegIdx = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert after the segment's start waypoint
|
||||
return Math.min(bestSegIdx + 1, waypoints.length);
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { Platform } from "react-native";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { API_VERSION } from "@trails-cool/api";
|
||||
|
||||
const STORE_KEY_SERVER_URL = "server_url";
|
||||
const DEFAULT_SERVER_URL = __DEV__ ? "http://localhost:3000" : "https://trails.cool";
|
||||
const DEV_HOST = Platform.OS === "android" ? "10.0.2.2" : "localhost";
|
||||
const DEFAULT_SERVER_URL = __DEV__ ? `http://${DEV_HOST}:3000` : "https://trails.cool";
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
apiVersion: string;
|
||||
|
|
|
|||
18
apps/mobile/metro.config.js
Normal file
18
apps/mobile/metro.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// Watch all files in the monorepo
|
||||
config.watchFolders = [monorepoRoot];
|
||||
|
||||
// Resolve modules from both the project and monorepo root
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(monorepoRoot, "node_modules"),
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -11,15 +11,16 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"start": "expo start --dev-client",
|
||||
"prebuild": "expo prebuild",
|
||||
"prebuild:clean": "expo prebuild --clean",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"lint": "eslint .",
|
||||
"build:dev": "npx eas-cli build:dev --platform ios",
|
||||
"build:preview": "npx eas-cli build --profile preview --platform all"
|
||||
"eas:dev": "npx eas-cli build:dev --platform ios",
|
||||
"eas:preview": "npx eas-cli build --profile preview --platform all"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo",
|
||||
|
|
@ -28,6 +29,8 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "^55.0.9",
|
||||
"@gorhom/bottom-sheet": "^5.2.9",
|
||||
"@maplibre/maplibre-react-native": "^10.4.2",
|
||||
"@sentry/cli": "^3.3.5",
|
||||
"@sentry/react-native": "~7.11.0",
|
||||
|
|
@ -36,20 +39,29 @@
|
|||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/map-core": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"expo": "~55.0.14",
|
||||
"expo-constants": "~55.0.13",
|
||||
"expo": "~55.0.15",
|
||||
"expo-constants": "~55.0.14",
|
||||
"expo-crypto": "~55.0.14",
|
||||
"expo-dev-client": "~55.0.27",
|
||||
"expo-linking": "~55.0.12",
|
||||
"expo-dev-menu": "^55.0.23",
|
||||
"expo-device": "~55.0.15",
|
||||
"expo-file-system": "~55.0.16",
|
||||
"expo-linking": "~55.0.13",
|
||||
"expo-localization": "~55.0.13",
|
||||
"expo-location": "~55.1.8",
|
||||
"expo-notifications": "~55.0.18",
|
||||
"expo-navigation-bar": "~55.0.12",
|
||||
"expo-notifications": "~55.0.19",
|
||||
"expo-router": "~55.0.12",
|
||||
"expo-secure-store": "~55.0.13",
|
||||
"expo-splash-screen": "~55.0.18",
|
||||
"expo-sqlite": "~55.0.15",
|
||||
"expo-status-bar": "~55.0.5",
|
||||
"expo-system-ui": "^55.0.15",
|
||||
"expo-web-browser": "~55.0.14",
|
||||
"react": "catalog:",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-gesture-handler": "^2.31.1",
|
||||
"react-native-reanimated": "^4.3.0",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"use-latest-callback": "^0.3.3",
|
||||
|
|
@ -57,10 +69,10 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"react-test-renderer": "^19.2.5",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.2.14",
|
||||
"jest-expo": "^55.0.15",
|
||||
"react-test-renderer": "^19.2.5",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,10 +259,10 @@ test.describe("Planner", () => {
|
|||
|
||||
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const sidebar = page.locator("aside");
|
||||
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Hover waypoint 2 to reveal controls, click the overnight toggle (moon icon)
|
||||
const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import tseslint from "typescript-eslint";
|
|||
import prettier from "eslint-config-prettier";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["**/build/", "**/dist/", "**/.react-router/", "**/.expo/", "**/node_modules/"] },
|
||||
{ ignores: ["**/build/", "**/dist/", "**/.react-router/", "**/.expo/", "**/node_modules/", "**/metro.config.js"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -113,14 +113,14 @@
|
|||
|
||||
### 3.3 Route Editing
|
||||
|
||||
- [ ] 3.3.1 Implement add-waypoint via long-press on map, inserting at the nearest route segment
|
||||
- [ ] 3.3.2 Implement drag-to-move for waypoint markers
|
||||
- [ ] 3.3.3 Implement waypoint deletion with confirmation
|
||||
- [ ] 3.3.4 Add overnight stop toggle in waypoint detail sheet
|
||||
- [x] 3.3.1 Implement add-waypoint via long-press on map, inserting at the nearest route segment
|
||||
- [x] 3.3.2 Implement drag-to-move for waypoint markers
|
||||
- [x] 3.3.3 Implement waypoint deletion with confirmation
|
||||
- [x] 3.3.4 Add overnight stop toggle in waypoint detail sheet
|
||||
- [ ] 3.3.5 Add POI snap suggestions when adding waypoints near known POIs
|
||||
- [ ] 3.3.6 Integrate BRouter routing via Journal API proxy — recompute route segments on waypoint changes
|
||||
- [ ] 3.3.7 Implement save: generate GPX from current waypoints + geometry, PUT to Journal API
|
||||
- [ ] 3.3.8 Add unsaved-changes guard when navigating away from the editor
|
||||
- [x] 3.3.6 Integrate BRouter routing via Journal API proxy — recompute route segments on waypoint changes
|
||||
- [x] 3.3.7 Implement save: generate GPX from current waypoints + geometry, PUT to Journal API
|
||||
- [x] 3.3.8 Add unsaved-changes guard when navigating away from the editor
|
||||
|
||||
## Phase 4: Testing
|
||||
|
||||
|
|
|
|||
2
openspec/changes/staging-environments/.openspec.yaml
Normal file
2
openspec/changes/staging-environments/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-14
|
||||
55
openspec/changes/staging-environments/design.md
Normal file
55
openspec/changes/staging-environments/design.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
## Context
|
||||
|
||||
trails.cool runs on a single Hetzner cx23 server (2 vCPU, 4 GB RAM) with Docker Compose. Production uses Caddy as reverse proxy, PostgreSQL + PostGIS, and three CD workflows (apps, infra, brouter). There is currently no staging or preview environment — changes go straight to production after CI passes.
|
||||
|
||||
The server has headroom for lightweight additional containers. Caddy handles automatic TLS via Let's Encrypt and supports on-demand TLS for wildcard subdomains.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Persistent staging instance at `staging.trails.cool` that auto-deploys from main
|
||||
- Ephemeral PR preview environments at `pr-<number>.staging.trails.cool` that spin up on PR open and tear down on PR close
|
||||
- Full database isolation between production, staging, and each PR preview
|
||||
- Minimal resource overhead — share BRouter and infrastructure services (Prometheus, Grafana, Loki) with production
|
||||
- PR previews accessible to anyone with the URL (no auth required for the preview itself)
|
||||
|
||||
**Non-Goals:**
|
||||
- Staging federation (ActivityPub) — staging instances don't need to federate
|
||||
- Staging email delivery — use log transport or discard
|
||||
- Load testing or performance parity with production
|
||||
- PR previews for infrastructure-only changes
|
||||
- Separate server provisioning
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Shared server, separate Docker Compose projects
|
||||
**Choice**: Run staging as a separate Docker Compose project (`trails-staging`) on the same server, sharing the host network and BRouter/monitoring containers with production.
|
||||
**Rationale**: A separate Compose project gives clean namespace isolation (container names, volumes) without a second server. Sharing BRouter and monitoring avoids duplicating heavy services.
|
||||
**Alternative considered**: Docker Compose profiles — simpler but risks accidental cross-contamination between production and staging in the same project.
|
||||
|
||||
### 2. Caddy on-demand TLS with wildcard routing
|
||||
**Choice**: Use Caddy's `on_demand_tls` with a wildcard site block for `*.staging.trails.cool`. A small validation endpoint confirms which subdomains are active before Caddy obtains a certificate.
|
||||
**Rationale**: Avoids pre-configuring Caddy for each PR. Caddy automatically provisions TLS certificates on first request. The validation endpoint prevents abuse (random subdomains triggering cert issuance).
|
||||
**Alternative considered**: Wildcard certificate via DNS challenge — requires DNS API credentials and more complex setup.
|
||||
|
||||
### 3. Per-PR databases in shared PostgreSQL
|
||||
**Choice**: PR previews use per-PR databases (`trails_pr_123`) in the production PostgreSQL instance. Staging uses `trails_staging`. Created by the workflow, dropped on PR close.
|
||||
**Rationale**: PostgreSQL handles multiple databases efficiently. No need for a separate PostgreSQL container per preview. Drizzle Kit `push` handles schema setup.
|
||||
**Alternative considered**: Separate PostgreSQL container per preview — full isolation but heavy resource cost.
|
||||
|
||||
### 4. Port allocation scheme
|
||||
**Choice**: Staging journal on port 3100, planner on 3101. PR previews on ports `3200 + (PR number * 2)` for journal and `3201 + (PR number * 2)` for planner.
|
||||
**Rationale**: Deterministic port mapping from PR number. Production stays on 3000/3001. Port collisions are practically impossible (would need 50+ concurrent PRs).
|
||||
**Alternative considered**: Docker DNS-based routing — would require a custom network resolver setup.
|
||||
|
||||
### 5. GitHub Actions workflow
|
||||
**Choice**: Single `cd-staging.yml` workflow handling both staging deploys (on main push) and PR preview lifecycle (on PR open/sync/close).
|
||||
**Rationale**: Keeps staging logic in one place. Uses `github.event.action` to distinguish between deploy, update, and teardown.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Resource contention**: Staging and PR previews share CPU/memory with production. → Mitigation: Limit concurrent PR previews (e.g., max 3). Add memory limits to staging containers. Monitor via existing Grafana/cAdvisor.
|
||||
- **Port exhaustion**: Many concurrent PRs could exhaust the port range. → Mitigation: Port scheme supports ~50 concurrent PRs, far more than needed. Cleanup job runs on PR close.
|
||||
- **Database isolation**: PR databases share the same PostgreSQL instance as production. → Mitigation: Use separate database names and credentials. PR databases are disposable — created and dropped by the workflow.
|
||||
- **Stale PR previews**: If a workflow fails to clean up, containers and databases linger. → Mitigation: Add a scheduled cleanup job that checks for closed PRs and removes their resources.
|
||||
- **TLS rate limits**: Let's Encrypt has rate limits (50 certs/week per registered domain). → Mitigation: `*.staging.trails.cool` previews are subdomains of a single domain, counting as one. On-demand TLS with validation prevents abuse.
|
||||
29
openspec/changes/staging-environments/proposal.md
Normal file
29
openspec/changes/staging-environments/proposal.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
## Why
|
||||
|
||||
There is no way to test changes in a production-like environment before merging. The only testing options are local dev (`pnpm dev:full`) or deploying directly to production. This makes it risky to test features that depend on real infrastructure (Caddy TLS, Docker networking, PostgreSQL migrations, OAuth callbacks) and impossible for non-developers (e.g., design reviewers) to preview PRs. A staging environment would catch integration issues earlier and give PR reviewers a live URL to test against.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a **persistent staging instance** (`staging.trails.cool` / `planner.staging.trails.cool`) running on the same Hetzner server as production, using a separate Docker Compose project with its own PostgreSQL database, port range, and Caddy configuration
|
||||
- Add **ephemeral PR preview environments** that spin up automatically when a PR is opened, serve at `pr-<number>.staging.trails.cool`, and tear down when the PR is merged or closed
|
||||
- Add a **GitHub Actions workflow** (`cd-staging.yml`) that deploys the staging instance on pushes to main and manages PR preview lifecycle
|
||||
- Add a **Docker Compose override** (`docker-compose.staging.yml`) for staging-specific configuration (ports, database, domain)
|
||||
- Add **Caddy wildcard routing** for `*.staging.trails.cool` to dynamically route to the correct preview or staging container
|
||||
- Add a **cleanup job** to tear down PR preview containers and their databases when PRs close
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `staging-environment`: Persistent staging instance configuration, PR preview lifecycle, Docker Compose staging overrides, Caddy routing, GitHub Actions integration, database isolation, and cleanup
|
||||
|
||||
### Modified Capabilities
|
||||
- `infrastructure`: Caddy gains wildcard subdomain routing for staging; Docker Compose gains staging profiles and a staging-specific override file
|
||||
|
||||
## Impact
|
||||
|
||||
- **DNS**: Requires a wildcard DNS record `*.staging.trails.cool` pointing to the same server
|
||||
- **TLS**: Caddy handles automatic TLS for staging subdomains via Let's Encrypt
|
||||
- **Disk/memory**: Each PR preview runs journal + planner + a shared staging PostgreSQL instance on the production server. Resource usage scales with active PRs.
|
||||
- **Database**: Staging uses a separate PostgreSQL database (`trails_staging`). PR previews use per-PR databases (`trails_pr_<number>`), created and dropped by the workflow.
|
||||
- **Secrets**: Staging shares the same SOPS-encrypted secrets as production (same server), with staging-specific overrides for domain and database.
|
||||
- **GitHub Actions**: New workflow triggered on PR open/sync/close and main push.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Caddy reverse proxy routing
|
||||
Caddy SHALL route requests to staging and PR preview containers via wildcard subdomain matching, in addition to the existing production routing.
|
||||
|
||||
#### Scenario: Staging subdomain routing
|
||||
- **WHEN** a request arrives for `staging.trails.cool`
|
||||
- **THEN** Caddy proxies it to the staging journal container on port 3100
|
||||
|
||||
#### Scenario: Planner staging routing
|
||||
- **WHEN** a request arrives for `planner.staging.trails.cool`
|
||||
- **THEN** Caddy proxies it to the staging planner container on port 3101
|
||||
|
||||
#### Scenario: PR preview routing
|
||||
- **WHEN** a request arrives for `pr-123.staging.trails.cool`
|
||||
- **THEN** Caddy proxies it to the PR 123 journal container on the correct dynamically assigned port
|
||||
|
||||
#### Scenario: On-demand TLS for staging subdomains
|
||||
- **WHEN** a first request arrives for a new staging subdomain
|
||||
- **THEN** Caddy automatically provisions a TLS certificate via Let's Encrypt
|
||||
- **AND** a validation endpoint confirms the subdomain is an active staging/preview environment before certificate issuance
|
||||
|
||||
### Requirement: Docker Compose deployment
|
||||
The staging environment SHALL be deployed as a separate Docker Compose project alongside production on the same server.
|
||||
|
||||
#### Scenario: Staging compose project
|
||||
- **WHEN** the staging deployment runs
|
||||
- **THEN** it creates containers in the `trails-staging` project namespace, separate from the `trails-cool` production project
|
||||
|
||||
#### Scenario: Shared services
|
||||
- **WHEN** staging containers need BRouter routing
|
||||
- **THEN** they connect to the production BRouter container via Docker network, not a duplicate instance
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Persistent staging instance
|
||||
A persistent staging instance SHALL run at `staging.trails.cool` (journal) and `planner.staging.trails.cool` (planner), auto-deploying from main on every push.
|
||||
|
||||
#### Scenario: Deploy staging on main push
|
||||
- **WHEN** a commit is pushed to main that changes `apps/` or `packages/`
|
||||
- **THEN** the staging journal and planner containers are rebuilt and redeployed with the latest main
|
||||
|
||||
#### Scenario: Staging uses isolated database
|
||||
- **WHEN** the staging instance is running
|
||||
- **THEN** it uses the `trails_staging` database, separate from the production `trails` database
|
||||
|
||||
#### Scenario: Staging is accessible
|
||||
- **WHEN** a user navigates to `https://staging.trails.cool`
|
||||
- **THEN** they see the journal app served over HTTPS with a valid TLS certificate
|
||||
|
||||
### Requirement: PR preview environments
|
||||
Ephemeral preview environments SHALL be created for each PR that changes app or package code.
|
||||
|
||||
#### Scenario: Preview created on PR open
|
||||
- **WHEN** a PR is opened that changes files in `apps/` or `packages/`
|
||||
- **THEN** a preview environment is deployed at `pr-<number>.staging.trails.cool` within 5 minutes
|
||||
- **AND** a comment is posted on the PR with the preview URL
|
||||
|
||||
#### Scenario: Preview updated on PR push
|
||||
- **WHEN** new commits are pushed to a PR branch with an active preview
|
||||
- **THEN** the preview containers are rebuilt and redeployed with the latest branch code
|
||||
|
||||
#### Scenario: Preview torn down on PR close
|
||||
- **WHEN** a PR is merged or closed
|
||||
- **THEN** its preview containers are stopped and removed
|
||||
- **AND** its database (`trails_pr_<number>`) is dropped
|
||||
|
||||
#### Scenario: Preview database isolation
|
||||
- **WHEN** a PR preview is running
|
||||
- **THEN** it uses a dedicated database `trails_pr_<number>` with schema applied via Drizzle Kit push
|
||||
|
||||
### Requirement: PR preview cleanup
|
||||
A scheduled cleanup job SHALL remove orphaned preview resources from closed PRs.
|
||||
|
||||
#### Scenario: Stale preview cleanup
|
||||
- **WHEN** the cleanup job runs
|
||||
- **THEN** any preview containers or databases belonging to closed/merged PRs are removed
|
||||
|
||||
### Requirement: Resource limits
|
||||
Staging and preview containers SHALL have resource limits to protect production.
|
||||
|
||||
#### Scenario: Memory limits enforced
|
||||
- **WHEN** a staging or preview container is running
|
||||
- **THEN** it has a memory limit of 256MB per app container
|
||||
|
||||
#### Scenario: Concurrent preview limit
|
||||
- **WHEN** more than 3 PR previews are active
|
||||
- **THEN** the oldest preview is torn down before the new one is created
|
||||
38
openspec/changes/staging-environments/tasks.md
Normal file
38
openspec/changes/staging-environments/tasks.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
## 1. DNS & TLS Setup
|
||||
|
||||
- [ ] 1.1 Add wildcard DNS record `*.staging.trails.cool` pointing to the Hetzner server IP
|
||||
- [ ] 1.2 Add `staging.trails.cool` and `planner.staging.trails.cool` DNS A records
|
||||
|
||||
## 2. Docker Compose Staging Configuration
|
||||
|
||||
- [ ] 2.1 Create `infrastructure/docker-compose.staging.yml` with staging journal (port 3100), planner (port 3101), memory limits (256MB), and `trails_staging` database URL
|
||||
- [ ] 2.2 Create `infrastructure/staging.env.template` documenting required staging environment variables (DOMAIN, DATABASE_URL, JWT_SECRET, SESSION_SECRET)
|
||||
- [ ] 2.3 Add a shared Docker network (`trails-shared`) to production `docker-compose.yml` so staging can reach BRouter and PostgreSQL
|
||||
- [ ] 2.4 Verify staging containers start with `docker compose -f docker-compose.staging.yml -p trails-staging up -d` on the server
|
||||
|
||||
## 3. Caddy Wildcard Routing
|
||||
|
||||
- [ ] 3.1 Add `staging.trails.cool` site block proxying to journal on port 3100
|
||||
- [ ] 3.2 Add `planner.staging.trails.cool` site block proxying to planner on port 3101
|
||||
- [ ] 3.3 Add `*.staging.trails.cool` wildcard site block with on-demand TLS for PR previews — extract PR number from subdomain, proxy to `localhost:3200 + (PR * 2)`
|
||||
- [ ] 3.4 Create a TLS validation endpoint (small script or Caddy matcher) that checks if the requested subdomain corresponds to a running container
|
||||
- [ ] 3.5 Reload Caddy and verify staging routes work with `curl -sf https://staging.trails.cool/api/health`
|
||||
|
||||
## 4. GitHub Actions Workflow
|
||||
|
||||
- [ ] 4.1 Create `.github/workflows/cd-staging.yml` triggered on push to main (paths: `apps/`, `packages/`) and on PR open/synchronize/close (same paths)
|
||||
- [ ] 4.2 Implement the **staging deploy** job: build images, SSH to server, `docker compose -f docker-compose.staging.yml -p trails-staging pull && up -d`, run Drizzle push against `trails_staging`
|
||||
- [ ] 4.3 Implement the **PR preview deploy** job: compute ports from PR number, create `trails_pr_<number>` database if not exists, build images tagged with PR number, deploy containers, post preview URL as PR comment
|
||||
- [ ] 4.4 Implement the **PR preview teardown** job: stop and remove PR containers, drop `trails_pr_<number>` database, delete PR comment
|
||||
- [ ] 4.5 Add the concurrent preview limit check: if >3 active previews, tear down the oldest before deploying a new one
|
||||
|
||||
## 5. Cleanup & Safety
|
||||
|
||||
- [ ] 5.1 Create a scheduled cleanup job (weekly cron in GitHub Actions or pg-boss on the server) that lists running `trails-pr-*` containers, checks PR status via `gh pr view`, and tears down orphans
|
||||
- [ ] 5.2 Add memory limits (`deploy.resources.limits.memory: 256m`) to staging containers in the compose override
|
||||
- [ ] 5.3 Test full lifecycle: open a test PR → verify preview deploys → push a commit → verify preview updates → close PR → verify teardown
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [ ] 6.1 Add a "Staging & Previews" section to CLAUDE.md documenting the staging URL, PR preview URL pattern, port scheme, and how to debug staging issues
|
||||
- [ ] 6.2 Update the Deployment table in CLAUDE.md with the new `cd-staging.yml` workflow
|
||||
14
package.json
14
package.json
|
|
@ -21,15 +21,17 @@
|
|||
"db:studio": "drizzle-kit studio --config packages/db/drizzle.config.ts",
|
||||
"dev:services": "docker compose -f docker-compose.dev.yml up -d",
|
||||
"dev:full": "./scripts/dev.sh",
|
||||
"dev:ios": "pnpm --filter @trails-cool/mobile build:dev",
|
||||
"dev:android": "pnpm --filter @trails-cool/mobile build:dev -- --platform android"
|
||||
"dev:ios": "pnpm --filter @trails-cool/mobile ios",
|
||||
"dev:android": "pnpm --filter @trails-cool/mobile android"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"brace-expansion@>=4.0.0 <5.0.5": "5.0.5",
|
||||
"path-to-regexp@<0.1.13": "0.1.13",
|
||||
"lodash@<4.18.1": "4.18.1"
|
||||
"lodash@<4.18.1": "4.18.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@sentry/cli",
|
||||
|
|
@ -75,5 +77,11 @@
|
|||
"typescript-eslint": "^8.58.1",
|
||||
"vite": "catalog:",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"expo": "~55.0.15",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2876
pnpm-lock.yaml
generated
2876
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -22,3 +22,4 @@ catalog:
|
|||
"@sentry/react": ^10.48.0
|
||||
postgres: ^3.4.9
|
||||
"@types/node": ^22.0.0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue