aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-20 15:33:59 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-20 15:43:27 +0200
commit594b106010c0a5f9de38f6f0f3bda9b5f92f6699 (patch)
tree53dd28558e39a8fcbd7bac84f76c63b32637679f /src/frontend
parentf2a37bc6366beccce247f834adee752b8e6323ae (diff)
Implementar vista de horario por parada y día
Closes #155
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/api/schema.ts27
-rw-r--r--src/frontend/app/api/transit.ts25
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/routes-$id.tsx4
-rw-r--r--src/frontend/app/routes/stops-$id.schedule.tsx338
-rw-r--r--src/frontend/app/routes/stops-$id.tsx11
6 files changed, 403 insertions, 3 deletions
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index 71eeae9..864ea57 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -261,3 +261,30 @@ 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>;
+
+// Stop Schedule
+export const ScheduledTripSchema = z.object({
+ scheduledDeparture: z.number().int(),
+ routeId: z.string(),
+ routeShortName: z.string().nullable(),
+ routeColor: z.string(),
+ routeTextColor: z.string(),
+ headsign: z.string().nullable(),
+ originStop: z.string().nullable().optional(),
+ destinationStop: z.string().nullable().optional(),
+ operator: z.string().nullable().optional(),
+ pickupType: z.string().nullable().optional(),
+ dropOffType: z.string().nullable().optional(),
+ isFirstStop: z.boolean().optional(),
+ isLastStop: z.boolean().optional(),
+});
+
+export const StopScheduleResponseSchema = z.object({
+ stopCode: z.string(),
+ stopName: z.string(),
+ stopLocation: PositionSchema.optional().nullable(),
+ trips: z.array(ScheduledTripSchema),
+});
+
+export type ScheduledTrip = z.infer<typeof ScheduledTripSchema>;
+export type StopScheduleResponse = z.infer<typeof StopScheduleResponseSchema>;
diff --git a/src/frontend/app/api/transit.ts b/src/frontend/app/api/transit.ts
index fbff5fa..28d9fe5 100644
--- a/src/frontend/app/api/transit.ts
+++ b/src/frontend/app/api/transit.ts
@@ -1,8 +1,10 @@
import {
RouteDetailsSchema,
RouteSchema,
+ StopScheduleResponseSchema,
type Route,
type RouteDetails,
+ type StopScheduleResponse,
} from "./schema";
export const fetchRoutes = async (feeds: string[] = []): Promise<Route[]> => {
@@ -49,3 +51,26 @@ export const fetchRouteDetails = async (
const data = await resp.json();
return RouteDetailsSchema.parse(data);
};
+
+export const fetchStopSchedule = async (
+ id: string,
+ date?: string
+): Promise<StopScheduleResponse> => {
+ const params = new URLSearchParams({ id });
+ if (date) {
+ params.set("date", date);
+ }
+
+ const resp = await fetch(`/api/stops/schedule?${params.toString()}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
+ }
+
+ const data = await resp.json();
+ return StopScheduleResponseSchema.parse(data);
+};
diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx
index 7c02728..08dcc26 100644
--- a/src/frontend/app/routes.tsx
+++ b/src/frontend/app/routes.tsx
@@ -7,6 +7,7 @@ export default [
route("/routes/:id", "routes/routes-$id.tsx"),
route("/stops", "routes/stops.tsx"),
route("/stops/:id", "routes/stops-$id.tsx"),
+ route("/stops/:id/schedule", "routes/stops-$id.schedule.tsx"),
route("/settings", "routes/settings.tsx"),
route("/about", "routes/about.tsx"),
route("/favourites", "routes/favourites.tsx"),
diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx
index 278a848..200b0bd 100644
--- a/src/frontend/app/routes/routes-$id.tsx
+++ b/src/frontend/app/routes/routes-$id.tsx
@@ -150,7 +150,9 @@ export default function RouteDetailsPage() {
const rightNode = useMemo(() => <FavoriteStar id={id} />, [id]);
usePageRightNode(rightNode);
- useBackButton({ to: "/routes" });
+ const backTo =
+ (location.state as { backTo?: string } | null)?.backTo ?? "/routes";
+ useBackButton({ to: backTo });
const weekDays = useMemo(() => {
const base = new Date();
diff --git a/src/frontend/app/routes/stops-$id.schedule.tsx b/src/frontend/app/routes/stops-$id.schedule.tsx
new file mode 100644
index 0000000..fa18c92
--- /dev/null
+++ b/src/frontend/app/routes/stops-$id.schedule.tsx
@@ -0,0 +1,338 @@
+import { useQuery } from "@tanstack/react-query";
+import { ArrowUpToLine, CalendarDays, Clock, Timer } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router";
+import type { ScheduledTrip } from "~/api/schema";
+import { fetchStopSchedule } from "~/api/transit";
+import RouteIcon from "~/components/RouteIcon";
+import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext";
+import "../tailwind-full.css";
+
+const formatDateKey = (d: Date): string => {
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+};
+
+const formatTime = (seconds: number): string => {
+ const h = Math.floor(seconds / 3600) % 24;
+ const m = Math.floor((seconds % 3600) / 60);
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
+};
+
+function TripRow({
+ trip,
+ isPast,
+ backTo,
+}: {
+ trip: ScheduledTrip;
+ isPast: boolean;
+ backTo: string;
+}) {
+ const { t } = useTranslation();
+ const isDropOffOnly = trip.pickupType === "NONE";
+ const isPickUpOnly = trip.dropOffType === "NONE";
+
+ const badges: React.ReactNode[] = [];
+ if (trip.isFirstStop) {
+ badges.push(
+ <span
+ key="first"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
+ >
+ {t("schedule.trip_start", "Inicio")}
+ </span>
+ );
+ }
+ if (trip.isLastStop) {
+ badges.push(
+ <span
+ key="last"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
+ >
+ {t("schedule.trip_end", "Final")}
+ </span>
+ );
+ }
+ if (isDropOffOnly) {
+ badges.push(
+ <span
+ key="dropoff"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
+ >
+ {t("routes.drop_off_only", "Bajada")}
+ </span>
+ );
+ }
+ if (isPickUpOnly) {
+ badges.push(
+ <span
+ key="pickup"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400"
+ >
+ {t("routes.pickup_only", "Subida")}
+ </span>
+ );
+ }
+
+ return (
+ <Link
+ to={`/routes/${encodeURIComponent(trip.routeId)}`}
+ state={{ backTo: backTo }}
+ className={`flex items-center gap-2.5 px-3 py-3 border-b border-border last:border-b-0 hover:bg-surface/60 active:bg-surface transition-colors ${isPast ? "opacity-40" : ""}`}
+ >
+ {/* Time */}
+ <span
+ className={`w-11 shrink-0 text-sm font-mono font-semibold tabular-nums ${isPast ? "line-through text-muted" : "text-text"}`}
+ >
+ {formatTime(trip.scheduledDeparture)}
+ </span>
+
+ {/* Route icon */}
+ <div className="shrink-0">
+ <RouteIcon
+ line={trip.routeShortName ?? "?"}
+ colour={trip.routeColor}
+ textColour={trip.routeTextColor}
+ mode="pill"
+ />
+ </div>
+
+ {/* Destination + origin → dest + operator */}
+ <div className="flex-1 min-w-0">
+ <span className="block text-sm font-medium text-text truncate">
+ {trip.headsign ?? trip.routeShortName}
+ </span>
+ {(trip.originStop || trip.destinationStop) && (
+ <p className="text-[11px] text-muted truncate leading-tight">
+ {trip.originStop}
+ {trip.originStop && trip.destinationStop ? " → " : ""}
+ {trip.destinationStop}
+ </p>
+ )}
+ {trip.operator && (
+ <p className="text-[10px] text-muted/70 truncate leading-tight">
+ {trip.operator}
+ </p>
+ )}
+ </div>
+
+ {/* Badges */}
+ {badges.length > 0 && (
+ <div className="shrink-0 flex flex-col gap-1 items-end">{badges}</div>
+ )}
+ </Link>
+ );
+}
+
+export default function StopSchedulePage() {
+ const { id } = useParams<{ id: string }>();
+ const { t, i18n } = useTranslation();
+
+ const weekDays = useMemo(() => {
+ const base = new Date();
+ return [-1, 0, 1, 2, 3, 4, 5].map((offset) => {
+ const date = new Date(base);
+ date.setDate(base.getDate() + offset);
+
+ let label: string;
+ if (offset === -1) {
+ label = t("routes.day_yesterday", "Ayer");
+ } else if (offset === 0) {
+ label = t("routes.day_today", "Hoy");
+ } else if (offset === 1) {
+ label = t("routes.day_tomorrow", "Mañana");
+ } else {
+ label = date.toLocaleDateString(i18n.language || "es-ES", {
+ weekday: "short",
+ day: "numeric",
+ month: "short",
+ });
+ }
+
+ return { key: formatDateKey(date), date, label };
+ });
+ }, [i18n.language, t]);
+
+ const [selectedDateKey, setSelectedDateKey] = useState<string>(() =>
+ formatDateKey(new Date())
+ );
+
+ const isToday = selectedDateKey === formatDateKey(new Date());
+
+ const now = new Date();
+ const nowSeconds =
+ now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
+
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const nowMarkerRef = useRef<HTMLDivElement>(null);
+ const scrollKey = `stop-schedule-scroll-${id}-${selectedDateKey}`;
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ["stop-schedule", id, selectedDateKey],
+ queryFn: () => fetchStopSchedule(id!, selectedDateKey),
+ enabled: !!id,
+ });
+
+ // Restore scroll position after data loads
+ useEffect(() => {
+ if (!data || !scrollRef.current) return;
+ const saved = sessionStorage.getItem(scrollKey);
+ if (saved) {
+ scrollRef.current.scrollTop = parseInt(saved, 10);
+ }
+ }, [data, scrollKey]);
+
+ const handleScroll = () => {
+ if (scrollRef.current) {
+ sessionStorage.setItem(scrollKey, String(scrollRef.current.scrollTop));
+ }
+ };
+
+ const scrollToTop = () => {
+ scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
+ const scrollToNow = () => {
+ nowMarkerRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ };
+
+ const nowIndex = useMemo(() => {
+ if (!data || !isToday) return -1;
+ return data.trips.findIndex(
+ (trip) => trip.scheduledDeparture >= nowSeconds
+ );
+ }, [data, isToday, nowSeconds]);
+
+ usePageTitle(
+ data?.stopName
+ ? `${t("schedule.title_prefix", "Horarios")} · ${data.stopName}`
+ : t("schedule.title", "Horarios de parada")
+ );
+
+ useBackButton({ to: `/stops/${id}` });
+
+ return (
+ <div className="flex flex-col h-full overflow-hidden bg-background">
+ {/* Toolbar */}
+ <div className="px-3 py-2 bg-surface border-b border-border shrink-0 flex items-center gap-2">
+ <CalendarDays size={15} className="text-muted shrink-0" />
+ <select
+ className="flex-1 px-2 py-1.5 bg-surface text-text text-sm rounded-md border border-border focus:ring-2 focus:ring-primary outline-none"
+ value={selectedDateKey}
+ onChange={(e) => setSelectedDateKey(e.target.value)}
+ aria-label={t("routes.week_date", "Fecha")}
+ >
+ {weekDays.map((day) => (
+ <option key={day.key} value={day.key}>
+ {day.label}
+ </option>
+ ))}
+ </select>
+ <button
+ className="shrink-0 p-1.5 rounded-md border border-border text-muted hover:text-text hover:bg-background active:scale-95 transition-all"
+ onClick={scrollToTop}
+ aria-label={t("schedule.scroll_to_top", "Ir al inicio")}
+ title={t("schedule.scroll_to_top", "Ir al inicio")}
+ >
+ <ArrowUpToLine size={15} />
+ </button>
+ {isToday && (
+ <button
+ className={`shrink-0 p-1.5 rounded-md border transition-all active:scale-95 ${nowIndex === -1 ? "text-muted/40 border-border/40 cursor-default" : "border-border text-muted hover:text-text hover:bg-background"}`}
+ onClick={nowIndex !== -1 ? scrollToNow : undefined}
+ aria-label={t("schedule.scroll_to_now", "Ir a ahora")}
+ title={t("schedule.scroll_to_now", "Ahora")}
+ disabled={nowIndex === -1}
+ >
+ <Timer size={15} />
+ </button>
+ )}
+ </div>
+
+ {/* Content */}
+ <div
+ ref={scrollRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-y-auto bg-background"
+ >
+ {isLoading ? (
+ <div className="flex flex-col">
+ {[...Array(12)].map((_, i) => (
+ <div
+ key={i}
+ className="flex items-center gap-2.5 px-3 py-3 border-b border-border animate-pulse"
+ >
+ <div className="w-11 h-4 rounded bg-slate-200 dark:bg-slate-700 shrink-0" />
+ <div className="w-10 h-5 rounded-full bg-slate-200 dark:bg-slate-700 shrink-0" />
+ <div className="flex-1 flex flex-col gap-1">
+ <div className="h-4 rounded bg-slate-200 dark:bg-slate-700 max-w-30" />
+ <div className="h-3 rounded bg-slate-100 dark:bg-slate-800 max-w-48" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : isError ? (
+ <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
+ <p className="text-sm text-muted">
+ {t("schedule.error", "No se pudo cargar el horario.")}
+ </p>
+ </div>
+ ) : !data || data.trips.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
+ <div className="bg-surface p-4 rounded-full mb-4 border border-border">
+ <Clock size={28} className="text-muted" />
+ </div>
+ <h4 className="text-base font-bold text-text mb-1">
+ {t("schedule.no_service", "Sin servicio")}
+ </h4>
+ <p className="text-sm text-muted max-w-xs">
+ {t(
+ "schedule.no_service_desc",
+ "No hay viajes programados en esta parada para la fecha seleccionada."
+ )}
+ </p>
+ </div>
+ ) : (
+ <>
+ <div className="px-3 py-1.5 bg-surface border-b border-border">
+ <p className="text-[11px] text-muted uppercase tracking-wide font-semibold">
+ {t("schedule.summary", {
+ count: data.trips.length,
+ defaultValue: "{{count}} salidas",
+ })}
+ </p>
+ </div>
+ <div>
+ {data.trips.map((trip, i) => (
+ <div key={`${trip.scheduledDeparture}-${trip.routeId}-${i}`}>
+ {i === nowIndex && (
+ <div
+ ref={nowMarkerRef}
+ className="flex items-center gap-2 px-3 py-1 bg-primary/10 border-b border-primary/20"
+ >
+ <Timer size={11} className="text-primary" />
+ <span className="text-[10px] text-primary font-semibold uppercase tracking-wide">
+ {t("schedule.now", "Ahora")}
+ </span>
+ </div>
+ )}
+ <TripRow
+ trip={trip}
+ isPast={isToday && trip.scheduledDeparture < nowSeconds}
+ backTo={`/stops/${id}/schedule`}
+ />
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 38f6c59..d1b7a48 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -1,7 +1,7 @@
-import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react";
+import { CalendarDays, CircleHelp, Eye, EyeClosed, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { useLocation, useParams } from "react-router";
+import { Link, useLocation, useParams } from "react-router";
import { fetchArrivals } from "~/api/arrivals";
import {
type Arrival,
@@ -266,6 +266,13 @@ export default function Estimates() {
</div>
<div className="flex items-center gap-2">
+ <Link
+ to={`/stops/${stopId}/schedule`}
+ className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
+ aria-label={t("stop.view_schedule", "Ver horarios")}
+ >
+ <CalendarDays className="w-5 h-5" />
+ </Link>
<button
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
onClick={() => setIsHelpModalOpen(true)}