diff options
| author | Max Nanis | 2026-03-03 09:57:08 +0000 |
|---|---|---|
| committer | Max Nanis | 2026-03-03 09:57:08 +0000 |
| commit | cb3a2fa810b19a441cc7baf42178c4a2d393663a (patch) | |
| tree | b2ad366338249416f029e1820a7d02e9caf87c57 | |
| parent | 19a97da80ae740bb1cf92fc911e6efc210aa05a8 (diff) | |
| parent | 979e789c388e7a1e9a90e448d82e297c9c296a3e (diff) | |
| download | amt-jb-cb3a2fa810b19a441cc7baf42178c4a2d393663a.tar.gz amt-jb-cb3a2fa810b19a441cc7baf42178c4a2d393663a.zip | |
Merges pull request #1
Deploy Marquee (basic for now)
| -rw-r--r-- | jb-ui/package.json | 1 | ||||
| -rw-r--r-- | jb-ui/pnpm-lock.yaml | 8 | ||||
| -rw-r--r-- | jb-ui/src/JBApp.tsx | 24 | ||||
| -rw-r--r-- | jb-ui/src/components/EventMarquee.tsx | 230 | ||||
| -rw-r--r-- | jb-ui/src/lib/utils.ts | 22 | ||||
| -rw-r--r-- | jb-ui/src/models/grlEventsSlice.ts | 58 | ||||
| -rw-r--r-- | jb-ui/src/store.ts | 2 | ||||
| -rw-r--r-- | tests/managers/test_amt.py | 31 | ||||
| -rw-r--r-- | tests_sandbox/test_flow.py | 30 | ||||
| -rw-r--r-- | tests_sandbox/utils.py | 0 |
10 files changed, 374 insertions, 32 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, } }) 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 --- a/tests_sandbox/utils.py +++ /dev/null |
