Dashboard tweaks, add bulk /check-upcs endpoint for import validation
This commit is contained in:
@@ -38,26 +38,54 @@ const getImageUrls = (pid, iid = 1) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main stats endpoint - replaces /api/klaviyo/events/stats
|
// The DB sits across an SSH tunnel (~190ms per round trip; the queries
|
||||||
router.get('/stats', async (req, res) => {
|
// themselves are ~5ms on the server), so /stats response time is dominated by
|
||||||
const startTime = Date.now();
|
// round-trip count, not query cost. Two mitigations:
|
||||||
console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`);
|
// 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<response>
|
||||||
|
|
||||||
// Set a timeout for the entire operation
|
const withTimeout = (promise, ms, label) => {
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
let timer;
|
||||||
setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000);
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
// Drain [name, fn] tasks with up to `width` workers, each holding its own
|
||||||
const mainOperation = async () => {
|
// pooled connection, so wall time is ~ceil(tasks / width) round trips. If a
|
||||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
// task rejects, remaining queued tasks are abandoned and every worker still
|
||||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
// releases its connection (mirrors the old single-connection failure mode).
|
||||||
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
async function runStatsTasks(tasks, width) {
|
||||||
|
const results = {};
|
||||||
|
const queue = [...tasks];
|
||||||
|
const worker = async () => {
|
||||||
const { connection, release } = await getDbConnection();
|
const { connection, release } = await getDbConnection();
|
||||||
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
|
||||||
try {
|
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 { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
const isSingleDay = ['today', 'yesterday'].includes(timeRange);
|
||||||
|
|
||||||
// Main order stats query (optionally excludes Cherry Box orders)
|
// Main order stats query (optionally excludes Cherry Box orders)
|
||||||
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
|
// 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}
|
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
|
// Cancelled orders query - uses date_cancelled instead of date_placed
|
||||||
// Shows orders cancelled during the selected period, regardless of when they were placed
|
// Shows orders cancelled during the selected period, regardless of when they were placed
|
||||||
const cancelledQuery = `
|
const cancelledQuery = `
|
||||||
@@ -90,9 +115,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
|
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)
|
// Refunds query (optionally excludes Cherry Box orders)
|
||||||
const refundsQuery = `
|
const refundsQuery = `
|
||||||
SELECT
|
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')}
|
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
|
// 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
|
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
|
||||||
const shippedQuery = `
|
const shippedQuery = `
|
||||||
@@ -115,9 +135,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
|
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)
|
// Best revenue day query (optionally excludes Cherry Box orders)
|
||||||
const bestDayQuery = `
|
const bestDayQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -131,13 +148,10 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
|
||||||
|
|
||||||
// Peak hour query - uses selected time range for the card value.
|
// Peak hour query - uses selected time range for the card value.
|
||||||
// date_placed stores Central wall-clock; the office reads the axis in
|
// date_placed stores Central wall-clock; the office reads the axis in
|
||||||
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
|
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
|
||||||
let peakHour = null;
|
// Only run for single-day ranges (see tasks below).
|
||||||
if (['today', 'yesterday'].includes(timeRange)) {
|
|
||||||
const peakHourQuery = `
|
const peakHourQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||||
@@ -149,21 +163,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 1
|
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')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
|
// 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
|
// Hourly counts in EASTERN display hours (column stores Central
|
||||||
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
||||||
// expression so the rolling window stays aligned.
|
// expression so the rolling window stays aligned.
|
||||||
@@ -179,29 +179,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
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
|
// Brands query - products.company links to product_categories.cat_id for brand name
|
||||||
// Only include products that have a brand assigned (INNER JOIN)
|
// Only include products that have a brand assigned (INNER JOIN)
|
||||||
@@ -223,8 +200,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 100
|
LIMIT 100
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
|
||||||
|
|
||||||
// Categories query - uses product_category_index to get category assignments
|
// Categories query - uses product_category_index to get category assignments
|
||||||
// Only include categories with valid types (no NULL/uncategorized)
|
// Only include categories with valid types (no NULL/uncategorized)
|
||||||
const categoriesQuery = `
|
const categoriesQuery = `
|
||||||
@@ -249,8 +224,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 100
|
LIMIT 100
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
|
||||||
|
|
||||||
// Shipping locations query - uses date_shipped to match shippedCount
|
// Shipping locations query - uses date_shipped to match shippedCount
|
||||||
const shippingQuery = `
|
const shippingQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -265,11 +238,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
GROUP BY ship_country, ship_state, ship_method_selected
|
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)
|
// Order value range query (optionally excludes Cherry Box orders)
|
||||||
// Excludes $0 orders from min calculation
|
// Excludes $0 orders from min calculation
|
||||||
const orderRangeQuery = `
|
const orderRangeQuery = `
|
||||||
@@ -280,7 +248,71 @@ router.get('/stats', async (req, res) => {
|
|||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
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
|
// Calculate period progress for incomplete periods
|
||||||
let periodProgress = 100;
|
let periodProgress = 100;
|
||||||
@@ -288,9 +320,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
periodProgress = calculatePeriodProgress(timeRange);
|
periodProgress = calculatePeriodProgress(timeRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previous period comparison data
|
|
||||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
timeRange: dateRange,
|
timeRange: dateRange,
|
||||||
stats: {
|
stats: {
|
||||||
@@ -384,27 +413,57 @@ router.get('/stats', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
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
|
// Main stats endpoint - replaces /api/klaviyo/events/stats
|
||||||
// background until MySQL responds, then this finally releases. Without it,
|
router.get('/stats', async (req, res) => {
|
||||||
// every timed-out request permanently leaks one pool slot.
|
const startTime = Date.now();
|
||||||
release();
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
|
const cacheKey = `${timeRange || ''}|${startDate || ''}|${endDate || ''}|${excludeCB}`;
|
||||||
|
|
||||||
|
// 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await Promise.race([mainOperation(), timeoutPromise]);
|
try {
|
||||||
|
const cached = statsCache.get(cacheKey);
|
||||||
|
const age = cached ? Date.now() - cached.timestamp : Infinity;
|
||||||
|
|
||||||
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`);
|
if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) {
|
||||||
res.json(response);
|
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) {
|
} catch (error) {
|
||||||
if (error.message.includes('timeout')) {
|
if (error.message.includes('timeout')) {
|
||||||
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
} else {
|
} else {
|
||||||
console.error('Error in /stats:', error);
|
console.error('Error in /stats:', error);
|
||||||
}
|
}
|
||||||
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
|
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -812,23 +871,15 @@ router.get('/products', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Projection endpoint - replaces /api/klaviyo/events/projection
|
// Same stale-while-revalidate treatment as /stats: several tunnel round trips
|
||||||
router.get('/projection', async (req, res) => {
|
// per computation (~800ms), polled every 60s by every dashboard.
|
||||||
|
const projectionCache = new Map(); // cacheKey -> { data, timestamp }
|
||||||
|
const projectionInflight = new Map(); // cacheKey -> Promise<data>
|
||||||
|
|
||||||
|
async function computeProjection(timeRange, startDate, endDate, excludeCB) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let release;
|
const { connection, release } = await getDbConnection();
|
||||||
try {
|
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);
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
|
||||||
@@ -877,15 +928,52 @@ router.get('/projection', async (req, res) => {
|
|||||||
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
|
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
|
||||||
projection.currentOrders = parseInt(current.currentOrders || 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)}`);
|
console.log(`[PROJECTION] Computed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
|
||||||
res.json(projection);
|
return projection;
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
} finally {
|
} finally {
|
||||||
// Release connection back to pool
|
release();
|
||||||
if (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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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=<upc>, 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
|
// Endpoint to check UPC and generate item number
|
||||||
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
||||||
const { upc, supplierId } = req.query;
|
const { upc, supplierId } = req.query;
|
||||||
|
|||||||
@@ -1268,14 +1268,18 @@ const StatCards = ({
|
|||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadProjection = async () => {
|
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 {
|
try {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
const params =
|
const response = await acotService.getProjection({ timeRange });
|
||||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
|
||||||
|
|
||||||
const response = await acotService.getProjection(params);
|
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setProjection(response);
|
setProjection(response);
|
||||||
@@ -1292,7 +1296,7 @@ const StatCards = ({
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [timeRange, startDate, endDate, stats?.periodProgress]);
|
}, [timeRange]);
|
||||||
|
|
||||||
// Auto-refresh for 'today' view
|
// Auto-refresh for 'today' view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -396,7 +396,6 @@ const StudioMobile = () => {
|
|||||||
queryKey: ["mobile-projection-today"],
|
queryKey: ["mobile-projection-today"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await acotService.getProjection({ timeRange: "today" })) as Projection,
|
(await acotService.getProjection({ timeRange: "today" })) as Projection,
|
||||||
enabled: stats != null && stats.periodProgress < 100,
|
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const trendPct = (cur: number, prev: number) =>
|
|||||||
// ── tiny presentational atoms ────────────────────────────────────────────────
|
// ── tiny presentational atoms ────────────────────────────────────────────────
|
||||||
const Lbl = ({ children }: { children: React.ReactNode }) => (
|
const Lbl = ({ children }: { children: React.ReactNode }) => (
|
||||||
<div
|
<div
|
||||||
className="text-[15px] font-medium uppercase"
|
className="text-[17px] font-medium uppercase"
|
||||||
style={{ color: NB.mut, letterSpacing: "0.22em" }}
|
style={{ color: NB.mut, letterSpacing: "0.22em" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -197,7 +197,7 @@ const Clock = () => {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<span className="text-[17px]" style={{ color: NB.mut, letterSpacing: "0.06em" }}>
|
<span className="text-[19px]" style={{ color: NB.mut, letterSpacing: "0.06em" }}>
|
||||||
{now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
|
{now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
|
||||||
{" · "}
|
{" · "}
|
||||||
<span style={{ color: NB.txt, fontWeight: 500 }}>
|
<span style={{ color: NB.txt, fontWeight: 500 }}>
|
||||||
@@ -223,7 +223,6 @@ const NightboardSmall = () => {
|
|||||||
queryKey: ["nightboard-projection-today"],
|
queryKey: ["nightboard-projection-today"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await acotService.getProjection({ timeRange: "today" })) as Projection,
|
(await acotService.getProjection({ timeRange: "today" })) as Projection,
|
||||||
enabled: stats != null && stats.periodProgress < 100,
|
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,7 +273,6 @@ const NightboardSmall = () => {
|
|||||||
queryKey: ["nightboard-projection-30d"],
|
queryKey: ["nightboard-projection-30d"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await acotService.getProjection({ timeRange: "last30days" })) as Projection,
|
(await acotService.getProjection({ timeRange: "last30days" })) as Projection,
|
||||||
enabled: sum30 != null && sum30.periodProgress < 100,
|
|
||||||
refetchInterval: 300_000,
|
refetchInterval: 300_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -422,6 +420,13 @@ const NightboardSmall = () => {
|
|||||||
(stats && stats.periodProgress > 0
|
(stats && stats.periodProgress > 0
|
||||||
? Math.round(stats.orderCount / (stats.periodProgress / 100))
|
? Math.round(stats.orderCount / (stats.periodProgress / 100))
|
||||||
: null);
|
: null);
|
||||||
|
const ordersTrend =
|
||||||
|
stats && stats.prevPeriodOrders > 0
|
||||||
|
? trendPct(
|
||||||
|
inProgress ? projOrders ?? stats.orderCount : stats.orderCount,
|
||||||
|
stats.prevPeriodOrders
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const rev30Current =
|
const rev30Current =
|
||||||
sum30 && sum30.periodProgress < 100
|
sum30 && sum30.periodProgress < 100
|
||||||
@@ -549,7 +554,7 @@ const NightboardSmall = () => {
|
|||||||
{/* top line */}
|
{/* top line */}
|
||||||
<div className="flex items-baseline justify-between pb-6">
|
<div className="flex items-baseline justify-between pb-6">
|
||||||
<span
|
<span
|
||||||
className="text-[16px] font-medium uppercase"
|
className="text-[18px] font-medium uppercase"
|
||||||
style={{ color: NB.mut, letterSpacing: "0.3em" }}
|
style={{ color: NB.mut, letterSpacing: "0.3em" }}
|
||||||
>
|
>
|
||||||
A Cherry On Top
|
A Cherry On Top
|
||||||
@@ -567,42 +572,42 @@ const NightboardSmall = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Lbl>Revenue today</Lbl>
|
<Lbl>Revenue today</Lbl>
|
||||||
<div
|
<div
|
||||||
className="text-[104px] font-extralight leading-[0.95] mt-2"
|
className="text-[112px] font-extralight leading-[0.95] mt-2"
|
||||||
style={{ color: NB.amberText, letterSpacing: "-0.03em" }}
|
style={{ color: NB.amberText, letterSpacing: "-0.03em" }}
|
||||||
>
|
>
|
||||||
{fmtMoney(stats?.revenue)}
|
{fmtMoney(stats?.revenue)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[18px] mt-3" style={{ color: NB.mut }}>
|
<div className="text-[20px] mt-3" style={{ color: NB.mut }}>
|
||||||
{inProgress && projRevenue != null && (
|
{inProgress && projRevenue != null && (
|
||||||
<>
|
<>
|
||||||
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtMoney(projRevenue)}</span>
|
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtMoney(projRevenue)}</span>
|
||||||
{" · "}
|
{" · "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<TrendArrow pct={revTrend} /> {revTrend != null && "vs last week"}
|
<TrendArrow pct={revTrend} /> {revTrend != null && "vs yesterday"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Lbl>Orders</Lbl>
|
<Lbl>Orders</Lbl>
|
||||||
<div className="text-[52px] font-light leading-none mt-2">
|
<div className="text-[58px] font-light leading-none mt-2">
|
||||||
{stats?.orderCount?.toLocaleString() ?? "—"}
|
{stats?.orderCount?.toLocaleString() ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
|
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||||
{projOrders != null && inProgress && (
|
{projOrders != null && inProgress && (
|
||||||
<>
|
<>
|
||||||
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{projOrders}</span>
|
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{projOrders}</span>
|
||||||
{" · "}
|
{" · "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{stats?.itemCount != null ? `${fmtCount(stats.itemCount)} items` : ""}
|
<TrendArrow pct={ordersTrend} /> {ordersTrend != null && "vs yesterday"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Lbl>Avg order</Lbl>
|
<Lbl>Avg order</Lbl>
|
||||||
<div className="text-[52px] font-light leading-none mt-2">
|
<div className="text-[58px] font-light leading-none mt-2">
|
||||||
{stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
|
{stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
|
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||||
{stats?.averageItemsPerOrder != null
|
{stats?.averageItemsPerOrder != null
|
||||||
? `${stats.averageItemsPerOrder.toFixed(1)} items / order`
|
? `${stats.averageItemsPerOrder.toFixed(1)} items / order`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -610,7 +615,7 @@ const NightboardSmall = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Lbl>On site now</Lbl>
|
<Lbl>On site now</Lbl>
|
||||||
<div className="text-[52px] font-light leading-none mt-2 flex items-center gap-4">
|
<div className="text-[58px] font-light leading-none mt-2 flex items-center gap-4">
|
||||||
<span className="relative inline-flex h-3 w-3">
|
<span className="relative inline-flex h-3 w-3">
|
||||||
<span
|
<span
|
||||||
className="absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping"
|
className="absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping"
|
||||||
@@ -620,7 +625,7 @@ const NightboardSmall = () => {
|
|||||||
</span>
|
</span>
|
||||||
{realtime?.last5MinUsers ?? "—"}
|
{realtime?.last5MinUsers ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
|
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||||
{realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
|
{realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -630,7 +635,7 @@ const NightboardSmall = () => {
|
|||||||
<div className="flex flex-col min-h-0 pt-4" style={{ borderTop: `1px solid ${NB.line}` }}>
|
<div className="flex flex-col min-h-0 pt-4" style={{ borderTop: `1px solid ${NB.line}` }}>
|
||||||
<div className="flex items-baseline justify-between pb-1">
|
<div className="flex items-baseline justify-between pb-1">
|
||||||
<Lbl>Last 30 days</Lbl>
|
<Lbl>Last 30 days</Lbl>
|
||||||
<span className="text-[17px]" style={{ color: NB.mut }}>
|
<span className="text-[19px]" style={{ color: NB.mut }}>
|
||||||
Revenue <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCompact(sum30?.revenue)}</span>{" "}
|
Revenue <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCompact(sum30?.revenue)}</span>{" "}
|
||||||
<TrendArrow pct={rev30Trend} />
|
<TrendArrow pct={rev30Trend} />
|
||||||
{" · "}
|
{" · "}
|
||||||
@@ -652,17 +657,17 @@ const NightboardSmall = () => {
|
|||||||
tickFormatter={(v: string) =>
|
tickFormatter={(v: string) =>
|
||||||
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
|
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
|
||||||
}
|
}
|
||||||
tick={{ fill: NB.mut, fontSize: 14 }}
|
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
interval={5}
|
interval={5}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tickFormatter={(v: number) => fmtCompact(v)}
|
tickFormatter={(v: number) => fmtCompact(v)}
|
||||||
tick={{ fill: NB.mut, fontSize: 14 }}
|
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
width={52}
|
width={58}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }}
|
cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }}
|
||||||
@@ -671,7 +676,7 @@ const NightboardSmall = () => {
|
|||||||
const row = payload[0].payload as { date: string; total: number };
|
const row = payload[0].payload as { date: string; total: number };
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg px-4 py-2.5 text-[15px]"
|
className="rounded-lg px-4 py-2.5 text-[17px]"
|
||||||
style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }}
|
style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }}
|
||||||
>
|
>
|
||||||
<div style={{ color: NB.mut }}>
|
<div style={{ color: NB.mut }}>
|
||||||
@@ -713,7 +718,7 @@ const NightboardSmall = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-5 text-[13px]" style={{ color: NB.mut }}>
|
<div className="flex gap-5 text-[15px]" style={{ color: NB.mut }}>
|
||||||
{activePhases.slice(0, 5).map((p) => (
|
{activePhases.slice(0, 5).map((p) => (
|
||||||
<span key={p.phase} className="flex items-center gap-2 whitespace-nowrap">
|
<span key={p.phase} className="flex items-center gap-2 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
@@ -739,15 +744,15 @@ const NightboardSmall = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="text-[13px] font-medium uppercase"
|
className="text-[15px] font-medium uppercase"
|
||||||
style={{ color: NB.mut, letterSpacing: "0.18em" }}
|
style={{ color: NB.mut, letterSpacing: "0.18em" }}
|
||||||
>
|
>
|
||||||
{cell.label}
|
{cell.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[34px] font-light mt-1.5" style={{ letterSpacing: "-0.01em" }}>
|
<div className="text-[38px] font-light mt-1.5" style={{ letterSpacing: "-0.01em" }}>
|
||||||
{cell.value}
|
{cell.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[15px] mt-1" style={{ color: NB.mut }}>
|
<div className="text-[17px] mt-1" style={{ color: NB.mut }}>
|
||||||
{cell.sub}
|
{cell.sub}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -811,11 +816,11 @@ const NightboardSmall = () => {
|
|||||||
return (
|
return (
|
||||||
<EventDialog key={event.id} event={event} scale={1.75}>
|
<EventDialog key={event.id} event={event} scale={1.75}>
|
||||||
<div
|
<div
|
||||||
className="w-[290px] shrink-0 rounded-2xl px-5 py-4 cursor-pointer transition-colors hover:brightness-125"
|
className="w-[315px] shrink-0 rounded-2xl px-5 py-4 cursor-pointer transition-colors hover:brightness-125"
|
||||||
style={{ background: NB.card }}
|
style={{ background: NB.card }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between text-[12px] font-medium uppercase"
|
className="flex items-center justify-between text-[13px] font-medium uppercase"
|
||||||
style={{ color: NB.mut, letterSpacing: "0.16em" }}
|
style={{ color: NB.mut, letterSpacing: "0.16em" }}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2.5">
|
<span className="flex items-center gap-2.5">
|
||||||
@@ -834,8 +839,8 @@ const NightboardSmall = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[21px] font-medium mt-2 truncate">{name || "—"}</div>
|
<div className="text-[23px] font-medium mt-2 truncate">{name || "—"}</div>
|
||||||
<div className="text-[16px] mt-1 truncate" style={{ color: NB.mut }}>
|
<div className="text-[18px] mt-1 truncate" style={{ color: NB.mut }}>
|
||||||
{detail}
|
{detail}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user