diff --git a/src/client/components/msComponent/MSComponentDetailed.tsx b/src/client/components/msComponent/MSComponentDetailed.tsx index 5293b692..1df302ee 100644 --- a/src/client/components/msComponent/MSComponentDetailed.tsx +++ b/src/client/components/msComponent/MSComponentDetailed.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, type ComponentProps, useEffect } from "react" import { Portal, Group, Span, Menu, Box, Heading, Skeleton, Wrap, HStack, Stack, Flex, Text, Card, Button, CloseButton, SkeletonText, type BadgeProps, type MenuItemProps, createOverlay, Dialog, type MenuSelectionDetails } from '@chakra-ui/react'; -import { COMPONENT_STATE, type ComponentClientApiJson, type ComponentCommonApiJson, type ComponentsApiJson, type ComponentState, type ComponentStateBody, isComponentClientApiJson, isComponentSourceApiJson, type MsSseEvent, type MsSseEventPayload } from "../../../core/Api.js"; +import { COMPONENT_STATE, type ComponentCommonApiJson, type ComponentsApiJson, type ComponentState, type ComponentStateBody, isComponentSourceApiJson, type MsSseEvent, type MsSseEventPayload } from "../../../core/Api.js"; import { capitalize } from "../../../core/StringUtils.js"; import { ChevronLeftButton, EllipsisButton, ExternalLinkIcon, EyeButton, EyeClosedIcon, EyeIcon, IdleIcon, PowerButton, PowerIcon, type PowerOffButton, PowerOffIcon, RetryButton, RetryIcon, UnlockButton, UnlockIconRaw } from "../icons/ChakraIcons.js"; import { PlayersContainer, PlayersContainerFetchable } from "../chakraPlayer/Player.js"; @@ -13,7 +13,7 @@ import { useSSEAnyEvent } from "@flamefrontend/sse-runtime-react"; import { Link } from "react-router"; -import { CountLiveIndicator, DateIndicator, DeadLetterIndicator, QueuedIndicator } from "./Stats.js"; +import { CountIndicatorStreamable, DateIndicatorStreamable, DeadLetterIndicatorStreamable, QueuedIndicatorStreamable } from "./Stats.js"; import { ListContainerFilterable } from "../playActivity/ActivityList.js"; import { useParams } from "react-router-dom"; import { ComponentStateBadge } from "../Badges.js"; @@ -64,10 +64,10 @@ export const MSComponentStats = (props: { data?: ComponentCommonApiJson, live?: } return ( - - - - + + + + ) } diff --git a/src/client/components/msComponent/MSComponentSummary.tsx b/src/client/components/msComponent/MSComponentSummary.tsx index 95c9137f..776c8f51 100644 --- a/src/client/components/msComponent/MSComponentSummary.tsx +++ b/src/client/components/msComponent/MSComponentSummary.tsx @@ -12,7 +12,7 @@ import { useSSEContext, useSSEAnyEvent } from "@flamefrontend/sse-runtime-react"; -import { CountLiveIndicator, DeadLetterIndicator, QueuedIndicator } from "./Stats.js"; +import { CountIndicatorStreamable, DeadLetterIndicatorStreamable, QueuedIndicatorStreamable } from "./Stats.js"; import { ComponentStateBadge } from "../Badges.js"; import { MSErrorBoundary } from "../ErrorBoundary.js"; @@ -75,21 +75,19 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetcha //colorPalette={data.mode === 'client' ? 'purple' : 'pink'} // color={data.mode === 'client' ? 'purple' : 'pink'} -const QuickStatsSource = (props: { data: ComponentCommonApiJson, streamable?: boolean }) => { - return ( +const QuickStatsSource = (props: { data: ComponentCommonApiJson, streamable?: boolean }) => ( - + - + - + ) -} export const MSComponentSummaryFetchable = (props: {componentId: number, data: ComponentCommonApiJson}) => { const { diff --git a/src/client/components/msComponent/Stats.tsx b/src/client/components/msComponent/Stats.tsx index 5d6b03e3..d1b5487e 100644 --- a/src/client/components/msComponent/Stats.tsx +++ b/src/client/components/msComponent/Stats.tsx @@ -1,7 +1,8 @@ -import { type ComponentProps, useState, useCallback } from "react" -import { Badge, Stat, HStack } from '@chakra-ui/react'; +import { type ComponentProps, useState, useCallback, type ReactNode, useEffect } 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 { @@ -11,267 +12,210 @@ import { import dayjs from "dayjs"; import { shortTodayAwareFormat } from "../../../core/TimeUtils.js"; -export const CountLiveIndicator = (props: { - data: Pick & { tracksDiscovered?: number, tracksScrobbled?: number }, - recent?: number +type Color = BadgeProps['colorPalette'] & IconBaseProps['color']; + +export const Indicator = (props: { + current?: number + currentText: string | ReactNode recentTimeout?: number - streamable?: boolean + total?: number + totalText?: string | ReactNode + helpText?: string | ReactNode + directionColors?: [Color, Color] as?: 'text' | 'stat' } & ComponentProps) => { const { - data, - recent: recentProp = 0, - recentTimeout: timeoutProp = 10000, - streamable, as = 'stat', + current = 0, + currentText, + recentTimeout: timeoutProp = 10000, + directionColors = ['green', 'red'], + total, + totalText, + helpText, ...rest } = props; - const sessionCount = props.data.mode === 'source' ? props.data.tracksDiscovered : props.data.tracksScrobbled; - - const [total, setTotal] = useState(props.data.countLive); - const [current, setCurrent] = useState(sessionCount); - const [recent, setRecent] = useState(recentProp); + const [lastCurrent, setLastCurrent] = useState(current); + const [recent, setRecent] = useState(0); + const [recentDirection, setRecentDirection] = useState<'up' | 'down'>('up'); const resetRecent = useCallback(() => { - setRecent(props.recent ?? 0); + setRecent(0); }, [setRecent]); const recentTimeout = useTimeout(resetRecent, timeoutProp ?? 10000); - if (props.streamable) { - const client = useSSEContext(); - useSSEAnyEvent(client, (payload) => { - if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === props.data.id) { - switch (payload.type) { - case 'scrobble': - case 'discovered': - recentTimeout.stop(); - setTotal(total + 1); - setCurrent(current + 1); - setRecent(recent + 1); - recentTimeout.start(); - break; - } + if (lastCurrent !== current) { + recentTimeout.stop(); + 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); + recentTimeout.start(); } if (as === 'stat') { return ( - - {props.data.mode === 'source' ? 'Discovered' : 'Scrobbled'} + + {currentText} - {/* {props.data.mode === 'source' ? 'Discovered' : 'Scrobbled'} since start and (Total) */} - {current} ({total}) - {recent !== 0 ? - + {current} {total !== undefined && <>({total})} + {recent !== 0 ? + {recentDirection === 'up' ? : } {recent} : null} - Since Start and (Total) + {helpText !== undefined && totalText !== undefined && {helpText ?? <>{props.currentText} and ({totalText})}} ); } return ( - {current} ({total}) {recent !== 0 ? - + {current} {total !== undefined && <>({total})} {recent !== 0 ? + {recentDirection === 'up' ? : } {recent} - : null} {props.data.mode === 'source' ? 'Discovered' : 'Scrobbled'} + : null} {currentText} ); -} +}; -export const QueuedIndicator = (props: { - data: Pick, - recent?: number - recentTimeout?: number - streamable?: boolean - as?: 'text' | 'stat' -} & ComponentProps) => { +export const CountIndicatorStatic = (props: Omit, 'totalText' | 'helpText' | 'directionColors' | 'currentText'> & Pick) => ( + +) +export const CountIndicatorStreamable = (props: { data: Pick & { tracksDiscovered?: number, tracksScrobbled?: number } } + & Omit, 'mode'>) => { const { data, - recent: recentProp = 0, - recentTimeout: timeoutProp = 10000, - streamable, - as = 'stat', ...rest } = props; + const [statsData, setStatsData] = useState({current: data.tracksDiscovered ?? data.tracksScrobbled, total: 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({current: statsData.current + 1, total: statsData.total + 1}); + break; + } + } + }); - const [current, setCurrent] = useState(props.data.queued); - const [recent, setRecent] = useState(recentProp); - const [recentDirection, setRecentDirection] = useState<'up' | 'down'>('up'); - const resetRecent = useCallback(() => { - setRecent(0); + return +} - }, [setRecent]); - const recentTimeout = useTimeout(resetRecent, timeoutProp); +export const QueuedIndicatorStatic = (props: Omit, 'currentText' | 'totalText' | 'helpText' | 'directionColors'>) => ( + +); - if (props.streamable) { - const client = useSSEContext(); - useSSEAnyEvent(client, (payload) => { - if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === props.data.id) { - recentTimeout.stop(); - switch (payload.type) { - case 'playQueued': - setCurrent(current + 1); - if (recentDirection === 'down') { - setRecent(1); - } else { - setRecent(recent + 1); - } - setRecentDirection('up'); - break; - case 'playDequeued': - setCurrent(current - 1); - if (recentDirection === 'up') { - setRecent(1); - } else { - setRecent(recent + 1); - } - setRecentDirection('down'); - break; - } - recentTimeout.start(); +export const QueuedIndicatorStreamable = (props: { data: Pick } + & ComponentProps) => { + const { + data, + ...rest + } = props; + const [statsData, setStatsData] = useState({current: 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({current: statsData.current + 1}); + break; + case 'playDequeued': + setStatsData({current: statsData.current - 1}); + break; } - }); - } - - if(as === 'stat') { - return ( - - Queued - - {current} - {recent !== 0 ? - {recentDirection === 'up' ? : } - {recent} - : null} - - - ); - } + } + }); - return ( - {current} {recent !== 0 ? - {recentDirection === 'up' ? : } - {recent} - : null} Queued - ); + return } -export const DeadLetterIndicator = (props: { - data: Pick, - recent?: number - recentTimeout?: number - streamable?: boolean - as?: 'text' | 'stat' -} & ComponentProps ) => { +export const DeadLetterIndicatorStatic = (props: Omit, 'currentText' | 'totalText' | 'helpText' | 'directionColors'>) => ( + +); +export const DeadLetterIndicatorStreamable = (props: { data: Pick } + & ComponentProps) => { const { data, - recent: recentProp = 0, - recentTimeout: timeoutProp = 10000, - streamable, - as = 'stat', ...rest } = props; + const [statsData, setStatsData] = useState({current: data.deadLetterPlays, total: 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({current: statsData.current + 1, total: statsData.total + 1}); + break; + case 'deadLetterRemoved': + setStatsData({current: statsData.current - 1, total: statsData.total - 1}); + break; + case 'deadLetterDequeued': + setStatsData({current: statsData.current - 1, total: statsData.total}); + break; + case 'deadQueued': + setStatsData({current: statsData.current + 1, total: statsData.total}); + break; + } + } + }); - const [current, setCurrent] = useState(props.data.deadLetterPlays); - const [total, setTotal] = useState(props.data.deadLetterPlaysTotal); - const [recent, setRecent] = useState(recentProp); - const [recentDirection, setRecentDirection] = useState<'up' | 'down'>('up'); - const resetRecent = useCallback(() => { - setRecent(0); + return +} - }, [setRecent]); - const recentTimeout = useTimeout(resetRecent, timeoutProp ?? 10000); +export const DateIndicatorStreamable = (props: Omit, 'streamedDate'>) => { + + const { + data: { + id, + lastActiveAt, + lastReadyAt, + state + } = {}, + } = props; + + const useActive = state < 5 + const [current, setCurrent] = useState(useActive ? lastActiveAt : lastReadyAt); - if (streamable) { const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { - if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === props.data.id) { - recentTimeout.stop(); - switch (payload.type) { - case 'deadLetter': - recentTimeout.stop(); - setCurrent(current + 1); - setTotal(total + 1); - if (recentDirection === 'down') { - setRecent(1); - } else { - setRecent(recent + 1); - } - setRecentDirection('up'); - break; - case 'deadLetterRemoved': - setCurrent(current - 1); - setTotal(total - 1); - if (recentDirection === 'down') { - setRecent(recent + 1); - } else { - setRecent(1); - } - setRecentDirection('down'); - break; - case 'deadLetterDequeued': - setCurrent(current - 1); - if (recentDirection === 'down') { - setRecent(recent + 1); - } else { - setRecent(1); - } - setRecentDirection('down'); - break; - case 'deadQueued': - setCurrent(current + 1); - if (recentDirection === 'down') { - setRecent(1); - } else { - setRecent(recent + 1); - } - setRecentDirection('up'); - break; - } - recentTimeout.start(); + if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === id) { + // TODO update state from event + setCurrent(dayjs().toISOString()); } }); - } - - if(as === 'stat') { - return ( - - Dead - - {current} ({total}) - {recent !== 0 ? - {recentDirection === 'up' ? : } - {recent} - : null} - - Queuable and (Total) - - ); - } - return ( - {current} ({total}) {recent !== 0 ? - {recentDirection === 'up' ? : } - {recent} - : null} Dead - ); + return } export const DateIndicator = (props: { data: Pick, streamable?: boolean as?: 'text' | 'stat' + streamedDate?: string } & ComponentProps) => { const { data: { - id, lastActiveAt, lastReadyAt, state @@ -282,17 +226,9 @@ export const DateIndicator = (props: { } = props; const useActive = state < 5; - - const [current, setCurrent] = useState(useActive ? lastActiveAt : lastReadyAt); - - if (props.streamable) { - const client = useSSEContext(); - useSSEAnyEvent(client, (payload) => { - if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === props.data.id) { - // TODO update state from event - setCurrent(dayjs().toISOString()); - } - }); + let usedDate: string = props.streamedDate; + if(usedDate === undefined) { + usedDate = useActive ? lastActiveAt : lastReadyAt; } if(as === 'stat') { @@ -300,13 +236,13 @@ export const DateIndicator = (props: { {useActive? 'Last Active At' : 'Last Ready At'} - {shortTodayAwareFormat(dayjs(current))} + {shortTodayAwareFormat(dayjs(usedDate))} ); } return ( - {shortTodayAwareFormat(dayjs(current))} {useActive? 'Last Active At' : 'Last Ready At'} + {shortTodayAwareFormat(dayjs(usedDate))} {useActive? 'Last Active At' : 'Last Ready At'} ); } \ No newline at end of file diff --git a/src/stories/component/ComponentStatDead.stories.tsx b/src/stories/component/ComponentStatDead.stories.tsx index 22db8c30..bcb08c44 100644 --- a/src/stories/component/ComponentStatDead.stories.tsx +++ b/src/stories/component/ComponentStatDead.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { sse } from 'msw'; import { Container } from '@chakra-ui/react'; -import { DeadLetterIndicator } from "../../client/components/msComponent/Stats.js"; +import { DeadLetterIndicatorStreamable } from "../../client/components/msComponent/Stats.js"; import { sseProviderOptions } from "../../client/AppNext.js"; import {Provider} from "../../client/components/Provider.js"; import { SSEProvider } from "@flamefrontend/sse-runtime-react"; @@ -13,7 +13,7 @@ import { faker } from "@faker-js/faker"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ title: 'Component/Details/Stat Dead', - component: DeadLetterIndicator, + component: DeadLetterIndicatorStreamable, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout layout: 'padded', @@ -31,7 +31,7 @@ const meta = preview.meta({ // streamable: false, // }, render: function Render(args) { - return () + return () }, decorators: [ (Story) => ( options={sseProviderOptions}>), @@ -39,17 +39,15 @@ decorators: [ // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args }); -const randomQueue = () => faker.helpers.arrayElement(['deadLetter']); +const randomQueue = () => faker.helpers.arrayElement(['deadLetter','deadLetterRemoved','deadLetterDequeued','deadQueued']); export const StatsQueued = meta.story({ args: { data: { - deadLetterScrobbles: 3, - deadLetterScrobblesTotal: 5, - mode: 'client', + deadLetterPlays: 3, + deadLetterPlaysTotal: 5, id: 1, - }, - streamable: true + } }, parameters: { msw: { diff --git a/src/stories/component/ComponentStatLive.stories.tsx b/src/stories/component/ComponentStatLive.stories.tsx index 38d94d29..ec95df94 100644 --- a/src/stories/component/ComponentStatLive.stories.tsx +++ b/src/stories/component/ComponentStatLive.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { sse } from 'msw'; import { Container } from '@chakra-ui/react'; -import { CountLiveIndicator } from "../../client/components/msComponent/Stats.js"; +import { CountIndicatorStreamable } from "../../client/components/msComponent/Stats.js"; import { sseProviderOptions } from "../../client/AppNext.js"; import {Provider} from "../../client/components/Provider.js"; import { SSEProvider } from "@flamefrontend/sse-runtime-react"; @@ -12,7 +12,7 @@ import type {MsSseEvent} from "../../core/Api.js"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ title: 'Component/Details/Stats Count', - component: CountLiveIndicator, + component: CountIndicatorStreamable, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout layout: 'padded', @@ -30,7 +30,7 @@ const meta = preview.meta({ // streamable: false, // }, render: function Render(args) { - return () + return () }, decorators: [ (Story) => ( options={sseProviderOptions}>), @@ -57,7 +57,6 @@ export const StatsCountLive = meta.story({ mode: 'source', id: 1 }, - streamable: true }, parameters: { msw: { @@ -79,7 +78,6 @@ export const StatsCountLiveReset = meta.story({ mode: 'source', id: 1, }, - streamable: true, recentTimeout: 2000 }, parameters: { diff --git a/src/stories/component/ComponentStatQueued.stories.tsx b/src/stories/component/ComponentStatQueued.stories.tsx index e7d2570b..189c6e23 100644 --- a/src/stories/component/ComponentStatQueued.stories.tsx +++ b/src/stories/component/ComponentStatQueued.stories.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { sse } from 'msw'; import { Container } from '@chakra-ui/react'; -import { QueuedIndicator } from "../../client/components/msComponent/Stats.js"; +import { QueuedIndicatorStreamable } from "../../client/components/msComponent/Stats.js"; import { sseProviderOptions } from "../../client/AppNext.js"; import {Provider} from "../../client/components/Provider.js"; import { SSEProvider } from "@flamefrontend/sse-runtime-react"; @@ -13,7 +13,7 @@ import { faker } from "@faker-js/faker"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ title: 'Component/Details/Stat Queued', - component: QueuedIndicator, + component: QueuedIndicatorStreamable, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout layout: 'padded', @@ -31,7 +31,7 @@ const meta = preview.meta({ // streamable: false, // }, render: function Render(args) { - return () + return () }, decorators: [ (Story) => ( options={sseProviderOptions}>), @@ -39,16 +39,14 @@ decorators: [ // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args }); -const randomQueue = () => faker.helpers.arrayElement(['scrobbleQueued', 'scrobbleDequeued']); +const randomQueue = () => faker.helpers.arrayElement(['playQueued', 'playDequeued']); export const StatsQueued = meta.story({ args: { data: { queued: 3, - mode: 'client', id: 1, }, - streamable: true }, parameters: { msw: {