diff --git a/src/client/components/Dashboard/LibraryStatistics.tsx b/src/client/components/Dashboard/LibraryStatistics.tsx index 1266798..8e6aa8c 100644 --- a/src/client/components/Dashboard/LibraryStatistics.tsx +++ b/src/client/components/Dashboard/LibraryStatistics.tsx @@ -1,105 +1,106 @@ import React, { ReactElement } from "react"; -import { isEmpty, isUndefined, map } from "lodash"; import Header from "../shared/Header"; -import { GetLibraryStatisticsQuery } from "../../graphql/generated"; +import { GetLibraryStatisticsQuery, DirectorySize } from "../../graphql/generated"; -type LibraryStatisticsProps = { - stats: GetLibraryStatisticsQuery['getLibraryStatistics']; +type Stats = Omit & { + comicDirectorySize: DirectorySize; }; -export const LibraryStatistics = ( - props: LibraryStatisticsProps, -): ReactElement => { - const { stats } = props; +/** Props for {@link LibraryStatistics}. */ +interface LibraryStatisticsProps { + stats: Stats | null | undefined; +} + +/** + * Displays a snapshot of library metrics: total comic files, tagging coverage, + * file-type breakdown, and the publisher with the most issues. + * + * Returns `null` when `stats` is absent or the statistics array is empty. + */ +export const LibraryStatistics = ({ stats }: LibraryStatisticsProps): ReactElement | null => { + if (!stats) return null; + + const facet = stats.statistics?.[0]; + if (!facet) return null; + + const { issues, issuesWithComicInfoXML, fileTypes, publisherWithMostComicsInLibrary, fileLessComics } = facet; + const topPublisher = publisherWithMostComicsInLibrary?.[0]; + return (
A brief snapshot of your library. - } + subHeaderContent={A brief snapshot of your library.} iconClassNames="fa-solid fa-binoculars mr-2" /> -
-
-
-
Library size
-
- {props.stats.totalDocuments} files -
- {props.stats.comicDirectorySize?.fileCount && ( -
- - {props.stats.comicDirectorySize.fileCount} comic files - -
- )} -
- {/* comicinfo and comicvine tagged issues */} -
- {!isUndefined(props.stats.statistics) && - !isEmpty(props.stats.statistics?.[0]?.issues) && ( -
- - {props.stats.statistics?.[0]?.issues?.length || 0} - {" "} - tagged with ComicVine -
- )} - {!isUndefined(props.stats.statistics) && - !isEmpty(props.stats.statistics?.[0]?.issuesWithComicInfoXML) && ( -
- - {props.stats.statistics?.[0]?.issuesWithComicInfoXML?.length || 0} - {" "} - - with ComicInfo.xml - -
- )} -
- -
- {!isUndefined(props.stats.statistics) && - !isEmpty(props.stats.statistics?.[0]?.fileTypes) && - map(props.stats.statistics?.[0]?.fileTypes, (fileType, idx) => { - return ( - - {fileType.data.length} {fileType.id} - - ); - })} -
- - {/* file types */} -
- {/* publisher with most issues */} - {!isUndefined(props.stats.statistics) && - !isEmpty( - props.stats.statistics?.[0]?.publisherWithMostComicsInLibrary?.[0], - ) && ( - <> - - { - props.stats.statistics?.[0] - ?.publisherWithMostComicsInLibrary?.[0]?.id - } - - {" has the most issues "} - - { - props.stats.statistics?.[0] - ?.publisherWithMostComicsInLibrary?.[0]?.count - } - - - )} -
+
+ {/* Total records in database */} +
+
In database
+
+ {stats.totalDocuments} comics +
+ + {/* Missing files */} + {fileLessComics && fileLessComics.length > 0 && ( +
+
Missing files
+
+ {fileLessComics.length} +
+
+ )} + + {/* Disk space consumed */} + {stats.comicDirectorySize.totalSizeInGB != null && ( +
+
Size on disk
+
+ {stats.comicDirectorySize.totalSizeInGB.toFixed(2)} GB +
+
+ )} + + {/* Tagging coverage */} +
+ {issues && issues.length > 0 && ( +
+ {issues.length} + tagged with ComicVine +
+ )} + {issuesWithComicInfoXML && issuesWithComicInfoXML.length > 0 && ( +
+ {issuesWithComicInfoXML.length} + with ComicInfo.xml +
+ )} +
+ + {/* File-type breakdown */} + {fileTypes && fileTypes.length > 0 && ( +
+ {fileTypes.map((ft) => ( + + {ft.data.length} {ft.id} + + ))} +
+ )} + + {/* Publisher with most issues */} + {topPublisher && ( +
+ {topPublisher.id} + {" has the most issues "} + {topPublisher.count} +
+ )}
); diff --git a/src/client/components/shared/T2Table.tsx b/src/client/components/shared/T2Table.tsx index 59de47a..6eb5dd2 100644 --- a/src/client/components/shared/T2Table.tsx +++ b/src/client/components/shared/T2Table.tsx @@ -1,55 +1,51 @@ -import React, { ReactElement, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; +import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; import { ColumnDef, + Row, flexRender, getCoreRowModel, - getFilteredRowModel, useReactTable, PaginationState, } from "@tanstack/react-table"; -/** - * Props for {@link T2Table}. - */ -interface T2TableProps { +/** Props for {@link T2Table}. */ +interface T2TableProps { /** Row data to render. */ - sourceData?: unknown[]; + sourceData?: TData[]; /** Total number of records across all pages, used for pagination display. */ totalPages?: number; /** Column definitions (TanStack Table {@link ColumnDef} array). */ - columns?: unknown[]; + columns?: ColumnDef[]; /** Callbacks for navigating between pages. */ paginationHandlers?: { - nextPage?(...args: unknown[]): unknown; - previousPage?(...args: unknown[]): unknown; + nextPage?(pageIndex: number, pageSize: number): void; + previousPage?(pageIndex: number, pageSize: number): void; }; /** Called with the TanStack row object when a row is clicked. */ - rowClickHandler?(...args: unknown[]): unknown; + rowClickHandler?(row: Row): void; /** Returns additional CSS classes for a given row (e.g. for highlight states). */ - getRowClassName?(row: any): string; + getRowClassName?(row: Row): string; /** Optional slot rendered in the toolbar area (e.g. a search input). */ - children?: any; + children?: ReactNode; } /** * A paginated data table with a two-row sticky header. * - * The header rounds its corners only while stuck to the top of the scroll - * container, detected via {@link IntersectionObserver} on a sentinel element - * placed immediately before the table. The second header row's `top` offset - * is measured from the DOM at mount time so the two rows stay flush regardless - * of font size or padding changes. + * Header stickiness is detected via {@link IntersectionObserver} on a sentinel + * element placed immediately before the table. The second header row's `top` + * offset is measured at mount time so both rows stay flush regardless of font + * size or padding changes. */ -export const T2Table = (tableOptions: T2TableProps): ReactElement => { - const { - sourceData, - columns, - paginationHandlers: { nextPage, previousPage }, - totalPages, - rowClickHandler, - getRowClassName, - } = tableOptions; - +export const T2Table = ({ + sourceData = [], + columns = [], + paginationHandlers: { nextPage, previousPage } = {}, + totalPages = 0, + rowClickHandler, + getRowClassName, + children, +}: T2TableProps): ReactElement => { const sentinelRef = useRef(null); const firstHeaderRowRef = useRef(null); const [isSticky, setIsSticky] = useState(false); @@ -76,30 +72,18 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => { pageSize: 15, }); - const pagination = useMemo( - () => ({ - pageIndex, - pageSize, - }), - [pageIndex, pageSize], - ); + const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); - /** Advances to the next page and notifies the parent via {@link T2TableProps.paginationHandlers}. */ + /** Advances to the next page and notifies the parent. */ const goToNextPage = () => { - setPagination({ - pageIndex: pageIndex + 1, - pageSize, - }); - nextPage(pageIndex, pageSize); + setPagination({ pageIndex: pageIndex + 1, pageSize }); + nextPage?.(pageIndex, pageSize); }; - /** Goes back one page and notifies the parent via {@link T2TableProps.paginationHandlers}. */ + /** Goes back one page and notifies the parent. */ const goToPreviousPage = () => { - setPagination({ - pageIndex: pageIndex - 1, - pageSize, - }); - previousPage(pageIndex, pageSize); + setPagination({ pageIndex: pageIndex - 1, pageSize }); + previousPage?.(pageIndex, pageSize); }; const table = useReactTable({ @@ -108,43 +92,37 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => { manualPagination: true, getCoreRowModel: getCoreRowModel(), pageCount: sourceData.length ?? -1, - state: { - pagination, - }, + state: { pagination }, onPaginationChange: setPagination, }); return (
-
-
- {/* Search bar */} - {tableOptions.children} +
+ {children} - {/* Pagination controls */} -
-
- Page {pageIndex} of {Math.ceil(totalPages / pageSize)} -
-

- {totalPages} comics in all -

-
- - -
+
+
+ Page {pageIndex} of {Math.ceil(totalPages / pageSize)} +
+

+ {totalPages} comics in all +

+
+ +
@@ -152,37 +130,35 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
- {table.getHeaderGroups().map((headerGroup, groupIndex) => { - return ( - - {headerGroup.headers.map((header) => ( - - ))} - - ); - })} + {table.getHeaderGroups().map((headerGroup, groupIndex) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - {table.getRowModel().rows.map((row, rowIndex) => ( + {table.getRowModel().rows.map((row) => ( rowClickHandler(row)} + onClick={() => rowClickHandler?.(row)} className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`} > {row.getVisibleCells().map((cell) => ( diff --git a/src/client/graphql/generated.ts b/src/client/graphql/generated.ts index a9771c9..14c0c90 100644 --- a/src/client/graphql/generated.ts +++ b/src/client/graphql/generated.ts @@ -1302,7 +1302,7 @@ export type GetVolumeGroupsQuery = { __typename?: 'Query', getComicBookGroups: A export type GetLibraryStatisticsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } }; +export type GetLibraryStatisticsQuery = { __typename?: 'Query', getLibraryStatistics: { __typename?: 'LibraryStatistics', totalDocuments: number, comicDirectorySize: { __typename?: 'DirectorySize', fileCount: number, totalSizeInGB: number }, statistics: Array<{ __typename?: 'StatisticsFacet', fileTypes?: Array<{ __typename?: 'FileTypeStats', id: string, data: Array }> | null, issues?: Array<{ __typename?: 'IssueStats', data: Array, id?: { __typename?: 'VolumeInfo', id?: number | null, name?: string | null } | null }> | null, fileLessComics?: Array<{ __typename?: 'Comic', id: string }> | null, issuesWithComicInfoXML?: Array<{ __typename?: 'Comic', id: string }> | null, publisherWithMostComicsInLibrary?: Array<{ __typename?: 'PublisherStats', id: string, count: number }> | null }> } }; export type GetWeeklyPullListQueryVariables = Exact<{ input: WeeklyPullListInput; @@ -1945,6 +1945,7 @@ export const GetLibraryStatisticsDocument = ` totalDocuments comicDirectorySize { fileCount + totalSizeInGB } statistics { fileTypes { diff --git a/src/client/graphql/queries/dashboard.graphql b/src/client/graphql/queries/dashboard.graphql index 032efd0..5cf4cff 100644 --- a/src/client/graphql/queries/dashboard.graphql +++ b/src/client/graphql/queries/dashboard.graphql @@ -181,6 +181,7 @@ query GetLibraryStatistics { totalDocuments comicDirectorySize { fileCount + totalSizeInGB } statistics { fileTypes {
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +