From ffe2203ba5f64ea1b07328afeea64ea481ac6b26 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 19 Jun 2026 19:30:08 +0000 Subject: [PATCH] implement virtual list with tanstack/virtual --- package-lock.json | 30 +++++ package.json | 1 + src/client/components/ActivityDetail.tsx | 59 ++++++++- .../components/playActivity/ListParts.tsx | 114 ++++++++++++++++++ .../components/playActivity/PlayList.tsx | 87 +++---------- .../playActivity/VirtualListDynamic.tsx | 67 ++++++++++ .../playActivity/VirtualListExperimental.tsx | 100 +++++++++++++++ .../playActivity/VirtualListNormal.tsx | 74 ++++++++++++ 8 files changed, 461 insertions(+), 71 deletions(-) create mode 100644 src/client/components/playActivity/ListParts.tsx create mode 100644 src/client/components/playActivity/VirtualListDynamic.tsx create mode 100644 src/client/components/playActivity/VirtualListExperimental.tsx create mode 100644 src/client/components/playActivity/VirtualListNormal.tsx diff --git a/package-lock.json b/package-lock.json index eb29142f..b0a4c0ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0", + "@tanstack/react-virtual": "^3.14.3", "@tsconfig/node18": "^1.0.1", "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.5", @@ -5623,6 +5624,35 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz", + "integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", + "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 9a8ff798..be6de4c3 100644 --- a/package.json +++ b/package.json @@ -171,6 +171,7 @@ "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0", + "@tanstack/react-virtual": "^3.14.3", "@tsconfig/node18": "^1.0.1", "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.5", diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index 29ff4a49..05a98d19 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -1,5 +1,5 @@ import React, { ComponentProps, useState, Fragment } from "react" -import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton } from '@chakra-ui/react'; +import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton, Collapsible } from '@chakra-ui/react'; import { ComponentType } from "../../core/Atomic"; import { PlayData } from "./PlayData"; import { ErrorAlert } from "./ErrorAlert"; @@ -18,6 +18,7 @@ import { MarkOptional } from "ts-essentials"; import { QueryPlaysOpts } from "../../backend/common/database/drizzle/repositories/PlayRepository"; import { tanQueries } from "../queries"; import { PaginatedResponse } from "../../backend/common/database/drizzle/repositories/BaseRepository"; +import { LuChevronRight } from "react-icons/lu"; export interface ActivityDetailProps { activity: PlayApiCommonDetailed @@ -40,7 +41,7 @@ export const ActivitySummary = (props: ActivitySummaryProps) => { return ( - + {play.data.track} {play.data.artists.map(x => x.name).join(' / ')} @@ -54,7 +55,6 @@ export const ActivitySummary = (props: ActivitySummaryProps) => { }} justify="flex-start" alignItems="flex-end"> - ) @@ -162,4 +162,57 @@ export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => } return +} + +export const ActivityCollapsible = (props: ActivitySummaryProps & { key?: string, live?: boolean, componentId: number, query: QueryPlaysOpts }) => { + const { + activity: { + play + } = {}, + activity, + sortBy, + live = false + } = props; + return ( + + + + + + {live ? : } + + + {live ? : } + + + ) } \ No newline at end of file diff --git a/src/client/components/playActivity/ListParts.tsx b/src/client/components/playActivity/ListParts.tsx new file mode 100644 index 00000000..4d56e257 --- /dev/null +++ b/src/client/components/playActivity/ListParts.tsx @@ -0,0 +1,114 @@ +import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; +import { ComponentType } from '../../../core/Atomic.js'; +import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" +import dayjs, { Dayjs } from 'dayjs'; +import doy from 'dayjs/plugin/dayOfYear.js'; +import { PlayApiCommon, PlayApiCommonDetailed, SortPlaysByProps } from '../../../core/Api.js'; +import { QueryPlaysOpts } from '../../../backend/common/database/drizzle/repositories/PlayRepository.js'; +import { VscDebugRestart } from 'react-icons/vsc'; +import { sortByNewestDate } from '../../../core/PlayUtils.js'; + +dayjs.extend(doy); + +export interface GroupInfo { + count: number + date: Dayjs +} + +export interface GroupData { + plays: PlayApiCommon[] + date: Dayjs +} + +export interface ActivityLogProps extends SortPlaysByProps { + data: PlayApiCommon[] + componentId: number + componentType: ComponentType + render?: 'virtNormal' | 'virtDynamic' | 'virtExp' | 'accordian' + query: QueryPlaysOpts + live?: boolean +} + + +export const GroupHeader = (props: { data: GroupInfo } & ComponentProps) => { + const { + data, + ...rest + } = props; + const gData = data; + 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} + + + + + + + + ) +} + +export const isGroupInfo = (val: any): val is GroupInfo => val.date !== undefined; + +export const generateGroupInfo = (data: PlayApiCommon[]): GroupInfo[] => { + + const groupsReduced = data.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: [] }); + + return groupsReduced.groups.concat(groupsReduced.active); +} + + +export const generateGroupPlays = (data: PlayApiCommon[]): GroupData[] => { + + if(data.length === 0) { + return []; + } + const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { + const date = dayjs(curr.play.data.playDate); + if (acc.active === undefined) { + return { ...acc, active: { plays: [curr], date } }; + } + if (acc.active.date.dayOfYear() !== date.dayOfYear()) { + return { groups: [...acc.groups, acc.active], active: { plays: [curr], date } } + } + + return { groups: acc.groups, active: { ...acc.active, plays: acc.active.plays.concat(curr) } }; + }, { groups: [] }); + + if(groupsReduced.active !== null && groupsReduced !== undefined) { + return groupsReduced.groups.concat(groupsReduced.active); + } + + return groupsReduced.groups; +} + +export const generateFlatItems = (data: PlayApiCommon[]) => { + const groups = generateGroupPlays(data); + groups.sort((a, b) => sortByNewestDate(a.date, b.date)); + return groups.map((x) => { + x.plays.sort((a, b) => sortByNewestDate(a.playedAt, b.playedAt)); + return [{count: x.plays.length, date: x.date}, ...x.plays]; + }).flat(1); +} \ No newline at end of file diff --git a/src/client/components/playActivity/PlayList.tsx b/src/client/components/playActivity/PlayList.tsx index b964737b..e326c4b2 100644 --- a/src/client/components/playActivity/PlayList.tsx +++ b/src/client/components/playActivity/PlayList.tsx @@ -1,62 +1,22 @@ -import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText } from '@chakra-ui/react'; +import { Accordion, Span, Stack, Text, Box, HStack, Flex, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; import { ComponentType } from '../../../core/Atomic.js'; -import React, { ComponentProps, Fragment } from "react" +import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" import dayjs, { Dayjs } from 'dayjs'; import doy from 'dayjs/plugin/dayOfYear.js'; import { VscDebugRestart } from "react-icons/vsc"; import { ActivityDetailFetchable, ActivityDetails, ActivitySummary, ActivitySummaryFetchable } from '../ActivityDetail.js'; import "./PlayList.scss"; -import { PlayApiCommon, PlayApiCommonDetailed, SortPlaysByProps } from '../../../core/Api.js'; -import { QueryPlaysOpts } from '../../../backend/common/database/drizzle/repositories/PlayRepository.js'; +import { PlayApiCommonDetailed } from '../../../core/Api.js'; import { useQuery } from '@tanstack/react-query'; import { ErrorAlert } from '../ErrorAlert.js'; import { tanQueries } from '../../queries/index.js'; +import { VirtualizedListNormal } from './VirtualListNormal.js'; +import { VirtualizedListDynamic } from './VirtualListDynamic.js'; +import { VirtualizedListExp } from './VirtualListExperimental.js'; +import { ActivityLogProps, generateGroupPlays, GroupHeader } from './ListParts.js'; dayjs.extend(doy); -export interface ActivityLogProps extends SortPlaysByProps { - data: PlayApiCommon[] - componentId: number - componentType: ComponentType - render?: 'virtCollapse' | 'virtAccordian' | 'accordian' - query: QueryPlaysOpts - live?: boolean -} - -interface GroupInfo { - count: number - date: Dayjs -} - -interface GroupData { - plays: PlayApiCommon[] - date: Dayjs -} - -const generateGroupPlays = (data: PlayApiCommon[]): GroupData[] => { - - if(data.length === 0) { - return []; - } - const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { - const date = dayjs(curr.play.data.playDate); - if (acc.active === undefined) { - return { ...acc, active: { plays: [curr], date } }; - } - if (acc.active.date.dayOfYear() !== date.dayOfYear()) { - return { groups: [...acc.groups, acc.active], active: { plays: [curr], date } } - } - - return { groups: acc.groups, active: { ...acc.active, plays: acc.active.plays.concat(curr) } }; - }, { groups: [] }); - - if(groupsReduced.active !== null && groupsReduced !== undefined) { - return groupsReduced.groups.concat(groupsReduced.active); - } - - return groupsReduced.groups; -} - export const PlayList = (props: ActivityLogProps) => { const { @@ -68,6 +28,15 @@ export const PlayList = (props: ActivityLogProps) => { if (render === 'accordian') { return } + if(render === 'virtNormal') { + return + } + if(render === 'virtDynamic') { + return + } + if(render === 'virtExp') { + return + } } @@ -81,28 +50,9 @@ const PlainAccordian = (props: ActivityLogProps) => { return ( {groups.map((g) => { - let headerText: string; - if (g.date.isToday()) { - headerText = 'Today'; - } else { - headerText = g.date.format('MMM DD'); - if (g.date.year() !== dayjs().year()) { - headerText += `, ${g.date.year()}`; - } - } return ( - - - - - {headerText} - - - - - - - + + {g.plays.map((activity, index) => { const { play } = activity; @@ -128,6 +78,7 @@ const PlainAccordian = (props: ActivityLogProps) => { ); } + export const ListContainer = (props?: ComponentProps) => { return } diff --git a/src/client/components/playActivity/VirtualListDynamic.tsx b/src/client/components/playActivity/VirtualListDynamic.tsx new file mode 100644 index 00000000..e06b8f19 --- /dev/null +++ b/src/client/components/playActivity/VirtualListDynamic.tsx @@ -0,0 +1,67 @@ +import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; +import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" +import { ActivityCollapsible } from '../ActivityDetail.js'; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual" +import { ActivityLogProps, generateFlatItems, GroupHeader, isGroupInfo } from './ListParts.js'; + +const itemContainerStyle: React.ComponentProps<"div">['style'] = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + paddingBottom: 4, +}; + +const containerStyle: React.ComponentProps<"div">['style'] = { + width: "100%", + position: "relative", +} + +export const VirtualizedListDynamic = (props: ActivityLogProps) => { + const { + data = [], + sortBy, + live = false, + } = props; + + const items = useMemo(() => { + return generateFlatItems(data); + }, [data]); + + const parentRef = React.useRef(null) + + // The virtualizer + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 85, + directDomUpdates: true, + debug: true + }); + + return (
+ + {virtualizer.getVirtualItems().map((virtualItem) => { + const item = items[virtualItem.index] + return ( + + {isGroupInfo(item) ? : } + ) + })} + +
); +} \ No newline at end of file diff --git a/src/client/components/playActivity/VirtualListExperimental.tsx b/src/client/components/playActivity/VirtualListExperimental.tsx new file mode 100644 index 00000000..0f368efa --- /dev/null +++ b/src/client/components/playActivity/VirtualListExperimental.tsx @@ -0,0 +1,100 @@ +import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; +import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" +import { ActivityCollapsible } from '../ActivityDetail.js'; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual" +import { ActivityLogProps, generateFlatItems, GroupHeader, isGroupInfo } from './ListParts.js'; + +export const VirtualizedListExp = (props: ActivityLogProps) => { + const { + data = [], + sortBy, + live = false, + } = props; + + const items = useMemo(() => { + return generateFlatItems(data); + }, [data]); + + const parentRef = React.useRef(null) + const innerRef = React.useRef(null) + const rowRefsMap = React.useRef(new Map()) + + // The virtualizer + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 85, + onChange: (instance) => { + innerRef.current!.style.height = `${instance.getTotalSize()}px` + instance.getVirtualItems().forEach((virtualRow) => { + const rowRef = rowRefsMap.current.get(virtualRow.index) + if (!rowRef) return + rowRef.style.transform = `translateY(${virtualRow.start}px)` + }) + }, + }); + + const indexes = virtualizer.getVirtualIndexes(); + + React.useEffect(() => { + virtualizer.measure() + }, []) + + const contentProps = useMemo( + (): React.ComponentProps<"div"> => ({ + style: { + width: "100%", + position: "relative", + }, + }), + [virtualizer], + ) + + const getItemProps = useCallback( + (item: VirtualItem): React.ComponentProps<"div"> => ({ + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + paddingBottom: 4, + }, + }), + [], + ) + return (
+ + + {indexes.map((index) => { + const item = items[index]; + + return ( +
{ + if (el) { + virtualizer.measureElement(el) + rowRefsMap.current.set(index, el) + } + }} + > + + {isGroupInfo(item) ? : } + +
+ ); + })} +
+
); +} \ No newline at end of file diff --git a/src/client/components/playActivity/VirtualListNormal.tsx b/src/client/components/playActivity/VirtualListNormal.tsx new file mode 100644 index 00000000..ec818648 --- /dev/null +++ b/src/client/components/playActivity/VirtualListNormal.tsx @@ -0,0 +1,74 @@ +import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; +import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" +import { ActivityCollapsible } from '../ActivityDetail.js'; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual" +import { ActivityLogProps, generateFlatItems, GroupHeader, isGroupInfo } from './ListParts.js'; + +export const VirtualizedListNormal = (props: ActivityLogProps) => { + const { + data = [], + sortBy, + live = false, + } = props; + + const items = useMemo(() => { + return generateFlatItems(data); + }, [data]); + + const parentRef = React.useRef(null) + + // The virtualizer + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 85, + }); + + const contentProps = useMemo( + (): React.ComponentProps<"div"> => ({ + style: { + height: `${virtualizer.getTotalSize()}px`, + width: "100%", + position: "relative", + }, + }), + [virtualizer], + ) + + const getItemProps = useCallback( + (item: VirtualItem): React.ComponentProps<"div"> => ({ + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + paddingBottom: 4, + height: `${item.size}px`, + transform: `translateY(${item.start}px)`, + }, + }), + [], + ) + return ( + + + + {virtualizer.getVirtualItems().map((virtualItem) => { + const item = items[virtualItem.index] + return ( +
+ + {isGroupInfo(item) ? : } + +
) + })} +
+
+
+ +
); +} -- 2.51.2