From 9943c11571fa67cf54c957425a3455b9031bc112 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 19:27:08 -0400 Subject: [PATCH] Add progress overlay to AI Catalog Price Check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a modal overlay with animated progress bar and batch-aware status messages while Claude is analyzing. Progress animates in two phases: ease-out to ~85% over the estimated duration, then a slow crawl to 99% so it never falsely "completes" before the server responds. - Overlay driven by CSS (hidden until .active added by JS) - Item count passed from controller as data-item-count on the run button - Batch count derived from item count (batches of 25) to show accurate "Analyzing batch N of M…" messages Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/CatalogItemsController.cs | 3 + .../Views/CatalogItems/AiPriceCheck.cshtml | 55 ++++++++++++- wwwroot/js/catalog-price-check.js | 77 ++++++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs index b9e92b3..e4a6aa4 100644 --- a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs +++ b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs @@ -938,6 +938,9 @@ namespace PowderCoating.Web.Controllers r => r.CompanyId == currentUser.CompanyId); var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault(); + var activeItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive); + ViewBag.ActiveItemCount = activeItems.Count(); + CatalogPriceCheckReportDto? dto = null; if (report != null) { diff --git a/src/PowderCoating.Web/Views/CatalogItems/AiPriceCheck.cshtml b/src/PowderCoating.Web/Views/CatalogItems/AiPriceCheck.cshtml index 3d12332..6857157 100644 --- a/src/PowderCoating.Web/Views/CatalogItems/AiPriceCheck.cshtml +++ b/src/PowderCoating.Web/Views/CatalogItems/AiPriceCheck.cshtml @@ -35,16 +35,69 @@ .summary-stat .num { font-size: 2rem; font-weight: 700; line-height: 1; } .summary-stat .lbl { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; } .run-btn-wrap { min-height: 3rem; } + + /* Progress overlay */ + #price-check-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + z-index: 1050; + align-items: center; + justify-content: center; + } + #price-check-overlay.active { display: flex; } + .progress-card { + background: #fff; + border-radius: 1rem; + padding: 2.5rem 2rem; + width: 100%; + max-width: 440px; + text-align: center; + box-shadow: 0 20px 60px rgba(0,0,0,0.25); + } + .progress-card .icon { font-size: 3rem; color: #4f46e5; margin-bottom: 1rem; } + .progress-card h5 { font-weight: 700; margin-bottom: 0.25rem; } + .progress-card .status-msg { font-size: 0.9rem; color: #64748b; min-height: 1.4em; margin-bottom: 1.25rem; } + .progress-bar-track { + height: 8px; + background: #e2e8f0; + border-radius: 99px; + overflow: hidden; + margin-bottom: 0.75rem; + } + .progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #4f46e5, #7c3aed); + border-radius: 99px; + width: 0%; + transition: width 0.6s ease; + } + .progress-card .pct-label { font-size: 0.8rem; color: #94a3b8; } } + +
+
+
+
Analyzing your catalog
+

Preparing items…

+
+
+
+
0% complete
+
+
+
Back to Catalog
@Html.AntiForgeryToken() - diff --git a/wwwroot/js/catalog-price-check.js b/wwwroot/js/catalog-price-check.js index 22441b7..de62a8b 100644 --- a/wwwroot/js/catalog-price-check.js +++ b/wwwroot/js/catalog-price-check.js @@ -3,10 +3,83 @@ var form = document.getElementById('runForm'); var btn = document.getElementById('runBtn'); - if (!form || !btn) return; + var overlay = document.getElementById('price-check-overlay'); + var bar = document.getElementById('overlay-bar'); + var pctLabel = document.getElementById('overlay-pct'); + var statusMsg = document.getElementById('overlay-status'); + + if (!form || !btn || !overlay) return; + + // Estimate total seconds based on item count (roughly 12s per batch of 25, min 15s). + function estimateDuration(itemCount) { + var batches = Math.max(1, Math.ceil(itemCount / 25)); + return Math.max(15, batches * 12); + } + + // Messages keyed to approximate progress milestones (0–100). + function messageAt(pct, batchCount) { + if (pct < 10) return 'Loading catalog items…'; + if (pct < 20) return 'Sending items to Claude…'; + if (batchCount <= 1) { + if (pct < 75) return 'Analyzing your catalog with AI…'; + if (pct < 92) return 'Reviewing pricing data…'; + } else { + var batchDone = Math.floor((pct / 100) * batchCount); + if (batchDone < batchCount) { + return 'Analyzing batch ' + (batchDone + 1) + ' of ' + batchCount + '…'; + } + } + if (pct < 97) return 'Compiling results…'; + return 'Almost done…'; + } + + function setProgress(pct, batchCount) { + var clamped = Math.min(99, Math.max(0, pct)); + bar.style.width = clamped + '%'; + pctLabel.textContent = Math.round(clamped); + statusMsg.textContent = messageAt(clamped, batchCount); + } form.addEventListener('submit', function () { btn.disabled = true; - btn.innerHTML = 'Analyzing with AI…'; + + var itemCount = parseInt(btn.getAttribute('data-item-count') || '0', 10); + var batchCount = Math.max(1, Math.ceil(itemCount / 25)); + var totalSecs = estimateDuration(itemCount); + + overlay.classList.add('active'); + setProgress(0, batchCount); + + // Animate progress: fast to ~85%, then slow crawl toward 99%. + // Uses two easing phases so it never "finishes" before the server responds. + var start = Date.now(); + var phase1End = totalSecs * 0.80 * 1000; // 80% of time → 85% progress + var raf; + + function tick() { + var elapsed = Date.now() - start; + var pct; + + if (elapsed < phase1End) { + // Phase 1: ease-out from 0 → 85 + var t = elapsed / phase1End; + pct = 85 * (1 - Math.pow(1 - t, 2)); + } else { + // Phase 2: slow crawl 85 → 99 (never quite reaches 99) + var t2 = (elapsed - phase1End) / (totalSecs * 1000); + pct = 85 + 14 * (1 - Math.exp(-t2 * 1.5)); + } + + setProgress(pct, batchCount); + raf = requestAnimationFrame(tick); + } + + raf = requestAnimationFrame(tick); + + // Safety: if the server takes much longer than estimated, keep the overlay up. + // The page navigation itself will tear it down. + window.addEventListener('pagehide', function () { + cancelAnimationFrame(raf); + }); }); }());