aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components')
-rw-r--r--src/frontend/app/components/LineIcon.css7
-rw-r--r--src/frontend/app/components/SchedulesTable.css46
-rw-r--r--src/frontend/app/components/SchedulesTable.tsx6
-rw-r--r--src/frontend/app/components/StopSheet.tsx2
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css158
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx217
6 files changed, 412 insertions, 24 deletions
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index fe4a87f..0b93023 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -38,15 +38,20 @@
--line-vigo-l24: rgb(191, 191, 191);
--line-vigo-l25: rgb(172, 100, 4);
--line-vigo-l27: rgb(112, 74, 42);
+ --line-vigo-l27-text: #ffffff;
--line-vigo-l28: rgb(176, 189, 254);
--line-vigo-l29: rgb(248, 184, 90);
--line-vigo-l31: rgb(255, 255, 0);
--line-vigo-a: rgb(119, 41, 143);
--line-vigo-a-text: #ffffff;
--line-vigo-h: rgb(0, 96, 168);
+ --line-vigo-h-text: #ffffff;
--line-vigo-h1: rgb(0, 96, 168);
+ --line-vigo-h1-text: #ffffff;
--line-vigo-h2: rgb(0, 96, 168);
+ --line-vigo-h2-text: #ffffff;
--line-vigo-h3: rgb(0, 96, 168);
+ --line-vigo-h3-text: #ffffff;
--line-vigo-lzd: rgb(61, 78, 167);
--line-vigo-n1: rgb(191, 191, 191);
--line-vigo-n4: rgb(102, 51, 102);
@@ -56,7 +61,9 @@
--line-vigo-ptl: rgb(150, 220, 153);
--line-vigo-turistico: rgb(102, 51, 102);
--line-vigo-u1: rgb(172, 100, 4);
+ --line-vigo-u1-text: #ffffff;
--line-vigo-u2: rgb(172, 100, 4);
+ --line-vigo-u2-text: #ffffff;
--line-santiago-l1: #f32621;
--line-santiago-l4: #ffcc33;
diff --git a/src/frontend/app/components/SchedulesTable.css b/src/frontend/app/components/SchedulesTable.css
index 74d7569..c0c5cb7 100644
--- a/src/frontend/app/components/SchedulesTable.css
+++ b/src/frontend/app/components/SchedulesTable.css
@@ -52,25 +52,25 @@
border: 1px solid var(--card-border);
}
-.card-header {
+.timetable-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
-.line-info {
+.timetable-card .line-info {
flex-shrink: 0;
}
-.destination-info {
+.timetable-card .destination-info {
flex: 1;
text-align: left;
margin: 0 1rem;
color: var(--text-primary);
}
-.destination-info strong {
+.timetable-card .destination-info strong {
font-size: 0.95rem;
}
@@ -78,14 +78,14 @@
color: var(--text-secondary);
}
-.time-info {
+.timetable-card .time-info {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
-.departure-time {
+.timetable-card .timetable-card .departure-time {
font-weight: bold;
font-family: monospace;
font-size: 1.1rem;
@@ -96,18 +96,18 @@
color: var(--text-secondary);
}
-.card-body {
+.timetable-card .card-body {
line-height: 1.4;
}
-.route-streets {
+.timetable-card .route-streets {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.8;
word-break: break-word;
}
-.service-id {
+.timetable-card .timetable-card .service-id {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-secondary);
@@ -124,7 +124,7 @@
background: var(--service-background-past);
}
-.no-data {
+.timetable-container .no-data {
text-align: center;
color: var(--text-secondary);
font-style: italic;
@@ -142,66 +142,66 @@
.timetable-card {
padding: 0.75rem;
}
- .card-header {
+ .timetable-card .card-header {
margin-bottom: 0.5rem;
}
- .destination-info {
+ .timetable-card .destination-info {
margin: 0 0.5rem;
}
- .destination-info strong {
+ .timetable-card .destination-info strong {
font-size: 0.9rem;
}
- .departure-time {
+ .timetable-card .departure-time {
font-size: 1rem;
}
- .service-id {
+ .timetable-card .service-id {
font-size: 0.8rem;
padding: 0.2rem 0.4rem;
}
}
@media (max-width: 480px) {
- .card-header {
+ .timetable-card .card-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
- .destination-info {
+ .timetable-card .destination-info {
text-align: left;
margin: 0;
order: 2;
}
- .time-info {
+ .timetable-card .time-info {
align-items: flex-start;
order: 1;
align-self: flex-end;
}
- .line-info {
+ .timetable-card .line-info {
order: 0;
align-self: flex-start;
}
/* Create a flex container for line and time on mobile */
- .card-header {
+ .timetable-card .card-header {
position: relative;
}
- .line-info {
+ .timetable-card .line-info {
position: absolute;
left: 0;
top: 0;
}
- .time-info {
+ .timetable-card .time-info {
position: absolute;
right: 0;
top: 0;
}
- .destination-info {
+ .timetable-card .destination-info {
margin-top: 2rem;
text-align: left;
}
diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx
index 9f3f062..07d3136 100644
--- a/src/frontend/app/components/SchedulesTable.tsx
+++ b/src/frontend/app/components/SchedulesTable.tsx
@@ -73,6 +73,12 @@ const parseServiceId = (serviceId: string): string => {
case 150:
displayLine = "REF";
break;
+ case 201:
+ displayLine = "U1";
+ break;
+ case 202:
+ displayLine = "U2";
+ break;
case 500:
displayLine = "TUR";
break;
diff --git a/src/frontend/app/components/StopSheet.tsx b/src/frontend/app/components/StopSheet.tsx
index 0c19cb6..2f37519 100644
--- a/src/frontend/app/components/StopSheet.tsx
+++ b/src/frontend/app/components/StopSheet.tsx
@@ -140,7 +140,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
</div>
<div
- className={`stop-sheet-lines-container ${stop.lines.length >= 6 ? "scrollable" : ""}`}
+ className={`stop-sheet-lines-container ${stop.lines.length >= 10 ? "scrollable" : ""}`}
>
{stop.lines.map((line) => (
<div key={line} className="stop-sheet-line-icon">
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>
+ );
+};