import { Accordion, Container, Stack, Heading, type MenuSelectionDetails, HStack, Dialog, Portal, CloseButton, Button, Text, RadioGroup } from '@chakra-ui/react'; import { useSSEAnyEvent, useSSEContext } from '@flamefrontend/sse-runtime-react'; import { type InfiniteData, useInfiniteQuery, type UseInfiniteQueryResult, useMutation, useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; import doy from 'dayjs/plugin/dayOfYear.js'; import { type ComponentProps, Fragment, useCallback, useMemo, useState } from "react"; import type {MsSseEvent, MsSseEventPayload, PaginatedResponse, PlayApiCommonDetailed, QueryPlaysOptsJson} from '../../../core/Api.js'; import {actionContextSchema, queueContextSchema, type ComponentType, type QueueContext} from '../../../core/Atomic.js'; import { type QueryPlaysOptsJsonRefreshable, tanQueries, useQueryWatcher } from '../../queries/index.js'; import { ActivityDetailFetchable, ActivityDetails, ActivitySummary, ActivitySummaryFetchable, ActivitySummarySkeleton } from '../ActivityDetail.js'; import { ErrorAlert } from '../ErrorAlert.js'; import { ListFilters, ListRefereshButton, todayRange } from './ListFilters.js'; import { type ActivityLogProps, generateGroupPlays, GroupHeader } from './ListParts.js'; import { NoPlayResults, VirtualizedListDynamic } from './VirtualListDynamic.js'; import { VirtualizedListExp } from './VirtualListExperimental.js'; import { VirtualizedListNormal } from './VirtualListNormal.js'; import { menuItem, type MenuItemRender } from '../buttonMenus/menuItemUtils.js'; import { RetryIcon } from '../icons/ChakraIcons.js'; import { PrimaryButtonMenu } from '../buttonMenus/PrimaryButtonMenu.js'; import ky from 'ky'; import { formOptions, useForm } from '@tanstack/react-form'; import z from 'zod'; import { FormCheckbox, FormRadio, type RadioFormItem } from '../form/formComponents.js'; dayjs.extend(doy); export const ActivityList = (props: ActivityLogProps & Pick) => { const { data = [], sortBy = 'played', render = 'accordian' } = props; if (render === 'accordian') { return } if (render === 'virtNormal') { return } if (render === 'virtDynamic') { return } if (render === 'virtExp') { return } } const PlainAccordian = (props: ActivityLogProps) => { const { data = [], sortBy, live = false } = props; const groups = generateGroupPlays(data); return ( {groups.map((g) => { return ( {g.plays.map((activity, index) => { const { play } = activity; return ( {live ? : } {live ? : } ) })} ) })} ); } export const ListContainer = (props?: ComponentProps) => { return } export const ListContainerFetchable = (props: { componentId: number, componentType: ComponentType, filters?: QueryPlaysOptsJsonRefreshable } & Pick, 'render'>) => { const { componentId, filters = {} } = props; const query: QueryPlaysOptsJsonRefreshable = filters; // { ...filters, order: 'desc', sort: 'playedAt' }; const { isPending, isError, data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status } = useInfiniteQuery({ ...tanQueries.activities.list(componentId, query), initialPageParam: 0, getNextPageParam: (lastPage, allPages, lastPageParam) => { if (lastPage.data.length < lastPage.meta.limit) { return undefined } return lastPage.meta.offset + lastPage.meta.limit }, }); const allPlays = useMemo(() => data === undefined ? [] : data.pages.flatMap(x => x.data).filter(x => x !== null && x !== undefined),[data]); const queryClient = useQueryClient(); const client = useSSEContext(); useSSEAnyEvent(client, (payload) => { if ('componentId' in (payload.data as object) && (payload.data as Record).componentId === componentId) { switch (payload.type) { case 'playInsert': { const componentData = payload.data as MsSseEventPayload; console.debug(`[Insert Check ${componentData.data.uid}] Recieved playInsert for Component ${componentId}, checking if Play can be inserted...`); if(playInWindow(componentData.data, query)) { queryClient.setQueryData(tanQueries.activities.list(componentId, query).queryKey, (old: InfiniteData, unknown>) => { return insertInfinitePlay(componentData.data, old); }); } } break; case 'playDelete': { queryClient.setQueryData(tanQueries.activities.list(componentId, query).queryKey, (old: InfiniteData, unknown>) => { const componentData = payload.data as MsSseEventPayload>; // playDelete events don't happen outside of ui the except for retention cleanup // and its unlikely the user would have fetched data during a retention cleanup event // // so its likey this data will actually be updated so we optimistically build the new data even though we don't know // for sure that any page actually has our uid const newQueryData: InfiniteData, unknown> = { pages: [], pageParams: { ...old.pageParams } }; for(const p of old.pages) { const newPageData = p.data.filter(x => x.uid !== componentData.data.uid); if(newPageData.length !== p.data.length) { newQueryData.pages.push({...p, data: newPageData}); } else { newQueryData.pages.push(p); } } return newQueryData; }); } } } }); let rendered; if (isPending) { rendered = ; } else if (isError || status === 'error') { rendered = } else if(!isFetching && allPlays.length === 0) { rendered = } else { rendered = 0 ? data.pages[0].meta.total : undefined} hasNextPage={hasNextPage} fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} render="virtDynamic" data={allPlays} live {...props} sortBy="played" query={query} />; } return rendered; } /** this is based on the assumption data is always playedAt descending */ const insertInfinitePlay = (data: PlayApiCommonDetailed, queryData: InfiniteData, unknown>): InfiniteData, unknown> => { const newQueryData: InfiniteData, unknown> = { pages: [], pageParams: { ...queryData.pageParams } }; console.debug(`[Insert Page Index ${data.uid}] Trying to insert Play...`); const playedAt = dayjs(data.play.data.playDate); let pageIndex = 0; let inserted = false; for (const p of queryData.pages) { if(inserted === true) { newQueryData.pages.push(p); continue; } let beforeIndex: number; try { beforeIndex = p.data.findIndex(x => dayjs(x.play.data.playDate).isBefore(playedAt)); } catch (e) { console.warn(new Error(`[Insert Page Index ${data.uid}] Error while trying to find index for existing data insert`, { cause: e })); } if (beforeIndex === -1 || beforeIndex === undefined) { if (!inserted) { const first = p.data[0].playedAt; const last = p.data[p.data.length - 1].playedAt; console.debug(`[Insert ${data.uid}] Play playedAt ${data.play.data.playDate} not between ranges on page ${pageIndex}: ${first} to ${last}`); } newQueryData.pages.push(p); } else { const first = p.data[0].playedAt; const last = p.data[p.data.length - 1].playedAt; console.debug(`[Insert ${data.uid}] Play playedAt ${data.play.data.playDate} between ranges on page ${pageIndex}: ${first} to ${last}, inserting at index ${beforeIndex}`); inserted = true; const { data: playData, meta } = p; newQueryData.pages.push({ meta, data: [...playData.slice(0, beforeIndex), { ...data, // @ts-expect-error only used for inserts in this context isNew: true } , ...playData.slice(beforeIndex)] }); } pageIndex++; } return newQueryData; } const playInWindow = (data: PlayApiCommonDetailed, query: QueryPlaysOptsJson): boolean => { if (query.state !== undefined && !query.state.includes(data.state)) { console.debug(`[Insert Check ${data.uid}] Play is in state ${data.state} not included in current filters of ${query.state.join(',')}`); return false; } if (query.text !== undefined) { let someFound = false; for (const t of query.text) { if (data.play.data.track?.toLocaleLowerCase().includes(t)) { someFound = true; break; } if (data.play.data.track.toLocaleLowerCase().includes(t)) { someFound = true; break; } if ((data.play.data.artists ?? []).some(x => x.name.toLocaleLowerCase().includes(t))) { someFound = true; } } if (!someFound) { console.debug(`[Insert Check ${data.uid}] Play does not match current phrase filters`); return false; } } const played = dayjs(data.play.data.playDate); if (query.playedAt.type === 'between' && ( !played.isAfter(dayjs(query.playedAt.range[0])) || !played.isBefore(dayjs(query.playedAt.range[1])))) { console.debug(`[Insert Check ${data.uid}] Play playedAt ${data.play.data.playDate} is not between filter date range of ${query.playedAt.range[0]} and ${query.playedAt.range[1]}`); return false; } // TODO playedAt with comparisons that aren't between return true; } const opts = formOptions.strictSchema(actionContextSchema, { defaultValues: { transform: true, dupeCheck: true, useCache: true, action: 'failures' }, validators: [{ run: actionContextSchema, triggers: [], }], }); const playTypeRadioItems: RadioFormItem[] = [ {value: 'all', label: 'All Plays'}, {value: 'failures', label: 'Only Unsuccessful Plays'} ]; const RetryWithDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (vals: QueueContext & {action: 'all' | 'failures'}) => void }) => { const form = useForm({ ...opts, onSubmit: ({ schemaOutputs }) => { console.log(schemaOutputs[0]); props.onSubmit(schemaOutputs[0]); props.setOpen(false); } }); return ( props.setOpen(e.open)} > Retry With...
{ e.preventDefault() e.stopPropagation() form.handleSubmit() }}> Retry all Plays shown using the options below: ()} /> ()} /> ()} /> ()} />
) } const MenuRetryFailures = menuItem(RetryIcon, 'failures', 'Retry All Unsuccessful'); const MenuRetryAll = menuItem(RetryIcon, 'all', 'Retry All'); const MenuRetryWith = menuItem(RetryIcon, 'allWith', 'Retry All With...'); const menuItems: MenuItemRender[] = [ (extra) => , (extra) => , (extra) => ]; // instanced so that todayRange is updated on component mount const defaultFilter = (): QueryPlaysOptsJsonRefreshable => ({ playedAt: { type: 'between', range: todayRange, inclusive: true }, order: 'desc', sort: 'playedAt' }); export const ListContainerFilterable = (props: { componentId: number, componentType: ComponentType } & Pick, 'render'>) => { const { componentType } = props; const [filters, setFilter] = useState(defaultFilter()); const primaryRefresh = ; const { isFetching: isListFetching } = useQueryWatcher(tanQueries.activities.list(props.componentId, filters).queryKey) const [retryOpen, setRetryOpen] = useState(false); const { mutate, isPending, variables, isSuccess } = useMutation({ mutationKey: ['retryBulk', props.componentId, filters], // eslint-disable-next-line arrow-body-style mutationFn: (data: { action: string, context?: QueueContext }) => { return ky.post<{ filters: QueryPlaysOptsJsonRefreshable, context?: QueueContext }>(`api/components/${props.componentId}/plays/queue`, { json: { filters: { ...filters, state: data.action === 'failures' ? ['failed'] : filters.state }, context: data.context } }); } }); const menuCb = useCallback((value: MenuSelectionDetails) => { switch(value.value) { case 'failures': mutate({action: 'failures'}); break; case 'all': mutate({action: 'all'}); break; case 'allWith': setRetryOpen(true); break; } }, [mutate]); return ( mutate({action: vals.action, context: vals})}/> {componentType === 'source' ? 'Plays' : 'Scrobbles'} ) }