aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app/components/arrivals/ArrivalCard.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app/components/arrivals/ArrivalCard.tsx')
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.tsx231
1 files changed, 157 insertions, 74 deletions
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>
);
};