import { type ComponentProps, useState, useCallback, useEffect, type ReactNode, type PropsWithChildren } from "react" import { Badge, Stat, HStack, type BadgeProps } from '@chakra-ui/react'; import type {ComponentClientApiJson, ComponentCommonApiJson, MsSseEvent} from "../../../core/Api.js"; import { TextMuted } from "../TextMuted.js"; import type { IconBaseProps } from "react-icons/lib"; import { DownArrowIcon, UpArrowIcon } from "../icons/ChakraIcons.js"; import { useTimeout } from 'react-use-timeout'; import { useSSEContext, useSSEAnyEvent } from "@flamefrontend/sse-runtime-react"; import dayjs from "dayjs"; import { shortTodayAwareFormat } from "../../../core/TimeUtils.js"; import { TextTip } from "../ToggleTip.js"; type Color = BadgeProps['colorPalette'] & IconBaseProps['color']; export const StaticStat = (props: PropsWithChildren<{ label: string | ReactNode helpText?: string | ReactNode }> & ComponentProps) => { const { children, label, helpText, ...rest } = props; const presentationText = helpText !== undefined ? {helpText} : label return ( {presentationText} {children} ); } export const Indicator = (props: { current?: number currentText: string | ReactNode recentTimeout?: number total?: number totalText?: string | ReactNode helpText?: string | ReactNode directionColors?: [Color, Color] as?: 'text' | 'stat' } & ComponentProps) => { const { as = 'stat', current = 0, currentText, recentTimeout: timeoutProp = 10000, directionColors = ['green', 'red'], total, totalText, helpText, ...rest } = props; const [lastCurrent, setLastCurrent] = useState(current); const [recent, setRecent] = useState(0); const [recentDirection, setRecentDirection] = useState<'up' | 'down'>('up'); const resetRecent = useCallback(() => { setRecent(0); }, [setRecent]); const recentTimeout = useTimeout(resetRecent, timeoutProp ?? 10000); // derived state is adjusted during render (allowed by React) but the timer side effect happens in the effect below if (lastCurrent !== current) { if (lastCurrent > current) { if (recentDirection === 'up') { setRecent(1); setRecentDirection('down'); } else { setRecent(recent + 1); } } else { if (recentDirection === 'down') { setRecent(1); setRecentDirection('up'); } else { setRecent(recent + 1); } } setLastCurrent(current); } useEffect(() => { if (recent !== 0) { recentTimeout.restart(); } // only (re)start the reset timer when current changes }, [current]); let contextText: string | ReactNode; if(helpText !== undefined) { contextText = helpText; } if(totalText !== undefined) { contextText = <>{currentText} and ({totalText}); } const presentationText = contextText !== undefined ? {contextText} : currentText if (as === 'stat') { return ( {presentationText} {current} {total !== undefined && <>({total})} {recent !== 0 ? {recentDirection === 'up' ? : } {recent} : null} ); } return ( {current} {total !== undefined && <>({total})} {recent !== 0 ? {recentDirection === 'up' ? : } {recent} : null} {currentText} ); }; export const CountIndicatorStatic = (props: Omit, 'totalText' | 'helpText' | 'directionColors' | 'currentText'> & Pick) => ( ) export const CountIndicatorStreamable = (props: { data: Pick & { tracksDiscovered?: number, tracksScrobbled?: number } } & Omit, 'mode'>) => { const { data, ...rest } = props; const [statsData, setStatsData] = useState({current: data.tracksDiscovered ?? data.tracksScrobbled, total: data.countLive}); // SSE events are tracked locally but re-sync to the server's values whenever the parent's data is refreshed useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setStatsData({current: data.tracksDiscovered ?? data.tracksScrobbled, total: data.countLive}); }, [data.tracksDiscovered, data.tracksScrobbled, data.countLive]); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === data.id) { switch (payload.type) { case 'scrobble': case 'discovered': setStatsData((old) => ({current: (old.current ?? 0) + 1, total: old.total + 1})); break; } } }); return } export const QueuedIndicatorStatic = (props: Omit, 'currentText' | 'totalText' | 'helpText' | 'directionColors'>) => ( ); export const QueuedIndicatorStreamable = (props: { data: Pick } & ComponentProps) => { const { data, ...rest } = props; const [statsData, setStatsData] = useState({current: data.queued}); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setStatsData({current: data.queued}); }, [data.queued]); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === data.id) { switch (payload.type) { case 'playQueued': setStatsData((old) => ({current: old.current + 1})); break; case 'playDequeued': setStatsData((old) => ({current: old.current - 1})); break; } } }); return } export const DeadLetterIndicatorStatic = (props: Omit, 'currentText' | 'totalText' | 'helpText' | 'directionColors'>) => ( ); export const DeadLetterIndicatorStreamable = (props: { data: Pick } & ComponentProps) => { const { data, ...rest } = props; const [statsData, setStatsData] = useState({current: data.deadLetterPlays, total: data.deadLetterPlaysTotal}); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setStatsData({current: data.deadLetterPlays, total: data.deadLetterPlaysTotal}); }, [data.deadLetterPlays, data.deadLetterPlaysTotal]); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === data.id) { switch (payload.type) { case 'deadLetter': setStatsData((old) => ({current: old.current, total: old.total + 1})); break; case 'deadLetterRemoved': case 'removeDeadLetter': setStatsData((old) => ({current: old.current, total: old.total - 1})); break; case 'deadLetterDequeued': setStatsData((old) => ({current: old.current - 1, total: old.total})); break; case 'deadQueued': setStatsData((old) => ({current: old.current + 1, total: old.total})); break; } } }); return } export const DateIndicatorStreamable = (props: Omit, 'streamedDate'>) => { const { data: { id, lastActiveAt, lastReadyAt, state }, } = props; const useActive = state < 5 const [current, setCurrent] = useState(useActive ? lastActiveAt : lastReadyAt); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setCurrent(useActive ? lastActiveAt : lastReadyAt); }, [useActive, lastActiveAt, lastReadyAt]); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === id) { // TODO update state from event setCurrent(dayjs().toISOString()); } }); return } export const DateIndicator = (props: { data: Pick, streamable?: boolean as?: 'text' | 'stat' streamedDate?: string } & ComponentProps) => { const { data: { lastActiveAt, lastReadyAt, state }, streamable, as = 'stat', ...rest } = props; const useActive = state < 5; let usedDate: string | null | undefined = props.streamedDate; if(usedDate === undefined) { usedDate = useActive ? lastActiveAt : lastReadyAt; } if(as === 'stat') { return ( {useActive? 'Last Active At' : 'Last Ready At'} {shortTodayAwareFormat(dayjs(usedDate))} ); } return ( {shortTodayAwareFormat(dayjs(usedDate))} {useActive? 'Last Active At' : 'Last Ready At'} ); }