diff options
Diffstat (limited to 'src/frontend/app/components/Stops')
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.css | 158 | ||||
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx | 217 |
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> + ); +}; |
