From 4a866f5352a51916ddb9849b2d68213856196c9c Mon Sep 17 00:00:00 2001
From: Ariel Costas Guerrero <ariel@costas.dev>
Date: Tue, 23 Dec 2025 21:33:17 +0100
Subject: Full real-time page, coruña real time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../app/components/arrivals/ArrivalCard.tsx        | 231 ++++++++++++++-------
 1 file changed, 157 insertions(+), 74 deletions(-)

(limited to 'src/frontend/app/components/arrivals/ArrivalCard.tsx')

diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx
index 5cfbaa3..6952f8f 100644
--- a/src/frontend/app/components/arrivals/ArrivalCard.tsx
+++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx
@@ -1,5 +1,6 @@
 import { AlertTriangle, LocateIcon } from "lucide-react";
-import React, { useMemo } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import Marquee from "react-fast-marquee";
 import { useTranslation } from "react-i18next";
 import LineIcon from "~/components/LineIcon";
 import { type Arrival } from "../../api/schema";
@@ -7,9 +8,56 @@ import "./ArrivalCard.css";
 
 interface ArrivalCardProps {
   arrival: Arrival;
+  onClick?: () => void;
 }
 
-export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
+const AutoMarquee = ({ text }: { text: string }) => {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [shouldScroll, setShouldScroll] = useState(false);
+
+  useEffect(() => {
+    const el = containerRef.current;
+    if (!el) return;
+
+    const checkScroll = () => {
+      const charWidth = 8;
+      const availableWidth = el.offsetWidth;
+      const textWidth = text.length * charWidth;
+      setShouldScroll(textWidth > availableWidth);
+    };
+
+    checkScroll();
+    const observer = new ResizeObserver(checkScroll);
+    observer.observe(el);
+    return () => observer.disconnect();
+  }, [text]);
+
+  if (shouldScroll) {
+    return (
+      <div ref={containerRef} className="w-full overflow-hidden">
+        <Marquee speed={40} gradient={false}>
+          <div className="mr-32 text-xs font-mono text-slate-500 dark:text-slate-400">
+            {text}
+          </div>
+        </Marquee>
+      </div>
+    );
+  }
+
+  return (
+    <div
+      ref={containerRef}
+      className="w-full overflow-hidden text-xs font-mono text-slate-500 dark:text-slate-400 truncate"
+    >
+      {text}
+    </div>
+  );
+};
+
+export const ArrivalCard: React.FC<ArrivalCardProps> = ({
+  arrival,
+  onClick,
+}) => {
   const { t } = useTranslation();
   const { route, headsign, estimate, delay, shift } = arrival;
 
@@ -36,6 +84,14 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
       kind?: "regular" | "gps" | "delay" | "warning";
     }> = [];
 
+    // Badge/Shift info as a chip
+    if (headsign.badge) {
+      chips.push({
+        label: headsign.badge,
+        kind: "regular",
+      });
+    }
+
     // Delay chip
     if (delay) {
       const delta = Math.round(delay.minutes);
@@ -43,7 +99,7 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
 
       if (delta === 0) {
         chips.push({
-          label: "OK",
+          label: t("estimates.delay_on_time"),
           tone: "delay-ok",
           kind: "delay",
         });
@@ -55,14 +111,14 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
               ? "delay-warn"
               : "delay-critical";
         chips.push({
-          label: `R${delta}`,
+          label: t("estimates.delay_positive", { minutes: delta }),
           tone,
           kind: "delay",
         });
       } else {
         const tone = absDelta <= 2 ? "delay-ok" : "delay-early";
         chips.push({
-          label: `A${absDelta}`,
+          label: t("estimates.delay_negative", { minutes: absDelta }),
           tone,
           kind: "delay",
         });
@@ -80,23 +136,42 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
     // Precision chips
     if (estimate.precision === "unsure") {
       chips.push({
-        label: "!",
+        label: t("estimates.low_accuracy"),
         tone: "warning",
         kind: "warning",
       });
     } else if (estimate.precision === "confident") {
       chips.push({
-        label: "", // Just the icon for reduced
+        label: t("estimates.bus_gps_position"),
         kind: "gps",
       });
     }
 
+    if (estimate.precision === "scheduled") {
+      chips.push({
+        label: t("estimates.no_realtime"),
+        tone: "warning",
+        kind: "warning",
+      });
+    }
+
     return chips;
-  }, [delay, shift, estimate.precision]);
+  }, [delay, shift, estimate.precision, t, headsign.badge]);
+
+  const isClickable = !!onClick && estimate.precision !== "past";
+  const Tag = isClickable ? "button" : "div";
 
   return (
-    <div className="flex-none flex items-center gap-2.5 min-h-12 rounded px-3 py-2.5 transition-all bg-slate-50 dark:bg-slate-800 shadow-sm">
-      <div className="shrink-0 min-w-[7ch]">
+    <Tag
+      type={isClickable ? "button" : undefined}
+      onClick={isClickable ? onClick : undefined}
+      className={`w-full text-left flex items-start gap-3 rounded-xl px-3 py-3 transition-all bg-slate-50 dark:bg-slate-800 border border-gray-200 dark:border-gray-700 shadow-sm ${
+        isClickable
+          ? "hover:border-blue-400 dark:hover:border-blue-500 active:scale-[0.98] cursor-pointer"
+          : ""
+      }`}
+    >
+      <div className="shrink-0 min-w-[7ch] mt-0.5">
         <LineIcon
           line={route.shortName}
           colour={route.colour}
@@ -105,72 +180,80 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
         />
       </div>
       <div className="flex-1 min-w-0 flex flex-col gap-1">
-        <span
-          className={`text-base font-medium overflow-hidden text-ellipsis line-clamp-2 leading-tight ${estimate.precision == "past" ? "line-through" : ""}`}
-        >
-          {headsign.destination}
-        </span>
-        {metaChips.length > 0 && (
-          <div className="flex items-center gap-1 flex-wrap">
-            {metaChips.map((chip, idx) => {
-              let chipColourClasses = "";
-              switch (chip.tone) {
-                case "delay-ok":
-                  chipColourClasses =
-                    "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300";
-                  break;
-                case "delay-warn":
-                  chipColourClasses =
-                    "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300";
-                  break;
-                case "delay-critical":
-                  chipColourClasses =
-                    "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300";
-                  break;
-                case "delay-early":
-                  chipColourClasses =
-                    "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300";
-                  break;
-                case "warning":
-                  chipColourClasses =
-                    "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300";
-                  break;
-                default:
-                  chipColourClasses =
-                    "bg-black/[0.06] dark:bg-white/[0.12] text-slate-600 dark:text-slate-400";
-              }
-
-              return (
-                <span
-                  key={`${chip.label}-${idx}`}
-                  className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`}
-                >
-                  {chip.kind === "gps" && (
-                    <LocateIcon className="w-3 h-3 my-0.5 inline-block" />
-                  )}
-                  {chip.kind === "warning" && (
-                    <AlertTriangle className="w-3 h-3 my-0.5 inline-block" />
-                  )}
-                  {chip.label}
-                </span>
-              );
-            })}
+        <div className="flex justify-between items-start gap-2">
+          <div className="flex-1 min-w-0">
+            <span
+              className={`text-base font-bold overflow-hidden text-ellipsis line-clamp-2 leading-tight text-slate-900 dark:text-slate-100 ${estimate.precision == "past" ? "line-through opacity-60" : ""}`}
+            >
+              {headsign.destination}
+            </span>
+            {headsign.marquee && (
+              <div className="mt-0.5">
+                <AutoMarquee text={headsign.marquee} />
+              </div>
+            )}
+          </div>
+          <div
+            className={`
+              inline-flex items-center justify-center px-2 py-1 rounded-lg shrink-0 min-w-12
+              ${timeClass}
+            `.trim()}
+          >
+            <div className="flex flex-col items-center leading-none">
+              <span className="text-lg font-bold">{etaValue}</span>
+              <span className="text-[0.55rem] font-bold uppercase tracking-tighter opacity-80">
+                {etaUnit}
+              </span>
+            </div>
           </div>
-        )}
-      </div>
-      <div
-        className={`
-          inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0
-          ${timeClass}
-        `.trim()}
-      >
-        <div className="flex flex-col items-center leading-none">
-          <span className="text-lg font-bold leading-none">{etaValue}</span>
-          <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90">
-            {etaUnit}
-          </span>
+        </div>
+
+        <div className="flex items-center gap-0.5 flex-wrap">
+          {metaChips.map((chip, idx) => {
+            let chipColourClasses = "";
+            switch (chip.tone) {
+              case "delay-ok":
+                chipColourClasses =
+                  "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300";
+                break;
+              case "delay-warn":
+                chipColourClasses =
+                  "bg-amber-600/10 dark:bg-yellow-600/20 text-amber-700 dark:text-yellow-300";
+                break;
+              case "delay-critical":
+                chipColourClasses =
+                  "bg-red-400/10 dark:bg-red-600/20 text-red-600 dark:text-red-300";
+                break;
+              case "delay-early":
+                chipColourClasses =
+                  "bg-blue-400/10 dark:bg-blue-600/20 text-blue-700 dark:text-blue-300";
+                break;
+              case "warning":
+                chipColourClasses =
+                  "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300";
+                break;
+              default:
+                chipColourClasses =
+                  "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400";
+            }
+
+            return (
+              <span
+                key={`${chip.label}-${idx}`}
+                className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`}
+              >
+                {chip.kind === "gps" && (
+                  <LocateIcon className="w-2.5 h-2.5 inline-block" />
+                )}
+                {chip.kind === "warning" && (
+                  <AlertTriangle className="w-2.5 h-2.5 inline-block" />
+                )}
+                {chip.label}
+              </span>
+            );
+          })}
         </div>
       </div>
-    </div>
+    </Tag>
   );
 };
-- 
cgit v1.3

