virtualized activity log

This commit is contained in:
FoxxMD
2026-03-24 17:47:51 +00:00
parent 0daaec81ca
commit 2735c283ce
10 changed files with 329 additions and 120 deletions
+11
View File
@@ -101,6 +101,7 @@
"react-icons": "^5.6.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"react-virtuoso": "^4.17.0",
"round-robin-js": "^3.0.10",
"sass": "^1.93.3",
"serialize-error": "^13.0.1",
@@ -16973,6 +16974,16 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-virtuoso": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.17.0.tgz",
"integrity": "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w==",
"license": "MIT",
"peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19",
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+1
View File
@@ -139,6 +139,7 @@
"react-icons": "^5.6.0",
"react-redux": "^8.1.2",
"react-router-dom": "^6.15.0",
"react-virtuoso": "^4.17.0",
"round-robin-js": "^3.0.10",
"sass": "^1.93.3",
"serialize-error": "^13.0.1",
+14 -13
View File
@@ -4,20 +4,21 @@ import { useColorMode } from "./Color-Mode";
import { ComponentProps, PropsWithChildren, useMemo } from "react";
import { safeStringify } from "../../core/StringUtils";
import { MarkOptional } from "ts-essentials";
import { shikiAdapter } from "./shikiAdapter";
const shikiAdapter = createShikiAdapter<HighlighterGeneric<any, any>>({
async load() {
const { createHighlighter } = await import("shiki")
return createHighlighter({
langs: ["json", "plaintext"],
themes: ["github-dark", "github-light"],
})
},
theme: {
light: "github-light",
dark: "github-dark",
},
});
// const shikiAdapter = createShikiAdapter<HighlighterGeneric<any, any>>({
// async load() {
// const { createHighlighter } = await import("shiki")
// return createHighlighter({
// langs: ["json", "plaintext"],
// themes: ["github-dark", "github-light"],
// })
// },
// theme: {
// light: "github-light",
// dark: "github-dark",
// },
// });
export interface ChakraCodeBaseProps {
language?: string
-93
View File
@@ -1,93 +0,0 @@
import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container } from '@chakra-ui/react';
import { JsonPlayObject, PlayActivity } from '../../core/Atomic';
import { ShortDateDisplay } from './DateDisplay';
import { TextMuted } from './TextMuted';
import { capitalize } from '../../core/StringUtils';
import { ComponentProps } from "react"
import { VscDebugRestart } from "react-icons/vsc";
import { PlayData, PlayInfoContainer } from './PlayData';
import { ActivityDetails } from './ActivityDetail';
export interface ActivityLogProps {
data: PlayActivity[]
}
export const CList = (props: ActivityLogProps) => {
return (
<Stack gap="2">
<Box>
<Flex direction="row" justify="space-between">
<Text fontWeight="semibold">Today</Text>
<IconButton variant="ghost" size="xs" maxWidth="fit-content">
<VscDebugRestart />
</IconButton>
</Flex>
<Separator orientation="horizontal" height="4" />
</Box>
<Accordion.Root variant="enclosed" collapsible multiple>
{props.data.map((activity, index) => {
const { play } = activity;
return (
<Accordion.Item key={index} value={index.toString()}>
<Flex justify="space-between">
<Accordion.ItemTrigger truncate cursor="pointer">
<Accordion.ItemIndicator />
<Stack gap="1" truncate>
<Span>{play.data.track}</Span>
<TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted>
<HStack gap="1">
<ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" />
<TextMuted>{play.meta?.source}</TextMuted>
</HStack>
</Stack>
</Accordion.ItemTrigger>
<Stack style={{
paddingBlock: "var(--accordion-padding-y)",
paddingInline: "var(--accordion-padding-x)"
}} justify="flex-start" alignItems="flex-end">
<StatusBadge maxWidth="fit-content" data={activity} />
{activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content">
<VscDebugRestart />
</IconButton> : null}
</Stack>
</Flex>
<Accordion.ItemContent>
<Accordion.ItemBody borderTopColor="gray.border" >
<ActivityDetails activity={activity}/>
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
)
})}
</Accordion.Root>
</Stack>
);
}
const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayActivity }) => {
const { data, ...rest } = props;
let badgeColor = undefined,
badgeText = capitalize(data.status);
switch (data.status) {
case 'queued':
badgeColor = 'gray';
break;
case 'scrobbled':
badgeColor = 'green';
break;
case 'error':
badgeColor = 'red';
break;
}
return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}</Badge>
}
export const ListContainer = (props?: ComponentProps<typeof CList>) => {
return <Container maxWidth="3xl"><CList {...props}/></Container>
}
@@ -0,0 +1,9 @@
[data-testid="virtuoso-item-list"] div[data-index]:first-child div.chakra-collapsible__root {
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
}
[data-testid="virtuoso-item-list"] div[data-index]:last-child div.chakra-collapsible__root {
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
@@ -0,0 +1,238 @@
import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Collapsible } from '@chakra-ui/react';
import { JsonPlayObject, PlayActivity } from '../../../core/Atomic.js';
import { ShortDateDisplay } from '../DateDisplay.js';
import { TextMuted } from '../TextMuted.js';
import { LuChevronRight } from "react-icons/lu"
import { capitalize } from '../../../core/StringUtils.js';
import { ComponentProps, useMemo } from "react"
import dayjs, { Dayjs } from 'dayjs';
import doy from 'dayjs/plugin/dayOfYear.js';
import { VscDebugRestart } from "react-icons/vsc";
import { PlayData, PlayInfoContainer } from '../PlayData.js';
import { GroupedVirtuoso } from 'react-virtuoso'
import { ActivityDetails } from '../ActivityDetail.js';
import { sortByNewestPlayDate } from '../../../core/PlayUtils.js';
import "./PlayList.scss";
dayjs.extend(doy);
export interface ActivityLogProps {
data: PlayActivity[]
sortBy?: 'played' | 'seen'
virtual?: boolean
}
interface GroupInfo {
count: number
date: Dayjs
}
export const PlayList = (props: ActivityLogProps) => {
const {
data = [],
sortBy = 'played',
virtual = false,
} = props;
if(virtual) {
const sorted = useMemo(() => props.data.toSorted((a, b) => sortByNewestPlayDate(a.play, b.play)), [data, sortBy]);
const groupsReduced = useMemo(() => {
return sorted.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => {
const date = dayjs(curr.play.data.playDate);
if (acc.active === undefined) {
return { ...acc, active: { count: 1, date } };
}
if (acc.active.date.dayOfYear() !== date.dayOfYear()) {
return { groups: [...acc.groups, acc.active], active: { count: 1, date } }
}
return { groups: acc.groups, active: { ...acc.active, count: acc.active.count + 1 } };
}, { groups: [] });
}, [sorted, sortBy]);
const allGroups = groupsReduced.groups.concat(groupsReduced.active);
return (
<GroupedVirtuoso
style={{ height: '700px' }}
fixedItemHeight={80}
fixedGroupHeight={50}
groupCounts={allGroups.map(x => x.count)}
groupContent={(index) => {
const gData = allGroups[index];
let headerText: string;
if (gData.date.isToday()) {
headerText = 'Today';
} else {
headerText = gData.date.format('MMM DD');
if (gData.date.year() !== dayjs().year()) {
headerText += `, ${gData.date.year()}`;
}
}
return (
<Box paddingY="2">
<Flex direction="row" justify="space-between">
<Text fontWeight="semibold">{headerText}</Text>
<IconButton variant="ghost" size="xs" maxWidth="fit-content">
<VscDebugRestart />
</IconButton>
</Flex>
<Separator orientation="horizontal" height="4" />
</Box>
)
}}
itemContent={(index, groupIndex) => {
const activity = sorted[index];
const { play } = activity;
return (
<Collapsible.Root key={index}
lazyMount
_open={{
background: "var(--chakra-colors-bg-subtle)"
}}
style={{
borderColor: "var(--chakra-colors-border)",
borderWidth: '1px',
}}
>
<Flex justify="space-between">
<Collapsible.Trigger
userSelect="text"
paddingY="3"
display="flex"
gap="2"
alignItems="center"
truncate cursor="pointer"
style={{
paddingBlock: "var(--chakra-spacing-2)",
paddingInline: "var(--chakra-spacing-4)"
}}
>
<Collapsible.Indicator
transition="transform 0.2s"
_open={{ transform: "rotate(90deg)" }}
>
<LuChevronRight />
</Collapsible.Indicator>
<Stack gap="1" truncate alignItems="flex-start">
<Span>{play.data.track}</Span>
<TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted>
<HStack gap="1">
<ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" />
<TextMuted>{play.meta?.source}</TextMuted>
</HStack>
</Stack>
</Collapsible.Trigger>
<Stack style={{
paddingBlock: "var(--chakra-spacing-2)",
paddingInline: "var(--chakra-spacing-4)"
}} justify="flex-start" alignItems="flex-end">
<StatusBadge maxWidth="fit-content" data={activity} />
{activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content">
<VscDebugRestart />
</IconButton> : null}
</Stack>
</Flex>
<Collapsible.Content borderTopColor="gray.border"
style={{
paddingBlock: "var(--chakra-spacing-4)",
paddingInline: "var(--chakra-spacing-4)"
}}>
<ActivityDetails activity={activity} />
</Collapsible.Content>
</Collapsible.Root>
)
}}
/>
);
}
return (
<Stack gap="2">
<Box>
<Flex direction="row" justify="space-between">
<Text fontWeight="semibold">Today</Text>
<IconButton variant="ghost" size="xs" maxWidth="fit-content">
<VscDebugRestart />
</IconButton>
</Flex>
<Separator orientation="horizontal" height="4" />
</Box>
<Accordion.Root variant="enclosed" collapsible multiple>
{props.data.map((activity, index) => {
const { play } = activity;
return (
<Accordion.Item key={index} value={index.toString()}>
<Flex justify="space-between">
<Accordion.ItemTrigger truncate cursor="pointer">
<Accordion.ItemIndicator />
<Stack gap="1" truncate>
<Span>{play.data.track}</Span>
<TextMuted truncate>{play.data.artists.join(' / ')}</TextMuted>
<HStack gap="1">
<ShortDateDisplay date={play.data.playDate} prefix="Played" /><Separator orientation="vertical" height="4" />
<TextMuted>{play.meta?.source}</TextMuted>
</HStack>
</Stack>
</Accordion.ItemTrigger>
<Stack style={{
paddingBlock: "var(--accordion-padding-y)",
paddingInline: "var(--accordion-padding-x)"
}} justify="flex-start" alignItems="flex-end">
<StatusBadge maxWidth="fit-content" data={activity} />
{activity.status === 'error' ? <IconButton variant="ghost" size="xs" maxWidth="fit-content">
<VscDebugRestart />
</IconButton> : null}
</Stack>
</Flex>
<Accordion.ItemContent>
<Accordion.ItemBody borderTopColor="gray.border" >
<ActivityDetails activity={activity}/>
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
)
})}
</Accordion.Root>
</Stack>
);
}
const StatusBadge = (props: ComponentProps<typeof Badge> & { data: PlayActivity }) => {
const { data, ...rest } = props;
let badgeColor = undefined,
badgeText = capitalize(data.status);
switch (data.status) {
case 'queued':
badgeColor = 'gray';
break;
case 'scrobbled':
badgeColor = 'green';
break;
case 'error':
badgeColor = 'red';
break;
}
return <Badge variant="surface" colorPalette={badgeColor} {...rest}>{badgeText}</Badge>
}
export const ListContainer = (props?: ComponentProps<typeof PlayList>) => {
return <Container maxWidth="3xl"><PlayList {...props} /></Container>
}
+16
View File
@@ -0,0 +1,16 @@
import { createShikiAdapter } from "@chakra-ui/react"
import type { HighlighterGeneric } from "shiki"
export const shikiAdapter = createShikiAdapter<HighlighterGeneric<any, any>>({
async load() {
const { createHighlighter } = await import("shiki")
return createHighlighter({
langs: ["json", "plaintext"],
themes: ["github-dark", "github-light"],
})
},
theme: {
light: "github-light",
dark: "github-dark",
},
});
+14
View File
@@ -75,6 +75,13 @@ export const normalizePlays = (plays: PlayObject[],
...defaultMeta
}
if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) {
cleanPlay.meta.lifecycle.original.data.playDate = lastDate;
}
if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) {
cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's');
}
if(index + 1 <= plays.length - 1) {
const listenTime = (plays[index+1].data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2});
lastDate = cleanPlay.data.playDate.subtract(listenTime, 'seconds');
@@ -111,6 +118,13 @@ export const normalizePlays = (plays: PlayObject[],
...defaultMeta
}
if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) {
cleanPlay.meta.lifecycle.original.data.playDate = lastDate;
}
if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) {
cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's');
}
if(progressDirection === 'newer') {
const listenTime = (cleanPlay.data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2});
lastDate = cleanPlay.data.playDate.add(listenTime, 'seconds');
+11 -4
View File
@@ -1,9 +1,10 @@
import { PlayPlatformId } from "../backend/common/infrastructure/Atomic.js";
import { PlayObject, PlayObjectLifecycleless } from "./Atomic.js";
import { AmbPlayObject, DateLike, PlayObject, PlayObjectLifecycleless } from "./Atomic.js";
import dayjs from "dayjs";
/** sorts playObj formatted objects by playDate in descending (newest first) order */
export const sortByNewestPlayDate = (a: PlayObject, b: PlayObject) => {
export const sortByNewestPlayDate = (a: AmbPlayObject<DateLike>, b: AmbPlayObject<DateLike>) => {
const {
data: {
playDate: aPlayDate
@@ -23,10 +24,16 @@ export const sortByNewestPlayDate = (a: PlayObject, b: PlayObject) => {
if (bPlayDate === undefined) {
return -1;
}
return aPlayDate.isBefore(bPlayDate) ? 1 : -1;
};export const genGroupIdStr = (id: PlayPlatformId) => {
const realA = typeof aPlayDate === 'string' ? dayjs(aPlayDate) : aPlayDate;
const realB = typeof bPlayDate === 'string' ? dayjs(bPlayDate) : bPlayDate;
return realA.isBefore(realB) ? 1 : -1;
};
export const genGroupIdStr = (id: PlayPlatformId) => {
return `${id[0]}-${id[1]}`;
};
export const lifecyclelessInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => {
const {
meta: {
+15 -10
View File
@@ -3,12 +3,14 @@ import React from 'react';
import { fn } from 'storybook/test';
import { Container } from '@chakra-ui/react';
import { CList } from "../client/components/List";
import { PlayList } from "../client/components/playActivity/PlayList.js";
import {Provider} from "../client/components/Provider";
import { generateJsonPlays } from "../core/PlayTestUtils.js";
import { generateJsonPlays, normalizePlays } from "../core/PlayTestUtils.js";
import { ErrorLike, JsonPlayObject } from "../core/Atomic.js";
import {examplePlay, lastfmErrorExample} from './storyUtils.js';
import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures'
import { generateArray } from "../core/DataUtils.js";
import dayjs from "dayjs";
const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)";
@@ -30,7 +32,7 @@ const errorExample: ErrorLike = {
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = preview.meta({
title: 'Examples/ActivityLog',
component: CList,
component: PlayList,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'padded',
@@ -40,12 +42,9 @@ const meta = preview.meta({
// More on argTypes: https://storybook.js.org/docs/api/argtypes
args: {
data:[
// ...generateJsonPlays(2).map((x) => ({play: x, status: 'queued'})),
// {play: examplePlay(), status: 'scrobbled'},
// {play: lastfmErrorExample(), status: 'error'}
] ,
},
render: function Render(args, { loaded: { data } }) { return (<CList {...args} data={data ?? []}/>) },
render: function Render(args, { loaded: { data } }) { return (<PlayList {...args} data={data ?? []}/>) },
decorators: [
(Story) => (<Provider><Container maxWidth="4xl"><Story/></Container></Provider>),
]
@@ -56,13 +55,19 @@ decorators: [
export const List = meta.story({
loaders: [
async () => {
const queued = await generatePlayWithLifecycle();
const queued = normalizePlays(generateArray(7,() => generatePlayWithLifecycle()), {endDate: dayjs()}).map(x => ({play: x, status: 'queued'}));
const scrobbled = await playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}}));
const scrobbleError = await playWithLifecycleScrobble(generatePlayWithLifecycle(), {error: true});
const promisedScrobbled = generateArray(10,() => playWithLifecycleScrobble(generatePlayWithLifecycle({lifecycleSteps: {preCompare: [true, 'skipped', true]}})));
const promised = await Promise.all(promisedScrobbled);
const yesterdayScrobbled = normalizePlays(promised, {endDate: dayjs().subtract(1, 'd').subtract(100, 'm')}).map((x) => ({play: x, status: 'scrobbled'}));
return {data: [
{play: queued, status: 'queued'},
...queued,
{play: scrobbled, status: 'scrobbled'},
{play: scrobbleError, status: 'error'}
{play: scrobbleError, status: 'error'},
...yesterdayScrobbled
]};
}
],