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 ( <> {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 ( <> {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!)}

) } const EventUserCreated = ({ event }: EventItemProps) => { return ( <> {getUnicodeFlagIcon(event.payload.country_iso)}

{truncate(event.product_user_id!, 6, "*****")} 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() { // --- Redux --- const reduxItems = useSelector(selectAllEvents); const targetSpeedRedux = useSelector(selectCurrentSpeed); // px/sec // --- 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(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; 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 (
{/* Ticker wrapper */}
{/* The ticker band */}
{/* Scrolling strip */}
{scrollItems.map(item => ( ))} {/* Trailing spacer so new content doesn't immediately snap in */}
); }