diff --git a/package-lock.json b/package-lock.json index 6da2d3d2..7d1efd30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6819a908..e32bfff7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/components/CodeBlock.tsx b/src/client/components/CodeBlock.tsx index 000869a2..59b770f9 100644 --- a/src/client/components/CodeBlock.tsx +++ b/src/client/components/CodeBlock.tsx @@ -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"; - -const shikiAdapter = createShikiAdapter>({ - async load() { - const { createHighlighter } = await import("shiki") - return createHighlighter({ - langs: ["json", "plaintext"], - themes: ["github-dark", "github-light"], - }) - }, - theme: { - light: "github-light", - dark: "github-dark", - }, -}); +import { shikiAdapter } from "./shikiAdapter"; + +// const shikiAdapter = createShikiAdapter>({ +// 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 diff --git a/src/client/components/List.tsx b/src/client/components/List.tsx deleted file mode 100644 index dfb126cb..00000000 --- a/src/client/components/List.tsx +++ /dev/null @@ -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 ( - - - - - Today - - - - - - - - - {props.data.map((activity, index) => { - const { play } = activity; - return ( - - - - - - {play.data.track} - {play.data.artists.join(' / ')} - - - {play.meta?.source} - - - - - - {activity.status === 'error' ? - - : null} - - - - - - - - - - ) - })} - - - ); -} - -const StatusBadge = (props: ComponentProps & { 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 {badgeText} -} - -export const ListContainer = (props?: ComponentProps) => { - return -} \ No newline at end of file diff --git a/src/client/components/playActivity/PlayList.scss b/src/client/components/playActivity/PlayList.scss new file mode 100644 index 00000000..2621f18c --- /dev/null +++ b/src/client/components/playActivity/PlayList.scss @@ -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; +} \ No newline at end of file diff --git a/src/client/components/playActivity/PlayList.tsx b/src/client/components/playActivity/PlayList.tsx new file mode 100644 index 00000000..174a4d83 --- /dev/null +++ b/src/client/components/playActivity/PlayList.tsx @@ -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 ( + 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 ( + + + + {headerText} + + + + + + + + ) + }} + itemContent={(index, groupIndex) => { + const activity = sorted[index]; + const { play } = activity; + return ( + + + + + + + + {play.data.track} + {play.data.artists.join(' / ')} + + + {play.meta?.source} + + + + + + {activity.status === 'error' ? + + : null} + + + + + + + + ) + }} + /> + ); + +} + + return ( + + + + + Today + + + + + + + + + {props.data.map((activity, index) => { + const { play } = activity; + return ( + + + + + + {play.data.track} + {play.data.artists.join(' / ')} + + + {play.meta?.source} + + + + + + {activity.status === 'error' ? + + : null} + + + + + + + + + + ) + })} + + + ); +} + +const StatusBadge = (props: ComponentProps & { 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 {badgeText} +} + +export const ListContainer = (props?: ComponentProps) => { + return +} \ No newline at end of file diff --git a/src/client/components/shikiAdapter.ts b/src/client/components/shikiAdapter.ts new file mode 100644 index 00000000..5eda0f91 --- /dev/null +++ b/src/client/components/shikiAdapter.ts @@ -0,0 +1,16 @@ +import { createShikiAdapter } from "@chakra-ui/react" +import type { HighlighterGeneric } from "shiki" + +export const shikiAdapter = createShikiAdapter>({ + async load() { + const { createHighlighter } = await import("shiki") + return createHighlighter({ + langs: ["json", "plaintext"], + themes: ["github-dark", "github-light"], + }) + }, + theme: { + light: "github-light", + dark: "github-dark", + }, +}); \ No newline at end of file diff --git a/src/core/PlayTestUtils.ts b/src/core/PlayTestUtils.ts index 5724b60a..96280c64 100644 --- a/src/core/PlayTestUtils.ts +++ b/src/core/PlayTestUtils.ts @@ -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'); diff --git a/src/core/PlayUtils.ts b/src/core/PlayUtils.ts index 3b8b5859..304d3c11 100644 --- a/src/core/PlayUtils.ts +++ b/src/core/PlayUtils.ts @@ -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, b: AmbPlayObject) => { 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: { diff --git a/src/stories/List.stories.tsx b/src/stories/List.stories.tsx index 171aaa02..9b14b1d3 100644 --- a/src/stories/List.stories.tsx +++ b/src/stories/List.stories.tsx @@ -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 () }, + render: function Render(args, { loaded: { data } }) { return () }, decorators: [ (Story) => (), ] @@ -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 ]}; } ],