Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 48 additions & 266 deletions src/app/api/route-planner/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
}
21 changes: 14 additions & 7 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -77,8 +81,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
{/* PWA Meta Tags */}
<meta name="application-name" content="Runnr" />
<meta name="apple-mobile-web-app-capable" content="yes" />
Expand Down Expand Up @@ -114,12 +119,14 @@ export default function RootLayout({
userSelect: 'none'
}}
>
<UserProvider>
<PWARegistration />
<InstallPrompt />
<Header />
<main className="flex-1 p-4 sm:p-6 md:p-8 lg:p-10 pb-safe min-h-screen">{children}</main>
</UserProvider>
<ThemeProvider>
<UserProvider>
<PWARegistration />
<InstallPrompt />
<Header />
<main className="flex-1 p-4 sm:p-6 md:p-8 lg:p-10 pb-safe min-h-screen">{children}</main>
</UserProvider>
</ThemeProvider>
</body>
</html>
);
Expand Down
Loading
Loading