diff --git a/src/client/components/Import/Import.tsx b/src/client/components/Import/Import.tsx index 9025e10..e86a488 100644 --- a/src/client/components/Import/Import.tsx +++ b/src/client/components/Import/Import.tsx @@ -7,9 +7,10 @@ import { useShallow } from "zustand/react/shallow"; import axios from "axios"; import { useGetJobResultStatisticsQuery, - useGetImportStatisticsQuery, + useGetCachedImportStatisticsQuery, useStartIncrementalImportMutation } from "../../graphql/generated"; +import { RealTimeImportStats } from "./RealTimeImportStats"; interface ImportProps { path: string; @@ -22,7 +23,6 @@ interface ImportProps { export const Import = (props: ImportProps): ReactElement => { const queryClient = useQueryClient(); const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0); - const [showPreview, setShowPreview] = useState(false); const { importJobQueue, getSocket, disconnectSocket } = useStore( useShallow((state) => ({ importJobQueue: state.importJobQueue, @@ -31,23 +31,10 @@ export const Import = (props: ImportProps): ReactElement => { })), ); - const { - data: importStats, - isLoading: isLoadingStats, - refetch: refetchStats - } = useGetImportStatisticsQuery( - {}, - { - enabled: showPreview, - refetchOnWindowFocus: false, - } - ); - const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ onSuccess: (data) => { if (data.startIncrementalImport.success) { importJobQueue.setStatus("running"); - setShowPreview(false); } }, }); @@ -64,6 +51,21 @@ export const Import = (props: ImportProps): ReactElement => { }); const { data, isError, isLoading, refetch } = useGetJobResultStatisticsQuery(); + + // Get cached import statistics to determine if Start Import button should be shown + const { data: cachedStats } = useGetCachedImportStatisticsQuery( + {}, + { + refetchOnWindowFocus: false, + refetchInterval: false, + } + ); + + // Determine if we should show the Start Import button + const hasNewFiles = cachedStats?.getCachedImportStatistics?.success && + cachedStats.getCachedImportStatistics.stats && + cachedStats.getCachedImportStatistics.stats.newFiles > 0 && + cachedStats.getCachedImportStatistics.stats.pendingFiles === 0; useEffect(() => { const socket = getSocket("/"); @@ -94,11 +96,6 @@ export const Import = (props: ImportProps): ReactElement => { ); }; - const handleShowPreview = () => { - setShowPreview(true); - refetchStats(); - }; - /** * Starts smart import, resetting session if queue was drained */ @@ -188,6 +185,11 @@ export const Import = (props: ImportProps): ReactElement => {
+ {/* Real-Time Import Statistics Widget */} +
+ +
+
{
- {!showPreview && (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( -
+ {/* Start Smart Import Button - shown when there are new files and no import is running */} + {hasNewFiles && + (importJobQueue.status === "drained" || importJobQueue.status === undefined) && ( +
)} - {/* Import Preview Panel */} - {showPreview && !isLoadingStats && importStats?.getImportStatistics && ( -
- - - Import Preview - - - - -
-
-

- Directory: {importStats.getImportStatistics.directory} -

-
- -
-
-
- {importStats.getImportStatistics.stats.totalLocalFiles} -
-
- Total Files -
-
- -
-
- {importStats.getImportStatistics.stats.newFiles} -
-
- New Comics -
-
- -
-
- {importStats.getImportStatistics.stats.alreadyImported} -
-
- Already Imported -
-
- -
-
- {(() => { - const percentage = importStats.getImportStatistics.stats.percentageImported; - const numValue = typeof percentage === 'number' ? percentage : parseFloat(percentage); - return !isNaN(numValue) ? numValue.toFixed(1) : '0.0'; - })()}% -
-
- Already in Library -
-
-
- - {importStats.getImportStatistics.stats.newFiles > 0 && ( -
-
-

- Ready to import {importStats.getImportStatistics.stats.newFiles} new comic{importStats.getImportStatistics.stats.newFiles !== 1 ? 's' : ''}! -

-

- {importStats.getImportStatistics.stats.alreadyImported} comic{importStats.getImportStatistics.stats.alreadyImported !== 1 ? 's' : ''} will be skipped (already in library). -

-
-
- )} - - {importStats.getImportStatistics.stats.newFiles === 0 && ( -
-
-

- No new comics to import! -

-

- All {importStats.getImportStatistics.stats.totalLocalFiles} comic{importStats.getImportStatistics.stats.totalLocalFiles !== 1 ? 's' : ''} in the directory {importStats.getImportStatistics.stats.totalLocalFiles !== 1 ? 'are' : 'is'} already in your library. -

-
-
- )} - -
- {importStats.getImportStatistics.stats.newFiles > 0 && ( - - )} - -
-
-
- )} - - {showPreview && isLoadingStats && ( -
-
- - Analyzing comics folder... - -
- )} {(importJobQueue.status === "running" || importJobQueue.status === "paused") && ( diff --git a/src/client/components/Import/RealTimeImportStats.tsx b/src/client/components/Import/RealTimeImportStats.tsx new file mode 100644 index 0000000..df2f71a --- /dev/null +++ b/src/client/components/Import/RealTimeImportStats.tsx @@ -0,0 +1,290 @@ +import React, { ReactElement, useEffect, useState } from "react"; +import { useGetCachedImportStatisticsQuery } from "../../graphql/generated"; +import { useStore } from "../../store"; +import { format } from "date-fns"; + +interface ImportStatsData { + totalLocalFiles: number; + alreadyImported: number; + newFiles: number; + percentageImported: string; + pendingFiles: number; + lastUpdated: string; +} + +/** + * Real-time import statistics widget + * Displays live statistics from the file watcher and updates via Socket.IO + */ +export const RealTimeImportStats = (): ReactElement => { + const [stats, setStats] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const getSocket = useStore((state) => state.getSocket); + + // Fetch initial cached statistics + const { + data: cachedStats, + isLoading, + error, + refetch, + } = useGetCachedImportStatisticsQuery( + {}, + { + refetchOnWindowFocus: false, + refetchInterval: false, + } + ); + + // Set initial stats from GraphQL query + useEffect(() => { + if (cachedStats?.getCachedImportStatistics?.success && cachedStats.getCachedImportStatistics.stats) { + setStats({ + totalLocalFiles: cachedStats.getCachedImportStatistics.stats.totalLocalFiles, + alreadyImported: cachedStats.getCachedImportStatistics.stats.alreadyImported, + newFiles: cachedStats.getCachedImportStatistics.stats.newFiles, + percentageImported: cachedStats.getCachedImportStatistics.stats.percentageImported, + pendingFiles: cachedStats.getCachedImportStatistics.stats.pendingFiles, + lastUpdated: cachedStats.getCachedImportStatistics.lastUpdated || new Date().toISOString(), + }); + } + }, [cachedStats]); + + // Setup Socket.IO listener for real-time updates + useEffect(() => { + const socket = getSocket("/"); + + const handleConnect = () => { + setIsConnected(true); + console.log("Real-time import stats: Socket connected"); + }; + + const handleDisconnect = () => { + setIsConnected(false); + console.log("Real-time import stats: Socket disconnected"); + }; + + const handleStatsUpdate = (data: any) => { + console.log("Real-time import stats update received:", data); + if (data.stats) { + setStats({ + totalLocalFiles: data.stats.totalLocalFiles, + alreadyImported: data.stats.alreadyImported, + newFiles: data.stats.newFiles, + percentageImported: data.stats.percentageImported, + pendingFiles: data.stats.pendingFiles || 0, + lastUpdated: data.lastUpdated || new Date().toISOString(), + }); + } + }; + + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("IMPORT_STATISTICS_UPDATED", handleStatsUpdate); + + // Check initial connection state + if (socket.connected) { + setIsConnected(true); + } + + return () => { + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("IMPORT_STATISTICS_UPDATED", handleStatsUpdate); + }; + }, [getSocket]); + + if (isLoading) { + return ( +
+
+
+ + Loading statistics... + +
+
+ ); + } + + if (error) { + return ( +
+
+ + + + + Error loading statistics + +
+
+ ); + } + + // Handle cache not initialized or no stats available + if (!stats || cachedStats?.getCachedImportStatistics?.success === false) { + return ( +
+
+
+ + + +
+

+ Statistics Cache Initializing +

+

+ {cachedStats?.getCachedImportStatistics?.message || "The file watcher is starting up. Statistics will appear shortly."} +

+
+
+ +
+
+ ); + } + + const percentageValue = typeof stats.percentageImported === 'number' + ? stats.percentageImported + : parseFloat(stats.percentageImported); + + return ( +
+ {/* Header with connection status */} +
+

+ + + + Real-Time Folder Statistics +

+
+ + + {isConnected ? "Live" : "Offline"} + +
+
+ + {/* Statistics Grid */} +
+
+
+ {stats.totalLocalFiles} +
+
+ Total Files +
+
+ +
+
+ {stats.newFiles} +
+
+ New Files +
+
+ +
+
+ {stats.alreadyImported} +
+
+ Imported +
+
+ +
+
+ {stats.pendingFiles} +
+
+ Pending +
+
+ +
+
+ {!isNaN(percentageValue) ? percentageValue.toFixed(1) : '0.0'}% +
+
+ In Library +
+
+
+ + {/* Last Updated */} +
+ + + + + Last updated: {format(new Date(stats.lastUpdated), "MMM d, yyyy 'at' h:mm:ss a")} + + +
+ + {/* Info message about pending files */} + {stats.pendingFiles > 0 && ( +
+

+ {stats.pendingFiles} file{stats.pendingFiles !== 1 ? 's are' : ' is'} being stabilized before import (write in progress). +

+
+ )} + + {/* Info message about new files */} + {stats.newFiles > 0 && stats.pendingFiles === 0 && ( +
+

+ {stats.newFiles} new file{stats.newFiles !== 1 ? 's are' : ' is'} ready to be imported. +

+
+ )} + + {/* All caught up message */} + {stats.newFiles === 0 && stats.pendingFiles === 0 && stats.totalLocalFiles > 0 && ( +
+

+ + + + All files in the folder are already imported! +

+
+ )} +
+ ); +}; + +export default RealTimeImportStats; diff --git a/src/client/graphql/generated.ts b/src/client/graphql/generated.ts index 76d49f0..895ef23 100644 --- a/src/client/graphql/generated.ts +++ b/src/client/graphql/generated.ts @@ -52,6 +52,23 @@ export type AutoMergeSettingsInput = { onMetadataUpdate?: InputMaybe; }; +export type CachedImportStatistics = { + __typename?: 'CachedImportStatistics'; + lastUpdated?: Maybe; + message?: Maybe; + stats?: Maybe; + success: Scalars['Boolean']['output']; +}; + +export type CachedImportStats = { + __typename?: 'CachedImportStats'; + alreadyImported: Scalars['Int']['output']; + newFiles: Scalars['Int']['output']; + pendingFiles: Scalars['Int']['output']; + percentageImported: Scalars['String']['output']; + totalLocalFiles: Scalars['Int']['output']; +}; + export type CanonicalMetadata = { __typename?: 'CanonicalMetadata'; ageRating?: Maybe; @@ -615,6 +632,7 @@ export type Query = { comics: ComicConnection; /** Fetch resource from Metron API */ fetchMetronResource: MetronResponse; + getCachedImportStatistics: CachedImportStatistics; getComicBookGroups: Array; getComicBooks: ComicBooksResult; /** Get generic ComicVine resource (issues, volumes, etc.) */ @@ -1084,6 +1102,11 @@ export type GetImportStatisticsQueryVariables = Exact<{ export type GetImportStatisticsQuery = { __typename?: 'Query', getImportStatistics: { __typename?: 'ImportStatistics', success: boolean, directory: string, stats: { __typename?: 'ImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string } } }; +export type GetCachedImportStatisticsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetCachedImportStatisticsQuery = { __typename?: 'Query', getCachedImportStatistics: { __typename?: 'CachedImportStatistics', success: boolean, message?: string | null, lastUpdated?: string | null, stats?: { __typename?: 'CachedImportStats', totalLocalFiles: number, alreadyImported: number, newFiles: number, percentageImported: string, pendingFiles: number } | null } }; + export type StartNewImportMutationVariables = Exact<{ sessionId: Scalars['String']['input']; }>; @@ -1884,6 +1907,65 @@ useInfiniteGetImportStatisticsQuery.getKey = (variables?: GetImportStatisticsQue useGetImportStatisticsQuery.fetcher = (variables?: GetImportStatisticsQueryVariables, options?: RequestInit['headers']) => fetcher(GetImportStatisticsDocument, variables, options); +export const GetCachedImportStatisticsDocument = ` + query GetCachedImportStatistics { + getCachedImportStatistics { + success + message + stats { + totalLocalFiles + alreadyImported + newFiles + percentageImported + pendingFiles + } + lastUpdated + } +} + `; + +export const useGetCachedImportStatisticsQuery = < + TData = GetCachedImportStatisticsQuery, + TError = unknown + >( + variables?: GetCachedImportStatisticsQueryVariables, + options?: Omit, 'queryKey'> & { queryKey?: UseQueryOptions['queryKey'] } + ) => { + + return useQuery( + { + queryKey: variables === undefined ? ['GetCachedImportStatistics'] : ['GetCachedImportStatistics', variables], + queryFn: fetcher(GetCachedImportStatisticsDocument, variables), + ...options + } + )}; + +useGetCachedImportStatisticsQuery.getKey = (variables?: GetCachedImportStatisticsQueryVariables) => variables === undefined ? ['GetCachedImportStatistics'] : ['GetCachedImportStatistics', variables]; + +export const useInfiniteGetCachedImportStatisticsQuery = < + TData = InfiniteData, + TError = unknown + >( + variables: GetCachedImportStatisticsQueryVariables, + options: Omit, 'queryKey'> & { queryKey?: UseInfiniteQueryOptions['queryKey'] } + ) => { + + return useInfiniteQuery( + (() => { + const { queryKey: optionsQueryKey, ...restOptions } = options; + return { + queryKey: optionsQueryKey ?? variables === undefined ? ['GetCachedImportStatistics.infinite'] : ['GetCachedImportStatistics.infinite', variables], + queryFn: (metaData) => fetcher(GetCachedImportStatisticsDocument, {...variables, ...(metaData.pageParam ?? {})})(), + ...restOptions + } + })() + )}; + +useInfiniteGetCachedImportStatisticsQuery.getKey = (variables?: GetCachedImportStatisticsQueryVariables) => variables === undefined ? ['GetCachedImportStatistics.infinite'] : ['GetCachedImportStatistics.infinite', variables]; + + +useGetCachedImportStatisticsQuery.fetcher = (variables?: GetCachedImportStatisticsQueryVariables, options?: RequestInit['headers']) => fetcher(GetCachedImportStatisticsDocument, variables, options); + export const StartNewImportDocument = ` mutation StartNewImport($sessionId: String!) { startNewImport(sessionId: $sessionId) { diff --git a/src/client/graphql/queries/import.graphql b/src/client/graphql/queries/import.graphql index c896f96..e8e0206 100644 --- a/src/client/graphql/queries/import.graphql +++ b/src/client/graphql/queries/import.graphql @@ -11,6 +11,21 @@ query GetImportStatistics($directoryPath: String) { } } +query GetCachedImportStatistics { + getCachedImportStatistics { + success + message + stats { + totalLocalFiles + alreadyImported + newFiles + percentageImported + pendingFiles + } + lastUpdated + } +} + mutation StartNewImport($sessionId: String!) { startNewImport(sessionId: $sessionId) { success diff --git a/src/client/store/index.ts b/src/client/store/index.ts index 7e70b6b..aa3c3a0 100644 --- a/src/client/store/index.ts +++ b/src/client/store/index.ts @@ -49,7 +49,9 @@ export const useStore = create((set, get) => ({ getSocket: (namespace = "/") => { const ns = namespace === "/" ? "" : namespace; const existing = get().socketInstances[namespace]; - if (existing?.connected) return existing; + // Return existing socket if it exists, regardless of connection state + // This prevents creating duplicate sockets during connection phase + if (existing) return existing; const sessionId = localStorage.getItem("sessionId"); const socket = io(`${SOCKET_BASE_URI}${ns}`, {