diff --git a/src/app/api/route-planner/route.ts b/src/app/api/route-planner/route.ts
index 5806ebff..2a7d125b 100644
--- a/src/app/api/route-planner/route.ts
+++ b/src/app/api/route-planner/route.ts
@@ -1,287 +1,69 @@
import { NextResponse } from 'next/server';
-import polyline from '@mapbox/polyline';
-
-const OPENROUTESERVICE_API_KEY = process.env.OPENROUTESERVICE_API_KEY;
-const OPENROUTESERVICE_BASE_URL = 'https://api.openrouteservice.org';
-
-interface RouteRequest {
+import {
+ generateRoute,
+ isRoutePlannerConfigured,
+ RoutePlannerConfigError,
+ RoutePlannerInputError,
+ RoutePlannerNotFoundError,
+ RoutePlannerRateLimitError,
+} from '@/lib/route-planner';
+
+interface RouteRequestBody {
startLat: number;
startLng: number;
- targetDistance: number; // in meters
-}
-
-interface ORSStep {
- instruction: string;
- distance?: number;
- duration?: number;
-}
-
-interface ORSSegment {
- distance?: number;
- duration?: number;
- steps?: ORSStep[];
-}
-
-interface ORSRouteCandidate {
- geometry: string;
- summary?: { distance?: number; duration?: number };
- segments?: ORSSegment[];
-}
-
-interface ORSDirectionsResponse {
- routes?: ORSRouteCandidate[];
-}
-
-interface DirectionsRequestBody {
- coordinates: [number, number][];
- format: 'geojson';
- instructions: boolean;
- geometry_simplify: boolean;
- options: { avoid_features: string[] };
+ targetDistance: number; // meters
+ variant?: number;
}
export async function POST(request: Request) {
try {
- const { startLat, startLng, targetDistance }: RouteRequest = await request.json();
-
- if (!OPENROUTESERVICE_API_KEY) {
+ if (!isRoutePlannerConfigured()) {
return NextResponse.json(
- { error: 'Route planning service not configured. Please add OPENROUTESERVICE_API_KEY to your environment variables. Get a free API key at https://openrouteservice.org/' },
+ {
+ error:
+ 'Route planning service not configured. Please add OPENROUTESERVICE_API_KEY to your environment variables. Get a free API key at https://openrouteservice.org/',
+ },
{ status: 500 }
);
}
- // we'll generate loops manually by picking a pair of waypoints around the start.
- // the OpenRouteService instance we're running against currently doesn't support
- // the `round_trip` helper (see 400 errors in logs), so we fall back to this approach.
-
- const profile = 'foot-walking'; // Use walking profile for running routes
- const directionsUrl = `${OPENROUTESERVICE_BASE_URL}/v2/directions/${profile}`;
-
- console.log('Route request:', { startLat, startLng, targetDistance });
-
- let bestRoute: ORSRouteCandidate | null = null;
- let bestDifference = Infinity;
- let bestDistance = 0;
- let isOutAndBack = false;
-
- // generate random loop candidates
- async function attemptManualLoop(testTarget: number) {
- const bearing1 = Math.random() * 360;
- const bearing2 = (bearing1 + 120 + Math.random() * 120) % 360;
- const radius = Math.max(500, Math.min(testTarget / 3, 4000));
-
- function pointFor(bearing: number) {
- const latOffset = (radius / 111000) * Math.cos(bearing * Math.PI / 180);
- const lngOffset = (radius / 111000) * Math.sin(bearing * Math.PI / 180);
- const lat = Math.max(-85, Math.min(85, startLat + latOffset));
- const lng = ((startLng + lngOffset + 180) % 360) - 180;
- return [lng, lat] as [number, number];
- }
-
- const p1 = pointFor(bearing1);
- const p2 = pointFor(bearing2);
- const coords: [number, number][] = [[startLng, startLat], p1, p2, [startLng, startLat]];
-
- const body: DirectionsRequestBody = {
- coordinates: coords,
- format: 'geojson',
- instructions: true,
- geometry_simplify: false,
- options: { avoid_features: [] },
- };
-
- const res = await fetch(directionsUrl, {
- method: 'POST',
- headers: {
- Authorization: OPENROUTESERVICE_API_KEY as string,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(body),
- });
- return res;
- }
-
- const testLengths = [targetDistance, targetDistance * 1.1, targetDistance * 0.9];
- for (let attempt = 0; attempt < 5; attempt++) {
- for (const len of testLengths) {
- try {
- const res = await attemptManualLoop(len);
- if (!res.ok) {
- const body = await res.text().catch(() => '');
- console.warn('manual loop request failed', { attempt, len, status: res.status, body });
- continue;
- }
- const data = (await res.json()) as ORSDirectionsResponse;
- if (data.routes && data.routes.length > 0) {
- const candidate = data.routes[0];
- const distance = candidate.summary?.distance || 0;
- const diff = Math.abs(distance - targetDistance);
- if (diff < bestDifference) {
- bestDifference = diff;
- bestRoute = candidate;
- isOutAndBack = false;
- }
- if (diff < 200) break;
- }
- } catch (e) {
- console.error('error in manual loop attempt', attempt, len, e);
- }
- }
- if (bestRoute && bestDifference < 200) break;
- }
-
- // if we didn't manage to get a suitable loop, fall back to old out-and-back search
- if (!bestRoute) {
- const targetDistances = [
- targetDistance / 2,
- targetDistance / 2.5,
- targetDistance / 1.8,
- targetDistance / 3,
- targetDistance / 1.5,
- ];
-
- for (const testDistance of targetDistances) {
- // testDistance is already in meters; clamp between 500m and 4000m for the random offset
- const offsetDistance = Math.max(500, Math.min(testDistance, 4000));
- const bearing = Math.random() * 360;
- const latOffset = (offsetDistance / 111000) * Math.cos(bearing * Math.PI / 180);
- const lngOffset = (offsetDistance / 111000) * Math.sin(bearing * Math.PI / 180);
- const testTargetLat = Math.max(-85, Math.min(85, startLat + latOffset));
- const testTargetLng = ((startLng + lngOffset + 180) % 360) - 180;
-
- try {
- const testResponse = await fetch(directionsUrl, {
- method: 'POST',
- headers: {
- 'Authorization': OPENROUTESERVICE_API_KEY as string as string,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- coordinates: [
- [startLng, startLat],
- [testTargetLng, testTargetLat],
- ],
- format: 'geojson',
- instructions: true,
- geometry_simplify: false,
- options: {
- avoid_features: [],
- },
- }),
- });
-
- if (testResponse.ok) {
- const testData = (await testResponse.json()) as ORSDirectionsResponse;
- if (testData.routes && testData.routes.length > 0) {
- const testRoute = testData.routes[0];
- const oneWayDistance = testRoute.summary?.distance || 0;
- const roundTripDistance = oneWayDistance * 2;
- const difference = Math.abs(roundTripDistance - targetDistance);
+ const body = (await request.json()) as RouteRequestBody;
+ const { startLat, startLng, targetDistance, variant } = body;
- if (difference < bestDifference) {
- bestDifference = difference;
- bestRoute = testRoute;
- bestDistance = oneWayDistance;
- isOutAndBack = true;
- }
-
- if (difference < 200) {
- break;
- }
- }
- }
- } catch (error) {
- console.error('Error testing distance:', testDistance, error);
- }
- }
-
- if (!bestRoute) {
- console.error('No valid routes found for any strategy');
- return NextResponse.json(
- { error: 'Unable to find a suitable route for the selected distance. Try a different starting location.' },
- { status: 404 }
- );
- }
+ if (
+ typeof startLat !== 'number' ||
+ typeof startLng !== 'number' ||
+ typeof targetDistance !== 'number'
+ ) {
+ return NextResponse.json(
+ { error: 'startLat, startLng, and targetDistance are required numbers' },
+ { status: 400 }
+ );
}
- // bestRoute is now defined (either loop or out-and-back)
- const selectedRoute = bestRoute!;
-
- // log summary info
- console.log('Best route found:', {
+ const result = await generateRoute({
+ startLat,
+ startLng,
targetDistance,
- actualDistance: isOutAndBack ? bestDistance * 2 : selectedRoute.summary?.distance,
- difference: bestDifference,
- type: isOutAndBack ? 'out-and-back' : 'loop',
+ variant: typeof variant === 'number' ? variant : 0,
});
- // Decode the polyline geometry
- const decodedCoordinates = polyline.decode(selectedRoute.geometry);
-
- let finalRoute;
- let stats;
-
- if (isOutAndBack) {
- const reversedCoordinates = decodedCoordinates.slice().reverse();
- let loopGeom = [...decodedCoordinates, ...reversedCoordinates.slice(1)];
- // ensure the loop explicitly ends where it started
- const first = loopGeom[0];
- const last = loopGeom[loopGeom.length - 1];
- if (first[0] !== last[0] || first[1] !== last[1]) {
- loopGeom = [...loopGeom, first];
- }
- const routeDuration = selectedRoute.summary?.duration || 0;
- const totalDistance = bestDistance * 2;
- const totalDuration = routeDuration * 2;
- const instructions = [
- ...(selectedRoute.segments?.flatMap((segment) =>
- segment.steps?.map((step) => step.instruction) || []
- ) || []),
- 'Turn around and return the same way'
- ];
-
- finalRoute = {
- geometry: loopGeom,
- distance: totalDistance,
- duration: totalDuration,
- instructions,
- };
- stats = { distance: totalDistance, duration: totalDuration };
- } else {
- // direct loop returned from ORS
- const routeDistance = selectedRoute.summary?.distance || 0;
- const routeDuration = selectedRoute.summary?.duration || 0;
- const instructions =
- selectedRoute.segments?.flatMap((segment) =>
- segment.steps?.map((step) => step.instruction) || []
- ) || [];
- finalRoute = {
- geometry: decodedCoordinates,
- distance: routeDistance,
- duration: routeDuration,
- instructions,
- };
- stats = { distance: routeDistance, duration: routeDuration };
+ return NextResponse.json(result);
+ } catch (error) {
+ if (error instanceof RoutePlannerInputError) {
+ return NextResponse.json({ error: error.message }, { status: 400 });
+ }
+ if (error instanceof RoutePlannerConfigError) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+ if (error instanceof RoutePlannerRateLimitError) {
+ return NextResponse.json({ error: error.message }, { status: 429 });
+ }
+ if (error instanceof RoutePlannerNotFoundError) {
+ return NextResponse.json({ error: error.message }, { status: 404 });
}
- console.log('Route generated successfully:', {
- distance: stats.distance,
- duration: stats.duration,
- accuracy: `${((stats.distance / targetDistance) * 100).toFixed(1)}% of target`,
- type: isOutAndBack ? 'out-and-back' : 'loop'
- });
-
- return NextResponse.json({
- route: finalRoute,
- stats,
- });
-
- } catch (error) {
- console.error('Route planner API error:', error);
- return NextResponse.json(
- { error: 'Internal server error' },
- { status: 500 }
- );
+ console.error('[RoutePlanner] API error:', error);
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
-}
\ No newline at end of file
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 39105dad..8334974a 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { UserProvider } from "@/context/UserContext";
+import { ThemeProvider } from "@/context/ThemeContext";
import Header from "@/components/Header";
import PWARegistration from "@/components/PWARegistration";
import InstallPrompt from "@/components/InstallPrompt";
@@ -16,6 +17,9 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
+/** Runs before paint so preferred/saved theme applies without a flash. */
+const themeInitScript = `(function(){try{var k='runnr-theme';var t=localStorage.getItem(k);if(t!=='light'&&t!=='dark'){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}var r=document.documentElement;if(t==='dark')r.classList.add('dark');else r.classList.remove('dark');r.style.colorScheme=t;}catch(e){}})();`;
+
export const metadata: Metadata = {
title: "Runnr - Your Personalized Strava Dashboard",
description: "A personalized running dashboard powered by your Strava data with AI coaching",
@@ -77,8 +81,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
+
{/* PWA Meta Tags */}
@@ -114,12 +119,14 @@ export default function RootLayout({
userSelect: 'none'
}}
>
-
-
-
-
- {children}
-
+
+
+
+
+
+ {children}
+
+
);
diff --git a/src/app/route-planner/RoutePlannerContent.tsx b/src/app/route-planner/RoutePlannerContent.tsx
index 0173fd1e..3f3adb90 100644
--- a/src/app/route-planner/RoutePlannerContent.tsx
+++ b/src/app/route-planner/RoutePlannerContent.tsx
@@ -6,20 +6,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
-import { Loader2, MapPin, Route, Target, RotateCcw } from 'lucide-react';
+import { Loader2, MapPin, Route, Target, RotateCcw, Shuffle } from 'lucide-react';
import { LatLng } from 'leaflet';
interface RouteData {
geometry: [number, number][];
- distance: number; // in meters
- duration: number; // in seconds
+ distance: number;
+ duration: number;
instructions?: string[];
+ type?: 'loop' | 'out-and-back';
+ accuracy?: number;
}
interface RouteStats {
distance: number;
duration: number;
- elevation?: number;
}
// Dynamically import the map component to avoid SSR issues
@@ -35,37 +36,38 @@ const RoutePlannerMap = dynamic(() => import('./RoutePlannerMap'), {
),
});
+const runningDistances = [
+ { value: 3, label: '3K Fun Run' },
+ { value: 5, label: '5K' },
+ { value: 8, label: '8K' },
+ { value: 10, label: '10K' },
+ { value: 12, label: '12K' },
+ { value: 15, label: '15K' },
+ { value: 16.1, label: '10 Miles' },
+ { value: 21.1, label: 'Half Marathon' },
+ { value: 26.2, label: 'Marathon' },
+ { value: 30, label: '30K' },
+ { value: 42.2, label: 'Ultra (42K)' },
+];
+
export default function RoutePlannerContent() {
const [startLocation, setStartLocation] = useState(null);
const [targetDistance, setTargetDistance] = useState(5); // km
+ const [variant, setVariant] = useState(0);
const [route, setRoute] = useState(null);
const [routeStats, setRouteStats] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
- // Common running distances in km
- const runningDistances = [
- { value: 3, label: '3K Fun Run' },
- { value: 5, label: '5K' },
- { value: 8, label: '8K' },
- { value: 10, label: '10K' },
- { value: 12, label: '12K' },
- { value: 15, label: '15K' },
- { value: 16.1, label: '10 Miles' },
- { value: 21.1, label: 'Half Marathon' },
- { value: 26.2, label: 'Marathon' },
- { value: 30, label: '30K' },
- { value: 42.2, label: 'Ultra (42K)' },
- ];
-
const handleLocationSelect = useCallback((latlng: LatLng) => {
setStartLocation(latlng);
setRoute(null);
setRouteStats(null);
setError(null);
+ setVariant(0);
}, []);
- const generateRoute = async () => {
+ const requestRoute = async (variantIndex: number) => {
if (!startLocation) {
setError('Please select a starting location on the map');
return;
@@ -77,23 +79,24 @@ export default function RoutePlannerContent() {
try {
const response = await fetch('/api/route-planner', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startLat: startLocation.lat,
startLng: startLocation.lng,
- targetDistance: targetDistance * 1000, // Convert km to meters
+ targetDistance: targetDistance * 1000,
+ variant: variantIndex,
}),
});
+ const data = await response.json();
+
if (!response.ok) {
- throw new Error('Failed to generate route');
+ throw new Error(data.error || 'Failed to generate route');
}
- const data = await response.json();
setRoute(data.route);
setRouteStats(data.stats);
+ setVariant(variantIndex);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate route');
} finally {
@@ -101,6 +104,10 @@ export default function RoutePlannerContent() {
}
};
+ const generateRoute = () => requestRoute(variant);
+
+ const tryAnotherRoute = () => requestRoute(variant + 1);
+
const clearRoute = () => {
setRoute(null);
setRouteStats(null);
@@ -110,21 +117,15 @@ export default function RoutePlannerContent() {
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
- if (hours > 0) {
- return `${hours}h ${minutes}m`;
- }
+ if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
- const formatDistance = (meters: number) => {
- const km = meters / 1000;
- return `${km.toFixed(2)} km`;
- };
+ const formatDistance = (meters: number) => `${(meters / 1000).toFixed(2)} km`;
return (
- {/* Controls Panel */}
@@ -134,26 +135,25 @@ export default function RoutePlannerContent() {
-
-
- Target Distance
-
-
- WIP
-
-
-
- Experimental feature; loops are approximate and may not be perfect.
+
+ Click the map to set your start, pick a distance, then generate a loop on real roads.
+ Same location + distance always yields the same route; use "Another route" for a different direction.
+
Target Distance
setTargetDistance(parseFloat(value))}
+ onValueChange={(value) => {
+ setTargetDistance(parseFloat(value));
+ setRoute(null);
+ setRouteStats(null);
+ setVariant(0);
+ }}
>
-
+
@@ -206,12 +206,25 @@ export default function RoutePlannerContent() {
+ {route && (
+
+
+ Another route
+
+ )}
+
{error && (
{error}
@@ -220,8 +233,7 @@ export default function RoutePlannerContent() {
- {/* Route Stats */}
- {routeStats && (
+ {routeStats && route && (
Route Statistics
@@ -232,13 +244,21 @@ export default function RoutePlannerContent() {
{formatDistance(routeStats.distance)}
- Duration
+ Est. duration
{formatDuration(routeStats.duration)}
- {routeStats.elevation && (
+ {route.type && (
+
+ Type
+
+ {route.type === 'loop' ? 'Loop' : 'Out & back'}
+
+
+ )}
+ {route.accuracy != null && (
- Elevation
- {routeStats.elevation}m
+ vs target
+ {(route.accuracy * 100).toFixed(0)}%
)}
@@ -246,7 +266,6 @@ export default function RoutePlannerContent() {
)}
- {/* Map */}
@@ -261,4 +280,4 @@ export default function RoutePlannerContent() {
);
-}
\ No newline at end of file
+}
diff --git a/src/app/route-planner/RoutePlannerMap.tsx b/src/app/route-planner/RoutePlannerMap.tsx
index f0c282d1..f77f0dcc 100644
--- a/src/app/route-planner/RoutePlannerMap.tsx
+++ b/src/app/route-planner/RoutePlannerMap.tsx
@@ -1,12 +1,12 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMapEvents, useMap } from 'react-leaflet';
-import { LatLngExpression, LatLng } from 'leaflet';
+import { LatLngExpression, LatLng, LatLngBounds } from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix for default markers in react-leaflet
import L from 'leaflet';
-// leaflet's types don't expose _getIconUrl on the prototype
-delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl;
+// @ts-expect-error leaflet's types don't expose _getIconUrl
+delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
@@ -18,6 +18,7 @@ interface RouteData {
distance: number;
duration: number;
instructions?: string[];
+ type?: 'loop' | 'out-and-back';
}
interface RoutePlannerMapProps {
@@ -26,7 +27,6 @@ interface RoutePlannerMapProps {
onLocationSelect: (latlng: LatLng) => void;
}
-// Component to handle map clicks
function MapClickHandler({ onLocationSelect }: { onLocationSelect: (latlng: LatLng) => void }) {
useMapEvents({
click: (e) => {
@@ -36,38 +36,52 @@ function MapClickHandler({ onLocationSelect }: { onLocationSelect: (latlng: LatL
return null;
}
+function Recenter({ center }: { center: LatLngExpression }) {
+ const map = useMap();
+ useEffect(() => {
+ map.setView(center);
+ }, [center, map]);
+ return null;
+}
+
+/** Fit the map to the full route once it arrives */
+function FitRouteBounds({ geometry }: { geometry: [number, number][] }) {
+ const map = useMap();
+
+ useEffect(() => {
+ if (!geometry || geometry.length < 2) return;
+ const bounds = new LatLngBounds(geometry.map(([lat, lng]) => [lat, lng] as [number, number]));
+ map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 });
+ }, [geometry, map]);
+
+ return null;
+}
+
export default function RoutePlannerMap({ startLocation, route, onLocationSelect }: RoutePlannerMapProps) {
- // Default map center from environment (fallback to Odense, Denmark)
- const defaultCenter: LatLngExpression = [
- parseFloat(process.env.NEXT_PUBLIC_ROUTE_PLANNER_LAT || '55.4038'),
- parseFloat(process.env.NEXT_PUBLIC_ROUTE_PLANNER_LNG || '10.4024'),
- ];
+ const defaultCenter: LatLngExpression = useMemo(
+ () => [
+ parseFloat(process.env.NEXT_PUBLIC_ROUTE_PLANNER_LAT || '55.4038'),
+ parseFloat(process.env.NEXT_PUBLIC_ROUTE_PLANNER_LNG || '10.4024'),
+ ],
+ []
+ );
const [mapCenter, setMapCenter] = useState
(defaultCenter);
- // choose initial start location once
+ // Default start at map center on first load only
useEffect(() => {
if (!startLocation) {
- onLocationSelect(new LatLng(defaultCenter[0], defaultCenter[1]));
+ const [lat, lng] = defaultCenter as [number, number];
+ onLocationSelect(new LatLng(lat, lng));
}
- // only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // whenever the start location changes, recenter map
+ // Recenter when start changes and there is no active route yet
useEffect(() => {
- if (startLocation) {
+ if (startLocation && !route) {
setMapCenter([startLocation.lat, startLocation.lng]);
}
- }, [startLocation]);
-
- // keep map view in sync with mapCenter state
- function Recenter({ center }: { center: LatLngExpression }) {
- const map = useMap();
- useEffect(() => {
- map.setView(center);
- }, [center, map]);
- return null;
- }
+ }, [startLocation, route]);
return (
-
+ {!route && }
+ {route?.geometry && route.geometry.length >= 2 && (
+
+ )}
{startLocation && (
- Start Location
+ Start
+
{startLocation.lat.toFixed(4)}, {startLocation.lng.toFixed(4)}
)}
- {route && (
+ {route?.geometry && route.geometry.length >= 2 && (
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index ed468dc4..acb10758 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet';
+import ThemeToggle from '@/components/ThemeToggle';
import { Menu } from 'lucide-react';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
@@ -69,6 +70,7 @@ export default function Header() {
{loading ? (
+
@@ -77,7 +79,8 @@ export default function Header() {
{/* Desktop Navigation */}
-
+
+
{user.firstname.charAt(0)}{user.lastname.charAt(0)}
@@ -86,7 +89,8 @@ export default function Header() {
{/* Mobile Navigation */}
-
+
+
@@ -121,9 +125,12 @@ export default function Header() {
>
) : (
-
- Login with Strava
-
+
)}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 00000000..ee9e26be
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { Moon, Sun } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { useTheme } from '@/context/ThemeContext';
+
+export default function ThemeToggle({ className }: { className?: string }) {
+ const { theme, toggleTheme } = useTheme();
+ const isDark = theme === 'dark';
+
+ return (
+
+ {isDark ? : }
+
+ );
+}
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx
new file mode 100644
index 00000000..2f13d4e6
--- /dev/null
+++ b/src/context/ThemeContext.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react';
+
+export type Theme = 'light' | 'dark';
+
+const STORAGE_KEY = 'runnr-theme';
+
+type ThemeContextValue = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+};
+
+const ThemeContext = createContext
(null);
+
+function applyTheme(theme: Theme) {
+ const root = document.documentElement;
+ root.classList.toggle('dark', theme === 'dark');
+ root.style.colorScheme = theme;
+}
+
+function getInitialTheme(): Theme {
+ if (typeof window === 'undefined') return 'light';
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark') return stored;
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function readDomTheme(): Theme {
+ if (typeof document === 'undefined') return 'light';
+ return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
+}
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ // Prefer class set by the layout init script so the toggle icon matches on first client paint.
+ const [theme, setThemeState] = useState(readDomTheme);
+
+ useEffect(() => {
+ const initial = getInitialTheme();
+ setThemeState(initial);
+ applyTheme(initial);
+ }, []);
+
+ const setTheme = useCallback((next: Theme) => {
+ setThemeState(next);
+ localStorage.setItem(STORAGE_KEY, next);
+ applyTheme(next);
+ }, []);
+
+ const toggleTheme = useCallback(() => {
+ setThemeState((prev) => {
+ const next: Theme = prev === 'dark' ? 'light' : 'dark';
+ localStorage.setItem(STORAGE_KEY, next);
+ applyTheme(next);
+ return next;
+ });
+ }, []);
+
+ const value: ThemeContextValue = {
+ theme,
+ setTheme,
+ toggleTheme,
+ };
+
+ return {children} ;
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) {
+ throw new Error('useTheme must be used within ThemeProvider');
+ }
+ return ctx;
+}
diff --git a/src/lib/route-planner.ts b/src/lib/route-planner.ts
new file mode 100644
index 00000000..841df365
--- /dev/null
+++ b/src/lib/route-planner.ts
@@ -0,0 +1,523 @@
+/**
+ * Route planner: generates predictable, on-road loop routes via OpenRouteService.
+ *
+ * Strategy (in priority order):
+ * 1. ORS round_trip — best when supported; uses a fixed seed derived from location+distance
+ * 2. Deterministic multi-waypoint loop — evenly spaced bearings, radius sized for target distance
+ * 3. Out-and-back — single waypoint at half-distance, then reverse the leg
+ *
+ * No Math.random() in production paths — same start + distance always yields the same route.
+ */
+
+import polyline from '@mapbox/polyline';
+
+const OPENROUTESERVICE_API_KEY = process.env.OPENROUTESERVICE_API_KEY;
+const OPENROUTESERVICE_BASE_URL = 'https://api.openrouteservice.org';
+const PROFILE = 'foot-walking';
+
+const LOOP_CLOSURE_THRESHOLD_M = 80;
+const TARGET_TOLERANCE_M = 250; // accept routes within this of target
+const MAX_ORS_CALLS = 6;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface RoutePlannerInput {
+ startLat: number;
+ startLng: number;
+ targetDistance: number; // meters
+ /** Optional: rotate which variant to generate (0, 1, 2…). Defaults to 0 (most northward). */
+ variant?: number;
+}
+
+export interface PlannedRoute {
+ geometry: [number, number][]; // [lat, lng] for Leaflet
+ distance: number; // meters
+ duration: number; // seconds
+ instructions: string[];
+ type: 'loop' | 'out-and-back';
+ accuracy: number; // fraction of target (1.0 = exact)
+}
+
+export interface RoutePlannerResult {
+ route: PlannedRoute;
+ stats: { distance: number; duration: number };
+}
+
+interface ORSRoute {
+ geometry: { coordinates: [number, number][]; type?: string } | string;
+ summary?: { distance: number; duration: number };
+ segments?: Array<{
+ distance: number;
+ duration: number;
+ steps: Array<{ instruction: string; distance: number; duration: number }>;
+ }>;
+}
+
+interface ORSResponse {
+ routes?: ORSRoute[];
+ features?: Array<{
+ geometry?: ORSRoute['geometry'];
+ properties?: {
+ summary?: ORSRoute['summary'];
+ segments?: ORSRoute['segments'];
+ };
+ }>;
+}
+
+// ---------------------------------------------------------------------------
+// Geometry helpers
+// ---------------------------------------------------------------------------
+
+function toRad(deg: number): number {
+ return (deg * Math.PI) / 180;
+}
+
+function haversineMeters(a: [number, number], b: [number, number]): number {
+ const R = 6371000;
+ const dLat = toRad(b[0] - a[0]);
+ const dLng = toRad(b[1] - a[1]);
+ const lat1 = toRad(a[0]);
+ const lat2 = toRad(b[0]);
+ const sinHalfLat = Math.sin(dLat / 2);
+ const sinHalfLng = Math.sin(dLng / 2);
+ const h =
+ sinHalfLat * sinHalfLat +
+ Math.cos(lat1) * Math.cos(lat2) * sinHalfLng * sinHalfLng;
+ return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
+}
+
+function computePathDistance(path: [number, number][]): number {
+ if (path.length <= 1) return 0;
+ let sum = 0;
+ for (let i = 1; i < path.length; i++) {
+ sum += haversineMeters(path[i - 1], path[i]);
+ }
+ return sum;
+}
+
+function normalizeGeometry(geometry: unknown): [number, number][] {
+ if (!geometry) return [];
+
+ if (typeof geometry === 'string') {
+ try {
+ return polyline.decode(geometry) as [number, number][];
+ } catch {
+ return [];
+ }
+ }
+
+ if (typeof geometry === 'object' && geometry !== null) {
+ const geom = geometry as { type?: string; coordinates?: unknown };
+ if (Array.isArray(geom.coordinates)) {
+ // GeoJSON is [lng, lat] — convert to [lat, lng] for Leaflet
+ return (geom.coordinates as [number, number][]).map(([lng, lat]) => [lat, lng]);
+ }
+ }
+
+ return [];
+}
+
+function extractRoute(data: ORSResponse): ORSRoute | null {
+ if (data.routes?.[0]) return data.routes[0];
+ // GeoJSON FeatureCollection fallback (some ORS versions return this with format: geojson)
+ const feature = data.features?.[0];
+ if (feature?.geometry) {
+ return {
+ geometry: feature.geometry,
+ summary: feature.properties?.summary,
+ segments: feature.properties?.segments,
+ };
+ }
+ return null;
+}
+
+function ensureClosed(path: [number, number][]): [number, number][] {
+ if (path.length < 2) return path;
+ const first = path[0];
+ const last = path[path.length - 1];
+ if (haversineMeters(first, last) > LOOP_CLOSURE_THRESHOLD_M) {
+ return [...path, first];
+ }
+ return path;
+}
+
+function extractInstructions(route: ORSRoute, extra?: string): string[] {
+ const steps = (route.segments ?? []).flatMap(
+ (segment) => segment.steps?.map((s) => s.instruction) ?? []
+ );
+ if (extra) steps.push(extra);
+ return steps;
+}
+
+/** Deterministic seed from lat/lng/distance/variant — same inputs always produce same seed. */
+function routeSeed(lat: number, lng: number, distanceM: number, variant: number): number {
+ const key = `${lat.toFixed(5)}:${lng.toFixed(5)}:${Math.round(distanceM)}:${variant}`;
+ let hash = 0;
+ for (let i = 0; i < key.length; i++) {
+ hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
+ }
+ return hash % 1_000_000;
+}
+
+// ---------------------------------------------------------------------------
+// Waypoint geometry — deterministic circle-ish loops on a map
+// ---------------------------------------------------------------------------
+
+/**
+ * Place waypoints on a circle around the start point.
+ * Radius is derived from target circumference so ORS road distance lands near target.
+ * Roads are longer than straight lines, so we undersize the circle slightly (~0.85).
+ */
+function buildLoopWaypoints(
+ startLat: number,
+ startLng: number,
+ targetDistanceM: number,
+ variant: number,
+ pointCount: number,
+ radiusScale: number
+): [number, number][] {
+ // Circumference ≈ 2πr; roads add ~15–25% so undersize radius
+ const radiusM = Math.max(
+ 250,
+ Math.min((targetDistanceM / (2 * Math.PI)) * radiusScale, 8000)
+ );
+
+ // Rotate starting bearing by variant so users can request alternate routes
+ const startBearing = (variant * 60) % 360;
+ const cosLat = Math.cos(toRad(startLat));
+
+ // ORS expects [lng, lat]
+ const coords: [number, number][] = [[startLng, startLat]];
+
+ for (let i = 1; i <= pointCount; i++) {
+ const angleDeg = startBearing + (i * 360) / (pointCount + 1);
+ const angleRad = toRad(angleDeg);
+ const latOffset = (radiusM / 111_000) * Math.cos(angleRad);
+ const lngOffset = (radiusM / (111_000 * cosLat)) * Math.sin(angleRad);
+ const lat = Math.max(-85, Math.min(85, startLat + latOffset));
+ const lng = ((startLng + lngOffset + 180) % 360) - 180;
+ coords.push([lng, lat]);
+ }
+
+ // Close the loop back to start
+ coords.push([startLng, startLat]);
+ return coords;
+}
+
+function buildOutAndBackWaypoint(
+ startLat: number,
+ startLng: number,
+ halfDistanceM: number,
+ variant: number
+): [number, number][] {
+ const bearing = (variant * 90) % 360; // N, E, S, W by variant
+ const cosLat = Math.cos(toRad(startLat));
+ const angleRad = toRad(bearing);
+ const latOffset = (halfDistanceM / 111_000) * Math.cos(angleRad);
+ const lngOffset = (halfDistanceM / (111_000 * cosLat)) * Math.sin(angleRad);
+ const lat = Math.max(-85, Math.min(85, startLat + latOffset));
+ const lng = ((startLng + lngOffset + 180) % 360) - 180;
+ return [
+ [startLng, startLat],
+ [lng, lat],
+ ];
+}
+
+// ---------------------------------------------------------------------------
+// ORS HTTP
+// ---------------------------------------------------------------------------
+
+async function orsDirections(
+ coordinates: [number, number][],
+ extraBody?: Record
+): Promise<{ ok: boolean; status: number; data?: ORSResponse; rateLimited: boolean }> {
+ if (!OPENROUTESERVICE_API_KEY) {
+ throw new Error('OPENROUTESERVICE_API_KEY not configured');
+ }
+
+ const url = `${OPENROUTESERVICE_BASE_URL}/v2/directions/${PROFILE}`;
+ const body = {
+ coordinates,
+ format: 'geojson',
+ instructions: true,
+ geometry_simplify: false,
+ ...extraBody,
+ };
+
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Authorization: OPENROUTESERVICE_API_KEY,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (res.status === 429) {
+ return { ok: false, status: 429, rateLimited: true };
+ }
+
+ if (!res.ok) {
+ const errText = await res.text().catch(() => '');
+ console.warn('[RoutePlanner] ORS error', res.status, errText.slice(0, 200));
+ return { ok: false, status: res.status, rateLimited: false };
+ }
+
+ const data = (await res.json()) as ORSResponse;
+ return { ok: true, status: 200, data, rateLimited: false };
+}
+
+// ---------------------------------------------------------------------------
+// Candidate scoring
+// ---------------------------------------------------------------------------
+
+interface Candidate {
+ geometry: [number, number][];
+ distance: number;
+ duration: number;
+ instructions: string[];
+ type: 'loop' | 'out-and-back';
+ score: number; // lower is better (distance from target)
+}
+
+function scoreCandidate(
+ geometry: [number, number][],
+ targetDistance: number,
+ duration: number,
+ instructions: string[],
+ type: 'loop' | 'out-and-back'
+): Candidate | null {
+ if (geometry.length < 3) return null;
+
+ const distance = computePathDistance(geometry);
+ if (distance < 100) return null;
+
+ // Loops should roughly close
+ if (type === 'loop') {
+ const closure = haversineMeters(geometry[0], geometry[geometry.length - 1]);
+ if (closure > LOOP_CLOSURE_THRESHOLD_M * 3) return null;
+ }
+
+ const score = Math.abs(distance - targetDistance);
+ return { geometry, distance, duration, instructions, type, score };
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export function isRoutePlannerConfigured(): boolean {
+ return Boolean(OPENROUTESERVICE_API_KEY);
+}
+
+export async function generateRoute(input: RoutePlannerInput): Promise {
+ const { startLat, startLng, targetDistance, variant = 0 } = input;
+
+ if (!OPENROUTESERVICE_API_KEY) {
+ throw new RoutePlannerConfigError(
+ 'Route planning service not configured. Add OPENROUTESERVICE_API_KEY to your environment variables.'
+ );
+ }
+
+ if (!Number.isFinite(startLat) || !Number.isFinite(startLng) || !Number.isFinite(targetDistance)) {
+ throw new RoutePlannerInputError('Invalid start location or target distance');
+ }
+
+ if (targetDistance < 500 || targetDistance > 50_000) {
+ throw new RoutePlannerInputError('Target distance must be between 0.5 km and 50 km');
+ }
+
+ // Holder object avoids TS control-flow narrowing issues with let + nested functions
+ const state: { best: Candidate | null; rateLimitHit: boolean; orsCalls: number } = {
+ best: null,
+ rateLimitHit: false,
+ orsCalls: 0,
+ };
+
+ const consider = (c: Candidate | null) => {
+ if (!c) return;
+ if (!state.best || c.score < state.best.score) state.best = c;
+ };
+
+ const isGoodEnough = () => Boolean(state.best && state.best.score < TARGET_TOLERANCE_M);
+
+ // --- Strategy 1: ORS round_trip (single call, deterministic seed) ---
+ if (state.orsCalls < MAX_ORS_CALLS) {
+ state.orsCalls++;
+ const seed = routeSeed(startLat, startLng, targetDistance, variant);
+ const rt = await orsDirections([[startLng, startLat]], {
+ options: {
+ round_trip: {
+ length: targetDistance,
+ points: targetDistance > 15_000 ? 5 : 4,
+ seed,
+ },
+ },
+ });
+
+ if (rt.rateLimited) state.rateLimitHit = true;
+ else if (rt.ok && rt.data) {
+ const route = extractRoute(rt.data);
+ if (route) {
+ const geom = ensureClosed(normalizeGeometry(route.geometry));
+ consider(
+ scoreCandidate(
+ geom,
+ targetDistance,
+ route.summary?.duration ?? 0,
+ extractInstructions(route),
+ 'loop'
+ )
+ );
+ }
+ }
+ }
+
+ // --- Strategy 2: Deterministic multi-waypoint loops (vary radius scale / point count) ---
+ // Try a few radius scales so we converge toward target distance without randomness.
+ const radiusScales = isGoodEnough() ? [] : [0.82, 0.72, 0.92];
+ const pointCounts = [3, 4]; // fewer points = simpler loops on roads
+
+ for (const scale of radiusScales) {
+ if (isGoodEnough()) break;
+ for (const points of pointCounts) {
+ if (state.orsCalls >= MAX_ORS_CALLS) break;
+ if (isGoodEnough()) break;
+
+ state.orsCalls++;
+ const coords = buildLoopWaypoints(startLat, startLng, targetDistance, variant, points, scale);
+ const res = await orsDirections(coords);
+
+ if (res.rateLimited) {
+ state.rateLimitHit = true;
+ continue;
+ }
+ if (!res.ok || !res.data) continue;
+
+ const route = extractRoute(res.data);
+ if (!route) continue;
+
+ const geom = ensureClosed(normalizeGeometry(route.geometry));
+ consider(
+ scoreCandidate(
+ geom,
+ targetDistance,
+ route.summary?.duration ?? 0,
+ extractInstructions(route),
+ 'loop'
+ )
+ );
+ }
+ }
+
+ // --- Strategy 3: Out-and-back (reliable fallback) ---
+ if (!state.best || state.best.score > TARGET_TOLERANCE_M * 2) {
+ // Half the target; road distance is longer than straight line so aim slightly under half
+ const halfDistances = [targetDistance / 2.2, targetDistance / 2.0, targetDistance / 1.85];
+
+ for (const half of halfDistances) {
+ if (state.orsCalls >= MAX_ORS_CALLS) break;
+ if (isGoodEnough()) break;
+
+ state.orsCalls++;
+ const coords = buildOutAndBackWaypoint(startLat, startLng, half, variant);
+ const res = await orsDirections(coords);
+
+ if (res.rateLimited) {
+ state.rateLimitHit = true;
+ continue;
+ }
+ if (!res.ok || !res.data) continue;
+
+ const route = extractRoute(res.data);
+ if (!route) continue;
+
+ const outbound = normalizeGeometry(route.geometry);
+ if (outbound.length < 2) continue;
+
+ // Mirror outbound to create return leg (same road, reverse)
+ const inbound = outbound.slice().reverse().slice(1);
+ const full = ensureClosed([...outbound, ...inbound]);
+ const oneWayDuration = route.summary?.duration ?? 0;
+
+ consider(
+ scoreCandidate(
+ full,
+ targetDistance,
+ oneWayDuration * 2,
+ extractInstructions(route, 'Turn around and return the same way'),
+ 'out-and-back'
+ )
+ );
+ }
+ }
+
+ if (!state.best) {
+ if (state.rateLimitHit) {
+ throw new RoutePlannerRateLimitError(
+ 'Rate limit exceeded while generating routes. Please wait a moment and try again.'
+ );
+ }
+ throw new RoutePlannerNotFoundError(
+ 'Unable to find a suitable route for the selected distance. Try a different starting location or distance.'
+ );
+ }
+
+ const winner = state.best;
+ const planned: PlannedRoute = {
+ geometry: winner.geometry,
+ distance: winner.distance,
+ duration: winner.duration,
+ instructions: winner.instructions,
+ type: winner.type,
+ accuracy: winner.distance / targetDistance,
+ };
+
+ console.log('[RoutePlanner] Generated route', {
+ type: planned.type,
+ distanceM: Math.round(planned.distance),
+ targetM: Math.round(targetDistance),
+ accuracy: `${(planned.accuracy * 100).toFixed(1)}%`,
+ orsCalls: state.orsCalls,
+ variant,
+ });
+
+ return {
+ route: planned,
+ stats: { distance: planned.distance, duration: planned.duration },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Errors
+// ---------------------------------------------------------------------------
+
+export class RoutePlannerConfigError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RoutePlannerConfigError';
+ }
+}
+
+export class RoutePlannerInputError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RoutePlannerInputError';
+ }
+}
+
+export class RoutePlannerRateLimitError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RoutePlannerRateLimitError';
+ }
+}
+
+export class RoutePlannerNotFoundError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RoutePlannerNotFoundError';
+ }
+}