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/JBApp.tsx | 36 +++++-
jb-ui/src/components/EventMarquee.tsx | 233 ++++++++++++++++++++++++++++++++++
jb-ui/src/models/grlEventsSlice.ts | 48 +++++++
jb-ui/src/store.ts | 2 +
tests/managers/test_amt.py | 31 +++++
tests_sandbox/test_flow.py | 30 -----
tests_sandbox/utils.py | 0
7 files changed, 349 insertions(+), 31 deletions(-)
create mode 100644 jb-ui/src/components/EventMarquee.tsx
create mode 100644 jb-ui/src/models/grlEventsSlice.ts
delete mode 100644 tests_sandbox/test_flow.py
delete mode 100644 tests_sandbox/utils.py
diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx
index 5c99e61..c3d2b5f 100644
--- a/jb-ui/src/JBApp.tsx
+++ b/jb-ui/src/JBApp.tsx
@@ -9,19 +9,22 @@ import {
TaskStatusResponse,
UserWalletBalance,
UserWalletBalanceResponse,
- WalletApi
+ WalletApi,
+ EventType
} 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 Home from "@/pages/Home";
import Preview from "@/pages/Preview";
import Result from "@/pages/Result";
import Work from "@/pages/Work";
import { BrowserRouter, Outlet, Route, Routes, useSearchParams } from "react-router-dom";
import useWebSocket from 'react-use-websocket';
+import NewsTicker from "./components/EventMarquee";
import { useEffect } from "react";
@@ -29,6 +32,7 @@ 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;
@@ -36,10 +40,23 @@ function isStatsMessage(msg: Message): msg is StatsMessage {
return msg.kind === 'stats';
}
+function isEventMessage(msg: Message): msg is EventMessage {
+ return msg.kind === 'event';
+}
+
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();
@@ -108,6 +125,7 @@ const Layout = () => {
@@ -137,6 +155,22 @@ function JBApp() {
if (isStatsMessage(msg)) {
dispatch(addStatsData(msg.data))
+ } else if (isEventMessage(msg)) {
+ // For simplicity, we just log the event messages here.
+ // You can extend this to update the UI or trigger notifications as needed.
+
+ switch (msg.data.event_type) {
+ case EventType.TaskEnter:
+ case EventType.TaskFinish:
+ case EventType.UserCreated:
+ dispatch(addEvent(msg.data));
+ break
+
+ default:
+ // do nothing for now
+ break;
+ }
+
} else if (isPingMessage(msg)) {
const pong: PongMessage = {
"kind": PongMessageKindEnum.Pong,
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 (
+ <>
+
{event.product_user_id} Entered
+ >
+ )
+}
+
+const EventTaskFinish = ({ event }: EventItemProps) => {
+
+ return (
+ <>
+ {event.product_user_id} Finished
+ >
+ )
+}
+
+const EventUserCreated = ({ event }: EventItemProps) => {
+
+ return (
+ <>
+ {event.product_user_id} Created
+ >
+ )
+}
+
+
+const EventComponent = ({ event }: EventItemProps) => {
+
+ const renderContent = () => {
+ switch (event.event_type) {
+ case EventType.TaskEnter:
+ return
+ case EventType.TaskFinish:
+ return
+ case EventType.UserCreated:
+ return
+ default:
+ return <>Unknown event
>
+ }
+ }
+
+ return (
+
+ {renderContent()}
+
+ ◆
+
+ )
+}
+
+
+// ─── 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 (
+
+
+ {/* Ticker wrapper */}
+
+
+ {/* The ticker band */}
+
+
+ {/* Scrolling strip */}
+
+ {latestEvents.map((item, i) => (
+
+ ))}
+
+ {/* Trailing spacer so new content doesn't immediately snap in */}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/jb-ui/src/models/grlEventsSlice.ts b/jb-ui/src/models/grlEventsSlice.ts
new file mode 100644
index 0000000..f77bbc1
--- /dev/null
+++ b/jb-ui/src/models/grlEventsSlice.ts
@@ -0,0 +1,48 @@
+import { EventEnvelope } from "@/api_fsb";
+import { RootState } from "@/store";
+import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+const eventAdapter = createEntityAdapter({
+ selectId: (model: EventEnvelope) => model.event_uuid!,
+ sortComparer: (a, b) => {
+ return b.timestamp!.localeCompare(a.timestamp!);
+ },
+});
+
+
+const grlEventsSlice = createSlice({
+ name: 'grlEvents',
+ // initialState: eventAdapter.getInitialState(),
+ initialState: eventAdapter.getInitialState({ latestEvent: null as EventEnvelope | null }),
+
+ reducers: {
+ addEvents: eventAdapter.addMany,
+ upsertEvent: eventAdapter.upsertOne, // add or update
+
+ // addEvent: eventAdapter.addOne,
+ addEvent: (state, action: PayloadAction) => {
+ eventAdapter.addOne(state, action.payload);
+ const incoming = action.payload.timestamp!;
+ if (!state.latestEvent || incoming.localeCompare(state.latestEvent.timestamp!) > 0) {
+ state.latestEvent = action.payload;
+ }
+ },
+ }
+})
+
+
+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 selectLatestEvent = (state: RootState) => state.events.latestEvent;
+
+export default grlEventsSlice.reducer
diff --git a/jb-ui/src/store.ts b/jb-ui/src/store.ts
index 24f1654..7b4d3e3 100644
--- a/jb-ui/src/store.ts
+++ b/jb-ui/src/store.ts
@@ -1,5 +1,6 @@
import appReducers from "@/models/appSlice"
import grlStatsReducers from "@/models/grlStatsSlice"
+import grlEventsReducers from "@/models/grlEventsSlice"
import profilingQuestionsReducers from "@/models/profilingQuestionsSlice"
import type { Action, ThunkAction } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
@@ -9,6 +10,7 @@ export const store = configureStore({
reducer: {
app: appReducers,
stats: grlStatsReducers,
+ events: grlEventsReducers,
profilingQuestions: profilingQuestionsReducers,
}
})
diff --git a/tests/managers/test_amt.py b/tests/managers/test_amt.py
index 372cb07..a20d0d4 100644
--- a/tests/managers/test_amt.py
+++ b/tests/managers/test_amt.py
@@ -9,6 +9,37 @@ from mypy_boto3_mturk.type_defs import (
ListHITsResponseTypeDef,
)
+# from jb.decorators import HM
+# from jb.flow.tasks import refill_hits, check_stale_hits, check_expired_hits
+
+
+# def test_refill_hits(
+# set_hit_types_in_db_min_active_0,
+# hit_type_record, expire_all_hits, amt_manager
+# ):
+
+# assert HM.get_active_count(hit_type_in_db.id) == 0
+# assert hit_type_in_db.min_active > 0
+# refill_hits()
+# assert HM.get_active_count(hit_type_in_db.id) == hit_type_in_db.min_active
+
+# amt_hit_ids = HM.filter_active_ids(hit_type_id=hit_type_in_db.id)
+# amt_hit_id = list(amt_hit_ids)[0]
+# hit, _ = amt_manager.get_hit_if_exists(amt_hit_id=amt_hit_id)
+# assert hit
+
+
+# def test_check_stale_hits():
+# # todo: I'd have to create some purposely stale hits.
+# # just make sure it runs for now
+# check_stale_hits()
+
+
+# def test_check_expired_hits():
+# # todo: I'd have to create some purposely expired hits.
+# # just make sure it runs for now
+# check_expired_hits()
+
class TestMTurkClient:
diff --git a/tests_sandbox/test_flow.py b/tests_sandbox/test_flow.py
deleted file mode 100644
index bbf3633..0000000
--- a/tests_sandbox/test_flow.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# from jb.decorators import HM
-# from jb.flow.tasks import refill_hits, check_stale_hits, check_expired_hits
-
-
-# def test_refill_hits(
-# set_hit_types_in_db_min_active_0,
-# hit_type_record, expire_all_hits, amt_manager
-# ):
-
-# assert HM.get_active_count(hit_type_in_db.id) == 0
-# assert hit_type_in_db.min_active > 0
-# refill_hits()
-# assert HM.get_active_count(hit_type_in_db.id) == hit_type_in_db.min_active
-
-# amt_hit_ids = HM.filter_active_ids(hit_type_id=hit_type_in_db.id)
-# amt_hit_id = list(amt_hit_ids)[0]
-# hit, _ = amt_manager.get_hit_if_exists(amt_hit_id=amt_hit_id)
-# assert hit
-
-
-# def test_check_stale_hits():
-# # todo: I'd have to create some purposely stale hits.
-# # just make sure it runs for now
-# check_stale_hits()
-
-
-# def test_check_expired_hits():
-# # todo: I'd have to create some purposely expired hits.
-# # just make sure it runs for now
-# check_expired_hits()
diff --git a/tests_sandbox/utils.py b/tests_sandbox/utils.py
deleted file mode 100644
index e69de29..0000000
--
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(-)
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 (
<>
- {event.product_user_id} Entered
+ {getUnicodeFlagIcon(payload.country_iso)}
+
+ {truncate(event.product_user_id!, 6, "*****")} Entered{" "}
+ {formatSource(payload.source)}{" "}
+ (#{truncate(payload.survey_id!, 5, "***")})
+
>
)
}
const EventTaskFinish = ({ event }: EventItemProps) => {
+ const payload = event.payload as TaskFinishPayload;
return (
<>
- {event.product_user_id} Finished
+ {getUnicodeFlagIcon(event.payload.country_iso)}
+
+ {truncate(event.product_user_id!, 6, "*****")}{" "}
+ {formatStatus(payload.status)} {formatSource(payload.source)}{" "}
+ (#{truncate(payload.survey_id!, 5, "***")}) in{" "}
+ {formatSecondsVerbose(payload.duration_sec!)}
+
>
)
}
@@ -36,7 +48,8 @@ const EventUserCreated = ({ event }: EventItemProps) => {
return (
<>
- {event.product_user_id} Created
+ {getUnicodeFlagIcon(event.payload.country_iso)}
+ {truncate(event.product_user_id!, 6, "*****")} Created
>
)
}
@@ -59,10 +72,11 @@ const EventComponent = ({ event }: EventItemProps) => {
return (
+ className="inline-flex items-center text-[10px]
+ font-mono px-2">
{renderContent()}
- ◆
+ {/* ◆ */}
)
}
@@ -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(null); // The ticker band
+ const stripRef = useRef(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(null);
+ const lastTime = useRef(null);
+
+ // --- Item tracking ---
+ // Track which IDs are already in the scroller to only append
+ // genuinely new ones.
+ const seenUUIDs = useRef(new Set());
+ const pendingQueue = useRef([]);
+ const [scrollItems, setScrollItems] = useState([]);
+
+ // --- 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 (
@@ -210,7 +213,7 @@ export default function NewsTicker() {
zIndex: 2,
}}
>
- {latestEvents.map((item, i) => (
+ {scrollItems.map(item => (
))}
@@ -222,12 +225,6 @@ export default function NewsTicker() {
-
);
}
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) => {
- 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