From aaa95a5b9e7051f674de92944c0f0901bb7f0532 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 26 Dec 2024 17:14:15 -0500 Subject: [PATCH] Improve projected revenue --- .../klaviyo-server/routes/events.routes.js | 51 + .../klaviyo-server/services/events.service.js | 156 +++ .../src/components/dashboard/StatCards.jsx | 1162 +++++++++++------ 3 files changed, 982 insertions(+), 387 deletions(-) diff --git a/dashboard-server/klaviyo-server/routes/events.routes.js b/dashboard-server/klaviyo-server/routes/events.routes.js index f710719..9e25f06 100644 --- a/dashboard-server/klaviyo-server/routes/events.routes.js +++ b/dashboard-server/klaviyo-server/routes/events.routes.js @@ -149,6 +149,57 @@ export function createEventsRouter(apiKey, apiRevision) { } }); + // Add new route for smart revenue projection + router.get('/projection', async (req, res) => { + try { + const { timeRange, startDate, endDate } = req.query; + console.log('[Events Route] Projection request:', { + timeRange, + startDate, + endDate + }); + + let range; + if (startDate && endDate) { + range = timeManager.getCustomRange(startDate, endDate); + } else if (timeRange) { + range = timeManager.getDateRange(timeRange); + } else { + return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' }); + } + + if (!range) { + return res.status(400).json({ error: 'Invalid time range' }); + } + + const params = { + timeRange, + startDate: range.start.toISO(), + endDate: range.end.toISO() + }; + + // Try to get from cache first with a short TTL + const cacheKey = redisService._getCacheKey('projection', params); + const cachedData = await redisService.get(cacheKey); + + if (cachedData) { + console.log('[Events Route] Cache hit for projection'); + return res.json(cachedData); + } + + console.log('[Events Route] Calculating smart projection with params:', params); + const projection = await eventsService.calculateSmartProjection(params); + + // Cache the results with a short TTL (5 minutes) + await redisService.set(cacheKey, projection, 300); + + res.json(projection); + } catch (error) { + console.error("[Events Route] Error calculating projection:", error); + res.status(500).json({ error: error.message }); + } + }); + // Add new route for detailed stats router.get('/stats/details', async (req, res) => { try { diff --git a/dashboard-server/klaviyo-server/services/events.service.js b/dashboard-server/klaviyo-server/services/events.service.js index 551f805..19142d5 100644 --- a/dashboard-server/klaviyo-server/services/events.service.js +++ b/dashboard-server/klaviyo-server/services/events.service.js @@ -2022,4 +2022,160 @@ export class EventsService { throw error; } } + + async calculateSmartProjection(params = {}) { + try { + const { timeRange, startDate, endDate } = params; + + // Get current period dates + let periodStart, periodEnd; + if (startDate && endDate) { + periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(startDate)); + periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(endDate)); + } else if (timeRange) { + const range = this.timeManager.getDateRange(timeRange); + periodStart = range.start; + periodEnd = range.end; + } + + // Get the same day of week from the last 4 weeks for pattern matching + const historicalPeriods = []; + let historicalStart = periodStart.minus({ weeks: 4 }); + + for (let i = 0; i < 4; i++) { + historicalPeriods.push({ + start: historicalStart.plus({ weeks: i }), + end: historicalStart.plus({ weeks: i + 1 }).minus({ milliseconds: 1 }) + }); + } + + // Fetch current period data + const currentEvents = await this.getEvents({ + startDate: periodStart.toISO(), + endDate: periodEnd.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }); + + // Fetch historical data for pattern matching + const historicalPromises = historicalPeriods.map(period => + this.getEvents({ + startDate: period.start.toISO(), + endDate: period.end.toISO(), + metricId: METRIC_IDS.PLACED_ORDER + }) + ); + + const historicalResults = await Promise.all(historicalPromises); + + // Process current period data + const currentData = this._transformEvents(currentEvents.data || []); + const currentRevenue = currentData.reduce((sum, event) => { + const props = event.event_properties || {}; + return sum + Number(props.TotalAmount || 0); + }, 0); + + // Build hourly patterns from historical data + const hourlyPatterns = Array(24).fill(0).map(() => ({ + count: 0, + revenue: 0, + percentage: 0 + })); + + let totalHistoricalRevenue = 0; + let totalHistoricalOrders = 0; + + historicalResults.forEach(result => { + const events = this._transformEvents(result.data || []); + events.forEach(event => { + const datetime = this.timeManager.toDateTime(event.attributes?.datetime); + if (!datetime) return; + + const hour = datetime.hour; + const props = event.event_properties || {}; + const amount = Number(props.TotalAmount || 0); + + hourlyPatterns[hour].count++; + hourlyPatterns[hour].revenue += amount; + totalHistoricalRevenue += amount; + totalHistoricalOrders++; + }); + }); + + // Calculate percentages + hourlyPatterns.forEach(pattern => { + pattern.percentage = totalHistoricalRevenue > 0 ? + (pattern.revenue / totalHistoricalRevenue) * 100 : 0; + }); + + // Get current hour in the period's timezone + const now = this.timeManager.getNow(); + const currentHour = now.hour; + const currentMinute = now.minute; + + // 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; + } + // Add partial current hour + expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress; + + // Calculate projection based on patterns + let projectedRevenue = 0; + if (expectedPercentageSeen > 0) { + projectedRevenue = (currentRevenue / (expectedPercentageSeen / 100)); + } + + // Calculate confidence score (0-1) based on: + // 1. How much historical data we have + // 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 + + const confidence = ( + (patternConsistency * 0.4) + + (periodProgress / 100 * 0.4) + + (historicalDataAmount * 0.2) + ); + + // Return both the simple and pattern-based projections with metadata + return { + currentRevenue, + projectedRevenue, + confidence, + metadata: { + periodProgress, + patternConsistency, + historicalOrders: totalHistoricalOrders, + hourlyPatterns, + expectedPercentageSeen, + currentHour, + currentMinute + } + }; + } catch (error) { + console.error('[EventsService] Error calculating smart projection:', error); + throw error; + } + } + + _calculatePatternConsistency(hourlyPatterns) { + // Calculate the standard deviation of the percentage distribution + const mean = hourlyPatterns.reduce((sum, pattern) => sum + pattern.percentage, 0) / 24; + const variance = hourlyPatterns.reduce((sum, pattern) => { + const diff = pattern.percentage - mean; + return sum + (diff * diff); + }, 0) / 24; + const stdDev = Math.sqrt(variance); + + // Normalize to a 0-1 scale where lower standard deviation means higher consistency + // Using a sigmoid function to normalize + const normalizedConsistency = 1 / (1 + Math.exp(stdDev / 10)); + return normalizedConsistency; + } } diff --git a/dashboard/src/components/dashboard/StatCards.jsx b/dashboard/src/components/dashboard/StatCards.jsx index 52b3f2e..679cf04 100644 --- a/dashboard/src/components/dashboard/StatCards.jsx +++ b/dashboard/src/components/dashboard/StatCards.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback, Suspense, memo } from 'react'; -import axios from 'axios'; +import React, { useState, useEffect, useCallback, Suspense, memo } from "react"; +import axios from "axios"; import { Card, CardContent, @@ -28,14 +28,15 @@ import { XAxis, YAxis, CartesianGrid, - Tooltip, ResponsiveContainer, Legend, PieChart, Pie, - Cell + Cell, } from "recharts"; -import { +// Import Tooltip from recharts with alias to avoid naming conflict +import { Tooltip as RechartsTooltip } from "recharts"; +import { DollarSign, ShoppingCart, Package, @@ -54,7 +55,7 @@ import { CircleDollarSign, MapPin, Info, - Loader2 + Loader2, } from "lucide-react"; import { DateTime } from "luxon"; import { TIME_RANGES } from "@/lib/constants"; @@ -70,6 +71,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"; const formatCurrency = (value, minimumFractionDigits = 0) => { if (!value || isNaN(value)) return "$0"; @@ -82,14 +84,14 @@ const formatCurrency = (value, minimumFractionDigits = 0) => { }; const formatPercentage = (value) => { - if (typeof value !== 'number') return '0%'; + if (typeof value !== "number") return "0%"; return `${Math.round(value)}%`; }; const formatHour = (hour) => { const date = new Date(); date.setHours(hour, 0, 0); - return date.toLocaleString('en-US', { hour: 'numeric', hour12: true }); + return date.toLocaleString("en-US", { hour: "numeric", hour12: true }); }; // Reusable chart components @@ -100,7 +102,7 @@ const TimeSeriesChart = ({ color = "hsl(var(--primary))", type = "line", valueFormatter = (value) => value, - height = 400 + height = 400, }) => { const ChartComponent = type === "line" ? LineChart : BarChart; const DataComponent = type === "line" ? Line : Bar; @@ -113,23 +115,25 @@ const TimeSeriesChart = ({ return (
- + DateTime.fromISO(value).toFormat('LLL d')} + tickFormatter={(value) => DateTime.fromISO(value).toFormat("LLL d")} className="text-xs" /> - - + { if (!active || !payload?.length) return null; return (
-

{DateTime.fromISO(label).toFormat('LLL d')}

+

+ {DateTime.fromISO(label).toFormat("LLL d")} +

{payload.map((entry, i) => (

{entry.name}: {valueFormatter(entry.value)} @@ -158,12 +162,7 @@ const TimeSeriesChart = ({ ); }; -const DetailDialog = ({ - open, - onOpenChange, - title, - children -}) => ( +const DetailDialog = ({ open, onOpenChange, title, children }) => (

@@ -176,15 +175,24 @@ const DetailDialog = ({ // Detail view components const RevenueDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; - + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + // Ensure we have daily data points and they're properly formatted - const chartData = data.map(day => ({ - timestamp: DateTime.fromISO(day.timestamp).toFormat('yyyy-MM-dd'), - revenue: parseFloat(day.revenue || 0), - orders: parseInt(day.orders || 0), - date: DateTime.fromISO(day.timestamp).toFormat('LLL d') - })).sort((a, b) => DateTime.fromISO(a.timestamp) - DateTime.fromISO(b.timestamp)); + const chartData = data + .map((day) => ({ + timestamp: DateTime.fromISO(day.timestamp).toFormat("yyyy-MM-dd"), + revenue: parseFloat(day.revenue || 0), + orders: parseInt(day.orders || 0), + date: DateTime.fromISO(day.timestamp).toFormat("LLL d"), + })) + .sort( + (a, b) => DateTime.fromISO(a.timestamp) - DateTime.fromISO(b.timestamp) + ); return ( { }; const OrdersDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); return ( <> @@ -215,7 +228,7 @@ const OrdersDetails = ({ data }) => { ({ hour: formatHour(hour), - orders: count + orders: count, }))} dataKey="orders" name="Orders" @@ -238,7 +251,7 @@ const BarList = ({ data, valueFormatter = (v) => v }) => ( {valueFormatter(item.value)}
-
@@ -255,7 +268,9 @@ const StatGrid = ({ stats }) => (
{stat.label}
{stat.value}
{stat.description && ( -
{stat.description}
+
+ {stat.description} +
)} ))} @@ -264,7 +279,12 @@ const StatGrid = ({ stats }) => ( // Add detail view components const AverageOrderDetails = ({ data, orderCount }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); return ( { }; const CancellationsDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); - const cancelData = data[0]?.canceledOrders || { total: 0, count: 0, reasons: {}, items: [] }; - const timeSeriesData = data.map(day => ({ + const cancelData = data[0]?.canceledOrders || { + total: 0, + count: 0, + reasons: {}, + items: [], + }; + const timeSeriesData = data.map((day) => ({ timestamp: day.timestamp, total: day.canceledOrders?.total || 0, - count: day.canceledOrders?.count || 0 + count: day.canceledOrders?.count || 0, })); const reasonData = Object.entries(cancelData.reasons || {}) .map(([reason, count]) => ({ reason, - count + count, })) .sort((a, b) => b.count - a.count); @@ -333,7 +363,9 @@ const CancellationsDetails = ({ data }) => { {reasonData.map((item, index) => ( {item.reason} - {item.count.toLocaleString()} + + {item.count.toLocaleString()} + ))} @@ -346,7 +378,12 @@ const CancellationsDetails = ({ data }) => { }; const BrandsCategoriesDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); const stats = data[0]; const brandsList = stats?.brands?.list || []; @@ -372,8 +409,12 @@ const BrandsCategoriesDetails = ({ data }) => { {brandsList.map((brand) => ( {brand.name} - {brand.count?.toLocaleString()} - ${brand.revenue?.toFixed(2)} + + {brand.count?.toLocaleString()} + + + ${brand.revenue?.toFixed(2)} + ))} @@ -399,8 +440,12 @@ const BrandsCategoriesDetails = ({ data }) => { {categoriesList.map((category) => ( {category.name} - {category.count?.toLocaleString()} - ${category.revenue?.toFixed(2)} + + {category.count?.toLocaleString()} + + + ${category.revenue?.toFixed(2)} + ))} @@ -412,7 +457,12 @@ const BrandsCategoriesDetails = ({ data }) => { }; const ShippingDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); const shippedCount = data[0]?.shipping?.shippedCount || 0; const locations = data[0]?.shipping?.locations || {}; @@ -420,14 +470,14 @@ const ShippingDetails = ({ data }) => { // Shipping method name mappings const shippingMethodNames = { - 'usps_ground_advantage': 'USPS Ground Advantage', - 'usps_priority': 'USPS Priority', - 'Unknown': 'Digital', - 'fedex_ieconomy': 'FedEx Intl Economy', - 'fedex_homedelivery': 'FedEx Ground', - 'fedex_ground': 'FedEx Ground', - 'fedex_iground': 'FedEx Intl Ground', - 'fedex_2day': 'FedEx 2 Day' + usps_ground_advantage: "USPS Ground Advantage", + usps_priority: "USPS Priority", + Unknown: "Digital", + fedex_ieconomy: "FedEx Intl Economy", + fedex_homedelivery: "FedEx Ground", + fedex_ground: "FedEx Ground", + fedex_iground: "FedEx Intl Ground", + fedex_2day: "FedEx 2 Day", }; return ( @@ -450,8 +500,12 @@ const ShippingDetails = ({ data }) => { {methodStats.map((method) => ( - {shippingMethodNames[method.name] || method.name} - {method.value.toLocaleString()} + + {shippingMethodNames[method.name] || method.name} + + + {method.value.toLocaleString()} + {((method.value / shippedCount) * 100).toFixed(1)}% @@ -480,9 +534,15 @@ const ShippingDetails = ({ data }) => { {locations.byCountry?.map((country) => ( - {country.country} - {country.count.toLocaleString()} - {country.percentage.toFixed(1)}% + + {country.country} + + + {country.count.toLocaleString()} + + + {country.percentage.toFixed(1)}% + ))} @@ -509,8 +569,12 @@ const ShippingDetails = ({ data }) => { {locations.byState?.map((state) => ( {state.state} - {state.count.toLocaleString()} - {state.percentage.toFixed(1)}% + + {state.count.toLocaleString()} + + + {state.percentage.toFixed(1)}% + ))} @@ -522,19 +586,24 @@ const ShippingDetails = ({ data }) => { }; const OrderTypeDetails = ({ data, type }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); - const timeSeriesData = data.map(day => ({ + const timeSeriesData = data.map((day) => ({ timestamp: day.timestamp, count: day.count, value: day.value, - percentage: day.percentage + percentage: day.percentage, })); const typeColors = { - 'pre_orders': 'hsl(47.9 95.8% 53.1%)', // Yellow for pre-orders - 'local_pickup': 'hsl(192.2 70.1% 51.4%)', // Cyan for local pickup - 'on_hold': 'hsl(346.8 77.2% 49.8%)' // Red for on hold + pre_orders: "hsl(47.9 95.8% 53.1%)", // Yellow for pre-orders + local_pickup: "hsl(192.2 70.1% 51.4%)", // Cyan for local pickup + on_hold: "hsl(346.8 77.2% 49.8%)", // Red for on hold }; const color = typeColors[type]; @@ -578,34 +647,49 @@ const OrderTypeDetails = ({ data, type }) => { }; const PeakHourDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); - const hourlyData = data[0]?.hourlyOrders?.map((count, hour) => ({ - timestamp: hour, // Use raw hour number for x-axis - orders: count - })) || []; + const hourlyData = + data[0]?.hourlyOrders?.map((count, hour) => ({ + timestamp: hour, // Use raw hour number for x-axis + orders: count, + })) || []; return (
- + { const date = new Date(); date.setHours(hour, 0, 0); - return date.toLocaleString('en-US', { hour: 'numeric', hour12: true }); + return date.toLocaleString("en-US", { + hour: "numeric", + hour12: true, + }); }} className="text-xs" /> - { if (!active || !payload?.length) return null; const date = new Date(); date.setHours(label, 0, 0); - const time = date.toLocaleString('en-US', { hour: 'numeric', hour12: true }); + const time = date.toLocaleString("en-US", { + hour: "numeric", + hour12: true, + }); return (

{time}

@@ -614,11 +698,7 @@ const PeakHourDetails = ({ data }) => { ); }} /> - +
@@ -626,19 +706,24 @@ const PeakHourDetails = ({ data }) => { }; const RefundDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); const refundData = data[0]?.refunds || { total: 0, count: 0, reasons: {} }; - const timeSeriesData = data.map(day => ({ + const timeSeriesData = data.map((day) => ({ timestamp: day.timestamp, total: day.refunds?.total || 0, - count: day.refunds?.count || 0 + count: day.refunds?.count || 0, })); const reasonData = Object.entries(refundData.reasons || {}) .map(([reason, count]) => ({ reason, - count + count, })) .sort((a, b) => b.count - a.count); @@ -681,7 +766,9 @@ const RefundDetails = ({ data }) => { {reasonData.map((item, index) => ( {item.reason} - {item.count.toLocaleString()} + + {item.count.toLocaleString()} + ))} @@ -694,17 +781,23 @@ const RefundDetails = ({ data }) => { }; const OrderRangeDetails = ({ data }) => { - if (!data?.length) return
No data available for the selected time range.
; + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); // Get the data from the entire period const allData = data.reduce((acc, day) => { // Initialize distribution data structure if not exists if (!acc.orderValueDistribution) { - acc.orderValueDistribution = day.orderValueDistribution?.map(range => ({ - ...range, - count: 0, - total: 0 - })) || []; + acc.orderValueDistribution = + day.orderValueDistribution?.map((range) => ({ + ...range, + count: 0, + total: 0, + })) || []; } // Aggregate distribution data @@ -721,24 +814,35 @@ const OrderRangeDetails = ({ data }) => { return acc; }, {}); - const timeSeriesData = data.map(day => ({ + const timeSeriesData = data.map((day) => ({ timestamp: day.timestamp, largest: day.orderValueRange?.largest || 0, smallest: day.orderValueRange?.smallest || 0, - average: day.averageOrderValue || 0 + average: day.averageOrderValue || 0, })); // Transform distribution data using aggregated values - const formattedDistributionData = allData.orderValueDistribution?.map(range => { - const totalRevenue = allData.orderValueDistribution.reduce((sum, r) => sum + (r.total || 0), 0); - return { - range: range.max === 'Infinity' ? `$${range.min}+` : `$${range.min}-${range.max}`, - count: range.count, - total: range.total, - percentage: ((range.count / (allData.totalOrders || 1)) * 100).toFixed(1), - revenuePercentage: ((range.total / (totalRevenue || 1)) * 100).toFixed(1) - }; - }) || []; + const formattedDistributionData = + allData.orderValueDistribution?.map((range) => { + const totalRevenue = allData.orderValueDistribution.reduce( + (sum, r) => sum + (r.total || 0), + 0 + ); + return { + range: + range.max === "Infinity" + ? `$${range.min}+` + : `$${range.min}-${range.max}`, + count: range.count, + total: range.total, + percentage: ((range.count / (allData.totalOrders || 1)) * 100).toFixed( + 1 + ), + revenuePercentage: ((range.total / (totalRevenue || 1)) * 100).toFixed( + 1 + ), + }; + }) || []; return (
@@ -781,11 +885,21 @@ const OrderRangeDetails = ({ data }) => { {formattedDistributionData.map((range, index) => ( - {range.range} - {range.count.toLocaleString()} - {formatCurrency(range.total)} - {range.percentage}% - {range.revenuePercentage}% + + {range.range} + + + {range.count.toLocaleString()} + + + {formatCurrency(range.total)} + + + {range.percentage}% + + + {range.revenuePercentage}% + ))} @@ -793,7 +907,10 @@ const OrderRangeDetails = ({ data }) => {
- + { className="text-xs" tickFormatter={(value) => `${value}%`} /> - { if (!active || !payload?.length) return null; const data = payload[0].payload; return (

{data.range}

-

Orders: {data.count.toLocaleString()}

-

Revenue: {formatCurrency(data.total)}

-

% of Orders: {data.percentage}%

-

% of Revenue: {data.revenuePercentage}%

+

+ Orders: {data.count.toLocaleString()} +

+

+ Revenue: {formatCurrency(data.total)} +

+

+ % of Orders: {data.percentage}% +

+

+ % of Revenue: {data.revenuePercentage}% +

); }} @@ -835,11 +960,11 @@ const OrderRangeDetails = ({ data }) => { ); }; -const StatCard = ({ - title, - value, - description, - trend, +const StatCard = ({ + title, + value, + description, + trend, trendValue, valuePrefix = "", valueSuffix = "", @@ -853,10 +978,14 @@ const StatCard = ({ info, onDetailsClick, isLoading = false, - progress + progress, }) => ( - @@ -883,21 +1012,34 @@ const StatCard = ({
- {valuePrefix}{value}{valueSuffix} + {valuePrefix} + {value} + {valueSuffix}
{description && (
{description} {trend && ( - - {trend === 'up' ? : } - {trendPrefix}{trendValue}{trendSuffix} + + {trend === "up" ? ( + + ) : ( + + )} + {trendPrefix} + {trendValue} + {trendSuffix} )}
)}
-
)} @@ -911,12 +1053,12 @@ const useDataCache = () => { const getCacheKey = (timeRange, metric) => `${timeRange}_${metric}`; const setCacheData = (timeRange, metric, data) => { - setCache(prev => ({ + setCache((prev) => ({ ...prev, [getCacheKey(timeRange, metric)]: { data, - timestamp: Date.now() - } + timestamp: Date.now(), + }, })); }; @@ -1018,12 +1160,12 @@ const SkeletonTable = ({ rows = 5 }) => (
); -const StatCards = ({ - timeRange: initialTimeRange = 'today', +const StatCards = ({ + timeRange: initialTimeRange = "today", startDate, endDate, title = "Sales Dashboard", - description = "" + description = "", }) => { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -1035,184 +1177,229 @@ const StatCards = ({ const [detailDataLoading, setDetailDataLoading] = useState({}); const [detailData, setDetailData] = useState({}); const [isInitialLoad, setIsInitialLoad] = useState(true); + const [projection, setProjection] = useState(null); + const [projectionLoading, setProjectionLoading] = useState(false); const { setCacheData, getCacheData, clearCache } = useDataCache(); // Function to determine if we should use last30days for trend charts - const shouldUseLast30Days = useCallback((metric) => { - if (['brands_categories', 'shipping'].includes(metric)) { - return false; - } - const shortPeriods = ['today', 'yesterday', 'last7days', 'thisWeek', 'lastWeek']; - return shortPeriods.includes(timeRange); - }, [timeRange]); + const shouldUseLast30Days = useCallback( + (metric) => { + if (["brands_categories", "shipping"].includes(metric)) { + return false; + } + const shortPeriods = [ + "today", + "yesterday", + "last7days", + "thisWeek", + "lastWeek", + ]; + return shortPeriods.includes(timeRange); + }, + [timeRange] + ); // Function to fetch detail data for a specific metric - const fetchDetailData = useCallback(async (metric, orderType) => { - const detailTimeRange = shouldUseLast30Days(metric) ? 'last30days' : timeRange; - const cachedData = getCacheData(detailTimeRange, metric); - - if (cachedData) { - console.log(`Using cached data for ${metric}`); - setDetailData(prev => ({ ...prev, [metric]: cachedData })); - return cachedData; - } + const fetchDetailData = useCallback( + async (metric, orderType) => { + const detailTimeRange = shouldUseLast30Days(metric) + ? "last30days" + : timeRange; + const cachedData = getCacheData(detailTimeRange, metric); - console.log(`Fetching detail data for ${metric}`); - setDetailDataLoading(prev => ({ ...prev, [metric]: true })); - - try { - const params = { - ...(timeRange === 'custom' - ? { startDate, endDate } - : { timeRange: detailTimeRange }), - metric, - daily: true - }; - - // For metrics that need the full stats - if (['shipping', 'brands_categories'].includes(metric)) { - const response = await axios.get('/api/klaviyo/events/stats', { params }); - const data = [response.data.stats]; - setCacheData(detailTimeRange, metric, data); - setDetailData(prev => ({ ...prev, [metric]: data })); - setError(null); - return data; + if (cachedData) { + console.log(`Using cached data for ${metric}`); + setDetailData((prev) => ({ ...prev, [metric]: cachedData })); + return cachedData; } - // For order types (pre_orders, local_pickup, on_hold) - if (['pre_orders', 'local_pickup', 'on_hold'].includes(metric)) { - const response = await axios.get('/api/klaviyo/events/stats/details', { - params: { - ...params, - orderType: orderType - } + console.log(`Fetching detail data for ${metric}`); + setDetailDataLoading((prev) => ({ ...prev, [metric]: true })); + + try { + const params = { + ...(timeRange === "custom" + ? { startDate, endDate } + : { timeRange: detailTimeRange }), + metric, + daily: true, + }; + + // For metrics that need the full stats + if (["shipping", "brands_categories"].includes(metric)) { + const response = await axios.get("/api/klaviyo/events/stats", { + params, + }); + const data = [response.data.stats]; + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } + + // For order types (pre_orders, local_pickup, on_hold) + if (["pre_orders", "local_pickup", "on_hold"].includes(metric)) { + const response = await axios.get( + "/api/klaviyo/events/stats/details", + { + params: { + ...params, + orderType: orderType, + }, + } + ); + const data = response.data.stats; + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } + + // For refunds and cancellations + if (["refunds", "cancellations"].includes(metric)) { + const response = await axios.get( + "/api/klaviyo/events/stats/details", + { + params: { + ...params, + eventType: + metric === "refunds" ? "PAYMENT_REFUNDED" : "CANCELED_ORDER", + }, + } + ); + const data = response.data.stats; + + // Transform the data to match the expected format + const transformedData = data.map((day) => ({ + ...day, + timestamp: day.timestamp, + refunds: + metric === "refunds" + ? { + total: day.refunds?.total || 0, + count: day.refunds?.count || 0, + reasons: day.refunds?.reasons || {}, + } + : undefined, + canceledOrders: + metric === "cancellations" + ? { + total: day.canceledOrders?.total || 0, + count: day.canceledOrders?.count || 0, + reasons: day.canceledOrders?.reasons || {}, + } + : undefined, + })); + + setCacheData(detailTimeRange, metric, transformedData); + setDetailData((prev) => ({ ...prev, [metric]: transformedData })); + setError(null); + return transformedData; + } + + // For order range + if (metric === "order_range") { + const response = await axios.get( + "/api/klaviyo/events/stats/details", + { + params: { + ...params, + eventType: "PLACED_ORDER", + }, + } + ); + const data = response.data.stats; + console.log("Fetched order range data:", data); + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } + + // For all other metrics + const response = await axios.get("/api/klaviyo/events/stats/details", { + params, }); const data = response.data.stats; setCacheData(detailTimeRange, metric, data); - setDetailData(prev => ({ ...prev, [metric]: data })); + setDetailData((prev) => ({ ...prev, [metric]: data })); setError(null); return data; + } catch (error) { + console.error(`Error fetching detail data for ${metric}:`, error); + setError(error.response?.data?.error || error.message); + return null; + } finally { + setDetailDataLoading((prev) => ({ ...prev, [metric]: false })); } - - // For refunds and cancellations - if (['refunds', 'cancellations'].includes(metric)) { - const response = await axios.get('/api/klaviyo/events/stats/details', { - params: { - ...params, - eventType: metric === 'refunds' ? 'PAYMENT_REFUNDED' : 'CANCELED_ORDER' - } - }); - const data = response.data.stats; - - // Transform the data to match the expected format - const transformedData = data.map(day => ({ - ...day, - timestamp: day.timestamp, - refunds: metric === 'refunds' ? { - total: day.refunds?.total || 0, - count: day.refunds?.count || 0, - reasons: day.refunds?.reasons || {} - } : undefined, - canceledOrders: metric === 'cancellations' ? { - total: day.canceledOrders?.total || 0, - count: day.canceledOrders?.count || 0, - reasons: day.canceledOrders?.reasons || {} - } : undefined - })); - - setCacheData(detailTimeRange, metric, transformedData); - setDetailData(prev => ({ ...prev, [metric]: transformedData })); - setError(null); - return transformedData; - } - - // For order range - if (metric === 'order_range') { - const response = await axios.get('/api/klaviyo/events/stats/details', { - params: { - ...params, - eventType: 'PLACED_ORDER' - } - }); - const data = response.data.stats; - console.log('Fetched order range data:', data); - setCacheData(detailTimeRange, metric, data); - setDetailData(prev => ({ ...prev, [metric]: data })); - setError(null); - return data; - } - - // For all other metrics - const response = await axios.get('/api/klaviyo/events/stats/details', { params }); - const data = response.data.stats; - setCacheData(detailTimeRange, metric, data); - setDetailData(prev => ({ ...prev, [metric]: data })); - setError(null); - return data; - } catch (error) { - console.error(`Error fetching detail data for ${metric}:`, error); - setError(error.response?.data?.error || error.message); - return null; - } finally { - setDetailDataLoading(prev => ({ ...prev, [metric]: false })); - } - }, [timeRange, startDate, endDate, shouldUseLast30Days, setCacheData, getCacheData]); + }, + [ + timeRange, + startDate, + endDate, + shouldUseLast30Days, + setCacheData, + getCacheData, + ] + ); // Corrected preloadDetailData function const preloadDetailData = useCallback(() => { const metrics = [ - 'revenue', - 'orders', - 'average_order', - 'refunds', - 'cancellations', - 'order_range', - 'pre_orders', - 'local_pickup', - 'on_hold' + "revenue", + "orders", + "average_order", + "refunds", + "cancellations", + "order_range", + "pre_orders", + "local_pickup", + "on_hold", ]; return Promise.all( - metrics.map(metric => fetchDetailData(metric, metric)) - ).catch(error => { - console.error('Error during detail data preload:', error); + metrics.map((metric) => fetchDetailData(metric, metric)) + ).catch((error) => { + console.error("Error during detail data preload:", error); }); }, [fetchDetailData]); // Move trend calculation functions inside the component const calculateTrend = useCallback((current, previous) => { if (!current || !previous) return null; - const trend = current >= previous ? 'up' : 'down'; + const trend = current >= previous ? "up" : "down"; const diff = Math.abs(current - previous); - const percentage = ((diff / previous) * 100); - - return { - trend, + const percentage = (diff / previous) * 100; + + return { + trend, value: percentage, current, - previous + previous, }; }, []); 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 currentRevenue = + stats.periodProgress < 100 ? stats.projectedRevenue : stats.revenue; const prevRevenue = stats.prevPeriodRevenue; - + if (!currentRevenue || !prevRevenue) return null; - - const trend = currentRevenue >= prevRevenue ? 'up' : 'down'; + + const trend = currentRevenue >= prevRevenue ? "up" : "down"; const diff = Math.abs(currentRevenue - prevRevenue); - const percentage = ((diff / prevRevenue) * 100); - - return { - trend, + const percentage = (diff / prevRevenue) * 100; + + return { + trend, value: percentage, current: currentRevenue, - previous: prevRevenue + previous: prevRevenue, }; }, [stats]); @@ -1229,29 +1416,30 @@ const StatCards = ({ // Initial load effect useEffect(() => { let isMounted = true; - + const loadData = async () => { try { setLoading(true); setStats(null); - const params = timeRange === 'custom' - ? { startDate, endDate } - : { timeRange }; + const params = + timeRange === "custom" ? { startDate, endDate } : { timeRange }; + + const response = await axios.get("/api/klaviyo/events/stats", { + params, + }); - const response = await axios.get('/api/klaviyo/events/stats', { params }); - if (!isMounted) return; setDateRange(response.data.timeRange); setStats(response.data.stats); - setLastUpdate(DateTime.now().setZone('America/New_York')); + setLastUpdate(DateTime.now().setZone("America/New_York")); setError(null); // Start preloading detail data preloadDetailData(); } catch (error) { - console.error('Error loading data:', error); + console.error("Error loading data:", error); if (isMounted) { setError(error.message); } @@ -1264,22 +1452,64 @@ const StatCards = ({ }; loadData(); - return () => { isMounted = false; }; + return () => { + isMounted = false; + }; }, [timeRange, startDate, endDate]); + // Load smart projection separately + useEffect(() => { + let isMounted = true; + + const loadProjection = async () => { + if (!stats?.periodProgress || stats.periodProgress >= 100) return; + + try { + setProjectionLoading(true); + const params = + timeRange === "custom" ? { startDate, endDate } : { timeRange }; + + const response = await axios.get("/api/klaviyo/events/projection", { + params, + }); + + if (!isMounted) return; + setProjection(response.data); + } catch (error) { + console.error("Error loading projection:", error); + } finally { + if (isMounted) { + setProjectionLoading(false); + } + } + }; + + loadProjection(); + return () => { + isMounted = false; + }; + }, [timeRange, startDate, endDate, stats?.periodProgress]); + // Auto-refresh for 'today' view useEffect(() => { - if (timeRange !== 'today') return; + if (timeRange !== "today") return; const interval = setInterval(async () => { try { - const response = await axios.get('/api/klaviyo/events/stats', { - params: { timeRange: 'today' } - }); - setStats(response.data.stats); - setLastUpdate(DateTime.now().setZone('America/New_York')); + const [statsResponse, projectionResponse] = await Promise.all([ + axios.get("/api/klaviyo/events/stats", { + params: { timeRange: "today" }, + }), + axios.get("/api/klaviyo/events/projection", { + params: { timeRange: "today" }, + }), + ]); + + setStats(statsResponse.data.stats); + setProjection(projectionResponse.data); + setLastUpdate(DateTime.now().setZone("America/New_York")); } catch (error) { - console.error('Error auto-refreshing stats:', error); + console.error("Error auto-refreshing stats:", error); } }, 60000); @@ -1288,10 +1518,17 @@ const StatCards = ({ // Modified AsyncDetailView component const AsyncDetailView = memo(({ metric, type, orderCount }) => { - const detailTimeRange = shouldUseLast30Days(metric) ? 'last30days' : timeRange; - const cachedData = detailData[metric] || getCacheData(detailTimeRange, metric); + const detailTimeRange = shouldUseLast30Days(metric) + ? "last30days" + : timeRange; + const cachedData = + detailData[metric] || getCacheData(detailTimeRange, metric); const isLoading = detailDataLoading[metric]; - const isOrderTypeMetric = ['pre_orders', 'local_pickup', 'on_hold'].includes(metric); + const isOrderTypeMetric = [ + "pre_orders", + "local_pickup", + "on_hold", + ].includes(metric); useEffect(() => { let isMounted = true; @@ -1299,33 +1536,38 @@ const StatCards = ({ const loadData = async () => { if (!cachedData && !isLoading) { // Pass type only for order type metrics - const data = await fetchDetailData(metric, isOrderTypeMetric ? metric : undefined); + const data = await fetchDetailData( + metric, + isOrderTypeMetric ? metric : undefined + ); if (!isMounted) return; // The state updates are handled in fetchDetailData } }; loadData(); - return () => { isMounted = false; }; + return () => { + isMounted = false; + }; }, [metric, timeRange, isOrderTypeMetric]); // Depend on isOrderTypeMetric if (isLoading || (!cachedData && !error)) { switch (metric) { - case 'revenue': - case 'orders': - case 'average_order': + case "revenue": + case "orders": + case "average_order": return ; - case 'refunds': - case 'cancellations': - case 'order_range': - case 'pre_orders': - case 'local_pickup': - case 'on_hold': + case "refunds": + case "cancellations": + case "order_range": + case "pre_orders": + case "local_pickup": + case "on_hold": return ; - case 'brands_categories': - case 'shipping': + case "brands_categories": + case "shipping": return ; - case 'peak_hour': + case "peak_hour": return ; default: return
Loading...
; @@ -1337,82 +1579,113 @@ const StatCards = ({ Error - - Failed to load stats: {error} - + Failed to load stats: {error} ); } - if (!cachedData) return
No data available for the selected time range.
; + if (!cachedData) + return ( +
+ No data available for the selected time range. +
+ ); switch (metric) { - case 'revenue': + case "revenue": return ; - case 'orders': + case "orders": return ; - case 'average_order': - return ; - case 'refunds': + case "average_order": + return ( + + ); + case "refunds": return ; - case 'cancellations': + case "cancellations": return ; - case 'order_range': + case "order_range": return ; - case 'pre_orders': - case 'local_pickup': - case 'on_hold': + case "pre_orders": + case "local_pickup": + case "on_hold": return ; default: - return
Invalid metric selected.
; + return ( +
Invalid metric selected.
+ ); } }); - AsyncDetailView.displayName = 'AsyncDetailView'; + AsyncDetailView.displayName = "AsyncDetailView"; // Modified getDetailComponent to use memoized components const getDetailComponent = useCallback(() => { if (!selectedMetric || !stats) { - return
No data available for the selected time range.
; + return ( +
+ No data available for the selected time range. +
+ ); } const data = detailData[selectedMetric]; const isLoading = detailDataLoading[selectedMetric]; - const isOrderTypeMetric = ['pre_orders', 'local_pickup', 'on_hold'].includes(selectedMetric); + const isOrderTypeMetric = [ + "pre_orders", + "local_pickup", + "on_hold", + ].includes(selectedMetric); if (isLoading) { return ; } switch (selectedMetric) { - case 'revenue': - case 'best_revenue_day': + case "revenue": + case "best_revenue_day": return ; - case 'orders': + case "orders": return ; - case 'average_order': - return ; - case 'refunds': + case "average_order": + return ( + + ); + case "refunds": return ; - case 'cancellations': + case "cancellations": return ; - case 'order_range': + case "order_range": return ; - case 'pre_orders': - case 'local_pickup': - case 'on_hold': - return ; - case 'brands_categories': + case "pre_orders": + case "local_pickup": + case "on_hold": + return ( + + ); + case "brands_categories": return ; - case 'shipping': + case "shipping": return ; - case 'peak_hour': - if (!['today', 'yesterday'].includes(timeRange)) { - return
Peak hour details are only available for single-day periods.
; + case "peak_hour": + if (!["today", "yesterday"].includes(timeRange)) { + return ( +
+ Peak hour details are only available for single-day periods. +
+ ); } return ; default: - return
Invalid metric selected.
; + return ( +
Invalid metric selected.
+ ); } }, [selectedMetric, stats, timeRange, detailData, detailDataLoading]); @@ -1451,9 +1724,13 @@ const StatCards = ({
- {title} + + {title} + {description && ( - {description} + + {description} + )}
@@ -1482,9 +1759,7 @@ const StatCards = ({ Error - - Failed to load stats: {error} - + Failed to load stats: {error} @@ -1496,7 +1771,7 @@ const StatCards = ({ const revenueTrend = calculateRevenueTrend(); const orderTrend = calculateOrderTrend(); const aovTrend = calculateAOVTrend(); - const isSingleDay = ['today', 'yesterday'].includes(timeRange); + const isSingleDay = ["today", "yesterday"].includes(timeRange); return ( @@ -1504,13 +1779,41 @@ const StatCards = ({
- {title} + + {title} + {lastUpdate && !loading && ( - Last updated {lastUpdate.toFormat("h:mm a")} + + Last updated {lastUpdate.toFormat("h:mm a")} + {projection?.confidence > 0 && !projectionLoading && ( + + + + + ( + + {Math.round(projection.confidence * 100)}% + + ) + + + +

Confidence level of revenue projection based on historical data patterns

+
+
+
+ )} +
)}
+
-