aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-27 16:39:09 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-27 16:39:28 +0100
commitf81ff82f2a07f87f6eb4f43de49ede64215519e5 (patch)
tree67b4f9ef1c94184e2e1a9878c6feed8dc30ebcb3 /src/frontend/app
parentef2df90ffb195edcddd701511dc5953c7baa63af (diff)
Refactor route planner to use new GraphQL backend
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/api/planner.ts34
-rw-r--r--src/frontend/app/api/schema.ts69
-rw-r--r--src/frontend/app/components/LineIcon.css12
-rw-r--r--src/frontend/app/hooks/usePlanQuery.ts29
-rw-r--r--src/frontend/app/hooks/usePlanner.ts93
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json6
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json2
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json6
-rw-r--r--src/frontend/app/routes/planner.tsx106
9 files changed, 243 insertions, 114 deletions
diff --git a/src/frontend/app/api/planner.ts b/src/frontend/app/api/planner.ts
new file mode 100644
index 0000000..86f44f0
--- /dev/null
+++ b/src/frontend/app/api/planner.ts
@@ -0,0 +1,34 @@
+import { RoutePlanSchema, type RoutePlan } from "./schema";
+
+export const fetchPlan = async (
+ fromLat: number,
+ fromLon: number,
+ toLat: number,
+ toLon: number,
+ time?: Date,
+ arriveBy: boolean = false
+): Promise<RoutePlan> => {
+ let url = `/api/planner/plan?fromLat=${fromLat}&fromLon=${fromLon}&toLat=${toLat}&toLon=${toLon}&arriveBy=${arriveBy}`;
+ if (time) {
+ url += `&time=${time.toISOString()}`;
+ }
+
+ const resp = await fetch(url, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
+ }
+
+ const data = await resp.json();
+ try {
+ return RoutePlanSchema.parse(data);
+ } catch (e) {
+ console.error("Zod parsing failed for route plan:", e);
+ console.log("Received data:", data);
+ throw e;
+ }
+};
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index 9cc5bd4..05f3a87 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -8,7 +8,7 @@ export const RouteInfoSchema = z.object({
export const HeadsignInfoSchema = z.object({
badge: z.string().optional().nullable(),
- destination: z.string(),
+ destination: z.string().nullable(),
marquee: z.string().optional().nullable(),
});
@@ -108,3 +108,70 @@ export const ConsolidatedCirculationSchema = z.object({
export type ConsolidatedCirculation = z.infer<
typeof ConsolidatedCirculationSchema
>;
+
+// Route Planner
+export const PlannerPlaceSchema = z.object({
+ name: z.string().optional().nullable(),
+ lat: z.number(),
+ lon: z.number(),
+ stopId: z.string().optional().nullable(),
+ stopCode: z.string().optional().nullable(),
+});
+
+export const PlannerGeometrySchema = z.object({
+ type: z.string(),
+ coordinates: z.array(z.array(z.number())),
+});
+
+export const PlannerStepSchema = z.object({
+ distanceMeters: z.number(),
+ relativeDirection: z.string().optional().nullable(),
+ absoluteDirection: z.string().optional().nullable(),
+ streetName: z.string().optional().nullable(),
+ lat: z.number(),
+ lon: z.number(),
+});
+
+export const PlannerLegSchema = z.object({
+ mode: z.string().optional().nullable(),
+ routeName: z.string().optional().nullable(),
+ routeShortName: z.string().optional().nullable(),
+ routeLongName: z.string().optional().nullable(),
+ routeColor: z.string().optional().nullable(),
+ routeTextColor: z.string().optional().nullable(),
+ headsign: z.string().optional().nullable(),
+ agencyName: z.string().optional().nullable(),
+ from: PlannerPlaceSchema.optional().nullable(),
+ to: PlannerPlaceSchema.optional().nullable(),
+ startTime: z.string(),
+ endTime: z.string(),
+ distanceMeters: z.number(),
+ geometry: PlannerGeometrySchema.optional().nullable(),
+ steps: z.array(PlannerStepSchema),
+ intermediateStops: z.array(PlannerPlaceSchema),
+});
+
+export const ItinerarySchema = z.object({
+ durationSeconds: z.number(),
+ startTime: z.string(),
+ endTime: z.string(),
+ walkDistanceMeters: z.number(),
+ walkTimeSeconds: z.number(),
+ transitTimeSeconds: z.number(),
+ waitingTimeSeconds: z.number(),
+ legs: z.array(PlannerLegSchema),
+ cashFareEuro: z.number().optional().nullable(),
+ cardFareEuro: z.number().optional().nullable(),
+});
+
+export const RoutePlanSchema = z.object({
+ itineraries: z.array(ItinerarySchema),
+ timeOffsetSeconds: z.number().optional().nullable(),
+});
+
+export type PlannerPlace = z.infer<typeof PlannerPlaceSchema>;
+export type PlannerGeometry = z.infer<typeof PlannerGeometrySchema>;
+export type PlannerStep = z.infer<typeof PlannerStepSchema>;
+export type PlannerLeg = z.infer<typeof PlannerLegSchema>;
+export type Itinerary = z.infer<typeof ItinerarySchema>;
+export type RoutePlan = z.infer<typeof RoutePlanSchema>;
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index 448b5fd..a8a413c 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -127,18 +127,20 @@
}
.line-icon-rounded {
- display: block;
- width: 4.25ch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 4.25ch;
height: 4.25ch;
box-sizing: border-box;
background-color: var(--line-colour);
color: var(--line-text-colour);
- padding: 1.4ch 0.8ch;
+ padding: 0 0.8ch;
text-align: center;
- border-radius: 50%;
+ border-radius: 2.125ch;
font: 600 13px / 1 monospace;
letter-spacing: 0.05em;
- text-wrap: nowrap;
+ white-space: nowrap;
}
diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts
new file mode 100644
index 0000000..103f5f4
--- /dev/null
+++ b/src/frontend/app/hooks/usePlanQuery.ts
@@ -0,0 +1,29 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetchPlan } from "../api/planner";
+
+export const usePlanQuery = (
+ fromLat: number | undefined,
+ fromLon: number | undefined,
+ toLat: number | undefined,
+ toLon: number | undefined,
+ time?: Date,
+ arriveBy: boolean = false,
+ enabled: boolean = true
+) => {
+ return useQuery({
+ queryKey: [
+ "plan",
+ fromLat,
+ fromLon,
+ toLat,
+ toLon,
+ time?.toISOString(),
+ arriveBy,
+ ],
+ queryFn: () =>
+ fetchPlan(fromLat!, fromLon!, toLat!, toLon!, time, arriveBy),
+ enabled: !!(fromLat && fromLon && toLat && toLon) && enabled,
+ staleTime: 60000, // 1 minute
+ retry: false,
+ });
+};
diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts
index 6123f8a..a28167a 100644
--- a/src/frontend/app/hooks/usePlanner.ts
+++ b/src/frontend/app/hooks/usePlanner.ts
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useState } from "react";
-import {
- type PlannerSearchResult,
- type RoutePlan,
- planRoute,
-} from "../data/PlannerApi";
+import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi";
+import { usePlanQuery } from "./usePlanQuery";
const STORAGE_KEY = "planner_last_route";
const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
@@ -32,6 +29,48 @@ export function usePlanner() {
number | null
>(null);
+ const {
+ data: queryPlan,
+ isLoading: queryLoading,
+ error: queryError,
+ isFetching,
+ } = usePlanQuery(
+ origin?.lat,
+ origin?.lon,
+ destination?.lat,
+ destination?.lon,
+ searchTime ?? undefined,
+ arriveBy,
+ !!(origin && destination)
+ );
+
+ // Sync query result to local state and storage
+ useEffect(() => {
+ if (queryPlan) {
+ setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now
+
+ if (origin && destination) {
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin,
+ destination,
+ plan: queryPlan as any,
+ searchTime: searchTime ?? new Date(),
+ arriveBy,
+ selectedItineraryIndex: selectedItineraryIndex ?? undefined,
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
+ }
+ }
+ }, [
+ queryPlan,
+ origin,
+ destination,
+ searchTime,
+ arriveBy,
+ selectedItineraryIndex,
+ ]);
+
// Load from storage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -60,41 +99,11 @@ export function usePlanner() {
time?: Date,
arriveByParam: boolean = false
) => {
- setLoading(true);
- setError(null);
- try {
- const result = await planRoute(
- from.lat,
- from.lon,
- to.lat,
- to.lon,
- time,
- arriveByParam
- );
- setPlan(result);
- setOrigin(from);
- setDestination(to);
- setSearchTime(time ?? new Date());
- setArriveBy(arriveByParam);
- setSelectedItineraryIndex(null); // Reset when doing new search
-
- // Save to storage
- const toStore: StoredRoute = {
- timestamp: Date.now(),
- origin: from,
- destination: to,
- plan: result,
- searchTime: time ?? new Date(),
- arriveBy: arriveByParam,
- selectedItineraryIndex: undefined,
- };
- localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
- } catch (err) {
- setError("Failed to calculate route. Please try again.");
- setPlan(null);
- } finally {
- setLoading(false);
- }
+ setOrigin(from);
+ setDestination(to);
+ setSearchTime(time ?? new Date());
+ setArriveBy(arriveByParam);
+ setSelectedItineraryIndex(null);
};
const clearRoute = () => {
@@ -145,8 +154,8 @@ export function usePlanner() {
destination,
setDestination,
plan,
- loading,
- error,
+ loading: queryLoading || (isFetching && !plan),
+ error: queryError ? "Failed to calculate route. Please try again." : null,
searchTime,
arriveBy,
selectedItineraryIndex,
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index a8f3f52..2c58ebe 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -134,9 +134,11 @@
"walk_to": "Walk {{distance}} to {{destination}}",
"from_to": "From {{from}} to {{to}}",
"itinerary_details": "Itinerary Details",
+ "direction": "Direction",
+ "operator": "Operator",
"back": "← Back",
- "cash_fare": "€{{amount}}",
- "card_fare": "€{{amount}}"
+ "fare": "€{{amount}}",
+ "free": "Free"
},
"common": {
"loading": "Loading...",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 2bffac9..298733e 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -134,6 +134,8 @@
"walk_to": "Caminar {{distance}} hasta {{destination}}",
"from_to": "De {{from}} a {{to}}",
"itinerary_details": "Detalles del itinerario",
+ "direction": "Dirección",
+ "operator": "Operador",
"back": "← Atrás",
"fare": "{{amount}} €",
"free": "Gratuito"
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 5086feb..833279f 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -134,9 +134,11 @@
"walk_to": "Camiñar {{distance}} ata {{destination}}",
"from_to": "De {{from}} a {{to}}",
"itinerary_details": "Detalles do itinerario",
+ "direction": "Dirección",
+ "operator": "Operador",
"back": "← Atrás",
- "cash_fare": "{{amount}} €",
- "card_fare": "{{amount}} €"
+ "fare": "{{amount}} €",
+ "free": "Gratuíto"
},
"common": {
"loading": "Cargando...",
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index e99cb03..44488c8 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -1,53 +1,24 @@
import { Coins, CreditCard, Footprints } from "lucide-react";
-import maplibregl, { type StyleSpecification } from "maplibre-gl";
+import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Layer, Source, type MapRef } from "react-map-gl/maplibre";
import { useLocation } from "react-router";
-import { useApp } from "~/AppContext";
+import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema";
import LineIcon from "~/components/LineIcon";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
-import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
-export interface ConsolidatedCirculation {
- line: string;
- route: string;
- schedule?: {
- running: boolean;
- minutes: number;
- serviceId: string;
- tripId: string;
- shapeId?: string;
- };
- realTime?: {
- minutes: number;
- distance: number;
- };
- currentPosition?: {
- latitude: number;
- longitude: number;
- orientationDegrees: number;
- shapeIndex?: number;
- };
- isPreviousTrip?: boolean;
- previousTripShapeId?: string;
- nextStreets?: string[];
-}
-
-const FARE_CASH_PER_BUS = 1.63;
-const FARE_CARD_PER_BUS = 0.67;
-
const formatDistance = (meters: number) => {
- const intMeters = Math.round(meters);
- if (intMeters >= 1000) return `${(intMeters / 1000).toFixed(1)} km`;
- return `${intMeters} m`;
+ if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`;
+ const rounded = Math.round(meters / 100) * 100;
+ return `${rounded} m`;
};
const haversineMeters = (a: [number, number], b: [number, number]) => {
@@ -116,12 +87,8 @@ const ItinerarySummary = ({
const busLegsCount = itinerary.legs.filter(
(leg) => leg.mode !== "WALK"
).length;
- const cashFare = (
- itinerary.cashFareEuro ?? busLegsCount * FARE_CASH_PER_BUS
- ).toFixed(2);
- const cardFare = (
- itinerary.cardFareEuro ?? busLegsCount * FARE_CARD_PER_BUS
- ).toFixed(2);
+ const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2);
+ const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2);
return (
<div
@@ -132,9 +99,7 @@ const ItinerarySummary = ({
<div className="font-bold text-lg text-text">
{startTime} - {endTime}
</div>
- <div className="text-muted">
- {durationMinutes} min
- </div>
+ <div className="text-muted">{durationMinutes} min</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
@@ -168,6 +133,8 @@ const ItinerarySummary = ({
<LineIcon
line={leg.routeShortName || leg.routeName || leg.mode || ""}
mode="pill"
+ colour={leg.routeColor || undefined}
+ textColour={leg.routeTextColor || undefined}
/>
</div>
)}
@@ -180,7 +147,7 @@ const ItinerarySummary = ({
<span>
{t("planner.walk")}: {formatDistance(walkTotals.meters)}
{walkTotals.minutes
- ? ` • ${walkTotals.minutes} {t("estimates.minutes")}`
+ ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}`
: ""}
</span>
<span className="flex items-center gap-3">
@@ -566,7 +533,7 @@ const ItineraryDetail = ({
</div>
{/* Details Panel */}
- <div className="h-1/3 md:h-full md:w-96 lg:w-md overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700">
+ <div className="h-1/3 md:h-full md:w-96 lg:w-lg overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700">
<div className="px-4 py-4">
<h2 className="text-xl font-bold mb-4 text-slate-900 dark:text-slate-100">
{t("planner.itinerary_details")}
@@ -575,7 +542,7 @@ const ItineraryDetail = ({
<div>
{itinerary.legs.map((leg, idx) => (
<div key={idx} className="flex gap-3">
- <div className="flex flex-col items-center">
+ <div className="flex flex-col items-center w-20 shrink-0">
{leg.mode === "WALK" ? (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm"
@@ -587,6 +554,8 @@ const ItineraryDetail = ({
<LineIcon
line={leg.routeShortName || leg.routeName || ""}
mode="rounded"
+ colour={leg.routeColor || undefined}
+ textColour={leg.routeTextColor || undefined}
/>
)}
{idx < itinerary.legs.length - 1 && (
@@ -598,29 +567,42 @@ const ItineraryDetail = ({
{leg.mode === "WALK" ? (
t("planner.walk")
) : (
- <>
- <span>
+ <div className="flex flex-col">
+ <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1">
+ {t("planner.direction")}
+ </span>
+ <span className="leading-tight">
{leg.headsign ||
leg.routeLongName ||
leg.routeName ||
""}
</span>
- </>
+ </div>
)}
</div>
- <div className="text-sm text-gray-600 dark:text-gray-400">
- {new Date(leg.startTime).toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- timeZone: "Europe/Madrid",
- })}{" "}
- -{" "}
- {(
- (new Date(leg.endTime).getTime() -
- new Date(leg.startTime).getTime()) /
- 60000
- ).toFixed(0)}{" "}
- {t("estimates.minutes")}
+ <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1">
+ <span>
+ {new Date(leg.startTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: "Europe/Madrid",
+ })}{" "}
+ -{" "}
+ {(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ ).toFixed(0)}{" "}
+ {t("estimates.minutes")}
+ </span>
+ <span>•</span>
+ <span>{formatDistance(leg.distanceMeters)}</span>
+ {leg.agencyName && (
+ <>
+ <span>•</span>
+ <span className="italic">{leg.agencyName}</span>
+ </>
+ )}
</div>
{leg.mode !== "WALK" &&
leg.from?.stopId &&