aboutsummaryrefslogtreecommitdiff
path: root/jb-ui/src
diff options
context:
space:
mode:
authorMax Nanis2026-03-02 06:33:59 -0500
committerMax Nanis2026-03-02 06:33:59 -0500
commitdec4f45c0755f65a322f6c66833c711dec2c6abb (patch)
tree0cadd7e8274f0694b4891816f49ac4a0a6529014 /jb-ui/src
parent19a97da80ae740bb1cf92fc911e6efc210aa05a8 (diff)
downloadamt-jb-dec4f45c0755f65a322f6c66833c711dec2c6abb.tar.gz
amt-jb-dec4f45c0755f65a322f6c66833c711dec2c6abb.zip
grlEventsReducers, EventMarquee, and workin on calc speed and animations from redux (rather than example's array)
Diffstat (limited to 'jb-ui/src')
-rw-r--r--jb-ui/src/JBApp.tsx36
-rw-r--r--jb-ui/src/components/EventMarquee.tsx233
-rw-r--r--jb-ui/src/models/grlEventsSlice.ts48
-rw-r--r--jb-ui/src/store.ts2
4 files changed, 318 insertions, 1 deletions
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 = () => {
</main >
<footer className="bg-slate-700 text-zinc-200 p-2 text-center font-mono">
+ <NewsTicker />
<Footer />
</footer>
@@ -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 (
+ <>
+ <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>
+ );
+}
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<EventEnvelope>) => {
+ 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,
}
})