aboutsummaryrefslogtreecommitdiff
path: root/jb-ui/src/components/EventMarquee.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'jb-ui/src/components/EventMarquee.tsx')
-rw-r--r--jb-ui/src/components/EventMarquee.tsx230
1 files changed, 230 insertions, 0 deletions
diff --git a/jb-ui/src/components/EventMarquee.tsx b/jb-ui/src/components/EventMarquee.tsx
new file mode 100644
index 0000000..4ce4f15
--- /dev/null
+++ b/jb-ui/src/components/EventMarquee.tsx
@@ -0,0 +1,230 @@
+import { EventEnvelope, EventType, TaskEnterPayload, TaskFinishPayload } from "@/api_fsb";
+import { formatSecondsVerbose, formatSource, formatStatus, truncate } from "@/lib/utils";
+import { selectAllEvents, selectCurrentSpeed } from "@/models/grlEventsSlice";
+import getUnicodeFlagIcon from 'country-flag-icons/unicode';
+import { useEffect, useRef, useState } from "react";
+import { useSelector } from "react-redux";
+
+// ─── Config ───────────────────────────────────────────────────────────────────
+
+const SPEED_EASING = 0.025; // how quickly speed transitions (per frame)
+
+interface EventItemProps {
+ event: EventEnvelope;
+}
+
+const EventTaskEnter = ({ event }: EventItemProps) => {
+ const payload = event.payload as TaskEnterPayload;
+
+ return (
+ <>
+ <span className="px-2 text-lg">{getUnicodeFlagIcon(payload.country_iso)}</span>
+ <p>
+ {truncate(event.product_user_id!, 6, "*****")} Entered{" "}
+ {formatSource(payload.source)}{" "}
+ (#{truncate(payload.survey_id!, 5, "***")})
+ </p>
+ </>
+ )
+}
+
+const EventTaskFinish = ({ event }: EventItemProps) => {
+ const payload = event.payload as TaskFinishPayload;
+
+ return (
+ <>
+ <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span>
+ <p>
+ {truncate(event.product_user_id!, 6, "*****")}{" "}
+ {formatStatus(payload.status)} {formatSource(payload.source)}{" "}
+ (#{truncate(payload.survey_id!, 5, "***")}) in{" "}
+ {formatSecondsVerbose(payload.duration_sec!)}
+ </p>
+ </>
+ )
+}
+
+const EventUserCreated = ({ event }: EventItemProps) => {
+
+ return (
+ <>
+ <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span>
+ <p>{truncate(event.product_user_id!, 6, "*****")} Created</p>
+ </>
+ )
+}
+
+
+const EventComponent = ({ event }: EventItemProps) => {
+
+ const renderContent = () => {
+ switch (event.event_type) {
+ case EventType.TaskEnter:
+ return <EventTaskEnter event={event} />
+ case EventType.TaskFinish:
+ return <EventTaskFinish event={event} />
+ case EventType.UserCreated:
+ return <EventUserCreated event={event} />
+ default:
+ return <><p>Unknown event</p></>
+ }
+ }
+
+ return (
+ <div key={event.event_uuid!}
+ className="inline-flex items-center text-[10px]
+ font-mono px-2">
+ {renderContent()}
+
+ {/* <span className="text-red-500 pl-5">◆</span> */}
+ </div >
+ )
+}
+
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+export default function NewsTicker() {
+ // --- Redux ---
+ const reduxItems = useSelector(selectAllEvents);
+ const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec
+
+ // --- Scroll state (all refs — no re-renders from animation) ---
+ const containerRef = useRef<HTMLDivElement | null>(null); // The ticker band
+ const stripRef = useRef<HTMLDivElement | null>(null); // Scrolling strip
+ const translateX = useRef(0);
+ const currentSpeed = useRef(targetSpeedRedux);
+ const targetSpeed = useRef(targetSpeedRedux);
+
+ // Only state and refs persist across renders, the rest are re-initialized,
+ // that is why this isn't just a standard variable.
+ // Request Animation Frame reference (stores the animation frame ID)
+ const rafRef = useRef<number | null>(null);
+ const lastTime = useRef<number | null>(null);
+
+ // --- Item tracking ---
+ // Track which IDs are already in the scroller to only append
+ // genuinely new ones.
+ const seenUUIDs = useRef(new Set<string>());
+ const pendingQueue = useRef<EventEnvelope[]>([]);
+ const [scrollItems, setScrollItems] = useState<EventEnvelope[]>([]);
+
+ // --- Sync Redux → pendingQueue (only new items) ---
+ useEffect(() => {
+ const newItems = reduxItems.filter((item) => !seenUUIDs.current.has(item.event_uuid!));
+ if (newItems.length === 0) return;
+
+ newItems.forEach(item => seenUUIDs.current.add(item.event_uuid!));
+ pendingQueue.current.push(...newItems);
+ }, [reduxItems]);
+
+ // --- Sync target speed from Redux ---
+ useEffect(() => {
+ targetSpeed.current = targetSpeedRedux;
+ }, [targetSpeedRedux]);
+
+ // --- RAF loop ---
+ useEffect(() => {
+ // This timestamp is a "high-resolution timestamp" provided by
+ // the browser, which is more accurate for measuring frame intervals
+ // than Date.now()
+ const loop = (timestamp: number) => {
+ console.log("RAF loop, timestamp:", timestamp, lastTime.current);
+
+ if (!lastTime.current) lastTime.current = timestamp;
+ const delta = (timestamp - lastTime.current) / 1000;
+ lastTime.current = timestamp;
+
+ // Slow easement towards targetSpeed
+ currentSpeed.current +=
+ (targetSpeed.current - currentSpeed.current) * SPEED_EASING;
+
+ // flush pending queue into React state
+ if (pendingQueue.current.length > 0) {
+ const incoming = pendingQueue.current.splice(0);
+ setScrollItems((prev) => [...prev, ...incoming]);
+ }
+
+ // Advance strip, slides it over left by X pixels
+ translateX.current -= currentSpeed.current * delta;
+ if (stripRef.current) {
+ stripRef.current.style.transform = `translateX(${translateX.current}px)`;
+ }
+
+ // prune items that have fully exited the left edge
+ if (stripRef.current && containerRef.current) {
+ const children = Array.from(stripRef.current.children);
+ const containerLeft = containerRef.current.getBoundingClientRect().left;
+ let pruneCount = 0;
+ let removedWidth = 0;
+
+ for (const child of children) {
+ const rect = child.getBoundingClientRect();
+ if (rect.right < containerLeft - 20) {
+ pruneCount++;
+ removedWidth += rect.width;
+ } else {
+ break;
+ }
+ }
+
+ if (pruneCount > 0) {
+ translateX.current += removedWidth;
+ setScrollItems((prev) => prev.slice(pruneCount));
+ }
+ }
+
+ // Assign the next loop
+ rafRef.current = requestAnimationFrame(loop);
+ };
+
+ rafRef.current = requestAnimationFrame(loop);
+ return () => cancelAnimationFrame(rafRef.current!);
+ }, []);
+
+ return (
+ <div style={{
+ minHeight: "3vh", display: "flex", flexDirection: "column",
+ justifyContent: "center", alignItems: "center", gap: "0"
+ }}>
+
+ {/* Ticker wrapper */}
+ <div style={{ width: "100%", position: "relative" }}>
+
+ {/* The ticker band */}
+ <div
+ ref={containerRef}
+ style={{
+ overflow: "hidden",
+ padding: "0",
+ position: "relative",
+ }}
+ >
+
+ {/* Scrolling strip */}
+ <div
+ ref={stripRef}
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ whiteSpace: "nowrap",
+ padding: "10px 0",
+ willChange: "transform",
+ position: "relative",
+ zIndex: 2,
+ }}
+ >
+ {scrollItems.map(item => (
+ <EventComponent event={item} />
+ ))}
+
+ {/* Trailing spacer so new content doesn't immediately snap in */}
+ <span style={{ paddingRight: "100vw", display: "inline-block" }} />
+
+ </div>
+ </div>
+
+ </div>
+
+ </div>
+ );
+}