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 ( - + +