aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
authorCopilot <198982749+Copilot@users.noreply.github.com>2025-06-26 23:44:25 +0200
committerGitHub <noreply@github.com>2025-06-26 23:44:25 +0200
commit7b8594debceb93a1fa400d48fe1dcff943bd5af6 (patch)
tree73e68c7238a91d8931d669364d395ce2994164f4 /src/frontend/app/components
parent3dac17a9fb54c977c97280ed4c482e9d4266b7de (diff)
Implement stop sheet modal for map stop interactions (#27)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> Co-authored-by: Ariel Costas Guerrero <ariel@costas.dev>
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/GroupedTable.tsx124
-rw-r--r--src/frontend/app/components/LineIcon.css5
-rw-r--r--src/frontend/app/components/LineIcon.tsx4
-rw-r--r--src/frontend/app/components/NavBar.tsx30
-rw-r--r--src/frontend/app/components/RegularTable.tsx129
-rw-r--r--src/frontend/app/components/StopItem.css2
-rw-r--r--src/frontend/app/components/StopItem.tsx15
-rw-r--r--src/frontend/app/components/StopSheet.css146
-rw-r--r--src/frontend/app/components/StopSheet.tsx154
9 files changed, 465 insertions, 144 deletions
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
index 3a16d89..47c2d31 100644
--- a/src/frontend/app/components/GroupedTable.tsx
+++ b/src/frontend/app/components/GroupedTable.tsx
@@ -2,73 +2,79 @@ import { type StopDetails } from "../routes/estimates-$id";
import LineIcon from "./LineIcon";
interface GroupedTable {
- data: StopDetails;
- dataDate: Date | null;
+ data: StopDetails;
+ dataDate: Date | null;
}
export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => {
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} m`;
- }
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} m`;
}
+ };
- const groupedEstimates = data.estimates.reduce((acc, estimate) => {
- if (!acc[estimate.line]) {
- acc[estimate.line] = [];
- }
- acc[estimate.line].push(estimate);
- return acc;
- }, {} as Record<string, typeof data.estimates>);
+ const groupedEstimates = data.estimates.reduce(
+ (acc, estimate) => {
+ if (!acc[estimate.line]) {
+ acc[estimate.line] = [];
+ }
+ acc[estimate.line].push(estimate);
+ return acc;
+ },
+ {} as Record<string, typeof data.estimates>,
+ );
- const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
- const firstArrivalA = groupedEstimates[a][0].minutes;
- const firstArrivalB = groupedEstimates[b][0].minutes;
- return firstArrivalA - firstArrivalB;
- });
+ const sortedLines = Object.keys(groupedEstimates).sort((a, b) => {
+ const firstArrivalA = groupedEstimates[a][0].minutes;
+ const firstArrivalB = groupedEstimates[b][0].minutes;
+ return firstArrivalA - firstArrivalB;
+ });
- return <table className="table">
- <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption>
+ return (
+ <table className="table">
+ <caption>
+ Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}
+ </caption>
- <thead>
- <tr>
- <th>Línea</th>
- <th>Ruta</th>
- <th>Llegada</th>
- <th>Distancia</th>
- </tr>
- </thead>
-
- <tbody>
- {sortedLines.map((line) => (
- groupedEstimates[line].map((estimate, idx) => (
- <tr key={`${line}-${idx}`}>
- {idx === 0 && (
- <td rowSpan={groupedEstimates[line].length}>
- <LineIcon line={line} />
- </td>
- )}
- <td>{estimate.route}</td>
- <td>{`${estimate.minutes} min`}</td>
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : "No disponible"
- }
- </td>
- </tr>
- ))
- ))}
- </tbody>
+ <thead>
+ <tr>
+ <th>Línea</th>
+ <th>Ruta</th>
+ <th>Llegada</th>
+ <th>Distancia</th>
+ </tr>
+ </thead>
- {data?.estimates.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={4}>No hay estimaciones disponibles</td>
- </tr>
- </tfoot>
+ <tbody>
+ {sortedLines.map((line) =>
+ groupedEstimates[line].map((estimate, idx) => (
+ <tr key={`${line}-${idx}`}>
+ {idx === 0 && (
+ <td rowSpan={groupedEstimates[line].length}>
+ <LineIcon line={line} />
+ </td>
+ )}
+ <td>{estimate.route}</td>
+ <td>{`${estimate.minutes} min`}</td>
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : "No disponible"}
+ </td>
+ </tr>
+ )),
)}
+ </tbody>
+
+ {data?.estimates.length === 0 && (
+ <tfoot>
+ <tr>
+ <td colSpan={4}>No hay estimaciones disponibles</td>
+ </tr>
+ </tfoot>
+ )}
</table>
-}
+ );
+};
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index e7e8949..4b39351 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -55,7 +55,8 @@
font-weight: 600;
text-transform: uppercase;
color: inherit;
- /* Prevent color change on hover */
+ background-color: white;
+ border-radius: 0.25rem 0.25rem 0 0;
}
.line-c1 {
@@ -236,4 +237,4 @@
.line-u2 {
border-color: var(--line-u2);
-} \ No newline at end of file
+}
diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx
index 291b444..3d613e6 100644
--- a/src/frontend/app/components/LineIcon.tsx
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
-import './LineIcon.css';
+import React from "react";
+import "./LineIcon.css";
interface LineIconProps {
line: string;
diff --git a/src/frontend/app/components/NavBar.tsx b/src/frontend/app/components/NavBar.tsx
index eba7196..6a06e63 100644
--- a/src/frontend/app/components/NavBar.tsx
+++ b/src/frontend/app/components/NavBar.tsx
@@ -9,14 +9,14 @@ function isWithinVigo(lngLat: LngLatLike): boolean {
let lng: number, lat: number;
if (Array.isArray(lngLat)) {
[lng, lat] = lngLat;
- } else if ('lng' in lngLat && 'lat' in lngLat) {
+ } else if ("lng" in lngLat && "lat" in lngLat) {
lng = lngLat.lng;
lat = lngLat.lat;
} else {
return false;
}
// Rough bounding box for Vigo
- return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65;
+ return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65;
}
export default function NavBar() {
@@ -25,20 +25,20 @@ export default function NavBar() {
const navItems = [
{
- name: t('navbar.stops', 'Paradas'),
+ name: t("navbar.stops", "Paradas"),
icon: MapPin,
- path: '/stops'
+ path: "/stops",
},
{
- name: t('navbar.map', 'Mapa'),
+ name: t("navbar.map", "Mapa"),
icon: Map,
- path: '/map',
+ path: "/map",
callback: () => {
- if (mapPositionMode !== 'gps') {
+ if (mapPositionMode !== "gps") {
return;
}
- if (!('geolocation' in navigator)) {
+ if (!("geolocation" in navigator)) {
return;
}
@@ -50,20 +50,20 @@ export default function NavBar() {
updateMapState(coords, 16);
}
},
- () => { }
+ () => {},
);
- }
+ },
},
{
- name: t('navbar.settings', 'Ajustes'),
+ name: t("navbar.settings", "Ajustes"),
icon: Settings,
- path: '/settings'
- }
+ path: "/settings",
+ },
];
return (
<nav className="navigation-bar">
- {navItems.map(item => {
+ {navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.path);
@@ -71,7 +71,7 @@ export default function NavBar() {
<Link
key={item.name}
to={item.path}
- className={`navigation-bar__link ${isActive ? 'active' : ''}`}
+ className={`navigation-bar__link ${isActive ? "active" : ""}`}
onClick={item.callback ? item.callback : undefined}
title={item.name}
aria-label={item.name}
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
index e5b3782..8b01410 100644
--- a/src/frontend/app/components/RegularTable.tsx
+++ b/src/frontend/app/components/RegularTable.tsx
@@ -3,70 +3,85 @@ import { type StopDetails } from "../routes/estimates-$id";
import LineIcon from "./LineIcon";
interface RegularTableProps {
- data: StopDetails;
- dataDate: Date | null;
+ data: StopDetails;
+ dataDate: Date | null;
}
-export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => {
- const { t } = useTranslation();
+export const RegularTable: React.FC<RegularTableProps> = ({
+ data,
+ dataDate,
+}) => {
+ const { t } = useTranslation();
- const absoluteArrivalTime = (minutes: number) => {
- const now = new Date()
- const arrival = new Date(now.getTime() + minutes * 60000)
- return Intl.DateTimeFormat(navigator.language, {
- hour: '2-digit',
- minute: '2-digit'
- }).format(arrival)
- }
+ const absoluteArrivalTime = (minutes: number) => {
+ const now = new Date();
+ const arrival = new Date(now.getTime() + minutes * 60000);
+ return Intl.DateTimeFormat(
+ typeof navigator !== "undefined" ? navigator.language : "en",
+ {
+ hour: "2-digit",
+ minute: "2-digit",
+ }
+ ).format(arrival);
+ };
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- } else {
- return `${meters} ${t('estimates.meters', 'm')}`;
- }
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} ${t("estimates.meters", "m")}`;
}
+ };
- return <table className="table">
- <caption>{t('estimates.caption', 'Estimaciones de llegadas a las {{time}}', { time: dataDate?.toLocaleTimeString() })}</caption>
+ return (
+ <table className="table">
+ <caption>
+ {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", {
+ time: dataDate?.toLocaleTimeString(),
+ })}
+ </caption>
- <thead>
- <tr>
- <th>{t('estimates.line', 'Línea')}</th>
- <th>{t('estimates.route', 'Ruta')}</th>
- <th>{t('estimates.arrival', 'Llegada')}</th>
- <th>{t('estimates.distance', 'Distancia')}</th>
- </tr>
- </thead>
+ <thead>
+ <tr>
+ <th>{t("estimates.line", "Línea")}</th>
+ <th>{t("estimates.route", "Ruta")}</th>
+ <th>{t("estimates.arrival", "Llegada")}</th>
+ <th>{t("estimates.distance", "Distancia")}</th>
+ </tr>
+ </thead>
- <tbody>
- {data.estimates
- .sort((a, b) => a.minutes - b.minutes)
- .map((estimate, idx) => (
- <tr key={idx}>
- <td><LineIcon line={estimate.line} /></td>
- <td>{estimate.route}</td>
- <td>
- {estimate.minutes > 15
- ? absoluteArrivalTime(estimate.minutes)
- : `${estimate.minutes} ${t('estimates.minutes', 'min')}`}
- </td>
- <td>
- {estimate.meters > -1
- ? formatDistance(estimate.meters)
- : t('estimates.not_available', 'No disponible')
- }
- </td>
- </tr>
- ))}
- </tbody>
+ <tbody>
+ {data.estimates
+ .sort((a, b) => a.minutes - b.minutes)
+ .map((estimate, idx) => (
+ <tr key={idx}>
+ <td>
+ <LineIcon line={estimate.line} />
+ </td>
+ <td>{estimate.route}</td>
+ <td>
+ {estimate.minutes > 15
+ ? absoluteArrivalTime(estimate.minutes)
+ : `${estimate.minutes} ${t("estimates.minutes", "min")}`}
+ </td>
+ <td>
+ {estimate.meters > -1
+ ? formatDistance(estimate.meters)
+ : t("estimates.not_available", "No disponible")}
+ </td>
+ </tr>
+ ))}
+ </tbody>
- {data?.estimates.length === 0 && (
- <tfoot>
- <tr>
- <td colSpan={4}>{t('estimates.none', 'No hay estimaciones disponibles')}</td>
- </tr>
- </tfoot>
- )}
+ {data?.estimates.length === 0 && (
+ <tfoot>
+ <tr>
+ <td colSpan={4}>
+ {t("estimates.none", "No hay estimaciones disponibles")}
+ </td>
+ </tr>
+ </tfoot>
+ )}
</table>
-}
+ );
+};
diff --git a/src/frontend/app/components/StopItem.css b/src/frontend/app/components/StopItem.css
index 9feb2d1..54ab136 100644
--- a/src/frontend/app/components/StopItem.css
+++ b/src/frontend/app/components/StopItem.css
@@ -51,4 +51,4 @@
top: 0;
color: #0078d4;
font-weight: bold;
-} \ No newline at end of file
+}
diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx
index 29370b7..b781eb9 100644
--- a/src/frontend/app/components/StopItem.tsx
+++ b/src/frontend/app/components/StopItem.tsx
@@ -1,22 +1,21 @@
-import React from 'react';
-import { Link } from 'react-router';
-import StopDataProvider, { type Stop } from '../data/StopDataProvider';
-import LineIcon from './LineIcon';
+import React from "react";
+import { Link } from "react-router";
+import StopDataProvider, { type Stop } from "../data/StopDataProvider";
+import LineIcon from "./LineIcon";
interface StopItemProps {
stop: Stop;
}
const StopItem: React.FC<StopItemProps> = ({ stop }) => {
-
return (
<li className="list-item">
<Link className="list-item-link" to={`/estimates/${stop.stopId}`}>
- {stop.favourite && <span className="favourite-icon">★</span>} ({stop.stopId}) {StopDataProvider.getDisplayName(stop)}
+ {stop.favourite && <span className="favourite-icon">★</span>} (
+ {stop.stopId}) {StopDataProvider.getDisplayName(stop)}
<div className="line-icons">
- {stop.lines?.map(line => <LineIcon key={line} line={line} />)}
+ {stop.lines?.map((line) => <LineIcon key={line} line={line} />)}
</div>
-
</Link>
</li>
);
diff --git a/src/frontend/app/components/StopSheet.css b/src/frontend/app/components/StopSheet.css
new file mode 100644
index 0000000..3f7621e
--- /dev/null
+++ b/src/frontend/app/components/StopSheet.css
@@ -0,0 +1,146 @@
+/* Stop Sheet Styles */
+.stop-sheet-content {
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.stop-sheet-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.stop-sheet-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-color);
+ margin: 0;
+}
+
+.stop-sheet-id {
+ font-size: 1rem;
+ color: var(--subtitle-color);
+}
+
+.stop-sheet-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 32px;
+ color: var(--subtitle-color);
+ font-size: 1rem;
+}
+
+.stop-sheet-estimates {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.stop-sheet-subtitle {
+ font-size: 1.1rem;
+ font-weight: 500;
+ color: var(--text-color);
+ margin: 0 0 12px 0;
+}
+
+.stop-sheet-no-estimates {
+ text-align: center;
+ padding: 32px 16px;
+ color: var(--subtitle-color);
+ font-size: 0.95rem;
+}
+
+.stop-sheet-estimates-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.stop-sheet-estimate-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ background-color: var(--message-background-color);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+}
+
+.stop-sheet-estimate-line {
+ flex-shrink: 0;
+}
+
+.stop-sheet-estimate-details {
+ flex: 1;
+ min-width: 0;
+}
+
+.stop-sheet-estimate-route {
+ font-weight: 500;
+ color: var(--text-color);
+ font-size: 0.95rem;
+ margin-bottom: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.stop-sheet-estimate-time {
+ font-size: 0.85rem;
+ color: var(--subtitle-color);
+}
+
+.stop-sheet-estimate-distance {
+ color: var(--subtitle-color);
+}
+
+.stop-sheet-view-all {
+ display: block;
+ padding: 12px 16px;
+ background-color: var(--button-background-color);
+ color: white;
+ text-decoration: none;
+ text-align: center;
+ border-radius: 8px;
+ font-weight: 500;
+ transition: background-color 0.2s ease;
+
+ margin-block-start: 1rem;
+ margin-inline-start: auto;
+}
+
+.stop-sheet-view-all:hover {
+ background-color: var(--button-hover-background-color);
+ text-decoration: none;
+}
+
+/* Override react-modal-sheet styles for better integration */
+[data-rsbs-overlay] {
+ background-color: rgba(0, 0, 0, 0.3);
+}
+
+[data-rsbs-header] {
+ background-color: var(--background-color);
+ border-bottom: 1px solid var(--border-color);
+}
+
+[data-rsbs-header]:before {
+ background-color: var(--subtitle-color);
+}
+
+[data-rsbs-root] [data-rsbs-overlay] {
+ border-top-left-radius: 16px;
+ border-top-right-radius: 16px;
+}
+
+[data-rsbs-root] [data-rsbs-content] {
+ background-color: var(--background-color);
+ border-top-left-radius: 16px;
+ border-top-right-radius: 16px;
+ max-height: 95vh;
+ overflow: hidden;
+}
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
new file mode 100644
index 0000000..8075e9d
--- /dev/null
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -0,0 +1,154 @@
+import React, { useEffect, useState } from "react";
+import { Sheet } from "react-modal-sheet";
+import { Link } from "react-router";
+import { useTranslation } from "react-i18next";
+import LineIcon from "./LineIcon";
+import { type StopDetails } from "../routes/estimates-$id";
+import "./StopSheet.css";
+
+interface StopSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ stopId: number;
+ stopName: string;
+}
+
+const loadStopData = async (stopId: number): Promise<StopDetails> => {
+ const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+ return await resp.json();
+};
+
+export const StopSheet: React.FC<StopSheetProps> = ({
+ isOpen,
+ onClose,
+ stopId,
+ stopName,
+}) => {
+ const { t } = useTranslation();
+ const [data, setData] = useState<StopDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (isOpen && stopId) {
+ setLoading(true);
+ setData(null);
+ loadStopData(stopId)
+ .then((stopData) => {
+ setData(stopData);
+ })
+ .catch((error) => {
+ console.error("Failed to load stop data:", error);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }
+ }, [isOpen, stopId]);
+
+ const formatTime = (minutes: number) => {
+ if (minutes > 15) {
+ const now = new Date();
+ const arrival = new Date(now.getTime() + minutes * 60000);
+ return Intl.DateTimeFormat(
+ typeof navigator !== "undefined" ? navigator.language : "en",
+ {
+ hour: "2-digit",
+ minute: "2-digit",
+ }
+ ).format(arrival);
+ } else {
+ return `${minutes} ${t("estimates.minutes", "min")}`;
+ }
+ };
+
+ const formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} ${t("estimates.meters", "m")}`;
+ }
+ };
+
+ // Show only the next 4 arrivals
+ const limitedEstimates =
+ data?.estimates.sort((a, b) => a.minutes - b.minutes).slice(0, 4) || [];
+
+ return (
+ <Sheet
+ isOpen={isOpen}
+ onClose={onClose}
+ detent="content-height"
+ >
+ <Sheet.Container>
+ <Sheet.Header />
+ <Sheet.Content>
+ <div className="stop-sheet-content">
+ <div className="stop-sheet-header">
+ <h2 className="stop-sheet-title">{stopName}</h2>
+ <span className="stop-sheet-id">({stopId})</span>
+ </div>
+
+ {loading && (
+ <div className="stop-sheet-loading">
+ {t("common.loading", "Loading...")}
+ </div>
+ )}
+
+ {data && !loading && (
+ <>
+ <div className="stop-sheet-estimates">
+ <h3 className="stop-sheet-subtitle">
+ {t("estimates.next_arrivals", "Next arrivals")}
+ </h3>
+
+ {limitedEstimates.length === 0 ? (
+ <div className="stop-sheet-no-estimates">
+ {t("estimates.none", "No hay estimaciones disponibles")}
+ </div>
+ ) : (
+ <div className="stop-sheet-estimates-list">
+ {limitedEstimates.map((estimate, idx) => (
+ <div key={idx} className="stop-sheet-estimate-item">
+ <div className="stop-sheet-estimate-line">
+ <LineIcon line={estimate.line} />
+ </div>
+ <div className="stop-sheet-estimate-details">
+ <div className="stop-sheet-estimate-route">
+ {estimate.route}
+ </div>
+ <div className="stop-sheet-estimate-time">
+ {formatTime(estimate.minutes)}
+ {estimate.meters > -1 && (
+ <span className="stop-sheet-estimate-distance">
+ {" • "}
+ {formatDistance(estimate.meters)}
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ <Link
+ to={`/estimates/${stopId}`}
+ className="stop-sheet-view-all"
+ onClick={onClose}
+ >
+ {t("map.view_all_estimates", "Ver todas las estimaciones")}
+ </Link>
+ </>
+ )}
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop />
+ </Sheet>
+ );
+};