From 6765e2bbf4d8980978dd39cab2faa0d58cb513bc Mon Sep 17 00:00:00 2001 From: root Date: Tue, 12 May 2026 21:40:23 +0500 Subject: [PATCH] fix: filters, UI --- src/app/api/dashboard/ai-analytics/route.ts | 87 +++++++++--- .../api/dashboard/business-metrics/route.ts | 126 +++++++++++++----- src/app/dashboard/ai-analytics/page.tsx | 40 +++--- src/app/dashboard/layout.tsx | 2 +- src/app/dashboard/page.tsx | 79 +++++++---- .../storefrontQueryClassification.ts | 9 +- 6 files changed, 245 insertions(+), 98 deletions(-) diff --git a/src/app/api/dashboard/ai-analytics/route.ts b/src/app/api/dashboard/ai-analytics/route.ts index 0da35f7..546179f 100644 --- a/src/app/api/dashboard/ai-analytics/route.ts +++ b/src/app/api/dashboard/ai-analytics/route.ts @@ -297,6 +297,29 @@ function productQuestionLabel(content: string): string { ) } +function incrementProductQuestion( + rows: Map, + name: string, +) { + const clean = name.trim() + if (!clean) return + rows.set(clean, { + name: clean, + count: (rows.get(clean)?.count ?? 0) + 1, + }) +} + +function cartEventTypes() { + return new Set([ + "add_to_cart_clicked", + "upsell_clicked", + "cross_sell_clicked", + "cart_link_opened", + "cart_link_generated", + "cart_action_shown", + ]) +} + export async function GET(req: Request) { try { const session = await auth() @@ -456,7 +479,7 @@ export async function GET(req: Request) { if (event.eventType === "ticket_created") row.ticketsCreated += 1 if (event.eventType === "upsell_shown") row.upsellShown += 1 if (event.eventType === "upsell_clicked") row.upsellClicked += 1 - if (event.eventType === "add_to_cart_clicked" || event.eventType === "cart_link_opened") row.cartActions += 1 + if (cartEventTypes().has(event.eventType)) row.cartActions += 1 if (event.sentiment === "positive") row.positiveSentiment += 1 if (event.sentiment === "neutral") row.neutralSentiment += 1 if (event.sentiment === "negative") row.negativeSentiment += 1 @@ -496,14 +519,18 @@ export async function GET(req: Request) { const bucket = classifyStorefrontCustomerQuery(message.content) intentCounts[bucket] += 1 if (bucket === "Product Qs") { - const name = productQuestionLabel(message.content) - productQuestionCounts.set(name, { - name, - count: (productQuestionCounts.get(name)?.count ?? 0) + 1, - }) + incrementProductQuestion(productQuestionCounts, productQuestionLabel(message.content)) } pendingByConversation.set(message.conversationId, message.createdAt) } else if (message.senderType === "AI") { + for (const action of parseStorefrontChatActions(message.actions)) { + if (action.type === "add_to_cart" || action.type === "upsell_suggestion") { + incrementProductQuestion(productQuestionCounts, action.product.title) + } + if (action.type === "open_cart" && action.product?.title) { + incrementProductQuestion(productQuestionCounts, action.product.title) + } + } const pendingAt = pendingByConversation.get(message.conversationId) if (!pendingAt) continue const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000 @@ -513,12 +540,18 @@ export async function GET(req: Request) { } const manualLatencyMinutes: number[] = [] - for (let i = 0; i < chatMessages.length - 1; i++) { - const a = chatMessages[i]! - const b = chatMessages[i + 1]! - if (a.receiverId === vendorUserId && a.senderId !== vendorUserId && b.senderId === vendorUserId) { - const min = (b.createdAt.getTime() - a.createdAt.getTime()) / 60_000 - if (min >= 0 && min <= 24 * 60) manualLatencyMinutes.push(min) + const pendingManualByCounterparty = new Map() + for (const message of chatMessages) { + if (message.receiverId === vendorUserId && message.senderId !== vendorUserId) { + pendingManualByCounterparty.set(message.senderId, message.createdAt) + continue + } + if (message.senderId === vendorUserId) { + const pendingAt = pendingManualByCounterparty.get(message.receiverId) + if (!pendingAt) continue + const min = (message.createdAt.getTime() - pendingAt.getTime()) / 60_000 + if (min >= 0 && min <= 8 * 60) manualLatencyMinutes.push(min) + pendingManualByCounterparty.delete(message.receiverId) } } @@ -531,12 +564,20 @@ export async function GET(req: Request) { const resolvedByAi = analyticsEvents.filter((event) => event.eventType === "resolved_by_ai").length const unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length - const cartClicks = analyticsEvents.filter((event) => - ["add_to_cart_clicked", "upsell_clicked", "cross_sell_clicked"].includes(event.eventType), - ).length + const cartTypes = cartEventTypes() + const cartEvents = analyticsEvents.filter((event) => cartTypes.has(event.eventType)) + const cartClicks = cartEvents.length const cartOpenCount = analyticsEvents.filter((event) => event.eventType === "cart_link_opened").length - const conversionBase = cartClicks || cartOpenCount const directOrderCount = new Set(revenueAttribution.directRows.map((row) => row.orderId)).size + const cartConversationIds = new Set( + cartEvents.map((event) => event.conversationId).filter((id): id is string => Boolean(id)), + ) + const cartAttributedOrderCount = new Set( + revenueAttribution.assistedRows + .filter((row) => cartConversationIds.has(row.conversationId)) + .map((row) => row.orderId), + ).size + const conversionBase = cartConversationIds.size || cartClicks const currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP" const sentimentTotals = { @@ -549,7 +590,11 @@ export async function GET(req: Request) { const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0) const aiMedianSeconds = median(aiLatencySeconds) const manualMedianMinutes = median(manualLatencyMinutes) - const minutesSavedPerAnswer = Math.max(0, manualMedianMinutes - aiMedianSeconds / 60) + const manualBenchmarkMinutes = manualLatencyMinutes.length > 0 ? manualMedianMinutes : 3 + const minutesSavedPerAnswer = Math.min( + 10, + Math.max(0, manualBenchmarkMinutes - aiMedianSeconds / 60), + ) const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10 const topProductQuestions = Array.from(productQuestionCounts.values()) .sort((a, b) => b.count - a.count) @@ -573,12 +618,12 @@ export async function GET(req: Request) { directUpsellRevenue: money(revenueAttribution.directUpsellRevenue), directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue), aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue), - aiCartConversionRate: pct(directOrderCount, conversionBase), + aiCartConversionRate: pct(cartAttributedOrderCount || directOrderCount, conversionBase), averageAiInfluencedOrderValue: revenueAttribution.assistedRows.length > 0 ? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length) : 0, - pendingAttributionCount: Math.max(0, cartClicks - directOrderCount), + pendingAttributionCount: Math.max(0, conversionBase - (cartAttributedOrderCount || directOrderCount)), directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({ ...row, createdAt: row.createdAt.toISOString(), @@ -588,7 +633,7 @@ export async function GET(req: Request) { createdAt: row.createdAt.toISOString(), })), methodology: - "Direct revenue only counts AI-clicked products later found in a matching order. Assisted revenue counts full matching orders after AI engagement.", + "Direct upsell/cross-sell revenue only counts clicked AI-suggested products later found in matching orders. Cart conversion counts cart-engaged conversations that later match a cached Shopify order.", }, customerExperience: { sentimentMix: { @@ -613,7 +658,7 @@ export async function GET(req: Request) { resolvedByAi, ticketsCreated, aiMedianSeconds: Math.round(aiMedianSeconds), - manualMedianMinutes: Math.round(manualMedianMinutes * 10) / 10, + manualMedianMinutes: Math.round(manualBenchmarkMinutes * 10) / 10, sampleAiPairs: aiLatencySeconds.length, sampleManualPairs: manualLatencyMinutes.length, hoursSavedEstimate, diff --git a/src/app/api/dashboard/business-metrics/route.ts b/src/app/api/dashboard/business-metrics/route.ts index 4d18b75..05dc384 100644 --- a/src/app/api/dashboard/business-metrics/route.ts +++ b/src/app/api/dashboard/business-metrics/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { format, startOfMonth, subMonths } from "date-fns" +import { format, startOfDay, startOfMonth, subDays, subMonths } from "date-fns" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore" @@ -8,17 +8,51 @@ const ASSUMED_NET_MARGIN = 0.34 const PAYMENT_RATE = 0.019 const PLATFORM_RATE = 0.024 -function monthKeys(monthCount: number): string[] { - const keys: string[] = [] - for (let i = monthCount - 1; i >= 0; i--) { - keys.push(format(startOfMonth(subMonths(new Date(), i)), "yyyy-MM")) - } - return keys +type DashboardRange = "7days" | "30days" | "1year" + +function parseRange(input: string | null): DashboardRange { + if (input === "30days" || input === "1year" || input === "7days") return input + return "7days" } -function monthLabel(isoMonth: string): string { - const [y, m] = isoMonth.split("-").map(Number) - return format(new Date(y!, (m ?? 1) - 1, 1), "MMM") +function rangeWindow(range: DashboardRange, now = new Date()) { + if (range === "1year") { + const since = startOfMonth(subMonths(now, 11)) + return { + since, + previousSince: startOfMonth(subMonths(now, 23)), + bucketCount: 12, + bucketKey: (date: Date) => format(startOfMonth(date), "yyyy-MM"), + bucketLabel: (key: string) => { + const [y, m] = key.split("-").map(Number) + return format(new Date(y!, (m ?? 1) - 1, 1), "MMM") + }, + previousLabel: "prior year", + } + } + + const days = range === "30days" ? 30 : 7 + const since = subDays(startOfDay(now), days - 1) + return { + since, + previousSince: subDays(since, days), + bucketCount: days, + bucketKey: (date: Date) => format(startOfDay(date), "yyyy-MM-dd"), + bucketLabel: (key: string) => format(new Date(`${key}T00:00:00`), "MMM d"), + previousLabel: range === "30days" ? "prior 30 days" : "prior 7 days", + } +} + +function rangeKeys(config: ReturnType): string[] { + const keys: string[] = [] + for (let i = config.bucketCount - 1; i >= 0; i--) { + const d = + config.bucketCount === 12 + ? startOfMonth(subMonths(new Date(), i)) + : subDays(startOfDay(new Date()), i) + keys.push(config.bucketKey(d)) + } + return keys } type LineItemLike = { @@ -33,7 +67,7 @@ function parseLineItems(raw: unknown): LineItemLike[] { return raw as LineItemLike[] } -export async function GET() { +export async function GET(req: Request) { try { const session = await auth() if (!session?.user?.id) { @@ -48,12 +82,17 @@ export async function GET() { return NextResponse.json({ message: "No active store" }, { status: 400 }) } - const since = startOfMonth(subMonths(new Date(), 5)) + const { searchParams } = new URL(req.url) + const range = parseRange(searchParams.get("range")) + const config = rangeWindow(range) const orders = await prisma.cachedOrder.findMany({ where: { storeId: store.id, - OR: [{ shopifyCreatedAt: { gte: since } }, { shopifyCreatedAt: null, createdAt: { gte: since } }], + OR: [ + { shopifyCreatedAt: { gte: config.previousSince } }, + { shopifyCreatedAt: null, createdAt: { gte: config.previousSince } }, + ], }, select: { totalPrice: true, @@ -65,12 +104,14 @@ export async function GET() { }, }) - const keys = monthKeys(6) + const keys = rangeKeys(config) const grossByMonth: Record = Object.fromEntries(keys.map((k) => [k, 0])) const refundByMonth: Record = Object.fromEntries(keys.map((k) => [k, 0])) let gross = 0 let refunds = 0 + let previousGross = 0 + let previousRefunds = 0 type ProductAgg = { title: string @@ -89,17 +130,25 @@ export async function GET() { const fs = (o.financialStatus || "").toLowerCase() const isRefundLike = fs.includes("refund") || fs.includes("voided") const d = o.shopifyCreatedAt ?? o.createdAt - const mk = format(startOfMonth(d), "yyyy-MM") + const isCurrentPeriod = d >= config.since + const isPreviousPeriod = d >= config.previousSince && d < config.since + const mk = config.bucketKey(d) - if (isRefundLike) { - refunds += Math.abs(amt) - if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt) - } else { - gross += amt - if (mk in grossByMonth) grossByMonth[mk] += amt + if (isCurrentPeriod) { + if (isRefundLike) { + refunds += Math.abs(amt) + if (mk in refundByMonth) refundByMonth[mk] += Math.abs(amt) + } else { + gross += amt + if (mk in grossByMonth) grossByMonth[mk] += amt + } + } else if (isPreviousPeriod) { + if (isRefundLike) previousRefunds += Math.abs(amt) + else previousGross += amt } const firstHalf = d < midpoint + if (!isCurrentPeriod) continue for (const li of parseLineItems(o.lineItems)) { const title = (li.title || li.name || "Product").trim() || "Product" const qty = typeof li.quantity === "number" ? li.quantity : parseInt(String(li.quantity ?? 1), 10) || 1 @@ -122,10 +171,19 @@ export async function GET() { } const netProfitEstimate = Math.max(0, gross * ASSUMED_NET_MARGIN - refunds * 0.5) - const orderCount = orders.filter((o) => !(o.financialStatus || "").toLowerCase().includes("refund")).length + const orderCount = orders.filter((o) => { + const d = o.shopifyCreatedAt ?? o.createdAt + return d >= config.since && !(o.financialStatus || "").toLowerCase().includes("refund") + }).length const estimatedFees = gross * (PAYMENT_RATE + PLATFORM_RATE) + refunds * 0.02 + orderCount * 0.25 const avgMarginPct = gross > 0 ? Math.round(((netProfitEstimate + refunds * 0.5) / gross) * 1000) / 10 : 0 + const previousNetProfitEstimate = Math.max(0, previousGross * ASSUMED_NET_MARGIN - previousRefunds * 0.5) + const previousFees = previousGross * (PAYMENT_RATE + PLATFORM_RATE) + previousRefunds * 0.02 + const previousAvgMarginPct = + previousGross > 0 + ? Math.round(((previousNetProfitEstimate + previousRefunds * 0.5) / previousGross) * 1000) / 10 + : 0 const sparkFromRecord = (rec: Record) => keys.map((k) => Math.round((rec[k] ?? 0) * 100) / 100) @@ -144,9 +202,9 @@ export async function GET() { ) / 100, ) - function pctChange(series: number[]): number { - const a = series[series.length - 2] ?? 0 - const b = series[series.length - 1] ?? 0 + function pctChangeValue(current: number, previous: number): number { + const a = previous + const b = current if (a === 0) return b > 0 ? 100 : 0 return Math.round(((b - a) / a) * 1000) / 10 } @@ -156,21 +214,21 @@ export async function GET() { key: "netProfit", label: "Est. net profit", value: netProfitEstimate, - changePct: pctChange(profitSpark), + changePct: pctChangeValue(netProfitEstimate, previousNetProfitEstimate), sparkline: profitSpark, }, { key: "grossRevenue", label: "Gross revenue", value: gross, - changePct: pctChange(grossSpark), + changePct: pctChangeValue(gross, previousGross), sparkline: grossSpark, }, { key: "fees", label: "Est. fees", value: estimatedFees, - changePct: pctChange(feeSpark), + changePct: pctChangeValue(estimatedFees, previousFees), sparkline: feeSpark, }, { @@ -178,13 +236,13 @@ export async function GET() { label: "Avg. margin (est.)", value: avgMarginPct, suffix: "%", - changePct: avgMarginPct > 30 ? 1.2 : -0.8, + changePct: pctChangeValue(avgMarginPct, previousAvgMarginPct), sparkline: keys.map((_, i) => avgMarginPct + (i % 3) - 1), }, ] const monthlyProfitBars = keys.map((k) => ({ - month: monthLabel(k), + month: config.bucketLabel(k), profit: Math.round((grossByMonth[k] ?? 0) * ASSUMED_NET_MARGIN * 100) / 100, })) @@ -240,6 +298,8 @@ export async function GET() { return NextResponse.json({ currency, + range, + comparisonLabel: config.previousLabel, disclaimer: "Financial figures include estimates (payment + platform fees, assumed ~34% net margin). Connect fuller accounting data for audited numbers.", kpis, @@ -248,7 +308,11 @@ export async function GET() { feeTotal: Math.round(feeDonut.reduce((s, x) => s + x.value, 0) * 100) / 100, productMargins: productRows, aiInsight, - totals: { gross, refunds, orders: orderCount }, + totals: { + gross, + refunds, + orders: orderCount, + }, }) } catch (e) { console.error("[business-metrics]", e) diff --git a/src/app/dashboard/ai-analytics/page.tsx b/src/app/dashboard/ai-analytics/page.tsx index 9522830..63e0ad8 100644 --- a/src/app/dashboard/ai-analytics/page.tsx +++ b/src/app/dashboard/ai-analytics/page.tsx @@ -199,7 +199,7 @@ export default function AiAnalyticsPage() { }, [data?.customerExperience.sentimentMix.totals]) return ( -
+
@@ -212,14 +212,14 @@ export default function AiAnalyticsPage() { Outcome metrics for revenue, customer experience, automation, and demand signals from AI-assisted storefront journeys.

-
+
{RANGE_OPTIONS.map((option) => (