aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components/Stops
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-11-07 14:58:32 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-11-07 14:58:32 +0100
commit52256fd634300b39b915bf1db6020d9d2871a0b4 (patch)
treed32b219427e5974026c544132e3b0a65ffa0a82e /src/frontend/app/components/Stops
parent4420def7411a053e930b44117e2bf63625d824dc (diff)
Implement experimental consolidated circulation view
Diffstat (limited to 'src/frontend/app/components/Stops')
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css158
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx217
2 files changed, 375 insertions, 0 deletions
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
new file mode 100644
index 0000000..65e897b
--- /dev/null
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
@@ -0,0 +1,158 @@
+/* Consolidated Circulation List Styles */
+.consolidated-circulation-container {
+ width: 100%;
+ margin-block-start: 1.5rem;
+}
+
+.consolidated-circulation-caption {
+ font-size: 0.9rem;
+ color: var(--subtitle-color);
+ text-align: center;
+ margin-bottom: 1rem;
+ padding: 0.5rem;
+}
+
+.consolidated-circulation-no-data {
+ text-align: center;
+ padding: 2rem 1rem;
+ color: var(--subtitle-color);
+ font-size: 0.95rem;
+}
+
+.consolidated-circulation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding-block: 0 1rem;
+}
+
+.consolidated-circulation-card {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--message-background-color);
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ overflow: hidden;
+ transition: box-shadow 0.2s ease;
+}
+
+.consolidated-circulation-card:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.consolidated-circulation-card .card-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+}
+
+.consolidated-circulation-card .line-info {
+ flex-shrink: 0;
+}
+
+.consolidated-circulation-card .route-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.consolidated-circulation-card .route-info strong {
+ font-size: 0.95rem;
+ color: var(--text-color);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+}
+
+.consolidated-circulation-card .time-info {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.consolidated-circulation-card .arrival-time {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 1.05rem;
+ font-weight: 600;
+}
+
+.consolidated-circulation-card .arrival-time svg {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+}
+
+/* Time color states */
+.consolidated-circulation-card .arrival-time.time-running {
+ color: #22c55e;
+}
+
+.consolidated-circulation-card .arrival-time.time-running svg {
+ color: #22c55e;
+}
+
+.consolidated-circulation-card .arrival-time.time-delayed {
+ color: #09106e;
+}
+
+.consolidated-circulation-card .arrival-time.time-delayed svg {
+ color: #09106e;
+}
+
+.consolidated-circulation-card .arrival-time.time-scheduled {
+ color: var(--text-color);
+}
+
+.consolidated-circulation-card .arrival-time.time-scheduled svg {
+ color: var(--subtitle-color);
+}
+
+.consolidated-circulation-card .distance-info {
+ font-size: 0.75rem;
+ color: var(--subtitle-color);
+ text-align: right;
+}
+
+.consolidated-circulation-card .card-footer {
+ padding: 0.5rem 1rem 0.75rem 1rem;
+ border-top: 1px solid var(--border-color);
+ background-color: rgba(0, 0, 0, 0.02);
+}
+
+@media (prefers-color-scheme: dark) {
+ .consolidated-circulation-card .card-footer {
+ background-color: rgba(255, 255, 255, 0.03);
+ }
+}
+
+.consolidated-circulation-card .status-text {
+ font-size: 0.8rem;
+ color: var(--subtitle-color);
+ line-height: 1.4;
+ display: block;
+}
+
+/* Responsive adjustments */
+@media (max-width: 480px) {
+ .consolidated-circulation-card .card-header {
+ gap: 0.5rem;
+ padding: 0.75rem 0.875rem;
+ }
+
+ .consolidated-circulation-card .arrival-time {
+ font-size: 1rem;
+ }
+
+ .consolidated-circulation-card .card-footer {
+ padding: 0.5rem 0.875rem 0.625rem 0.875rem;
+ }
+
+ .consolidated-circulation-card .status-text {
+ font-size: 0.9rem;
+ }
+}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
new file mode 100644
index 0000000..a1b50f2
--- /dev/null
+++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
@@ -0,0 +1,217 @@
+import { useTranslation } from "react-i18next";
+import { Clock } from "lucide-react";
+import { type ConsolidatedCirculation } from "~routes/stops-$id";
+import LineIcon from "~components/LineIcon";
+import { type RegionConfig } from "~data/RegionConfig";
+
+import './ConsolidatedCirculationList.css';
+
+interface RegularTableProps {
+ data: ConsolidatedCirculation[];
+ dataDate: Date | null;
+ regionConfig: RegionConfig;
+}
+
+// Utility function to parse service ID and get the turn number
+const parseServiceId = (serviceId: string): string => {
+ const parts = serviceId.split("_");
+ if (parts.length === 0) return "";
+
+ const lastPart = parts[parts.length - 1];
+ if (lastPart.length < 6) return "";
+
+ const last6 = lastPart.slice(-6);
+ const lineCode = last6.slice(0, 3);
+ const turnCode = last6.slice(-3);
+
+ // Remove leading zeros from turn
+ const turnNumber = parseInt(turnCode, 10).toString();
+
+ // Parse line number with special cases
+ const lineNumber = parseInt(lineCode, 10);
+ let displayLine: string;
+
+ switch (lineNumber) {
+ case 1:
+ displayLine = "C1";
+ break;
+ case 3:
+ displayLine = "C3";
+ break;
+ case 30:
+ displayLine = "N1";
+ break;
+ case 33:
+ displayLine = "N4";
+ break;
+ case 8:
+ displayLine = "A";
+ break;
+ case 101:
+ displayLine = "H";
+ break;
+ case 150:
+ displayLine = "REF";
+ break;
+ case 500:
+ displayLine = "TUR";
+ break;
+ case 201:
+ displayLine = "U1";
+ break;
+ case 202:
+ displayLine = "U2";
+ break;
+ default:
+ displayLine = `L${lineNumber}`;
+ }
+
+ return `${displayLine}-${turnNumber}`;
+};
+
+export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({
+ data,
+ dataDate,
+ regionConfig,
+}) => {
+ const { t } = useTranslation();
+
+ 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 getDelayText = (estimate: ConsolidatedCirculation): string | null => {
+ if (!estimate.schedule || !estimate.realTime) {
+ return null;
+ }
+
+ const delay = estimate.realTime.minutes - estimate.schedule.minutes;
+
+ if (delay >= -1 && delay <= 2) {
+ return t("estimates.on_time", "on time");
+ } else if (delay > 2) {
+ return t("estimates.minutes_late", "{{minutes}} minutes late", { minutes: delay });
+ } else {
+ return t("estimates.minutes_early", "{{minutes}} minutes early", { minutes: Math.abs(delay) });
+ }
+ };
+
+ const getTripIdDisplay = (tripId: string): string => {
+ const parts = tripId.split("_");
+ return parts.length > 1 ? parts[1] : tripId;
+ };
+
+ const getTimeClass = (estimate: ConsolidatedCirculation): string => {
+ if (estimate.realTime && estimate.schedule?.running) {
+ return "time-running";
+ }
+
+ if (estimate.realTime && !estimate.schedule) {
+ return "time-running";
+ }
+
+ else if (estimate.realTime && !estimate.schedule?.running) {
+ return "time-delayed";
+ }
+
+ return "time-scheduled";
+ };
+
+ const sortedData = [...data].sort(
+ (a, b) => (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
+ (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
+ );
+
+ return (
+ <div className="consolidated-circulation-container">
+ <div className="consolidated-circulation-caption">
+ {t("estimates.caption", "Estimaciones de llegadas a las {{time}}", {
+ time: dataDate?.toLocaleTimeString(),
+ })}
+ </div>
+
+ {sortedData.length === 0 ? (
+ <div className="consolidated-circulation-no-data">
+ {t("estimates.none", "No hay estimaciones disponibles")}
+ </div>
+ ) : (
+ <div className="consolidated-circulation-list">
+ {sortedData.map((estimate, idx) => {
+ const displayMinutes = estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? 0;
+ const timeClass = getTimeClass(estimate);
+ const delayText = getDelayText(estimate);
+
+ return (
+ <div key={idx} className="consolidated-circulation-card">
+ <div className="card-header">
+ <div className="line-info">
+ <LineIcon line={estimate.line} region={regionConfig.id} />
+ </div>
+
+ <div className="route-info">
+ <strong>{estimate.route}</strong>
+ </div>
+
+ <div className="time-info">
+ <div className={`arrival-time ${timeClass}`}>
+ <Clock />
+ {estimate.realTime
+ ? `${displayMinutes} ${t("estimates.minutes", "min")}`
+ : absoluteArrivalTime(displayMinutes)}
+ </div>
+ {estimate.realTime && estimate.realTime.distance >= 0 && (
+ <div className="distance-info">
+ {formatDistance(estimate.realTime.distance)}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="card-footer">
+ <span className="status-text">
+ {delayText && (
+ <>
+ {t("estimates.bus_is", "Bus is")} {delayText}.{" "}
+ </>
+ )}
+ </span>
+ <span className="status-text">
+ {estimate.schedule ? (
+ <>
+ {t("estimates.service", "Service")}{" "}
+ {parseServiceId(estimate.schedule.serviceId)}
+ {", "}
+ {t("estimates.trip", "trip")}{" "}
+ {getTripIdDisplay(estimate.schedule.tripId)}
+ </>
+ ) : (
+ <>
+ {t("estimates.unknown_service", "Unknown service. It may be a reinforcement or the service has a different name than planned.")}
+ </>
+ )}
+ </span>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+};