Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 112 additions & 19 deletions apps/web/app/(app)/[emailAccountId]/stats/RuleStatsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,19 +29,54 @@ 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);

const { data, isLoading, error } = useOrgSWR<RuleStatsResponse>(
`/api/user/stats/rule-stats?${new URLSearchParams(params as Record<string, string>)}`,
);

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 (
Expand All @@ -35,21 +85,64 @@ export function RuleStatsChart({ dateRange, title }: RuleStatsChartProps) {
error={error}
loadingComponent={<Skeleton className="h-64 w-full rounded" />}
>
{data && chartData.length > 0 && (
<Card>
<Title>{title}</Title>
<BarChart
className="mt-4 h-72"
data={chartData}
index="group"
categories={["Executed Rules"]}
colors={["blue"]}
showLegend={false}
showGridLines={true}
/>
</Card>
{data && barChartData.length > 0 && (
<Tabs defaultValue="bar">
<Card>
<div className="flex items-center justify-between">
<Title>{title}</Title>
<TabsList>
<TabsTrigger value="bar">Bar Chart</TabsTrigger>
<TabsTrigger value="pie">Pie Chart</TabsTrigger>
</TabsList>
</div>

<TabsContent value="bar">
<BarChart
className="mt-4 h-72"
data={barChartData}
index="group"
categories={["Executed Rules"]}
colors={["blue"]}
showLegend={false}
showGridLines={true}
/>
</TabsContent>

<TabsContent value="pie">
<ShadcnCard className="border-0 shadow-none">
<CardHeader className="items-center pb-0">
<CardTitle className="text-base font-normal text-muted-foreground">
Rule Execution Distribution
</CardTitle>
</CardHeader>
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[300px] [&_.recharts-text]:fill-background"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent nameKey="value" hideLabel />
}
/>
<Pie data={pieChartData} dataKey="value">
<LabelList
dataKey="name"
className="fill-background"
stroke="none"
fontSize={12}
/>
</Pie>
</PieChart>
</ChartContainer>
</CardContent>
</ShadcnCard>
</TabsContent>
</Card>
</Tabs>
)}
{data && chartData.length === 0 && (
{data && barChartData.length === 0 && (
<Card>
<Title>{title}</Title>
<div className="mt-4 h-72 flex items-center justify-center text-muted-foreground">
Expand Down
10 changes: 5 additions & 5 deletions apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export function Stats() {
/>
)}

<RuleStatsChart
dateRange={dateRange}
title="Assistant processed emails"
/>

<DetailedStats
dateRange={dateRange}
period={period}
Expand All @@ -121,11 +126,6 @@ export function Stats() {
</CardBasic>

{isAccountOwner && <EmailActionsAnalytics />}

<RuleStatsChart
dateRange={dateRange}
title="Assistant processed emails"
/>
</div>

<StatsOnboarding />
Expand Down
74 changes: 29 additions & 45 deletions apps/web/app/api/user/stats/rule-stats/route.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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<string, { groupName: string; executedCount: number }>,
);
const results = await prisma.$queryRaw<
Array<{ rule_name: string; executed_count: bigint }>
>(Prisma.sql`
SELECT
COALESCE(r.name, 'No Rule') AS rule_name,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid label collision with real rule names.

'No Rule' can collide with an actual rule named “No Rule,” merging counts.

-      COALESCE(r.name, 'No Rule') AS rule_name,
+      COALESCE(r.name, 'Unknown Rule') AS rule_name,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
COALESCE(r.name, 'No Rule') AS rule_name,
COALESCE(r.name, 'Unknown Rule') AS rule_name,
🤖 Prompt for AI Agents
In apps/web/app/api/user/stats/rule-stats/route.ts around line 42, the COALESCE
fallback string 'No Rule' can collide with a real rule named "No Rule"; replace
the human-readable fallback with an unambiguous sentinel (for example
COALESCE(r.name, '__NO_RULE__')) or return NULL (COALESCE -> r.name) and handle
presentation in the client; ensure downstream code maps the sentinel/NULL to the
user-facing label so counts are not merged with real rule names.

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,
};
}
Expand Down
Loading
Loading