diff --git a/app/routes/app._index.jsx b/app/routes/app._index.jsx index ee52a6c..ef52e68 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -118,6 +118,219 @@ function FieldState({ fields }) { ); } +function formatCounter(done, total) { + if (Number.isFinite(done) && Number.isFinite(total) && total > 0) { + return `${done} / ${total}`; + } + + if (Number.isFinite(done)) { + return String(done); + } + + return "-"; +} + +function getStageState(job, bucket) { + const activeStepMap = { + download: "downloadImages", + watermark: "watermarkImages", + upload: "uploadImagesToShopifyFiles", + convert: "convertToShopifyReady", + shopify: "upsertToShopify", + details: "fetchWebsiteData", + }; + + if (job?.status === "error") { + return "error"; + } + + if (job?.status === "done") { + return "done"; + } + + if (job?.step === activeStepMap[bucket]) { + return "active"; + } + + const stats = job?.liveStats || {}; + if (stats[bucket]?.total || stats[bucket]?.done || stats.stageSummaries?.[activeStepMap[bucket]]) { + return "done"; + } + + return "pending"; +} + +const STEP_LABELS = { + queued: "Queued", + starting: "Preparing import", + fetchWebsiteData: "Collecting KYT catalog data", + downloadImages: "Downloading product images", + watermarkImages: "Applying watermarks", + uploadImagesToShopifyFiles: "Uploading images to Shopify", + convertToShopifyReady: "Preparing Shopify product data", + upsertToShopify: "Creating or updating products", + completed: "Import completed", +}; + +function getStepLabel(step) { + return STEP_LABELS[step] || step || "-"; +} + +function formatDateTime(value) { + if (!value) { + return "-"; + } + + return new Date(value).toLocaleString(); +} + +function formatElapsed(startedAt, updatedAt) { + if (!startedAt) { + return "-"; + } + + const startMs = new Date(startedAt).getTime(); + const endMs = updatedAt ? new Date(updatedAt).getTime() : Date.now(); + + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) { + return "-"; + } + + const seconds = Math.floor((endMs - startMs) / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${remainingSeconds}s`; + } + + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + + return `${remainingSeconds}s`; +} + +function parseRecentActivity(entry) { + const rawLine = String(entry?.line || ""); + const line = rawLine.replace(/^\[(log|info|warn|error)\]\s*/i, "").trim(); + + const watermarkMatch = line.match(/^\[WATERMARK\]\s+(\d+)\/(\d+)\s+processed/i); + if (watermarkMatch) { + return { + title: "Watermark progress updated", + detail: `${watermarkMatch[1]} of ${watermarkMatch[2]} images have been watermarked.`, + tone: "progress", + }; + } + + const imageDownloadMatch = line.match(/^\[IMAGES\]\s+(\d+)\/(\d+)\s+processed/i); + if (imageDownloadMatch) { + return { + title: "Image download progress updated", + detail: `${imageDownloadMatch[1]} of ${imageDownloadMatch[2]} product images have been downloaded.`, + tone: "progress", + }; + } + + const uploadProgressMatch = line.match(/^\[IMG-UPLOAD\]\s+(\d+)\/(\d+)\s+processed\s+\|\s+uploaded=(\d+)\s+skipped=(\d+)\s+failed=(\d+)/i); + if (uploadProgressMatch) { + return { + title: "Shopify image upload progress updated", + detail: `${uploadProgressMatch[3]} of ${uploadProgressMatch[2]} images are ready in Shopify files. ${uploadProgressMatch[4]} skipped and ${uploadProgressMatch[5]} failed so far.`, + tone: "progress", + }; + } + + const uploadOkMatch = line.match(/^\[IMG-UPLOAD-OK\]\s+(.+?)\s+\|\s+(.+?)\s+\|\s+fileId=(.+?)\s+\|\s+status=([A-Z]+)/i); + if (uploadOkMatch) { + return { + title: "Image uploaded to Shopify", + detail: `${uploadOkMatch[2]} is now in Shopify files with status ${uploadOkMatch[4]}.`, + tone: "success", + }; + } + + const uploadSkipMatch = line.match(/^\[IMG-UPLOAD-SKIP\]\s+(.+?)\s+\|\s+(.+?)\s+\|\s+(.+)/i); + if (uploadSkipMatch) { + return { + title: "Image reuse detected", + detail: `${uploadSkipMatch[2]} was already available, so upload was skipped.`, + tone: "neutral", + }; + } + + const uploadFailMatch = line.match(/^\[IMG-UPLOAD-FAIL\]\s+(.+?)\s+\|\s+(.+?)\s+->\s+(.+)/i); + if (uploadFailMatch) { + return { + title: "Image upload needs attention", + detail: `${uploadFailMatch[1]} could not be uploaded. ${uploadFailMatch[3]}`, + tone: "error", + }; + } + + const shopifyProgressMatch = line.match(/^\[SHOPIFY\]\s+(\d+)\/(\d+)\s+processed\s+\|\s+created=(\d+)\s+updated=(\d+)\s+failed=(\d+)/i); + if (shopifyProgressMatch) { + return { + title: "Product sync progress updated", + detail: `${shopifyProgressMatch[1]} of ${shopifyProgressMatch[2]} products processed. ${shopifyProgressMatch[3]} created, ${shopifyProgressMatch[4]} updated, ${shopifyProgressMatch[5]} failed.`, + tone: "progress", + }; + } + + const shopifyFailMatch = line.match(/^\[SHOPIFY-FAIL\]\s+(.+?)\s+->\s+(.+)/i); + if (shopifyFailMatch) { + return { + title: "Product sync needs attention", + detail: `${shopifyFailMatch[1]} could not be synced. ${shopifyFailMatch[2]}`, + tone: "error", + }; + } + + const detailProgressMatch = line.match(/^\[DETAIL\]\s+(\d+)\/(\d+)\s+completed/i); + if (detailProgressMatch) { + return { + title: "Product details collected", + detail: `${detailProgressMatch[1]} of ${detailProgressMatch[2]} products now have detailed catalog data.`, + tone: "progress", + }; + } + + const imageFailMatch = line.match(/^\[IMAGE-FAIL\]\s+(.+?)\s+\|\s+(.+?)\s+->\s+(.+)/i); + if (imageFailMatch) { + return { + title: "Image download needs attention", + detail: `${imageFailMatch[1]} has a download issue. ${imageFailMatch[3]}`, + tone: "error", + }; + } + + const watermarkFailMatch = line.match(/^\[WATERMARK-FAIL\]\s+(.+?)\s+->\s+(.+)/i); + if (watermarkFailMatch) { + return { + title: "Watermark step needs attention", + detail: `${watermarkFailMatch[1]} could not be watermarked. ${watermarkFailMatch[2]}`, + tone: "error", + }; + } + + const pipelineStepMatch = line.match(/^\[PIPELINE\s+(\d+)\/(\d+)\]\s+(.+)/i); + if (pipelineStepMatch) { + return { + title: `Pipeline step ${pipelineStepMatch[1]} of ${pipelineStepMatch[2]}`, + detail: pipelineStepMatch[3], + tone: "neutral", + }; + } + + return { + title: "Import activity updated", + detail: line, + tone: "neutral", + }; +} + function JobSummary({ job }) { if (!job) { return ( @@ -136,6 +349,154 @@ function JobSummary({ job }) { Math.round(((job.stepIndex || 0) / (job.totalSteps || 6)) * 100), ), ); + const liveStats = job.liveStats || {}; + const recentLogs = Array.isArray(job.logs) + ? job.logs.slice(-8).reverse().map((entry) => ({ + ...entry, + parsed: parseRecentActivity(entry), + })) + : []; + const summarySteps = job.summary?.steps || {}; + const downloadStats = liveStats.download || {}; + const watermarkStats = liveStats.watermark || {}; + const uploadStats = liveStats.upload || {}; + const shopifyStats = liveStats.shopify || {}; + const detailStats = liveStats.details || {}; + const activeStepLabel = job.detail || "Import running"; + const currentStepLabel = getStepLabel(job.step); + const analyticsCards = [ + { + key: "download", + label: "Downloaded", + value: formatCounter( + downloadStats.done ?? summarySteps.downloadImages?.downloaded, + downloadStats.total ?? summarySteps.downloadImages?.totalImagesFound, + ), + meta: `${downloadStats.skipped ?? summarySteps.downloadImages?.skipped ?? 0} skipped • ${downloadStats.failed ?? summarySteps.downloadImages?.failed ?? 0} failed`, + tone: "sunrise", + }, + { + key: "watermark", + label: "Watermarked", + value: formatCounter( + watermarkStats.done ?? summarySteps.watermarkImages?.processed, + watermarkStats.total ?? summarySteps.watermarkImages?.totalImagesFound, + ), + meta: `${watermarkStats.skipped ?? summarySteps.watermarkImages?.skipped ?? summarySteps.watermarkImages?.skippedCount ?? 0} skipped • ${watermarkStats.failed ?? summarySteps.watermarkImages?.failed ?? 0} failed`, + tone: "ice", + }, + { + key: "upload", + label: "Uploaded", + value: formatCounter( + uploadStats.uploaded ?? summarySteps.uploadImagesToShopifyFiles?.uploaded, + uploadStats.total ?? summarySteps.uploadImagesToShopifyFiles?.totalTasks, + ), + meta: `${uploadStats.skipped ?? summarySteps.uploadImagesToShopifyFiles?.skipped ?? 0} skipped • ${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} failed`, + tone: "mint", + }, + { + key: "shopify", + label: "Products synced", + value: formatCounter( + shopifyStats.done ?? summarySteps.upsertToShopify?.processed, + shopifyStats.total ?? summarySteps.upsertToShopify?.total, + ), + meta: `${shopifyStats.created ?? summarySteps.upsertToShopify?.created ?? 0} created • ${shopifyStats.updated ?? summarySteps.upsertToShopify?.updated ?? 0} updated`, + tone: "navy", + }, + ].filter((card) => card.value !== "-"); + const highlightCards = [ + { + key: "activity", + label: "Current activity", + value: activeStepLabel, + meta: `Step ${job.stepIndex || 0} of ${job.totalSteps || 6}`, + }, + { + key: "details", + label: "Product details", + value: formatCounter( + detailStats.done ?? summarySteps.fetchWebsiteData?.analysis?.detailFetchedNow, + detailStats.total ?? summarySteps.fetchWebsiteData?.analysis?.totalProductsUnique, + ), + meta: `${summarySteps.fetchWebsiteData?.analysis?.cachedDetailReused ?? 0} cached reused`, + }, + { + key: "ready", + label: "Images ready in Shopify", + value: String( + liveStats.uploadedOk ?? + summarySteps.uploadImagesToShopifyFiles?.uploaded ?? + 0, + ), + meta: `${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} upload issues`, + }, + { + key: "success", + label: "Sync success rate", + value: shopifyStats.successRate + ? `${shopifyStats.successRate}%` + : summarySteps.upsertToShopify?.successRate + ? `${summarySteps.upsertToShopify.successRate}%` + : job.status === "done" + ? "100%" + : "Running", + meta: job.status === "error" ? "Needs attention" : "Based on product sync stage", + }, + ]; + const stageItems = [ + { + key: "details", + title: "Fetch product details", + state: getStageState(job, "details"), + value: formatCounter( + detailStats.done ?? summarySteps.fetchWebsiteData?.analysis?.detailFetchedNow, + detailStats.total ?? summarySteps.fetchWebsiteData?.analysis?.totalProductsUnique, + ), + meta: `${summarySteps.fetchWebsiteData?.analysis?.cachedDetailReused ?? 0} reused from cache`, + }, + { + key: "download", + title: "Download product images", + state: getStageState(job, "download"), + value: formatCounter( + downloadStats.done ?? summarySteps.downloadImages?.downloaded, + downloadStats.total ?? summarySteps.downloadImages?.totalImagesFound, + ), + meta: `${downloadStats.failed ?? summarySteps.downloadImages?.failed ?? 0} failed`, + }, + { + key: "watermark", + title: "Apply watermark", + state: getStageState(job, "watermark"), + value: formatCounter( + watermarkStats.done ?? summarySteps.watermarkImages?.processed, + watermarkStats.total ?? summarySteps.watermarkImages?.totalImagesFound, + ), + meta: `${watermarkStats.failed ?? summarySteps.watermarkImages?.failed ?? 0} failed`, + }, + { + key: "upload", + title: "Upload images to Shopify", + state: getStageState(job, "upload"), + value: formatCounter( + uploadStats.uploaded ?? summarySteps.uploadImagesToShopifyFiles?.uploaded, + uploadStats.total ?? summarySteps.uploadImagesToShopifyFiles?.totalTasks, + ), + meta: `${uploadStats.skipped ?? summarySteps.uploadImagesToShopifyFiles?.skipped ?? 0} skipped • ${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} failed`, + }, + { + key: "shopify", + title: "Create or update products", + state: getStageState(job, "shopify"), + value: formatCounter( + shopifyStats.done ?? summarySteps.upsertToShopify?.processed, + shopifyStats.total ?? summarySteps.upsertToShopify?.total, + ), + meta: `${shopifyStats.created ?? summarySteps.upsertToShopify?.created ?? 0} created • ${shopifyStats.updated ?? summarySteps.upsertToShopify?.updated ?? 0} updated`, + }, + ]; return (
{stage.meta}
+{job.detail || "-"}
{entry.parsed?.detail || entry.line}
+{connectionMessage}
-Connect your store first, then start the import.
+{connectionMessage}
Your store is ready. You can start importing products now.
-{connectionMessage}
)}