diff --git a/apps/web/app/(app)/[emailAccountId]/stats/RuleStatsChart.tsx b/apps/web/app/(app)/[emailAccountId]/stats/RuleStatsChart.tsx index 9d7d7d4cff..aac1d8fbb1 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/RuleStatsChart.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/RuleStatsChart.tsx @@ -3,8 +3,23 @@ import { BarChart, Card, Title } from "@tremor/react"; import { useMemo } from "react"; import type { DateRange } from "react-day-picker"; +import { LabelList, Pie, PieChart } from "recharts"; +import { fromPairs } from "lodash"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; +import { + Card as ShadcnCard, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getDateRangeParams } from "./params"; import { useOrgSWR } from "@/hooks/useOrgSWR"; import type { RuleStatsResponse } from "@/app/api/user/stats/rule-stats/route"; @@ -14,6 +29,14 @@ interface RuleStatsChartProps { title: string; } +const CHART_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + export function RuleStatsChart({ dateRange, title }: RuleStatsChartProps) { const params = getDateRangeParams(dateRange); @@ -21,12 +44,39 @@ export function RuleStatsChart({ dateRange, title }: RuleStatsChartProps) { `/api/user/stats/rule-stats?${new URLSearchParams(params as Record)}`, ); - const chartData = useMemo(() => { - if (!data?.groupStats) return []; - return data.groupStats.map((group) => ({ - group: group.groupName, - "Executed Rules": group.executedCount, + const barChartData = useMemo(() => { + if (!data?.ruleStats) return []; + return data.ruleStats.map((rule) => ({ + group: rule.ruleName, + "Executed Rules": rule.executedCount, + })); + }, [data]); + + const { pieChartData, chartConfig } = useMemo(() => { + if (!data?.ruleStats) return { pieChartData: [], chartConfig: {} }; + + const pieData = data.ruleStats.map((rule, index) => ({ + name: rule.ruleName, + value: rule.executedCount, + fill: CHART_COLORS[index % CHART_COLORS.length], })); + + const config: ChartConfig = { + value: { + label: "Executed Rules", + }, + ...fromPairs( + data.ruleStats.map((rule, index) => [ + rule.ruleName, + { + label: rule.ruleName, + color: CHART_COLORS[index % CHART_COLORS.length], + }, + ]), + ), + }; + + return { pieChartData: pieData, chartConfig: config }; }, [data]); return ( @@ -35,21 +85,64 @@ export function RuleStatsChart({ dateRange, title }: RuleStatsChartProps) { error={error} loadingComponent={} > - {data && chartData.length > 0 && ( - - {title} - - + {data && barChartData.length > 0 && ( + + +
+ {title} + + Bar Chart + Pie Chart + +
+ + + + + + + + + + Rule Execution Distribution + + + + + + + } + /> + + + + + + + + +
+
)} - {data && chartData.length === 0 && ( + {data && barChartData.length === 0 && ( {title}
diff --git a/apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx b/apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx index 8307564b2e..490260916a 100644 --- a/apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx +++ b/apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx @@ -105,6 +105,11 @@ export function Stats() { /> )} + + {isAccountOwner && } - -
diff --git a/apps/web/app/api/user/stats/rule-stats/route.ts b/apps/web/app/api/user/stats/rule-stats/route.ts index a3566104b2..f8ceb2eb70 100644 --- a/apps/web/app/api/user/stats/rule-stats/route.ts +++ b/apps/web/app/api/user/stats/rule-stats/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; +import sumBy from "lodash/sumBy"; import prisma from "@/utils/prisma"; import { withEmailAccount } from "@/utils/middleware"; -import { z } from "zod"; +import { Prisma } from "@prisma/client"; const ruleStatsQuery = z.object({ fromDate: z.coerce.number().nullish(), @@ -19,60 +21,42 @@ async function getRuleStats({ fromDate?: number; toDate?: number; }) { - const dateFilter: { gte?: Date; lte?: Date } = {}; + // Build WHERE conditions as SQL fragments + const conditions: Prisma.Sql[] = [ + Prisma.sql`er."emailAccountId" = ${emailAccountId}`, + ]; + if (typeof fromDate === "number" && Number.isFinite(fromDate)) { - dateFilter.gte = new Date(fromDate); + conditions.push(Prisma.sql`er."createdAt" >= ${new Date(fromDate)}`); } if (typeof toDate === "number" && Number.isFinite(toDate)) { - dateFilter.lte = new Date(toDate); + conditions.push(Prisma.sql`er."createdAt" <= ${new Date(toDate)}`); } - const executedRules = await prisma.executedRule.findMany({ - where: { - emailAccountId, - ...(Object.keys(dateFilter).length > 0 && { createdAt: dateFilter }), - }, - include: { - rule: { - include: { - group: { - select: { - name: true, - }, - }, - }, - }, - }, - }); - - const groupStats = executedRules.reduce( - (acc, executedRule) => { - const groupName = executedRule.rule?.group?.name || "No Group"; - - if (!acc[groupName]) { - acc[groupName] = { - groupName, - executedCount: 0, - }; - } + const whereClause = Prisma.join(conditions, " AND "); - acc[groupName].executedCount += 1; - return acc; - }, - {} as Record, - ); + const results = await prisma.$queryRaw< + Array<{ rule_name: string; executed_count: bigint }> + >(Prisma.sql` + SELECT + COALESCE(r.name, 'No Rule') AS rule_name, + COUNT(er.id) AS executed_count + FROM "ExecutedRule" er + LEFT JOIN "Rule" r ON er."ruleId" = r.id + WHERE ${whereClause} + GROUP BY r.name + ORDER BY executed_count DESC + `); - const groupStatsArray = Object.values(groupStats).sort( - (a, b) => b.executedCount - a.executedCount, - ); + const ruleStats = results.map((row) => ({ + ruleName: row.rule_name, + executedCount: Number(row.executed_count), + })); - const totalExecutedRules = groupStatsArray.reduce( - (sum, group) => sum + group.executedCount, - 0, - ); + const totalExecutedRules = sumBy(ruleStats, (rs) => rs.executedCount); return { - groupStats: groupStatsArray, + ruleStats, totalExecutedRules, }; } diff --git a/apps/web/components/ui/chart.tsx b/apps/web/components/ui/chart.tsx new file mode 100644 index 0000000000..cda082af74 --- /dev/null +++ b/apps/web/components/ui/chart.tsx @@ -0,0 +1,370 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/utils/index"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +