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 */}
);
}