aboutsummaryrefslogtreecommitdiff
path: root/jb-ui
diff options
context:
space:
mode:
authorMax Nanis2026-03-03 09:57:08 +0000
committerMax Nanis2026-03-03 09:57:08 +0000
commitcb3a2fa810b19a441cc7baf42178c4a2d393663a (patch)
treeb2ad366338249416f029e1820a7d02e9caf87c57 /jb-ui
parent19a97da80ae740bb1cf92fc911e6efc210aa05a8 (diff)
parent979e789c388e7a1e9a90e448d82e297c9c296a3e (diff)
downloadamt-jb-cb3a2fa810b19a441cc7baf42178c4a2d393663a.tar.gz
amt-jb-cb3a2fa810b19a441cc7baf42178c4a2d393663a.zip
Merges pull request #1
Deploy Marquee (basic for now)
Diffstat (limited to 'jb-ui')
-rw-r--r--jb-ui/package.json1
-rw-r--r--jb-ui/pnpm-lock.yaml8
-rw-r--r--jb-ui/src/JBApp.tsx24
-rw-r--r--jb-ui/src/components/EventMarquee.tsx230
-rw-r--r--jb-ui/src/lib/utils.ts22
-rw-r--r--jb-ui/src/models/grlEventsSlice.ts58
-rw-r--r--jb-ui/src/store.ts2
7 files changed, 343 insertions, 2 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 5c99e61..f470a5d 100644
--- a/jb-ui/src/JBApp.tsx
+++ b/jb-ui/src/JBApp.tsx
@@ -1,5 +1,6 @@
import {
EventMessage,
+ EventType,
PingMessage,
PongMessage,
PongMessageKindEnum,
@@ -15,6 +16,7 @@ import Footer from "@/components/Footer";
import { useAppDispatch } from "@/hooks";
import { bpid, routeBasename, tagManager } from "@/lib/utils";
import { setAssignmentID, setProductUserID, setTurkSubmitTo } from "@/models/appSlice";
+import { addEvent } from "@/models/grlEventsSlice";
import { addStatsData } from "@/models/grlStatsSlice";
import Home from "@/pages/Home";
import Preview from "@/pages/Preview";
@@ -22,6 +24,7 @@ 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";
@@ -36,6 +39,10 @@ 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';
}
@@ -108,6 +115,7 @@ const Layout = () => {
</main >
<footer className="bg-slate-700 text-zinc-200 p-2 text-center font-mono">
+ <NewsTicker />
<Footer />
</footer>
@@ -137,6 +145,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..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>
+ );
+}
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
new file mode 100644
index 0000000..912c4ca
--- /dev/null
+++ b/jb-ui/src/models/grlEventsSlice.ts
@@ -0,0 +1,58 @@
+import { EventEnvelope } from "@/api_fsb";
+import { RootState } from "@/store";
+import { createEntityAdapter, createSelector, createSlice } from '@reduxjs/toolkit';
+
+const eventAdapter = createEntityAdapter({
+ selectId: (model: EventEnvelope) => model.event_uuid!,
+ sortComparer: (a, b) => {
+ // ASC by timestamp
+ return a.timestamp!.localeCompare(b.timestamp!);
+ },
+});
+
+
+const grlEventsSlice = createSlice({
+ name: 'grlEvents',
+ initialState: eventAdapter.getInitialState(),
+
+ reducers: {
+ addEvent: eventAdapter.addOne,
+ addEvents: eventAdapter.addMany,
+ upsertEvent: eventAdapter.upsertOne, // Add or Update
+
+ }
+})
+
+
+export const {
+ addEvent, addEvents, upsertEvent
+} = grlEventsSlice.actions;
+
+
+export const eventSelectors = eventAdapter.getSelectors(
+ (state: RootState) => state.events
+);
+
+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
+
+
+// --- 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
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,
}
})