Compare commits

...

10 Commits

Author SHA1 Message Date
225e63a985 Add shippedby to event feed 2025-01-09 11:10:49 -05:00
254a9a6511 Move pm2 config up a level 2025-01-09 09:54:30 -05:00
abe43c03b7 Update mini feed every 30s 2025-01-06 10:02:15 -05:00
8ad566c7f4 Attempt to add saleschart projections 2025-01-06 09:15:30 -05:00
304d09e3c4 Fix projected revenue trend regression 2025-01-04 11:15:35 -05:00
5063120731 Fix minisaleschart skeletons 2025-01-04 01:06:35 -05:00
1b797eecaf Reverse feed direction and add navigation arrows 2025-01-04 00:51:18 -05:00
194ac96732 Style date time weather to fit in better 2025-01-04 00:37:29 -05:00
a2eb5bfcd7 Add missing skeletons 2025-01-04 00:23:42 -05:00
e1f12539d3 Fix revenue projection calc for 12-1am 2025-01-04 00:15:29 -05:00
10 changed files with 807 additions and 750 deletions

View File

@@ -1,189 +0,0 @@
// ecosystem.config.js
const path = require('path');
const dotenv = require('dotenv');
// Load environment variables safely with error handling
const loadEnvFile = (envPath) => {
try {
console.log('Loading env from:', envPath);
const result = dotenv.config({ path: envPath });
if (result.error) {
console.warn(`Warning: .env file not found or invalid at ${envPath}:`, result.error.message);
return {};
}
console.log('Env variables loaded from', envPath, ':', Object.keys(result.parsed || {}));
return result.parsed || {};
} catch (error) {
console.warn(`Warning: Error loading .env file at ${envPath}:`, error.message);
return {};
}
};
// Load environment variables for each server
const authEnv = loadEnvFile(path.resolve(__dirname, 'auth-server/.env'));
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env'));
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env'));
const metaEnv = loadEnvFile(path.resolve(__dirname, 'meta-server/.env'));
const googleAnalyticsEnv = require('dotenv').config({
path: path.resolve(__dirname, 'google-server/.env')
}).parsed || {};
const typeformEnv = loadEnvFile(path.resolve(__dirname, 'typeform-server/.env'));
// Common log settings for all apps
const logSettings = {
log_rotate: true,
max_size: '10M',
retain: '10',
log_date_format: 'YYYY-MM-DD HH:mm:ss'
};
// Common app settings
const commonSettings = {
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
max_memory_restart: '1G',
time: true,
...logSettings,
ignore_watch: [
'node_modules',
'logs',
'.git',
'*.log'
],
min_uptime: 5000,
max_restarts: 5,
restart_delay: 4000,
listen_timeout: 50000,
kill_timeout: 5000,
node_args: '--max-old-space-size=1536'
};
module.exports = {
apps: [
{
...commonSettings,
name: 'auth-server',
script: './auth-server/index.js',
env: {
NODE_ENV: 'production',
PORT: 3003,
...authEnv
},
error_file: 'auth-server/logs/pm2/err.log',
out_file: 'auth-server/logs/pm2/out.log',
log_file: 'auth-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3003
},
env_development: {
NODE_ENV: 'development',
PORT: 3003
}
},
{
...commonSettings,
name: 'aircall-server',
script: './aircall-server/server.js',
env: {
NODE_ENV: 'production',
AIRCALL_PORT: 3002,
...aircallEnv
},
error_file: 'aircall-server/logs/pm2/err.log',
out_file: 'aircall-server/logs/pm2/out.log',
log_file: 'aircall-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
AIRCALL_PORT: 3002
}
},
{
...commonSettings,
name: 'klaviyo-server',
script: './klaviyo-server/server.js',
env: {
NODE_ENV: 'production',
KLAVIYO_PORT: 3004,
...klaviyoEnv
},
error_file: 'klaviyo-server/logs/pm2/err.log',
out_file: 'klaviyo-server/logs/pm2/out.log',
log_file: 'klaviyo-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
KLAVIYO_PORT: 3004
}
},
{
...commonSettings,
name: 'meta-server',
script: './meta-server/server.js',
env: {
NODE_ENV: 'production',
PORT: 3005,
...metaEnv
},
error_file: 'meta-server/logs/pm2/err.log',
out_file: 'meta-server/logs/pm2/out.log',
log_file: 'meta-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
PORT: 3005
}
},
{
name: "gorgias-server",
script: "./gorgias-server/server.js",
env: {
NODE_ENV: "development",
PORT: 3006
},
env_production: {
NODE_ENV: "production",
PORT: 3006
},
error_file: "./logs/gorgias-server-error.log",
out_file: "./logs/gorgias-server-out.log",
log_file: "./logs/gorgias-server-combined.log",
time: true
},
{
...commonSettings,
name: 'google-server',
script: path.resolve(__dirname, './google-server/server.js'),
watch: false,
env: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007,
...googleAnalyticsEnv
},
error_file: path.resolve(__dirname, './google-server/logs/pm2/err.log'),
out_file: path.resolve(__dirname, './google-server/logs/pm2/out.log'),
log_file: path.resolve(__dirname, './google-server/logs/pm2/combined.log'),
env_production: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007
}
},
{
...commonSettings,
name: 'typeform-server',
script: './typeform-server/server.js',
env: {
NODE_ENV: 'production',
TYPEFORM_PORT: 3008,
...typeformEnv
},
error_file: 'typeform-server/logs/pm2/err.log',
out_file: 'typeform-server/logs/pm2/out.log',
log_file: 'typeform-server/logs/pm2/combined.log',
env_production: {
NODE_ENV: 'production',
TYPEFORM_PORT: 3008
}
}
]
};

View File

@@ -1339,58 +1339,62 @@ export class EventsService {
event.attributes?.metric_id;
// Extract properties from all possible locations
const rawProps = {
...(event.attributes?.event_properties || {}),
...(event.attributes?.properties || {}),
...(event.attributes?.profile || {}),
value: event.attributes?.value,
datetime: event.attributes?.datetime
};
const rawProps = event.attributes?.event_properties || {};
// Only log for shipped orders and only show relevant fields
if (event.relationships?.metric?.data?.id === METRIC_IDS.SHIPPED_ORDER) {
console.log('[EventsService] Shipped Order:', {
orderId: rawProps.OrderId,
shippedBy: rawProps.ShippedBy,
datetime: event.attributes?.datetime
});
}
// Normalize shipping data
const shippingData = {
name: rawProps.ShippingName || rawProps.shipping_name || rawProps.shipping?.name,
street1: rawProps.ShippingStreet1 || rawProps.shipping_street1 || rawProps.shipping?.street1,
street2: rawProps.ShippingStreet2 || rawProps.shipping_street2 || rawProps.shipping?.street2,
city: rawProps.ShippingCity || rawProps.shipping_city || rawProps.shipping?.city,
state: rawProps.ShippingState || rawProps.shipping_state || rawProps.shipping?.state,
zip: rawProps.ShippingZip || rawProps.shipping_zip || rawProps.shipping?.zip,
country: rawProps.ShippingCountry || rawProps.shipping_country || rawProps.shipping?.country,
method: rawProps.ShipMethod || rawProps.shipping_method || rawProps.shipping?.method,
tracking: rawProps.TrackingNumber || rawProps.tracking_number
ShippingName: rawProps.ShippingName,
ShippingStreet1: rawProps.ShippingStreet1,
ShippingStreet2: rawProps.ShippingStreet2,
ShippingCity: rawProps.ShippingCity,
ShippingState: rawProps.ShippingState,
ShippingZip: rawProps.ShippingZip,
ShippingCountry: rawProps.ShippingCountry,
ShipMethod: rawProps.ShipMethod,
TrackingNumber: rawProps.TrackingNumber,
ShippedBy: rawProps.ShippedBy
};
// Normalize payment data
const paymentData = {
method: rawProps.PaymentMethod || rawProps.payment_method || rawProps.payment?.method,
name: rawProps.PaymentName || rawProps.payment_name || rawProps.payment?.name,
amount: Number(rawProps.PaymentAmount || rawProps.payment_amount || rawProps.payment?.amount || 0)
method: rawProps.PaymentMethod,
name: rawProps.PaymentName,
amount: Number(rawProps.PaymentAmount || 0)
};
// Normalize order flags
const orderFlags = {
type: rawProps.OrderType || rawProps.order_type || 'standard',
hasPreorder: Boolean(rawProps.HasPreorder || rawProps.has_preorder || rawProps.preorder),
localPickup: Boolean(rawProps.LocalPickup || rawProps.local_pickup || rawProps.pickup),
isOnHold: Boolean(rawProps.IsOnHold || rawProps.is_on_hold || rawProps.on_hold),
hasDigiItem: Boolean(rawProps.HasDigiItem || rawProps.has_digital_item || rawProps.digital_item),
hasNotions: Boolean(rawProps.HasNotions || rawProps.has_notions || rawProps.notions),
hasDigitalGC: Boolean(rawProps.HasDigitalGC || rawProps.has_digital_gc || rawProps.gift_card),
stillOwes: Boolean(rawProps.StillOwes || rawProps.still_owes || rawProps.balance_due)
type: rawProps.OrderType || 'standard',
hasPreorder: Boolean(rawProps.HasPreorder),
localPickup: Boolean(rawProps.LocalPickup),
isOnHold: Boolean(rawProps.IsOnHold),
hasDigiItem: Boolean(rawProps.HasDigiItem),
hasNotions: Boolean(rawProps.HasNotions),
hasDigitalGC: Boolean(rawProps.HasDigitalGC),
stillOwes: Boolean(rawProps.StillOwes)
};
// Normalize refund/cancel data
const refundData = {
reason: rawProps.CancelReason || rawProps.cancel_reason || rawProps.reason,
message: rawProps.CancelMessage || rawProps.cancel_message || rawProps.message,
orderMessage: rawProps.OrderMessage || rawProps.order_message || rawProps.note
reason: rawProps.CancelReason,
message: rawProps.CancelMessage,
orderMessage: rawProps.OrderMessage
};
// Transform items
const items = this._transformItems(rawProps.Items || rawProps.items || rawProps.line_items || []);
const items = this._transformItems(rawProps.Items || []);
// Calculate totals
const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.total_amount || rawProps.value || 0);
const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.value || 0);
const itemCount = items.reduce((sum, item) => sum + Number(item.Quantity || item.QuantityOrdered || 1), 0);
const transformed = {
@@ -1408,29 +1412,10 @@ export class EventsService {
},
relationships: event.relationships,
event_properties: {
// Basic properties
EmailAddress: rawProps.EmailAddress || rawProps.email,
FirstName: rawProps.FirstName || rawProps.first_name,
LastName: rawProps.LastName || rawProps.last_name,
OrderId: rawProps.OrderId || rawProps.FromOrder || rawProps.order_id,
...rawProps, // Include all original properties
Items: items, // Override with transformed items
TotalAmount: totalAmount,
ItemCount: itemCount,
Items: items,
// Shipping information
...shippingData,
// Payment information
...paymentData,
// Order flags
...orderFlags,
// Refund/cancel information
...refundData,
// Original properties (for backward compatibility)
...rawProps
ItemCount: itemCount
}
};
@@ -2112,21 +2097,43 @@ export class EventsService {
const currentHour = now.hour;
const currentMinute = now.minute;
// Handle the 12-1 AM edge case
const isInEdgeCase = currentHour < this.timeManager.dayStartHour;
const adjustedCurrentHour = isInEdgeCase ? currentHour + 24 : currentHour;
const adjustedDayStartHour = this.timeManager.dayStartHour;
// Calculate how much of the current hour has passed (0-1)
const hourProgress = currentMinute / 60;
// Calculate how much of the expected daily revenue we've seen so far
let expectedPercentageSeen = 0;
for (let i = 0; i < currentHour; i++) {
expectedPercentageSeen += hourlyPatterns[i].percentage;
let totalDayPercentage = 0;
// First, calculate total percentage for a full day
for (let i = 0; i < 24; i++) {
totalDayPercentage += hourlyPatterns[i].percentage;
}
if (isInEdgeCase) {
// If we're between 12-1 AM, we want to use almost the full day's percentage
// since we're at the end of the previous day
expectedPercentageSeen = totalDayPercentage;
// Subtract the remaining portion of the current hour
expectedPercentageSeen -= hourlyPatterns[currentHour].percentage * (1 - hourProgress);
} else {
// Normal case - add up percentages from day start to current hour
for (let i = adjustedDayStartHour; i < adjustedCurrentHour; i++) {
const hourIndex = i % 24;
expectedPercentageSeen += hourlyPatterns[hourIndex].percentage;
}
// Add partial current hour
expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress;
}
// Add partial current hour
expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress;
// Calculate projection based on patterns
let projectedRevenue = 0;
if (expectedPercentageSeen > 0) {
projectedRevenue = (currentRevenue / (expectedPercentageSeen / 100));
projectedRevenue = (currentRevenue / (expectedPercentageSeen / totalDayPercentage));
}
// Calculate confidence score (0-1) based on:
@@ -2134,8 +2141,19 @@ export class EventsService {
// 2. How consistent the patterns are
// 3. How far through the period we are
const patternConsistency = this._calculatePatternConsistency(hourlyPatterns);
const periodProgress = Math.min(100, Math.max(0, (now.diff(periodStart).milliseconds / periodEnd.diff(periodStart).milliseconds) * 100));
const historicalDataAmount = Math.min(totalHistoricalOrders / 1000, 1); // Normalize to 0-1, considering 1000+ orders as maximum confidence
// Calculate period progress considering the 1 AM day start
const totalDuration = periodEnd.diff(periodStart);
const elapsedDuration = now.diff(periodStart);
let periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
// Adjust period progress for the 12-1 AM edge case
if (isInEdgeCase) {
// If we're between 12-1 AM, we're actually at the end of the previous day
periodProgress = Math.min(100, Math.max(0, ((24 - adjustedDayStartHour + currentHour) / 24) * 100));
}
const historicalDataAmount = Math.min(totalHistoricalOrders / 1000, 1);
const confidence = (
(patternConsistency * 0.4) +
@@ -2154,8 +2172,11 @@ export class EventsService {
historicalOrders: totalHistoricalOrders,
hourlyPatterns,
expectedPercentageSeen,
totalDayPercentage,
currentHour,
currentMinute
currentMinute,
isInEdgeCase,
adjustedCurrentHour
}
};
} catch (error) {

View File

@@ -122,35 +122,37 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
switch (true) {
case code >= 200 && code < 300:
return <CloudLightning className={cn(iconProps, "text-gray-700")} />;
return <CloudLightning className={cn(iconProps, "text-yellow-300")} />;
case code >= 300 && code < 500:
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
return <CloudDrizzle className={cn(iconProps, "text-blue-300")} />;
case code >= 500 && code < 600:
return <CloudRain className={cn(iconProps, "text-blue-600")} />;
return <CloudRain className={cn(iconProps, "text-blue-300")} />;
case code >= 600 && code < 700:
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
return <CloudSnow className={cn(iconProps, "text-blue-200")} />;
case code >= 700 && code < 721:
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
case code === 721:
return <Haze className={cn(iconProps, "text-gray-700")} />;
return <Haze className={cn(iconProps, "text-gray-300")} />;
case code >= 722 && code < 781:
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
return <CloudFog className={cn(iconProps, "text-gray-300")} />;
case code === 781:
return <Tornado className={cn(iconProps, "text-gray-700")} />;
return <Tornado className={cn(iconProps, "text-gray-300")} />;
case code === 800:
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
<Sun className={cn(iconProps, "text-yellow-700")} />
<Sun className={cn(iconProps, "text-yellow-300")} />
) : (
<Moon className={cn(iconProps, "text-gray-500")} />
<Moon className={cn(iconProps, "text-gray-300")} />
);
case code >= 800 && code < 803:
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
return <CloudSun className={cn(iconProps, isNight ? "text-gray-300" : "text-gray-200")} />;
case code >= 803:
return <Cloud className={cn(iconProps, "text-gray-200")} />;
return <Cloud className={cn(iconProps, "text-gray-300")} />;
default:
return <CircleAlert className={cn(iconProps, "text-red-700")} />;
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
}
};
@@ -159,66 +161,68 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
// Thunderstorm (200-299)
if (code >= 200 && code < 300) {
return "bg-gradient-to-br from-slate-900 via-purple-900 to-slate-800";
return "bg-gradient-to-br from-slate-900 to-purple-800";
}
// Drizzle (300-399)
if (code >= 300 && code < 400) {
return "bg-gradient-to-br from-slate-700 via-blue-800 to-slate-700";
return "bg-gradient-to-br from-slate-800 to-blue-800";
}
// Rain (500-599)
if (code >= 500 && code < 600) {
return "bg-gradient-to-br from-slate-800 via-blue-900 to-slate-700";
return "bg-gradient-to-br from-slate-800 to-blue-800";
}
// Snow (600-699)
if (code >= 600 && code < 700) {
return "bg-gradient-to-br from-slate-200 via-blue-100 to-slate-100";
return "bg-gradient-to-br from-slate-700 to-blue-800";
}
// Atmosphere (700-799: mist, smoke, haze, fog, etc.)
if (code >= 700 && code < 800) {
return "bg-gradient-to-br from-slate-600 via-slate-500 to-slate-400";
return "bg-gradient-to-br from-slate-700 to-slate-500";
}
// Clear (800)
if (code === 800) {
if (isNight) {
return "bg-gradient-to-br from-slate-900 via-blue-950 to-slate-800";
return "bg-gradient-to-br from-slate-900 to-blue-900";
}
return "bg-gradient-to-br from-sky-400 via-blue-400 to-sky-500";
return "bg-gradient-to-br from-blue-600 to-sky-400";
}
// Clouds (801-804)
if (code > 800) {
if (isNight) {
return "bg-gradient-to-br from-slate-800 via-slate-700 to-slate-600";
return "bg-gradient-to-br from-slate-800 to-slate-600";
}
return "bg-gradient-to-br from-slate-400 via-slate-500 to-slate-400";
return "bg-gradient-to-br from-slate-600 to-slate-400";
}
// Default fallback
return "bg-gradient-to-br from-slate-700 via-slate-600 to-slate-500";
return "bg-gradient-to-br from-slate-700 to-slate-500";
};
const getTemperatureColor = (weatherCode, isNight) => {
const code = parseInt(weatherCode, 10);
// Use dark text for light backgrounds
if (code >= 600 && code < 700) { // Snow
return "text-slate-900";
// Snow - dark background, light text
if (code >= 600 && code < 700) {
return "text-white";
}
if (code === 800 && !isNight) { // Clear day
return "text-slate-900";
// Clear day - light background, dark text
if (code === 800 && !isNight) {
return "text-white";
}
if (code > 800 && !isNight) { // Cloudy day
return "text-slate-900";
// Cloudy day - medium background, ensure contrast
if (code > 800 && !isNight) {
return "text-white";
}
// Default to white text for all other (darker) backgrounds
// All other cases (darker backgrounds)
return "text-white";
};
@@ -236,213 +240,217 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
};
const WeatherDetails = () => (
<div className="space-y-4 p-3">
<div className="grid grid-cols-3 gap-2">
<Card className="p-2">
<div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-orange-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">High</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span>
<div className="space-y-4 p-3 bg-gradient-to-br from-slate-800 to-slate-700">
<div className="grid grid-cols-3 gap-2">
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">High</span>
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_max)}°F</span>
</div>
</div>
</div>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Low</span>
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span>
</Card>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Low</span>
<span className="text-sm font-bold text-white">{Math.round(weather.main.temp_min)}°F</span>
</div>
</div>
</div>
</Card>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-400" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Humidity</span>
<span className="text-sm font-bold">{weather.main.humidity}%</span>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Humidity</span>
<span className="text-sm font-bold text-white">{weather.main.humidity}%</span>
</div>
</div>
</div>
</Card>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-gray-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Wind</span>
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-slate-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Wind</span>
<span className="text-sm font-bold text-white">{Math.round(weather.wind.speed)} mph</span>
</div>
</div>
</div>
</Card>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-500" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunrise</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Sunrise</span>
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunrise)}</span>
</div>
</div>
</div>
</Card>
</Card>
<Card className="p-2">
<div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-400" />
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Sunset</span>
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span>
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm p-2">
<div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-300" />
<div className="flex flex-col">
<span className="text-xs text-slate-300">Sunset</span>
<span className="text-sm font-bold text-white">{formatTime(weather.sys?.sunset)}</span>
</div>
</div>
</div>
</Card>
</div>
{forecast && (
<div>
<div className="grid grid-cols-5 gap-2">
{forecast.map((day, index) => (
<Card key={index} className="p-2">
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium">
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
</span>
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
<div className="flex justify-center gap-1 items-baseline w-full">
<span className="text-sm font-medium">
{Math.round(day.main.temp_max)}°
</span>
<span className="text-xs text-muted-foreground">
{Math.round(day.main.temp_min)}°
</span>
</div>
<div className="flex flex-col items-center gap-1 w-full pt-1">
{day.rain?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudRain className="w-3 h-3 text-blue-400" />
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
</div>
)}
{day.snow?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudSnow className="w-3 h-3 text-blue-400" />
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
</div>
)}
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
<div className="flex items-center gap-1">
<Umbrella className="w-3 h-3 text-gray-400" />
<span className="text-xs">0"</span>
</div>
)}
</div>
</div>
</Card>
))}
</div>
</Card>
</div>
)}
</div>
);
return (
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
{/* Time Display */}
<Card className="bg-gradient-to-br mb-[7px] from-slate-900 via-sky-800 to-cyan-800 dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
<CardContent className="p-3 h-[106px] flex items-center">
<div className="flex justify-center items-baseline w-full">
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
<span className="text-7xl font-bold text-white">{hours}</span>
<span className="text-7xl font-bold text-white">:</span>
<span className="text-7xl font-bold text-white">{minutes}</span>
<span className="text-xl font-medium text-white/90 ml-1">{ampm}</span>
{forecast && (
<div>
<div className="grid grid-cols-5 gap-2">
{forecast.map((day, index) => {
const forecastTime = new Date(day.dt * 1000);
const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
return (
<Card
key={index}
className={cn(
getWeatherBackground(day.weather[0].id, isNight),
"p-2"
)}
>
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-white">
{forecastTime.toLocaleDateString('en-US', { weekday: 'short' })}
</span>
{getWeatherIcon(day.weather[0].id, forecastTime, true)}
<div className="flex justify-center gap-1 items-baseline w-full">
<span className="text-sm font-medium text-white">
{Math.round(day.main.temp_max)}°
</span>
<span className="text-xs text-slate-300">
{Math.round(day.main.temp_min)}°
</span>
</div>
<div className="flex flex-col items-center gap-1 w-full pt-1">
{day.rain?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudRain className="w-3 h-3 text-blue-300" />
<span className="text-xs text-white">{day.rain['3h'].toFixed(2)}"</span>
</div>
)}
{day.snow?.['3h'] > 0 && (
<div className="flex items-center gap-1">
<CloudSnow className="w-3 h-3 text-blue-300" />
<span className="text-xs text-white">{day.snow['3h'].toFixed(2)}"</span>
</div>
)}
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
<div className="flex items-center gap-1">
<Umbrella className="w-3 h-3 text-slate-300" />
<span className="text-xs text-white">0"</span>
</div>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
{/* Date and Weather Display */}
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
<Card className="h-full bg-gradient-to-br from-slate-900 via-violet-800 to-purple-800 flex items-center justify-center">
<CardContent className="h-full p-0">
<div className="flex flex-col items-center justify-center h-full">
<span className="text-7xl font-bold text-white">
{dateInfo.day}
</span>
<span className="text-sm font-bold text-white mt-2">
{dateInfo.weekday}
</span>
return (
<div className="flex flex-col items-center w-full transition-opacity duration-300 ${mounted ? 'opacity-100' : 'opacity-0'}">
{/* Time Display */}
<Card className="bg-gradient-to-br mb-[7px] from-indigo-900 to-blue-800 backdrop-blur-sm dark:bg-slate-800 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
<CardContent className="p-3 h-[106px] flex items-center">
<div className="flex justify-center items-baseline w-full">
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
<span className="text-6xl font-bold text-white">{hours}</span>
<span className="text-6xl font-bold text-white">:</span>
<span className="text-6xl font-bold text-white">{minutes}</span>
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span>
</div>
</div>
</CardContent>
</Card>
{weather?.main && (
<Popover>
<PopoverTrigger asChild>
<Card className={cn(
getWeatherBackground(
weather.weather[0]?.id,
datetime.getHours() >= 18 || datetime.getHours() < 6
),
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative"
)}>
<CardContent className="h-full p-3">
<div className="flex flex-col items-center">
{getWeatherIcon(weather.weather[0]?.id, datetime)}
<span className={cn(
"text-3xl font-bold ml-1 mt-2",
getTemperatureColor(
weather.weather[0]?.id,
datetime.getHours() >= 18 || datetime.getHours() < 6
)
)}>
{Math.round(weather.main.temp)}°
</span>
</div>
</CardContent>
{weather.alerts && (
<div className="absolute top-1 right-1">
<AlertTriangle className="w-5 h-5 text-red-500" />
</div>
)}
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[450px]"
align="start"
side="right"
sideOffset={10}
style={{
transform: `scale(${scaleFactor})`,
transformOrigin: 'left top'
}}
>
{weather.alerts && (
<Alert variant="warning" className="mb-3">
<AlertTriangle className="h-3 w-3" />
<AlertDescription className="text-xs">
{weather.alerts[0].event}
</AlertDescription>
</Alert>
)}
<WeatherDetails />
</PopoverContent>
</Popover>
)}
</div>
{/* Date and Weather Display */}
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full">
<Card className="h-full bg-gradient-to-br from-violet-900 to-purple-800 backdrop-blur-sm flex items-center justify-center">
<CardContent className="h-full p-0">
<div className="flex flex-col items-center justify-center h-full">
<span className="text-6xl font-bold text-white">
{dateInfo.day}
</span>
<span className="text-sm font-bold text-white mt-2">
{dateInfo.weekday}
</span>
</div>
</CardContent>
</Card>
{/* Calendar Display */}
<Card className="w-full">
<CardContent className="p-0">
<CalendarComponent
selected={datetime}
className="w-full"
/>
</CardContent>
</Card>
</div>
);
{weather?.main && (
<Popover>
<PopoverTrigger asChild>
<Card className={cn(
getWeatherBackground(
weather.weather[0]?.id,
datetime.getHours() >= 18 || datetime.getHours() < 6
),
"flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm"
)}>
<CardContent className="h-full p-3">
<div className="flex flex-col items-center">
{getWeatherIcon(weather.weather[0]?.id, datetime)}
<span className="text-3xl font-bold ml-1 mt-2 text-white">
{Math.round(weather.main.temp)}°
</span>
</div>
</CardContent>
{weather.alerts && (
<div className="absolute top-1 right-1">
<AlertTriangle className="w-5 h-5 text-red-500" />
</div>
)}
</Card>
</PopoverTrigger>
<PopoverContent
className="w-[450px] bg-gradient-to-br from-slate-800 to-slate-700 border-slate-600"
align="start"
side="right"
sideOffset={10}
style={{
transform: `scale(${scaleFactor})`,
transformOrigin: 'left top'
}}
>
{weather.alerts && (
<Alert variant="warning" className="mb-3 bg-amber-900/50 border-amber-700">
<AlertTriangle className="h-3 w-3 text-amber-500" />
<AlertDescription className="text-xs text-amber-200">
{weather.alerts[0].event}
</AlertDescription>
</Alert>
)}
<WeatherDetails />
</PopoverContent>
</Popover>
)}
</div>
{/* Calendar Display */}
<Card className="w-full bg-white dark:bg-slate-800">
<CardContent className="p-0">
<CalendarComponent
selected={datetime}
className="w-full"
/>
</CardContent>
</Card>
</div>
);
};
export default DateTimeWeatherDisplay;

View File

@@ -679,72 +679,26 @@ const EventDialog = ({ event, children }) => {
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
<>
<div className="grid gap-6 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Shipping Address</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-sm font-medium">{details.ShippingName}</p>
{details.ShippingStreet1 && (
<p className="text-sm text-muted-foreground">{details.ShippingStreet1}</p>
)}
{details.ShippingStreet2 && (
<p className="text-sm text-muted-foreground">{details.ShippingStreet2}</p>
)}
<p className="text-sm text-muted-foreground">
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
</p>
{details.ShippingCountry !== "US" && (
<p className="text-sm text-muted-foreground">{details.ShippingCountry}</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Tracking Information</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-sm font-medium">{details.TrackingNumber}</p>
<p className="text-sm text-muted-foreground">
{formatShipMethod(details.ShipMethod)}
</p>
</CardContent>
</Card>
<div className="mt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{toTitleCase(details.ShippingName)}
</span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500">
#{details.OrderId}
</span>
</div>
<div className="text-sm text-gray-500">
{formatShipMethodSimple(details.ShipMethod)}
{event.event_properties?.ShippedBy && (
<>
<span className="text-sm text-gray-500"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
</>
)}
</div>
</div>
<Card className="mt-6">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Shipped Items</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y">
{details.Items?.map((item, i) => (
<div key={i} className="flex gap-4 py-4 first:pt-0 last:pb-0">
{item.ImgThumb && (
<img
src={item.ImgThumb}
alt={item.ProductName}
className="w-16 h-16 object-cover rounded bg-muted"
/>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{item.ProductName}</p>
<p className="text-sm text-muted-foreground">
Shipped: {item.QuantitySent} of {item.QuantityOrdered}
</p>
{item.QuantityBackordered > 0 && (
<Badge variant="secondary" className="mt-2">
{item.QuantityBackordered} Backordered
</Badge>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</>
)}
@@ -1038,15 +992,19 @@ const EventCard = ({ event }) => {
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{toTitleCase(details.ShippingName)}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500">
#{details.OrderId}
</span>
<span className="text-sm text-gray-500"></span>
<span className="font-medium text-blue-600 dark:text-blue-400">
{formatShipMethodSimple(details.ShipMethod)}
</span>
</div>
<div className="text-sm text-gray-500">
{formatShipMethodSimple(details.ShipMethod)}
{event.event_properties?.ShippedBy && (
<>
<span className="text-sm text-gray-500"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
</>
)}
</div>
</div>
</>
@@ -1179,14 +1137,12 @@ const EventFeed = ({
},
});
// Ensure we have the datetime field in the response
// Keep the original event structure intact
const processedEvents = (response.data.data || []).map((event) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: {
...event.event_properties,
datetime: event.attributes?.datetime || event.datetime,
},
// Don't spread event_properties to preserve the nested structure
event_properties: event.attributes?.event_properties || {}
}));
setEvents(processedEvents);

View File

@@ -18,11 +18,14 @@ import {
Activity,
AlertCircle,
FileText,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { format } from "date-fns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { EventDialog } from "./EventFeed.jsx";
import { Button } from "@/components/ui/button";
const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF",
@@ -247,6 +250,11 @@ const EventCard = ({ event }) => {
#{details.OrderId} {formatShipMethodSimple(details.ShipMethod)}
</div>
</div>
{event.event_properties?.ShippedBy && (
<div className={`text-sm font-medium ${eventType.textColor} opacity-90 truncate mt-1`}>
Shipped by {event.event_properties.ShippedBy}
</div>
)}
</>
)}
@@ -319,6 +327,34 @@ const MiniEventFeed = ({
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const scrollRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const handleScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scrollToEnd = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'smooth'
});
}
};
const scrollToStart = () => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: 0,
behavior: 'smooth'
});
}
};
const fetchEvents = useCallback(async () => {
try {
@@ -338,10 +374,7 @@ const MiniEventFeed = ({
const processedEvents = (response.data.data || []).map((event) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: {
...event.event_properties,
datetime: event.attributes?.datetime || event.datetime,
},
event_properties: event.attributes?.event_properties || {}
}));
setEvents(processedEvents);
@@ -354,6 +387,7 @@ const MiniEventFeed = ({
left: scrollRef.current.scrollWidth,
behavior: 'instant'
});
handleScroll();
}, 0);
}
} catch (error) {
@@ -366,41 +400,67 @@ const MiniEventFeed = ({
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 60000);
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}, [fetchEvents]);
useEffect(() => {
handleScroll();
}, [events]);
return (
<div className="fixed bottom-0 left-0 right-0">
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
<div className="px-1 pt-2 pb-3">
<div className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
{loading && !events.length ? (
<LoadingState />
) : error ? (
<Alert variant="destructive" className="mx-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !events || events.length === 0 ? (
<div className="px-4">
<EmptyState />
</div>
) : (
events.map((event) => (
<EventCard
key={event.id}
event={event}
/>
))
)}
<Card className="bg-gradient-to-br rounded-none from-gray-900 to-gray-600 backdrop-blur">
<div className="px-1 pt-2 pb-3 relative">
{showLeftArrow && (
<Button
variant="ghost"
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
onClick={scrollToStart}
>
<ChevronLeft className="text-white" />
</Button>
)}
{showRightArrow && (
<Button
variant="ghost"
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-gray-900/50 hover:bg-gray-900/75 h-12 w-8 p-0 [&_svg]:!h-8 [&_svg]:!w-8"
onClick={scrollToEnd}
>
<ChevronRight className="text-white" />
</Button>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
>
<div className="flex flex-row gap-3 pr-4" style={{ width: 'max-content' }}>
{loading && !events.length ? (
<LoadingState />
) : error ? (
<Alert variant="destructive" className="mx-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !events || events.length === 0 ? (
<div className="px-4">
<EmptyState />
</div>
) : (
[...events].reverse().map((event) => (
<EventCard
key={event.id}
event={event}
/>
))
)}
</div>
</div>
</div>
</div>
</Card>
</div>
);

View File

@@ -17,6 +17,35 @@ import {
SkeletonBarChart,
processBasicData,
} from "./RealtimeAnalytics";
import { Skeleton } from "@/components/ui/skeleton";
const SkeletonCard = ({ colorScheme = "sky" }) => (
<Card className={`w-full h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
<div className="flex justify-between items-center">
<div className="space-y-1">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
const MiniRealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({
@@ -76,95 +105,68 @@ const MiniRealtimeAnalytics = () => {
};
}, [isPaused]);
if (loading && !basicData) {
return (
<div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
<Card className="h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-sky-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-sky-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-sky-300" />
<Skeleton className="h-5 w-5 bg-sky-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-sky-700" />
<Skeleton className="h-4 w-32 bg-sky-700" />
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-sky-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-sky-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-sky-300" />
<Skeleton className="h-5 w-5 bg-sky-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-sky-700" />
<Skeleton className="h-4 w-32 bg-sky-700" />
const renderContent = () => {
if (error) {
return (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (loading) {
return (
<div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
<SkeletonCard colorScheme="sky" />
<SkeletonCard colorScheme="sky" />
</div>
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
<CardContent className="p-4">
<div className="h-[216px]">
<div className="h-full w-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-sky-300/20"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-sky-300/20 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-sky-300/20 rounded-sm" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-2 bg-sky-300/20 rounded-sm"
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
<CardContent className="p-4">
<div className="h-[230px] relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-sky-700"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-sky-700 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-sky-700 rounded-sm" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-2 bg-sky-700 rounded-sm"
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
return (
<div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
{summaryCard(
"Last 30 Minutes",
@@ -245,7 +247,10 @@ const MiniRealtimeAnalytics = () => {
</CardContent>
</Card>
</div>
);
);
};
return renderContent();
};
export default MiniRealtimeAnalytics;

View File

@@ -23,45 +23,38 @@ import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, Ar
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
const SkeletonChart = () => (
<div className="h-[230px] w-full bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm rounded-lg p-4">
<div className="h-full relative">
<div className="h-[216px]">
<div className="h-full w-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-slate-500"
className="absolute w-full h-px bg-slate-600"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-slate-500 rounded-sm" />
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-slate-500 rounded-sm" />
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
))}
</div>
{/* Chart lines */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-slate-500 rounded-sm"
className="absolute inset-0 bg-slate-600 rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
<div
className="absolute inset-0 bg-slate-500 rounded-sm"
style={{
opacity: 0.3,
clipPath: "polygon(0 70%, 100% 40%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
@@ -135,28 +128,24 @@ const MiniStatCard = memo(({
MiniStatCard.displayName = "MiniStatCard";
const SkeletonCard = ({ colorScheme = "emerald" }) => (
<Card className={`w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm`}>
<Card className="w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
<Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
<div className="flex justify-between items-center">
<div className="space-y-1">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
<Skeleton className={`h-6 w-16 bg-${colorScheme}-300/20 rounded-full`} />
</div>
<div className="space-y-2">
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
<div className="flex justify-between items-center">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
</div>
</div>
</CardContent>
@@ -176,11 +165,26 @@ const MiniSalesChart = ({ className = "" }) => {
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0,
growth: {
revenue: 0,
orders: 0
}
periodProgress: 100
});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const fetchProjection = useCallback(async () => {
if (summaryStats.periodProgress >= 100) return;
try {
setProjectionLoading(true);
const response = await axios.get("/api/klaviyo/events/projection", {
params: { timeRange: "last30days" }
});
setProjection(response.data);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
setProjectionLoading(false);
}
}, [summaryStats.periodProgress]);
const fetchData = useCallback(async () => {
try {
@@ -211,33 +215,30 @@ const MiniSalesChart = ({ className = "" }) => {
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
periodProgress: day.periodProgress || 100,
}), {
totalRevenue: 0,
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0
prevOrders: 0,
periodProgress: 100
});
// Calculate growth percentages
const growth = {
revenue: totals.prevRevenue > 0
? ((totals.totalRevenue - totals.prevRevenue) / totals.prevRevenue) * 100
: 0,
orders: totals.prevOrders > 0
? ((totals.totalOrders - totals.prevOrders) / totals.prevOrders) * 100
: 0
};
setData(processedData);
setSummaryStats({ ...totals, growth });
setSummaryStats(totals);
setError(null);
// Fetch projection if needed
if (totals.periodProgress < 100) {
fetchProjection();
}
} catch (error) {
console.error("Error fetching data:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, []);
}, [fetchProjection]);
useEffect(() => {
fetchData();
@@ -305,11 +306,19 @@ const MiniSalesChart = ({ className = "" }) => {
title="30 Days Revenue"
value={formatCurrency(summaryStats.totalRevenue, false)}
previousValue={formatCurrency(summaryStats.prevRevenue, false)}
trend={summaryStats.growth.revenue >= 0 ? "up" : "down"}
trendValue={`${Math.abs(Math.round(summaryStats.growth.revenue))}%`}
trend={
summaryStats.periodProgress < 100
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down")
: (summaryStats.totalRevenue >= summaryStats.prevRevenue ? "up" : "down")
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((projection?.projectedRevenue || summaryStats.totalRevenue) - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalRevenue - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100))}%`
}
colorClass="text-emerald-300"
titleClass="text-emerald-300 font-bold text-md"
descriptionClass="text-emerald-300 text-md font-semibold"
descriptionClass="text-emerald-300 text-md font-semibold pb-1"
icon={PiggyBank}
iconColor="text-emerald-900"
iconBackground="bg-emerald-300"
@@ -320,11 +329,19 @@ const MiniSalesChart = ({ className = "" }) => {
title="30 Days Orders"
value={summaryStats.totalOrders.toLocaleString()}
previousValue={summaryStats.prevOrders.toLocaleString()}
trend={summaryStats.growth.orders >= 0 ? "up" : "down"}
trendValue={`${Math.abs(Math.round(summaryStats.growth.orders))}%`}
trend={
summaryStats.periodProgress < 100
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down")
: (summaryStats.totalOrders >= summaryStats.prevOrders ? "up" : "down")
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalOrders - summaryStats.prevOrders) / summaryStats.prevOrders) * 100))}%`
}
colorClass="text-blue-300"
titleClass="text-blue-300 font-bold text-md"
descriptionClass="text-blue-300 text-md font-semibold"
descriptionClass="text-blue-300 text-md font-semibold pb-1"
icon={Truck}
iconColor="text-blue-900"
iconBackground="bg-blue-300"

View File

@@ -246,25 +246,45 @@ const MiniStatCards = ({
}, []);
const calculateRevenueTrend = useCallback(() => {
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0)
return null;
const currentRevenue =
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
const prevRevenue = stats.prevPeriodRevenue;
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
// If period is complete, use actual revenue
// If period is incomplete, use smart projection when available, fallback to simple projection
const currentRevenue = stats.periodProgress < 100
? (projection?.projectedRevenue || stats.projectedRevenue)
: stats.revenue;
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
console.log('[MiniStatCards RevenueTrend Debug]', {
periodProgress: stats.periodProgress,
currentRevenue,
smartProjection: projection?.projectedRevenue,
simpleProjection: stats.projectedRevenue,
actualRevenue: stats.revenue,
prevRevenue,
isProjected: stats.periodProgress < 100
});
if (!currentRevenue || !prevRevenue) return null;
// Calculate absolute difference percentage
const trend = currentRevenue >= prevRevenue ? "up" : "down";
const diff = Math.abs(currentRevenue - prevRevenue);
const percentage = (diff / prevRevenue) * 100;
console.log('[MiniStatCards RevenueTrend Result]', {
trend,
percentage,
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
});
return {
trend,
value: percentage,
current: currentRevenue,
previous: prevRevenue,
};
}, [stats]);
}, [stats, projection]);
const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null;

View File

@@ -342,16 +342,8 @@ const calculateSummaryStats = (data = []) => {
return best;
}, null);
// Calculate growth percentages
const growth = {
revenue: prevRevenue
? ((totalRevenue - prevRevenue) / prevRevenue) * 100
: 0,
orders: prevOrders ? ((totalOrders - prevOrders) / prevOrders) * 100 : 0,
avgOrderValue: prevAvgOrderValue
? ((avgOrderValue - prevAvgOrderValue) / prevAvgOrderValue) * 100
: 0,
};
// Get period progress from the last day
const periodProgress = data[data.length - 1]?.periodProgress || 100;
return {
totalRevenue,
@@ -361,7 +353,7 @@ const calculateSummaryStats = (data = []) => {
prevRevenue,
prevOrders,
prevAvgOrderValue,
growth,
periodProgress,
movingAverages: {
revenue: data[data.length - 1]?.movingAverage || 0,
orders: data[data.length - 1]?.orderMovingAverage || 0,
@@ -371,7 +363,7 @@ const calculateSummaryStats = (data = []) => {
};
// Add memoized SummaryStats component
const SummaryStats = memo(({ stats = {} }) => {
const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
const {
totalRevenue = 0,
totalOrders = 0,
@@ -380,17 +372,39 @@ const SummaryStats = memo(({ stats = {} }) => {
prevRevenue = 0,
prevOrders = 0,
prevAvgOrderValue = 0,
growth = { revenue: 0, orders: 0, avgOrderValue: 0 },
periodProgress = 100
} = stats;
// Calculate projected values when period is incomplete
const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue;
const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down";
const revenueDiff = Math.abs(currentRevenue - prevRevenue);
const revenuePercentage = (revenueDiff / prevRevenue) * 100;
// Calculate order trends
const currentOrders = periodProgress < 100 ? (projection?.projectedOrders || totalOrders) : totalOrders;
const ordersTrend = currentOrders >= prevOrders ? "up" : "down";
const ordersDiff = Math.abs(currentOrders - prevOrders);
const ordersPercentage = (ordersDiff / prevOrders) * 100;
// Calculate AOV trends
const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue;
const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down";
const aovDiff = Math.abs(currentAOV - prevAvgOrderValue);
const aovPercentage = (aovDiff / prevAvgOrderValue) * 100;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
<StatCard
title="Total Revenue"
value={formatCurrency(totalRevenue, false)}
description={`Previous: ${formatCurrency(prevRevenue, false)}`}
trend={growth.revenue >= 0 ? "up" : "down"}
trendValue={formatPercentage(growth.revenue)}
description={
periodProgress < 100
? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}`
: `Previous: ${formatCurrency(prevRevenue, false)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : revenueTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(revenuePercentage)}
info="Total revenue for the selected period"
colorClass="text-green-600 dark:text-green-400"
/>
@@ -398,9 +412,13 @@ const SummaryStats = memo(({ stats = {} }) => {
<StatCard
title="Total Orders"
value={totalOrders.toLocaleString()}
description={`Previous: ${prevOrders.toLocaleString()} orders`}
trend={growth.orders >= 0 ? "up" : "down"}
trendValue={formatPercentage(growth.orders)}
description={
periodProgress < 100
? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}`
: `Previous: ${prevOrders.toLocaleString()}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : ordersTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(ordersPercentage)}
info="Total number of orders for the selected period"
colorClass="text-blue-600 dark:text-blue-400"
/>
@@ -408,9 +426,13 @@ const SummaryStats = memo(({ stats = {} }) => {
<StatCard
title="AOV"
value={formatCurrency(avgOrderValue)}
description={`Previous: ${formatCurrency(prevAvgOrderValue)}`}
trend={growth.avgOrderValue >= 0 ? "up" : "down"}
trendValue={formatPercentage(growth.avgOrderValue)}
description={
periodProgress < 100
? `Projected: ${formatCurrency(currentAOV)}`
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : aovTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(aovPercentage)}
info="Average value per order for the selected period"
colorClass="text-purple-600 dark:text-purple-400"
/>
@@ -519,6 +541,25 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
showPrevious: false,
});
const [summaryStats, setSummaryStats] = useState({});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
// Add function to fetch projection
const fetchProjection = useCallback(async (params) => {
if (!params) return;
try {
setProjectionLoading(true);
const response = await axios.get("/api/klaviyo/events/projection", {
params,
});
setProjection(response.data);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
setProjectionLoading(false);
}
}, []);
// Fetch data function
const fetchData = useCallback(async (params) => {
@@ -551,13 +592,18 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
setData(processedData);
setSummaryStats(stats);
setError(null);
// Fetch projection if needed
if (stats.periodProgress < 100) {
fetchProjection(params);
}
} catch (error) {
console.error("Error fetching data:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, []);
}, [fetchProjection]);
// Handle time range change
const handleTimeRangeChange = useCallback(
@@ -832,7 +878,11 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
(loading ? (
<SkeletonStats />
) : (
<SummaryStats stats={summaryStats} />
<SummaryStats
stats={summaryStats}
projection={projection}
projectionLoading={projectionLoading}
/>
))}
{/* Show metric toggles only if not in error state */}

View File

@@ -1256,6 +1256,98 @@ const SkeletonTable = ({ rows = 8 }) => (
</div>
);
const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
const {
totalRevenue = 0,
totalOrders = 0,
avgOrderValue = 0,
bestDay = null,
prevRevenue = 0,
prevOrders = 0,
prevAvgOrderValue = 0,
periodProgress = 100
} = stats;
// Calculate projected values when period is incomplete
const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue;
const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down";
const revenueDiff = Math.abs(currentRevenue - prevRevenue);
const revenuePercentage = (revenueDiff / prevRevenue) * 100;
// Calculate order trends
const currentOrders = periodProgress < 100 ? Math.round(totalOrders * (100 / periodProgress)) : totalOrders;
const ordersTrend = currentOrders >= prevOrders ? "up" : "down";
const ordersDiff = Math.abs(currentOrders - prevOrders);
const ordersPercentage = (ordersDiff / prevOrders) * 100;
// Calculate AOV trends
const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue;
const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down";
const aovDiff = Math.abs(currentAOV - prevAvgOrderValue);
const aovPercentage = (aovDiff / prevAvgOrderValue) * 100;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
<StatCard
title="Total Revenue"
value={formatCurrency(totalRevenue, false)}
description={
periodProgress < 100
? `Projected: ${formatCurrency(currentRevenue, false)}`
: `Previous: ${formatCurrency(prevRevenue, false)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : revenueTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(revenuePercentage)}
info="Total revenue for the selected period"
colorClass="text-green-600 dark:text-green-400"
/>
<StatCard
title="Total Orders"
value={totalOrders.toLocaleString()}
description={
periodProgress < 100
? `Projected: ${currentOrders.toLocaleString()}`
: `Previous: ${prevOrders.toLocaleString()}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : ordersTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(ordersPercentage)}
info="Total number of orders for the selected period"
colorClass="text-blue-600 dark:text-blue-400"
/>
<StatCard
title="AOV"
value={formatCurrency(avgOrderValue)}
description={
periodProgress < 100
? `Projected: ${formatCurrency(currentAOV)}`
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
}
trend={projectionLoading && periodProgress < 100 ? undefined : aovTrend}
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(aovPercentage)}
info="Average value per order for the selected period"
colorClass="text-purple-600 dark:text-purple-400"
/>
<StatCard
title="Best Day"
value={formatCurrency(bestDay?.revenue || 0, false)}
description={
bestDay?.timestamp
? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})} - ${bestDay.orders} orders`
: "No data"
}
info="Day with highest revenue in the selected period"
colorClass="text-orange-600 dark:text-orange-400"
/>
</div>
);
});
const StatCards = ({
timeRange: initialTimeRange = "today",
startDate,
@@ -1476,28 +1568,45 @@ const StatCards = ({
}, []);
const calculateRevenueTrend = useCallback(() => {
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0)
return null;
if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null;
// For incomplete periods, compare projected revenue to previous period
// For complete periods, compare actual revenue to previous period
const currentRevenue =
stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue;
const prevRevenue = stats.prevPeriodRevenue;
// If period is complete, use actual revenue
// If period is incomplete, use smart projection when available, fallback to simple projection
const currentRevenue = stats.periodProgress < 100
? (projection?.projectedRevenue || stats.projectedRevenue)
: stats.revenue;
const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue
console.log('[RevenueTrend Debug]', {
periodProgress: stats.periodProgress,
currentRevenue,
smartProjection: projection?.projectedRevenue,
simpleProjection: stats.projectedRevenue,
actualRevenue: stats.revenue,
prevRevenue,
isProjected: stats.periodProgress < 100
});
if (!currentRevenue || !prevRevenue) return null;
// Calculate absolute difference percentage
const trend = currentRevenue >= prevRevenue ? "up" : "down";
const diff = Math.abs(currentRevenue - prevRevenue);
const percentage = (diff / prevRevenue) * 100;
console.log('[RevenueTrend Result]', {
trend,
percentage,
calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%`
});
return {
trend,
value: percentage,
current: currentRevenue,
previous: prevRevenue,
};
}, [stats]);
}, [stats, projection]);
const calculateOrderTrend = useCallback(() => {
if (!stats?.prevPeriodOrders) return null;