From dec4f45c0755f65a322f6c66833c711dec2c6abb Mon Sep 17 00:00:00 2001
From: Max Nanis
Date: Mon, 2 Mar 2026 06:33:59 -0500
Subject: grlEventsReducers, EventMarquee, and workin on calc speed and
 animations from redux (rather than example's array)

---
 jb-ui/src/components/EventMarquee.tsx | 233 ++++++++++++++++++++++++++++++++++
 1 file changed, 233 insertions(+)
 create mode 100644 jb-ui/src/components/EventMarquee.tsx

(limited to 'jb-ui/src/components/EventMarquee.tsx')

diff --git a/jb-ui/src/components/EventMarquee.tsx b/jb-ui/src/components/EventMarquee.tsx
new file mode 100644
index 0000000..8664e3b
--- /dev/null
+++ b/jb-ui/src/components/EventMarquee.tsx
@@ -0,0 +1,233 @@
+import { EventEnvelope, EventType } from "@/api_fsb";
+import { selectLatestEvent, selectLatestEvents } from "@/models/grlEventsSlice";
+import { useEffect, useRef, useState } from "react";
+import { useSelector } from "react-redux";
+
+// ─── Config ───────────────────────────────────────────────────────────────────
+const BASE_SPEED = 60;       // px/sec at idle
+const MAX_SPEED = 380;       // px/sec at surge
+const RATE_WINDOW = 6_000;    // ms window to measure message frequency
+const SPEED_EASING = 0.025;  // how quickly speed transitions (per frame)
+const RATE_TO_SPEED = 55;    // px/sec added per msg/sec
+
+interface EventItemProps {
+    event: EventEnvelope;
+}
+
+const EventTaskEnter = ({ event }: EventItemProps) => {
+
+    return (
+        <>
+            <h2 className="text-lg">{event.product_user_id} Entered</h2>
+        </>
+    )
+}
+
+const EventTaskFinish = ({ event }: EventItemProps) => {
+
+    return (
+        <>
+            <h2 className="text-lg">{event.product_user_id} Finished</h2>
+        </>
+    )
+}
+
+const EventUserCreated = ({ event }: EventItemProps) => {
+
+    return (
+        <>
+            <h2 className="text-lg">{event.product_user_id} Created</h2>
+        </>
+    )
+}
+
+
+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]">
+            {renderContent()}
+
+            <span className="text-red-500 pl-5">◆</span>
+        </div >
+    )
+}
+
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+export default function NewsTicker() {
+    const latestEvent = useSelector(selectLatestEvent);
+    const latestEvents = useSelector(selectLatestEvents(10));
+    const itemsRef = useRef(latestEvents);
+
+
+    const [speed, setSpeed] = useState(BASE_SPEED);
+
+    const stripRef = useRef(null);
+    const containerRef = useRef(null);
+    const translateX = useRef(0);
+    const currentSpeed = useRef(BASE_SPEED);
+    const targetSpeed = useRef(BASE_SPEED);
+    const rafRef = useRef(null);
+    const lastTime = useRef(null);
+
+
+    const nextId = useRef(1);
+    const pendingQueue = useRef([]);
+    const messageTimes = useRef([]);
+
+
+    // keep itemsRef in sync
+    useEffect(() => {
+        console.log("Adding message to ticker:", latestEvent);
+        itemsRef.current = latestEvents;
+    }, [latestEvents]);
+
+    // const addMessage = useCallback(() => {
+    //     console.log("Adding message to ticker:", latestEvent);
+
+    //     // calc rate over window
+    //     messageTimes.current = messageTimes.current.filter(
+    //         (t) => now - t < RATE_WINDOW
+    //     );
+    //     // const rate = messageTimes.current.length / (RATE_WINDOW / 1000); // msgs/sec
+    //     // targetSpeed.current = Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED);
+
+    //     // pendingQueue.current.push({ id: nextId.current++, text });
+    // }, [latestEvent]);
+
+    // RAF animation loop
+    // useEffect(() => {
+    //     const loop = (timestamp) => {
+    //         if (!lastTime.current) lastTime.current = timestamp;
+    //         const delta = (timestamp - lastTime.current) / 1000; // seconds
+    //         lastTime.current = timestamp;
+
+    //         // ease speed toward target
+    //         currentSpeed.current +=
+    //             (targetSpeed.current - currentSpeed.current) * SPEED_EASING;
+
+    //         // consume pending queue
+    //         if (pendingQueue.current.length > 0) {
+    //             const newItems = pendingQueue.current.splice(0);
+    //             setItems((prev) => [...prev, ...newItems]);
+    //         }
+
+    //         // advance strip
+    //         translateX.current -= currentSpeed.current * delta;
+
+    //         // apply transform directly — no re-render
+    //         if (stripRef.current) {
+    //             stripRef.current.style.transform = `translateX(${translateX.current}px)`;
+    //         }
+
+    //         // update speed display periodically (for the indicator)
+    //         setSpeed(Math.round(currentSpeed.current));
+
+    //         // prune items that have scrolled off-screen
+    //         if (stripRef.current && containerRef.current) {
+    //             const children = Array.from(stripRef.current.children);
+    //             const containerLeft = containerRef.current.getBoundingClientRect().left;
+    //             let pruneCount = 0;
+    //             for (const child of children) {
+    //                 const rect = child.getBoundingClientRect();
+    //                 if (rect.right < containerLeft - 20) {
+    //                     pruneCount++;
+    //                 } else {
+    //                     break;
+    //                 }
+    //             }
+    //             if (pruneCount > 0) {
+    //                 // measure how much width we're removing so we can adjust translateX
+    //                 let removedWidth = 0;
+    //                 for (let i = 0; i < pruneCount; i++) {
+    //                     removedWidth += children[i].getBoundingClientRect().width;
+    //                 }
+    //                 translateX.current += removedWidth;
+    //                 setItems((prev) => prev.slice(pruneCount));
+    //             }
+    //         }
+
+    //         rafRef.current = requestAnimationFrame(loop);
+    //     };
+
+    //     rafRef.current = requestAnimationFrame(loop);
+    //     return () => cancelAnimationFrame(rafRef.current);
+    // }, []);
+
+    // speed bucket for visual indicator
+    // const speedLevel =
+    //     currentSpeed.current < 100 ? 0 :
+    //         currentSpeed.current < 180 ? 1 :
+    //             currentSpeed.current < 260 ? 2 : 3;
+    // const speedLabels = ["NORMAL", "ELEVATED", "HIGH", "SURGE"];
+    // const speedColors = ["#22d3ee", "#f59e0b", "#f97316", "#ef4444"];
+
+    return (
+        <div style={{
+            fontFamily: "'Courier New', monospace",
+            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,
+                        }}
+                    >
+                        {latestEvents.map((item, i) => (
+                            <EventComponent event={item} />
+                        ))}
+
+                        {/* Trailing spacer so new content doesn't immediately snap in */}
+                        <span style={{ paddingRight: "100vw", display: "inline-block" }} />
+
+                    </div>
+                </div>
+
+            </div>
+
+            <style>{`
+        @keyframes pulse {
+          0%, 100% { opacity: 1; }
+          50% { opacity: 0.3; }
+        }
+      `}</style>
+        </div>
+    );
+}
-- 
cgit v1.2.3


From 979e789c388e7a1e9a90e448d82e297c9c296a3e Mon Sep 17 00:00:00 2001
From: Max Nanis
Date: Tue, 3 Mar 2026 04:55:07 -0500
Subject: country-flag-icon, grlEventsSlice overhaul (ditch latestModel
 tracking for useRef list in Component).  speed calc in createSelector(),
 seperated components.

---
 jb-ui/package.json                    |   1 +
 jb-ui/pnpm-lock.yaml                  |   8 ++
 jb-ui/src/JBApp.tsx                   |  16 +--
 jb-ui/src/components/EventMarquee.tsx | 235 +++++++++++++++++-----------------
 jb-ui/src/lib/utils.ts                |  22 +++-
 jb-ui/src/models/grlEventsSlice.ts    |  46 ++++---
 6 files changed, 176 insertions(+), 152 deletions(-)

(limited to 'jb-ui/src/components/EventMarquee.tsx')

diff --git a/jb-ui/package.json b/jb-ui/package.json
index 66e3732..5081196 100644
--- a/jb-ui/package.json
+++ b/jb-ui/package.json
@@ -30,6 +30,7 @@
     "canvas-confetti": "^1.9.4",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "country-flag-icons": "^1.6.15",
     "lucide-react": "^0.562.0",
     "moment": "^2.30.1",
     "motion": "^12.31.0",
diff --git a/jb-ui/pnpm-lock.yaml b/jb-ui/pnpm-lock.yaml
index dc4cb4f..2ae5325 100644
--- a/jb-ui/pnpm-lock.yaml
+++ b/jb-ui/pnpm-lock.yaml
@@ -62,6 +62,9 @@ importers:
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
+      country-flag-icons:
+        specifier: ^1.6.15
+        version: 1.6.15
       lucide-react:
         specifier: ^0.562.0
         version: 0.562.0(react@19.2.3)
@@ -3812,6 +3815,9 @@ packages:
     resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
     engines: {node: '>=10'}
 
+  country-flag-icons@1.6.15:
+    resolution: {integrity: sha512-92HoA8l6DluEidku8tKBftjuFRj4Rv3zDW1lXxCuNnqAxhUSkvso9gM/Afj4F5BnK+wneHIe3ydI+s+4NA29/Q==}
+
   create-jest@29.7.0:
     resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -12743,6 +12749,8 @@ snapshots:
       path-type: 4.0.0
       yaml: 1.10.2
 
+  country-flag-icons@1.6.15: {}
+
   create-jest@29.7.0(@types/node@25.0.3)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@25.0.3)(typescript@5.9.3)):
     dependencies:
       '@jest/types': 29.6.3
diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx
index c3d2b5f..f470a5d 100644
--- a/jb-ui/src/JBApp.tsx
+++ b/jb-ui/src/JBApp.tsx
@@ -1,5 +1,6 @@
 import {
     EventMessage,
+    EventType,
     PingMessage,
     PongMessage,
     PongMessageKindEnum,
@@ -9,15 +10,14 @@ import {
     TaskStatusResponse,
     UserWalletBalance,
     UserWalletBalanceResponse,
-    WalletApi,
-    EventType
+    WalletApi
 } from "@/api_fsb";
 import Footer from "@/components/Footer";
 import { useAppDispatch } from "@/hooks";
 import { bpid, routeBasename, tagManager } from "@/lib/utils";
 import { setAssignmentID, setProductUserID, setTurkSubmitTo } from "@/models/appSlice";
-import { addStatsData } from "@/models/grlStatsSlice";
 import { addEvent } from "@/models/grlEventsSlice";
+import { addStatsData } from "@/models/grlStatsSlice";
 import Home from "@/pages/Home";
 import Preview from "@/pages/Preview";
 import Result from "@/pages/Result";
@@ -32,7 +32,6 @@ import Wallet from "@/components/Wallet";
 import "@/index.css";
 import { setTaskStatus, setUserWalletBalance } from "@/models/appSlice";
 import { Profiling } from "./components/Profiling";
-import { error } from "console";
 
 type Message = PingMessage | PongMessage | SubscribeMessage | EventMessage | StatsMessage;
 
@@ -48,15 +47,6 @@ function isPingMessage(msg: Message): msg is PingMessage {
     return msg.kind === 'ping';
 }
 
-// TaskEnter: 'task.enter',
-// TaskFinish: 'task.finish',
-// SessionEnter: 'session.enter',
-// SessionFinish: 'session.finish',
-// WalletCredit: 'wallet.credit',
-// WalletDebit: 'wallet.debit',
-// UserCreated: 'user.created',
-// UserActive: 'user.active'
-
 function QueryParamProcessor() {
     const dispatch = useAppDispatch()
     const [searchParams] = useSearchParams();
diff --git a/jb-ui/src/components/EventMarquee.tsx b/jb-ui/src/components/EventMarquee.tsx
index 8664e3b..4ce4f15 100644
--- a/jb-ui/src/components/EventMarquee.tsx
+++ b/jb-ui/src/components/EventMarquee.tsx
@@ -1,33 +1,45 @@
-import { EventEnvelope, EventType } from "@/api_fsb";
-import { selectLatestEvent, selectLatestEvents } from "@/models/grlEventsSlice";
+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 BASE_SPEED = 60;       // px/sec at idle
-const MAX_SPEED = 380;       // px/sec at surge
-const RATE_WINDOW = 6_000;    // ms window to measure message frequency
+
 const SPEED_EASING = 0.025;  // how quickly speed transitions (per frame)
-const RATE_TO_SPEED = 55;    // px/sec added per msg/sec
 
 interface EventItemProps {
     event: EventEnvelope;
 }
 
 const EventTaskEnter = ({ event }: EventItemProps) => {
+    const payload = event.payload as TaskEnterPayload;
 
     return (
         <>
-            <h2 className="text-lg">{event.product_user_id} Entered</h2>
+            <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 (
         <>
-            <h2 className="text-lg">{event.product_user_id} Finished</h2>
+            <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>
         </>
     )
 }
@@ -36,7 +48,8 @@ const EventUserCreated = ({ event }: EventItemProps) => {
 
     return (
         <>
-            <h2 className="text-lg">{event.product_user_id} Created</h2>
+            <span className="px-2 text-lg">{getUnicodeFlagIcon(event.payload.country_iso)}</span>
+            <p>{truncate(event.product_user_id!, 6, "*****")} Created</p>
         </>
     )
 }
@@ -59,10 +72,11 @@ const EventComponent = ({ event }: EventItemProps) => {
 
     return (
         <div key={event.event_uuid!}
-            className="inline-flex items-center text-[10px]">
+            className="inline-flex items-center text-[10px]
+            font-mono px-2">
             {renderContent()}
 
-            <span className="text-red-500 pl-5">◆</span>
+            {/* <span className="text-red-500 pl-5">◆</span> */}
         </div >
     )
 }
@@ -70,116 +84,105 @@ const EventComponent = ({ event }: EventItemProps) => {
 
 // ─── Main Component ───────────────────────────────────────────────────────────
 export default function NewsTicker() {
-    const latestEvent = useSelector(selectLatestEvent);
-    const latestEvents = useSelector(selectLatestEvents(10));
-    const itemsRef = useRef(latestEvents);
-
+    // --- Redux ---
+    const reduxItems = useSelector(selectAllEvents);
+    const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec
 
-    const [speed, setSpeed] = useState(BASE_SPEED);
-
-    const stripRef = useRef(null);
-    const containerRef = useRef(null);
+    // --- 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(BASE_SPEED);
-    const targetSpeed = useRef(BASE_SPEED);
-    const rafRef = useRef(null);
-    const lastTime = useRef(null);
-
+    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;
 
-    const nextId = useRef(1);
-    const pendingQueue = useRef([]);
-    const messageTimes = useRef([]);
+        newItems.forEach(item => seenUUIDs.current.add(item.event_uuid!));
+        pendingQueue.current.push(...newItems);
+    }, [reduxItems]);
 
+    // --- Sync target speed from Redux ---
+    useEffect(() => {
+        targetSpeed.current = targetSpeedRedux;
+    }, [targetSpeedRedux]);
 
-    // keep itemsRef in sync
+    // --- RAF loop ---
     useEffect(() => {
-        console.log("Adding message to ticker:", latestEvent);
-        itemsRef.current = latestEvents;
-    }, [latestEvents]);
-
-    // const addMessage = useCallback(() => {
-    //     console.log("Adding message to ticker:", latestEvent);
-
-    //     // calc rate over window
-    //     messageTimes.current = messageTimes.current.filter(
-    //         (t) => now - t < RATE_WINDOW
-    //     );
-    //     // const rate = messageTimes.current.length / (RATE_WINDOW / 1000); // msgs/sec
-    //     // targetSpeed.current = Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED);
-
-    //     // pendingQueue.current.push({ id: nextId.current++, text });
-    // }, [latestEvent]);
-
-    // RAF animation loop
-    // useEffect(() => {
-    //     const loop = (timestamp) => {
-    //         if (!lastTime.current) lastTime.current = timestamp;
-    //         const delta = (timestamp - lastTime.current) / 1000; // seconds
-    //         lastTime.current = timestamp;
-
-    //         // ease speed toward target
-    //         currentSpeed.current +=
-    //             (targetSpeed.current - currentSpeed.current) * SPEED_EASING;
-
-    //         // consume pending queue
-    //         if (pendingQueue.current.length > 0) {
-    //             const newItems = pendingQueue.current.splice(0);
-    //             setItems((prev) => [...prev, ...newItems]);
-    //         }
-
-    //         // advance strip
-    //         translateX.current -= currentSpeed.current * delta;
-
-    //         // apply transform directly — no re-render
-    //         if (stripRef.current) {
-    //             stripRef.current.style.transform = `translateX(${translateX.current}px)`;
-    //         }
-
-    //         // update speed display periodically (for the indicator)
-    //         setSpeed(Math.round(currentSpeed.current));
-
-    //         // prune items that have scrolled off-screen
-    //         if (stripRef.current && containerRef.current) {
-    //             const children = Array.from(stripRef.current.children);
-    //             const containerLeft = containerRef.current.getBoundingClientRect().left;
-    //             let pruneCount = 0;
-    //             for (const child of children) {
-    //                 const rect = child.getBoundingClientRect();
-    //                 if (rect.right < containerLeft - 20) {
-    //                     pruneCount++;
-    //                 } else {
-    //                     break;
-    //                 }
-    //             }
-    //             if (pruneCount > 0) {
-    //                 // measure how much width we're removing so we can adjust translateX
-    //                 let removedWidth = 0;
-    //                 for (let i = 0; i < pruneCount; i++) {
-    //                     removedWidth += children[i].getBoundingClientRect().width;
-    //                 }
-    //                 translateX.current += removedWidth;
-    //                 setItems((prev) => prev.slice(pruneCount));
-    //             }
-    //         }
-
-    //         rafRef.current = requestAnimationFrame(loop);
-    //     };
-
-    //     rafRef.current = requestAnimationFrame(loop);
-    //     return () => cancelAnimationFrame(rafRef.current);
-    // }, []);
-
-    // speed bucket for visual indicator
-    // const speedLevel =
-    //     currentSpeed.current < 100 ? 0 :
-    //         currentSpeed.current < 180 ? 1 :
-    //             currentSpeed.current < 260 ? 2 : 3;
-    // const speedLabels = ["NORMAL", "ELEVATED", "HIGH", "SURGE"];
-    // const speedColors = ["#22d3ee", "#f59e0b", "#f97316", "#ef4444"];
+        // 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={{
-            fontFamily: "'Courier New', monospace",
             minHeight: "3vh", display: "flex", flexDirection: "column",
             justifyContent: "center", alignItems: "center", gap: "0"
         }}>
@@ -210,7 +213,7 @@ export default function NewsTicker() {
                             zIndex: 2,
                         }}
                     >
-                        {latestEvents.map((item, i) => (
+                        {scrollItems.map(item => (
                             <EventComponent event={item} />
                         ))}
 
@@ -222,12 +225,6 @@ export default function NewsTicker() {
 
             </div>
 
-            <style>{`
-        @keyframes pulse {
-          0%, 100% { opacity: 1; }
-          50% { opacity: 0.3; }
-        }
-      `}</style>
         </div>
     );
 }
diff --git a/jb-ui/src/lib/utils.ts b/jb-ui/src/lib/utils.ts
index 04b5dc9..0c84192 100644
--- a/jb-ui/src/lib/utils.ts
+++ b/jb-ui/src/lib/utils.ts
@@ -36,6 +36,17 @@ export function usdCentFmt(value: number): string {
     })
 }
 
+export function formatStatus(status: string): string {
+    const table = {
+        'c': 'Completed',
+        't': 'Terminated',
+        'f': 'Failed',
+        'e': 'Entered',
+    }
+
+    return table[status as keyof typeof table] ?? "Unknown";
+}
+
 export function formatSource(source: Source): string {
     const table = {
         'g': 'GRS',
@@ -44,7 +55,7 @@ export function formatSource(source: Source): string {
         'd': 'Dynata',
         'et': 'Etx',
         'f': 'Full Circle',
-        'i': 'InnovateMr',
+        'i': 'InnovateMR',
         'l': 'Lucid',
         'm': 'Morning Consult',
         'n': 'Open Labs',
@@ -67,9 +78,16 @@ export function formatSource(source: Source): string {
 export function formatSecondsVerbose(seconds: number): string {
     const mins = Math.floor(seconds / 60)
     const secs = seconds % 60
+
     const parts = []
     if (mins > 0) parts.push(`${mins} min`)
-    if (secs > 0 || mins === 0) parts.push(`${secs} sec`)
+    if (secs > 0 || mins === 0) {
+        parts.push(`${secs.toLocaleString('en-US', {
+            minimumFractionDigits: 0,
+            maximumFractionDigits: 1
+        })} sec`)
+    }
+
     return parts.join(" ")
 }
 
diff --git a/jb-ui/src/models/grlEventsSlice.ts b/jb-ui/src/models/grlEventsSlice.ts
index f77bbc1..912c4ca 100644
--- a/jb-ui/src/models/grlEventsSlice.ts
+++ b/jb-ui/src/models/grlEventsSlice.ts
@@ -1,32 +1,25 @@
 import { EventEnvelope } from "@/api_fsb";
 import { RootState } from "@/store";
-import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { createEntityAdapter, createSelector, createSlice } from '@reduxjs/toolkit';
 
 const eventAdapter = createEntityAdapter({
     selectId: (model: EventEnvelope) => model.event_uuid!,
     sortComparer: (a, b) => {
-        return b.timestamp!.localeCompare(a.timestamp!);
+        // ASC by timestamp
+        return a.timestamp!.localeCompare(b.timestamp!);
     },
 });
 
 
 const grlEventsSlice = createSlice({
     name: 'grlEvents',
-    // initialState: eventAdapter.getInitialState(),
-    initialState: eventAdapter.getInitialState({ latestEvent: null as EventEnvelope | null }),
+    initialState: eventAdapter.getInitialState(),
 
     reducers: {
+        addEvent: eventAdapter.addOne,
         addEvents: eventAdapter.addMany,
-        upsertEvent: eventAdapter.upsertOne,  // add or update
-
-        // addEvent: eventAdapter.addOne,
-        addEvent: (state, action: PayloadAction<EventEnvelope>) => {
-            eventAdapter.addOne(state, action.payload);
-            const incoming = action.payload.timestamp!;
-            if (!state.latestEvent || incoming.localeCompare(state.latestEvent.timestamp!) > 0) {
-                state.latestEvent = action.payload;
-            }
-        },
+        upsertEvent: eventAdapter.upsertOne,  // Add or Update
+
     }
 })
 
@@ -35,14 +28,31 @@ export const {
     addEvent, addEvents, upsertEvent
 } = grlEventsSlice.actions;
 
-// Add this after the slice, before exports
+
 export const eventSelectors = eventAdapter.getSelectors(
     (state: RootState) => state.events
 );
 
-export const selectLatestEvents = (count: number) => (state: RootState) =>
-    eventSelectors.selectAll(state).slice(0, count);
+export const selectAllEvents = (state: RootState): EventEnvelope[] => eventSelectors.selectAll(state);
+
+const BASE_SPEED = 40;  // px/sec at idle
+const MAX_SPEED = 380;  // px/sec at surge
+
+const RATE_WINDOW_MS = 10_000;  // ms window to measure message frequency
+const RATE_TO_SPEED = 55;  // px/sec added per msg/sec
 
-export const selectLatestEvent = (state: RootState) => state.events.latestEvent;
+
+// --- Speed: derived from arrival rate of recent items --- 
+export const selectCurrentSpeed = createSelector(selectAllEvents, (items) => {
+    const now = Date.now();
+
+    const recentCount = items.filter((m) => {
+        const ts = new Date(m.timestamp!).getTime();
+        return now - ts < RATE_WINDOW_MS;
+    }).length;
+
+    const rate = recentCount / (RATE_WINDOW_MS / 1_000);  // msgs/sec
+    return Math.min(BASE_SPEED + rate * RATE_TO_SPEED, MAX_SPEED);
+});
 
 export default grlEventsSlice.reducer
-- 
cgit v1.2.3

