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/GroupedTable.tsx74
-rw-r--r--src/frontend/app/components/LineIcon.css239
-rw-r--r--src/frontend/app/components/LineIcon.tsx17
-rw-r--r--src/frontend/app/components/RegularTable.tsx70
-rw-r--r--src/frontend/app/components/StopItem.css54
-rw-r--r--src/frontend/app/components/StopItem.tsx25
6 files changed, 479 insertions, 0 deletions
diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx
new file mode 100644
index 0000000..3a16d89
--- /dev/null
+++ b/src/frontend/app/components/GroupedTable.tsx
@@ -0,0 +1,74 @@
+import { type StopDetails } from "../routes/estimates-$id";
+import LineIcon from "./LineIcon";
+
+interface GroupedTable {
+ 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 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;
+ });
+
+ 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>
+
+ {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
new file mode 100644
index 0000000..e7e8949
--- /dev/null
+++ b/src/frontend/app/components/LineIcon.css
@@ -0,0 +1,239 @@
+:root {
+ --line-c1: rgb(237, 71, 19);
+ --line-c3d: rgb(255, 204, 0);
+ --line-c3i: rgb(255, 204, 0);
+ --line-l4a: rgb(0, 153, 0);
+ --line-l4c: rgb(0, 153, 0);
+ --line-l5a: rgb(0, 176, 240);
+ --line-l5b: rgb(0, 176, 240);
+ --line-l6: rgb(204, 51, 153);
+ --line-l7: rgb(150, 220, 153);
+ --line-l9b: rgb(244, 202, 140);
+ --line-l10: rgb(153, 51, 0);
+ --line-l11: rgb(226, 0, 38);
+ --line-l12a: rgb(106, 150, 190);
+ --line-l12b: rgb(106, 150, 190);
+ --line-l13: rgb(0, 176, 240);
+ --line-l14: rgb(129, 142, 126);
+ --line-l15a: rgb(216, 168, 206);
+ --line-l15b: rgb(216, 168, 206);
+ --line-l15c: rgb(216, 168, 168);
+ --line-l16: rgb(129, 142, 126);
+ --line-l17: rgb(214, 245, 31);
+ --line-l18a: rgb(212, 80, 168);
+ --line-l18b: rgb(0, 0, 0);
+ --line-l18h: rgb(0, 0, 0);
+ --line-l23: rgb(0, 70, 210);
+ --line-l24: rgb(191, 191, 191);
+ --line-l25: rgb(172, 100, 4);
+ --line-l27: rgb(112, 74, 42);
+ --line-l28: rgb(176, 189, 254);
+ --line-l29: rgb(248, 184, 90);
+ --line-l31: rgb(255, 255, 0);
+ --line-a: rgb(119, 41, 143);
+ --line-h: rgb(0, 96, 168);
+ --line-h1: rgb(0, 96, 168);
+ --line-h2: rgb(0, 96, 168);
+ --line-h3: rgb(0, 96, 168);
+ --line-lzd: rgb(61, 78, 167);
+ --line-n1: rgb(191, 191, 191);
+ --line-n4: rgb(102, 51, 102);
+ --line-psa1: rgb(0, 153, 0);
+ --line-psa4: rgb(0, 153, 0);
+ --line-ptl: rgb(150, 220, 153);
+ --line-turistico: rgb(102, 51, 102);
+ --line-u1: rgb(172, 100, 4);
+ --line-u2: rgb(172, 100, 4);
+}
+
+.line-icon {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ margin-right: 0.5rem;
+ border-bottom: 3px solid;
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: inherit;
+ /* Prevent color change on hover */
+}
+
+.line-c1 {
+ border-color: var(--line-c1);
+}
+
+.line-c3d {
+ border-color: var(--line-c3d);
+}
+
+.line-c3i {
+ border-color: var(--line-c3i);
+}
+
+.line-l4a {
+ border-color: var(--line-l4a);
+}
+
+.line-l4c {
+ border-color: var(--line-l4c);
+}
+
+.line-l5a {
+ border-color: var(--line-l5a);
+}
+
+.line-l5b {
+ border-color: var(--line-l5b);
+}
+
+.line-l6 {
+ border-color: var(--line-l6);
+}
+
+.line-l7 {
+ border-color: var(--line-l7);
+}
+
+.line-l9b {
+ border-color: var(--line-l9b);
+}
+
+.line-l10 {
+ border-color: var(--line-l10);
+}
+
+.line-l11 {
+ border-color: var(--line-l11);
+}
+
+.line-l12a {
+ border-color: var(--line-l12a);
+}
+
+.line-l12b {
+ border-color: var(--line-l12b);
+}
+
+.line-l13 {
+ border-color: var(--line-l13);
+}
+
+.line-l14 {
+ border-color: var(--line-l14);
+}
+
+.line-l15a {
+ border-color: var(--line-l15a);
+}
+
+.line-l15b {
+ border-color: var(--line-l15b);
+}
+
+.line-l15c {
+ border-color: var(--line-l15c);
+}
+
+.line-l16 {
+ border-color: var(--line-l16);
+}
+
+.line-l17 {
+ border-color: var(--line-l17);
+}
+
+.line-l18a {
+ border-color: var(--line-l18a);
+}
+
+.line-l18b {
+ border-color: var(--line-l18b);
+}
+
+.line-l18h {
+ border-color: var(--line-l18h);
+}
+
+.line-l23 {
+ border-color: var(--line-l23);
+}
+
+.line-l24 {
+ border-color: var(--line-l24);
+}
+
+.line-l25 {
+ border-color: var(--line-l25);
+}
+
+.line-l27 {
+ border-color: var(--line-l27);
+}
+
+.line-l28 {
+ border-color: var(--line-l28);
+}
+
+.line-l29 {
+ border-color: var(--line-l29);
+}
+
+.line-l31 {
+ border-color: var(--line-l31);
+}
+
+.line-a {
+ border-color: var(--line-a);
+}
+
+.line-h {
+ border-color: var(--line-h);
+}
+
+.line-h1 {
+ border-color: var(--line-h1);
+}
+
+.line-h2 {
+ border-color: var(--line-h2);
+}
+
+.line-h3 {
+ border-color: var(--line-h3);
+}
+
+.line-lzd {
+ border-color: var(--line-lzd);
+}
+
+.line-n1 {
+ border-color: var(--line-n1);
+}
+
+.line-n4 {
+ border-color: var(--line-n4);
+}
+
+.line-psa1 {
+ border-color: var(--line-psa1);
+}
+
+.line-psa4 {
+ border-color: var(--line-psa4);
+}
+
+.line-ptl {
+ border-color: var(--line-ptl);
+}
+
+.line-turistico {
+ border-color: var(--line-turistico);
+}
+
+.line-u1 {
+ border-color: var(--line-u1);
+}
+
+.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
new file mode 100644
index 0000000..291b444
--- /dev/null
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import './LineIcon.css';
+
+interface LineIconProps {
+ line: string;
+}
+
+const LineIcon: React.FC<LineIconProps> = ({ line }) => {
+ const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`;
+ return (
+ <span className={`line-icon line-${formattedLine.toLowerCase()}`}>
+ {formattedLine}
+ </span>
+ );
+};
+
+export default LineIcon;
diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx
new file mode 100644
index 0000000..75b598b
--- /dev/null
+++ b/src/frontend/app/components/RegularTable.tsx
@@ -0,0 +1,70 @@
+import { type StopDetails } from "../routes/estimates-$id";
+import LineIcon from "./LineIcon";
+
+interface RegularTableProps {
+ data: StopDetails;
+ dataDate: Date | null;
+}
+
+export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => {
+
+ 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 formatDistance = (meters: number) => {
+ if (meters > 1024) {
+ return `${(meters / 1000).toFixed(1)} km`;
+ } else {
+ return `${meters} m`;
+ }
+ }
+
+ 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>
+ {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} 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/StopItem.css b/src/frontend/app/components/StopItem.css
new file mode 100644
index 0000000..9feb2d1
--- /dev/null
+++ b/src/frontend/app/components/StopItem.css
@@ -0,0 +1,54 @@
+/* Stop Item Styling */
+
+.stop-notes {
+ font-size: 0.85rem;
+ font-style: italic;
+ color: #666;
+ margin: 2px 0;
+}
+
+.stop-amenities {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 4px;
+}
+
+.amenity-tag {
+ font-size: 0.75rem;
+ background-color: #e8f4f8;
+ color: #0078d4;
+ border-radius: 4px;
+ padding: 2px 6px;
+ display: inline-block;
+}
+
+/* Different colors for different amenity types */
+.amenity-tag[data-amenity="shelter"] {
+ background-color: #e3f1df;
+ color: #107c41;
+}
+
+.amenity-tag[data-amenity="bench"] {
+ background-color: #f0e8fc;
+ color: #5c2e91;
+}
+
+.amenity-tag[data-amenity="real-time display"] {
+ background-color: #fff4ce;
+ color: #986f0b;
+}
+
+/* When there are alternate names available, show an indicator */
+.has-alternate-names {
+ position: relative;
+}
+
+.has-alternate-names::after {
+ content: "⋯";
+ position: absolute;
+ right: -15px;
+ 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
new file mode 100644
index 0000000..29370b7
--- /dev/null
+++ b/src/frontend/app/components/StopItem.tsx
@@ -0,0 +1,25 @@
+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)}
+ <div className="line-icons">
+ {stop.lines?.map(line => <LineIcon key={line} line={line} />)}
+ </div>
+
+ </Link>
+ </li>
+ );
+};
+
+export default StopItem;