aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Nanis2026-03-03 04:55:07 -0500
committerMax Nanis2026-03-03 04:55:07 -0500
commit979e789c388e7a1e9a90e448d82e297c9c296a3e (patch)
treeb2ad366338249416f029e1820a7d02e9caf87c57
parentdec4f45c0755f65a322f6c66833c711dec2c6abb (diff)
downloadamt-jb-979e789c388e7a1e9a90e448d82e297c9c296a3e.tar.gz
amt-jb-979e789c388e7a1e9a90e448d82e297c9c296a3e.zip
country-flag-icon, grlEventsSlice overhaul (ditch latestModel tracking for useRef list in Component). speed calc in createSelector(), seperated components.
-rw-r--r--jb-ui/package.json1
-rw-r--r--jb-ui/pnpm-lock.yaml8
-rw-r--r--jb-ui/src/JBApp.tsx16
-rw-r--r--jb-ui/src/components/EventMarquee.tsx235
-rw-r--r--jb-ui/src/lib/utils.ts22
-rw-r--r--jb-ui/src/models/grlEventsSlice.ts46
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 (
<>
- <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