From 4e4a09ee3f71cef03b8d5ecd083e3df17aa42c88 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 27 Jul 2026 13:47:06 -0400 Subject: [PATCH] Dashboard tweaks, add bulk /check-upcs endpoint for import validation --- .../dashboard/acot-server/routes/events.js | 376 +++++++++++------- inventory-server/src/routes/import.js | 70 ++++ .../src/components/dashboard/StatCards.jsx | 16 +- .../dashboard/mobile/StudioMobile.tsx | 1 - .../dashboard/nightboard/NightboardSmall.tsx | 61 +-- 5 files changed, 345 insertions(+), 179 deletions(-) diff --git a/inventory-server/dashboard/acot-server/routes/events.js b/inventory-server/dashboard/acot-server/routes/events.js index 665a423..c295ace 100644 --- a/inventory-server/dashboard/acot-server/routes/events.js +++ b/inventory-server/dashboard/acot-server/routes/events.js @@ -38,26 +38,54 @@ const getImageUrls = (pid, iid = 1) => { }; }; -// Main stats endpoint - replaces /api/klaviyo/events/stats -router.get('/stats', async (req, res) => { - const startTime = Date.now(); - console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`); - - // Set a timeout for the entire operation - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000); - }); - - try { - const mainOperation = async () => { - const { timeRange, startDate, endDate, excludeCherryBox } = req.query; - const excludeCB = parseBoolParam(excludeCherryBox); - console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`); - const { connection, release } = await getDbConnection(); - console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`); - try { +// The DB sits across an SSH tunnel (~190ms per round trip; the queries +// themselves are ~5ms on the server), so /stats response time is dominated by +// round-trip count, not query cost. Two mitigations: +// 1. computeStats runs its queries over several pooled connections (mysql2 +// serializes per connection), capped so concurrent refreshes can't starve +// the 20-slot pool. +// 2. Responses are cached per param-set and served stale-while-revalidate: +// the dashboards poll every 30-60s, so they get the cached payload +// instantly while a single-flight background refresh keeps it current. +const STATS_QUERY_WIDTH = 4; +const STATS_CACHE_TTL = 60 * 1000; // younger than this: serve as-is +const STATS_CACHE_MAX_STALE = 15 * 60 * 1000; // older than this: block on refresh +const statsCache = new Map(); // cacheKey -> { data, timestamp } +const statsInflight = new Map(); // cacheKey -> Promise +const withTimeout = (promise, ms, label) => { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms / 1000} seconds`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; + +// Drain [name, fn] tasks with up to `width` workers, each holding its own +// pooled connection, so wall time is ~ceil(tasks / width) round trips. If a +// task rejects, remaining queued tasks are abandoned and every worker still +// releases its connection (mirrors the old single-connection failure mode). +async function runStatsTasks(tasks, width) { + const results = {}; + const queue = [...tasks]; + const worker = async () => { + const { connection, release } = await getDbConnection(); + try { + let task; + while ((task = queue.shift()) !== undefined) { + results[task[0]] = await task[1](connection); + } + } finally { + release(); + } + }; + await Promise.all(Array.from({ length: Math.min(width, tasks.length) }, worker)); + return results; +} + +async function computeStats(timeRange, startDate, endDate, excludeCB) { const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); + const isSingleDay = ['today', 'yesterday'].includes(timeRange); // Main order stats query (optionally excludes Cherry Box orders) // Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately @@ -75,9 +103,6 @@ router.get('/stats', async (req, res) => { WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} `; - const [mainStats] = await connection.execute(mainStatsQuery, params); - const stats = mainStats[0]; - // Cancelled orders query - uses date_cancelled instead of date_placed // Shows orders cancelled during the selected period, regardless of when they were placed const cancelledQuery = ` @@ -90,9 +115,6 @@ router.get('/stats', async (req, res) => { AND ${whereClause.replace(/date_placed/g, 'date_cancelled')} `; - const [cancelledResult] = await connection.execute(cancelledQuery, params); - const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 }; - // Refunds query (optionally excludes Cherry Box orders) const refundsQuery = ` SELECT @@ -103,8 +125,6 @@ router.get('/stats', async (req, res) => { WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')} `; - const [refundStats] = await connection.execute(refundsQuery, params); - // Shipped orders query - uses date_shipped instead of date_placed // This counts orders that were SHIPPED during the selected period, regardless of when they were placed const shippedQuery = ` @@ -115,9 +135,6 @@ router.get('/stats', async (req, res) => { AND ${whereClause.replace(/date_placed/g, 'date_shipped')} `; - const [shippedResult] = await connection.execute(shippedQuery, params); - const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0); - // Best revenue day query (optionally excludes Cherry Box orders) const bestDayQuery = ` SELECT @@ -131,78 +148,38 @@ router.get('/stats', async (req, res) => { LIMIT 1 `; - const [bestDayResult] = await connection.execute(bestDayQuery, params); - // Peak hour query - uses selected time range for the card value. // date_placed stores Central wall-clock; the office reads the axis in // Eastern, and ET = CT + 1h year-round, so shift before taking HOUR(). - let peakHour = null; - if (['today', 'yesterday'].includes(timeRange)) { - const peakHourQuery = ` - SELECT - HOUR(ADDTIME(date_placed, '01:00:00')) as hour, - COUNT(*) as count - FROM _order - WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} - GROUP BY HOUR(ADDTIME(date_placed, '01:00:00')) - ORDER BY count DESC - LIMIT 1 - `; - - const [peakHourResult] = await connection.execute(peakHourQuery, params); - if (peakHourResult.length > 0) { - const hour = parseInt(peakHourResult[0].hour); - peakHour = { - hour, - count: parseInt(peakHourResult[0].count), - displayHour: DateTime.fromObject({ hour }).toFormat('h a') - }; - } - } + // Only run for single-day ranges (see tasks below). + const peakHourQuery = ` + SELECT + HOUR(ADDTIME(date_placed, '01:00:00')) as hour, + COUNT(*) as count + FROM _order + WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} + GROUP BY HOUR(ADDTIME(date_placed, '01:00:00')) + ORDER BY count DESC + LIMIT 1 + `; // Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days) - // Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour] - let hourlyOrders = null; - if (['today', 'yesterday'].includes(timeRange)) { - // Hourly counts in EASTERN display hours (column stores Central - // wall-clock; ET = CT + 1h year-round). currentHour comes from the same - // expression so the rolling window stays aligned. - const hourlyQuery = ` - SELECT - HOUR(ADDTIME(date_placed, '01:00:00')) as hour, - COUNT(*) as count, - HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour - FROM _order - WHERE order_status > 15 - AND ${getCherryBoxClause(excludeCB)} - AND date_placed >= NOW() - INTERVAL 24 HOUR - GROUP BY HOUR(ADDTIME(date_placed, '01:00:00')) - `; + // Hourly counts in EASTERN display hours (column stores Central + // wall-clock; ET = CT + 1h year-round). currentHour comes from the same + // expression so the rolling window stays aligned. + const hourlyQuery = ` + SELECT + HOUR(ADDTIME(date_placed, '01:00:00')) as hour, + COUNT(*) as count, + HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour + FROM _order + WHERE order_status > 15 + AND ${getCherryBoxClause(excludeCB)} + AND date_placed >= NOW() - INTERVAL 24 HOUR + GROUP BY HOUR(ADDTIME(date_placed, '01:00:00')) + `; - const [hourlyResult] = await connection.execute(hourlyQuery); - // Current hour in Eastern (fallback computes it directly) - const currentHour = hourlyResult.length > 0 - ? parseInt(hourlyResult[0].currentHour) - : DateTime.now().setZone(TIMEZONE).hour; - - // Build map of hour -> count - const hourCounts = {}; - hourlyResult.forEach(row => { - hourCounts[parseInt(row.hour)] = parseInt(row.count); - }); - - // Build array in chronological order starting from (currentHour + 1) which is 24 hours ago - hourlyOrders = []; - for (let i = 0; i < 24; i++) { - const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour - hourlyOrders.push({ - hour, - count: hourCounts[hour] || 0 - }); - } - } - // Brands query - products.company links to product_categories.cat_id for brand name // Only include products that have a brand assigned (INNER JOIN) const brandsQuery = ` @@ -223,8 +200,6 @@ router.get('/stats', async (req, res) => { LIMIT 100 `; - const [brandsResult] = await connection.execute(brandsQuery, params); - // Categories query - uses product_category_index to get category assignments // Only include categories with valid types (no NULL/uncategorized) const categoriesQuery = ` @@ -249,8 +224,6 @@ router.get('/stats', async (req, res) => { LIMIT 100 `; - const [categoriesResult] = await connection.execute(categoriesQuery, params); - // Shipping locations query - uses date_shipped to match shippedCount const shippingQuery = ` SELECT @@ -265,11 +238,6 @@ router.get('/stats', async (req, res) => { GROUP BY ship_country, ship_state, ship_method_selected `; - const [shippingResult] = await connection.execute(shippingQuery, params); - - // Process shipping data - const shippingStats = processShippingData(shippingResult, shippedCount); - // Order value range query (optionally excludes Cherry Box orders) // Excludes $0 orders from min calculation const orderRangeQuery = ` @@ -280,17 +248,78 @@ router.get('/stats', async (req, res) => { WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} `; - const [orderRangeResult] = await connection.execute(orderRangeQuery, params); - + // Run everything in parallel; heavier joins first so they don't tail-end + // the worker queue. + const rows = (sql, p = params) => (conn) => conn.execute(sql, p).then(([result]) => result); + const tasks = [ + ['brands', rows(brandsQuery)], + ['categories', rows(categoriesQuery)], + ['mainStats', rows(mainStatsQuery)], + ['prevPeriod', (conn) => getPreviousPeriodData(conn, timeRange, startDate, endDate, excludeCB)], + ['cancelled', rows(cancelledQuery)], + ['refunds', rows(refundsQuery)], + ['shipped', rows(shippedQuery)], + ['bestDay', rows(bestDayQuery)], + ['shipping', rows(shippingQuery)], + ['orderRange', rows(orderRangeQuery)], + ]; + if (isSingleDay) { + tasks.push(['peakHour', rows(peakHourQuery)]); + tasks.push(['hourly', rows(hourlyQuery, [])]); + } + + const r = await runStatsTasks(tasks, STATS_QUERY_WIDTH); + + const stats = r.mainStats[0]; + const cancelledStats = r.cancelled[0] || { cancelledCount: 0, cancelledTotal: 0 }; + const refundStats = r.refunds; + const shippedCount = parseInt(r.shipped[0]?.shippedCount || 0); + const bestDayResult = r.bestDay; + const brandsResult = r.brands; + const categoriesResult = r.categories; + const orderRangeResult = r.orderRange; + const prevPeriodData = r.prevPeriod; + const shippingStats = processShippingData(r.shipping, shippedCount); + + let peakHour = null; + if (isSingleDay && r.peakHour.length > 0) { + const hour = parseInt(r.peakHour[0].hour); + peakHour = { + hour, + count: parseInt(r.peakHour[0].count), + displayHour: DateTime.fromObject({ hour }).toFormat('h a') + }; + } + + // Returns data ordered chronologically: [24hrs ago, ..., 1hr ago, current hour] + let hourlyOrders = null; + if (isSingleDay) { + // Current hour in Eastern (fallback computes it directly) + const currentHour = r.hourly.length > 0 + ? parseInt(r.hourly[0].currentHour) + : DateTime.now().setZone(TIMEZONE).hour; + + const hourCounts = {}; + r.hourly.forEach(row => { + hourCounts[parseInt(row.hour)] = parseInt(row.count); + }); + + hourlyOrders = []; + for (let i = 0; i < 24; i++) { + const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour + hourlyOrders.push({ + hour, + count: hourCounts[hour] || 0 + }); + } + } + // Calculate period progress for incomplete periods let periodProgress = 100; if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) { periodProgress = calculatePeriodProgress(timeRange); } - - // Previous period comparison data - const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB); - + const response = { timeRange: dateRange, stats: { @@ -384,27 +413,57 @@ router.get('/stats', async (req, res) => { }; return response; - } finally { - // Always release the connection regardless of whether the outer Promise.race - // used our result. If the timeout wins, this IIFE keeps running in the - // background until MySQL responds, then this finally releases. Without it, - // every timed-out request permanently leaks one pool slot. - release(); - } - }; +} - const response = await Promise.race([mainOperation(), timeoutPromise]); +// Main stats endpoint - replaces /api/klaviyo/events/stats +router.get('/stats', async (req, res) => { + const startTime = Date.now(); + const { timeRange, startDate, endDate, excludeCherryBox } = req.query; + const excludeCB = parseBoolParam(excludeCherryBox); + const cacheKey = `${timeRange || ''}|${startDate || ''}|${endDate || ''}|${excludeCB}`; - console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`); - res.json(response); + // Single-flight refresh: concurrent misses for the same key share one + // computation instead of racing multi-connection batches against the pool. + const refresh = () => { + let inflight = statsInflight.get(cacheKey); + if (inflight) return inflight; + inflight = withTimeout(computeStats(timeRange, startDate, endDate, excludeCB), 15000, '[STATS]') + .then((data) => { + statsCache.set(cacheKey, { data, timestamp: Date.now() }); + // Arbitrary custom date ranges would otherwise grow the map forever + if (statsCache.size > 100) { + const oldest = [...statsCache.entries()] + .reduce((a, b) => (a[1].timestamp <= b[1].timestamp ? a : b)); + statsCache.delete(oldest[0]); + } + return data; + }) + .finally(() => statsInflight.delete(cacheKey)); + statsInflight.set(cacheKey, inflight); + return inflight; + }; + try { + const cached = statsCache.get(cacheKey); + const age = cached ? Date.now() - cached.timestamp : Infinity; + + if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) { + refresh().catch((err) => console.error('[STATS] Background refresh failed:', err.message)); + } + if (cached && age < STATS_CACHE_MAX_STALE) { + console.log(`[STATS] Cache hit (${Math.round(age / 1000)}s old) for ${cacheKey}`); + return res.json(cached.data); + } + + const data = await refresh(); + console.log(`[STATS] Computed ${cacheKey} in ${Date.now() - startTime}ms`); + res.json(data); } catch (error) { if (error.message.includes('timeout')) { console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`); } else { console.error('Error in /stats:', error); } - console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`); res.status(500).json({ error: error.message }); } }); @@ -812,23 +871,15 @@ router.get('/products', async (req, res) => { } }); -// Projection endpoint - replaces /api/klaviyo/events/projection -router.get('/projection', async (req, res) => { +// Same stale-while-revalidate treatment as /stats: several tunnel round trips +// per computation (~800ms), polled every 60s by every dashboard. +const projectionCache = new Map(); // cacheKey -> { data, timestamp } +const projectionInflight = new Map(); // cacheKey -> Promise + +async function computeProjection(timeRange, startDate, endDate, excludeCB) { const startTime = Date.now(); - let release; + const { connection, release } = await getDbConnection(); try { - const { timeRange, startDate, endDate, excludeCherryBox } = req.query; - const excludeCB = parseBoolParam(excludeCherryBox); - console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`); - - // Only provide projections for incomplete periods - if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) { - return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' }); - } - - const { connection, release: releaseConn } = await getDbConnection(); - release = releaseConn; - console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`); const now = DateTime.now().setZone(TIMEZONE); @@ -877,15 +928,52 @@ router.get('/projection', async (req, res) => { projection.currentRevenue = parseFloat(current.currentRevenue || 0); projection.currentOrders = parseInt(current.currentOrders || 0); - console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`); - res.json(projection); - - } catch (error) { - console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error); - res.status(500).json({ error: error.message }); + console.log(`[PROJECTION] Computed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`); + return projection; } finally { - // Release connection back to pool - if (release) release(); + release(); + } +} + +// Projection endpoint - replaces /api/klaviyo/events/projection +router.get('/projection', async (req, res) => { + const { timeRange, startDate, endDate, excludeCherryBox } = req.query; + const excludeCB = parseBoolParam(excludeCherryBox); + + // Only provide projections for incomplete periods + if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) { + return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' }); + } + + const cacheKey = `${timeRange}|${excludeCB}`; + const refresh = () => { + let inflight = projectionInflight.get(cacheKey); + if (inflight) return inflight; + inflight = computeProjection(timeRange, startDate, endDate, excludeCB) + .then((data) => { + projectionCache.set(cacheKey, { data, timestamp: Date.now() }); + return data; + }) + .finally(() => projectionInflight.delete(cacheKey)); + projectionInflight.set(cacheKey, inflight); + return inflight; + }; + + try { + const cached = projectionCache.get(cacheKey); + const age = cached ? Date.now() - cached.timestamp : Infinity; + + if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) { + refresh().catch((err) => console.error('[PROJECTION] Background refresh failed:', err.message)); + } + if (cached && age < STATS_CACHE_MAX_STALE) { + return res.json(cached.data); + } + + res.json(await refresh()); + } catch (error) { + console.error('[PROJECTION] Error:', error); + res.status(500).json({ error: error.message }); } }); diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 592d2ff..1eb8397 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1931,6 +1931,76 @@ router.post('/generate-upc', async (req, res) => { } }); +// Bulk "does this UPC already exist" check for the import validator. +// The one-at-a-time alternative is /search-products?q=, but that runs a +// five-column LIKE '%q%' scan across ten joined tables (~6s each) and every call +// queues on the single shared connection from getDbConnection() — a 20-product +// batch takes minutes. This answers the whole batch with one indexed IN() lookup +// on idx_upc. Matches /check-upc-and-generate-sku's semantics: `upc` only, not upc_2. +const MAX_UPCS_PER_CHECK = 500; + +router.get('/check-upcs', async (req, res) => { + const { upcs } = req.query; + + if (!upcs) { + return res.status(400).json({ error: 'upcs query parameter is required' }); + } + + // Dedupe and keep only well-formed barcodes — anything else can't match the + // column anyway, and dropping it keeps the IN() list tight. + const upcList = [...new Set( + String(upcs).split(',').map(s => s.trim()).filter(s => /^\d{8,14}$/.test(s)) + )]; + + if (upcList.length === 0) { + return res.json({ found: {}, checked: 0 }); + } + + if (upcList.length > MAX_UPCS_PER_CHECK) { + return res.status(400).json({ + error: `Too many UPCs (${upcList.length}); send at most ${MAX_UPCS_PER_CHECK} per request` + }); + } + + try { + const { connection } = await getDbConnection(); + + const placeholders = upcList.map(() => '?').join(','); + const [rows] = await connection.query( + `SELECT pid, upc, itemnumber, description FROM products WHERE upc IN (${placeholders})`, + upcList + ); + + // idx_upc is non-unique, so a UPC can legitimately hit more than one product. + // Report the lowest pid (the original) and say how many share it. + const found = {}; + for (const row of rows) { + const key = String(row.upc); + const existing = found[key]; + if (!existing) { + found[key] = { + pid: row.pid, + itemNumber: row.itemnumber, + title: row.description, + matchCount: 1 + }; + continue; + } + existing.matchCount += 1; + if (row.pid < existing.pid) { + existing.pid = row.pid; + existing.itemNumber = row.itemnumber; + existing.title = row.description; + } + } + + return res.json({ found, checked: upcList.length }); + } catch (error) { + console.error('Error checking UPCs:', error); + return res.status(500).json({ error: 'Failed to check UPCs', details: error.message }); + } +}); + // Endpoint to check UPC and generate item number router.get('/check-upc-and-generate-sku', async (req, res) => { const { upc, supplierId } = req.query; diff --git a/inventory/src/components/dashboard/StatCards.jsx b/inventory/src/components/dashboard/StatCards.jsx index 0ed0935..186663a 100644 --- a/inventory/src/components/dashboard/StatCards.jsx +++ b/inventory/src/components/dashboard/StatCards.jsx @@ -1268,14 +1268,18 @@ const StatCards = ({ let isMounted = true; const loadProjection = async () => { - if (!stats?.periodProgress || stats.periodProgress >= 100) return; + // The projection endpoint only serves these always-incomplete presets + // (it returns method:'none' for everything else), so key off timeRange + // instead of waiting for stats — this fires in parallel with the + // stats request rather than chained after it. + if (!["today", "thisWeek", "thisMonth"].includes(timeRange)) { + setProjection(null); + return; + } try { setProjectionLoading(true); - const params = - timeRange === "custom" ? { startDate, endDate } : { timeRange }; - - const response = await acotService.getProjection(params); + const response = await acotService.getProjection({ timeRange }); if (!isMounted) return; setProjection(response); @@ -1292,7 +1296,7 @@ const StatCards = ({ return () => { isMounted = false; }; - }, [timeRange, startDate, endDate, stats?.periodProgress]); + }, [timeRange]); // Auto-refresh for 'today' view useEffect(() => { diff --git a/inventory/src/components/dashboard/mobile/StudioMobile.tsx b/inventory/src/components/dashboard/mobile/StudioMobile.tsx index e0d3439..1dda8cb 100644 --- a/inventory/src/components/dashboard/mobile/StudioMobile.tsx +++ b/inventory/src/components/dashboard/mobile/StudioMobile.tsx @@ -396,7 +396,6 @@ const StudioMobile = () => { queryKey: ["mobile-projection-today"], queryFn: async () => (await acotService.getProjection({ timeRange: "today" })) as Projection, - enabled: stats != null && stats.periodProgress < 100, refetchInterval: 60_000, }); diff --git a/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx b/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx index 7d3e6ff..d369d47 100644 --- a/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx +++ b/inventory/src/components/dashboard/nightboard/NightboardSmall.tsx @@ -71,7 +71,7 @@ const trendPct = (cur: number, prev: number) => // ── tiny presentational atoms ──────────────────────────────────────────────── const Lbl = ({ children }: { children: React.ReactNode }) => (
{children} @@ -197,7 +197,7 @@ const Clock = () => { return () => clearInterval(t); }, []); return ( - + {now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })} {" · "} @@ -223,7 +223,6 @@ const NightboardSmall = () => { queryKey: ["nightboard-projection-today"], queryFn: async () => (await acotService.getProjection({ timeRange: "today" })) as Projection, - enabled: stats != null && stats.periodProgress < 100, refetchInterval: 60_000, }); @@ -274,7 +273,6 @@ const NightboardSmall = () => { queryKey: ["nightboard-projection-30d"], queryFn: async () => (await acotService.getProjection({ timeRange: "last30days" })) as Projection, - enabled: sum30 != null && sum30.periodProgress < 100, refetchInterval: 300_000, }); @@ -422,6 +420,13 @@ const NightboardSmall = () => { (stats && stats.periodProgress > 0 ? Math.round(stats.orderCount / (stats.periodProgress / 100)) : null); + const ordersTrend = + stats && stats.prevPeriodOrders > 0 + ? trendPct( + inProgress ? projOrders ?? stats.orderCount : stats.orderCount, + stats.prevPeriodOrders + ) + : null; const rev30Current = sum30 && sum30.periodProgress < 100 @@ -549,7 +554,7 @@ const NightboardSmall = () => { {/* top line */}
A Cherry On Top @@ -567,42 +572,42 @@ const NightboardSmall = () => {
Revenue today
{fmtMoney(stats?.revenue)}
-
+
{inProgress && projRevenue != null && ( <> proj {fmtMoney(projRevenue)} {" · "} )} - {revTrend != null && "vs last week"} + {revTrend != null && "vs yesterday"}
Orders -
+
{stats?.orderCount?.toLocaleString() ?? "—"}
-
+
{projOrders != null && inProgress && ( <> proj {projOrders} {" · "} )} - {stats?.itemCount != null ? `${fmtCount(stats.itemCount)} items` : ""} + {ordersTrend != null && "vs yesterday"}
Avg order -
+
{stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
-
+
{stats?.averageItemsPerOrder != null ? `${stats.averageItemsPerOrder.toFixed(1)} items / order` : ""} @@ -610,7 +615,7 @@ const NightboardSmall = () => {
On site now -
+
{ {realtime?.last5MinUsers ?? "—"}
-
+
{realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
@@ -630,7 +635,7 @@ const NightboardSmall = () => {
Last 30 days - + Revenue {fmtCompact(sum30?.revenue)}{" "} {" · "} @@ -652,17 +657,17 @@ const NightboardSmall = () => { tickFormatter={(v: string) => new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" }) } - tick={{ fill: NB.mut, fontSize: 14 }} + tick={{ fill: NB.mut, fontSize: 16 }} tickLine={false} axisLine={false} interval={5} /> fmtCompact(v)} - tick={{ fill: NB.mut, fontSize: 14 }} + tick={{ fill: NB.mut, fontSize: 16 }} tickLine={false} axisLine={false} - width={52} + width={58} /> { const row = payload[0].payload as { date: string; total: number }; return (
@@ -713,7 +718,7 @@ const NightboardSmall = () => { /> ))}
-
+
{activePhases.slice(0, 5).map((p) => ( { }} >
{cell.label}
-
+
{cell.value}
-
+
{cell.sub}
@@ -811,11 +816,11 @@ const NightboardSmall = () => { return (
@@ -834,8 +839,8 @@ const NightboardSmall = () => { )}
-
{name || "—"}
-
+
{name || "—"}
+
{detail}