add analytics (#162)

This commit is contained in:
KM Koushik
2025-05-11 23:34:21 +10:00
committed by GitHub
parent be7f030d75
commit b5ebd002e5
20 changed files with 1031 additions and 418 deletions

View File

@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "CumulatedMetrics" (
"teamId" INTEGER NOT NULL,
"domainId" INTEGER NOT NULL,
"delivered" BIGINT NOT NULL DEFAULT 0,
"hardBounced" BIGINT NOT NULL DEFAULT 0,
"complained" BIGINT NOT NULL DEFAULT 0,
CONSTRAINT "CumulatedMetrics_pkey" PRIMARY KEY ("teamId","domainId")
);

View File

@@ -0,0 +1,27 @@
BEGIN;
-- 1) Populate or update cumulated totals
INSERT INTO "CumulatedMetrics" (
"teamId",
"domainId",
"delivered",
"hardBounced",
"complained"
)
SELECT
du."teamId",
du."domainId",
SUM(du.delivered)::BIGINT AS delivered,
SUM(du."hardBounced")::BIGINT AS hardBounced,
SUM(du.complained)::BIGINT AS complained
FROM public."DailyEmailUsage" du
GROUP BY
du."teamId",
du."domainId"
ON CONFLICT ("teamId","domainId") DO UPDATE
SET
"delivered" = EXCLUDED."delivered",
"hardBounced" = EXCLUDED."hardBounced",
"complained" = EXCLUDED."complained";
COMMIT;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -375,3 +375,13 @@ model DailyEmailUsage {
@@id([teamId, domainId, date, type])
}
model CumulatedMetrics {
teamId Int
domainId Int
delivered BigInt @default(0)
hardBounced BigInt @default(0)
complained BigInt @default(0)
@@id([teamId, domainId])
}

View File

@@ -1,315 +0,0 @@
import React from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import { EmailStatusIcon } from "../emails/email-status-badge";
import { EmailStatus } from "@prisma/client";
import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs";
import { useUrlState } from "~/hooks/useUrlState";
import { useTheme } from "@unsend/ui";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@unsend/ui/src/select";
export default function DashboardChart() {
const [days, setDays] = useUrlState("days", "7");
const [domain, setDomain] = useUrlState("domain");
const { resolvedTheme } = useTheme();
const domainId = domain ? Number(domain) : undefined;
const statusQuery = api.email.dashboard.useQuery({
days: Number(days),
domain: domainId,
});
const { data: domainsQuery } = api.domain.domains.useQuery();
const handleDomain = (val: string) => {
setDomain(val === "All Domain" ? null : val);
};
const lightColors = {
delivered: "#40a02bcc",
bounced: "#d20f39cc",
complained: "#df8e1dcc",
opened: "#8839efcc",
clicked: "#04a5e5cc",
};
const darkColors = {
delivered: "#a6e3a1",
bounced: "#f38ba8",
complained: "#F9E2AF",
opened: "#cba6f7",
clicked: "#93c5fd",
};
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
return (
<div>
<div className="flex justify-between items-center">
<h1 className="font-semibold text-xl">Dashboard</h1>
<div className="flex gap-3">
<Select
value={domain ?? "All Domain"}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: "All Domain"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domain" className="capitalize">
All Domain
</SelectItem>
{domainsQuery &&
domainsQuery.map((domain) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
<TabsList>
<TabsTrigger value="7">7 Days</TabsTrigger>
<TabsTrigger value="30">30 Days</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<div className="flex flex-col gap-16 mt-10">
<div className="flex flex-wrap gap-2">
{!statusQuery.isLoading && statusQuery.data ? (
<>
<DashboardItemCard
status={"total"}
count={statusQuery.data.totalCounts.sent}
percentage={100}
/>
<DashboardItemCard
status={EmailStatus.DELIVERED}
count={statusQuery.data.totalCounts.delivered}
percentage={
statusQuery.data.totalCounts.delivered /
statusQuery.data.totalCounts.sent
}
/>
<DashboardItemCard
status={EmailStatus.BOUNCED}
count={statusQuery.data.totalCounts.bounced}
percentage={
statusQuery.data.totalCounts.bounced /
statusQuery.data.totalCounts.sent
}
/>
<DashboardItemCard
status={EmailStatus.COMPLAINED}
count={statusQuery.data.totalCounts.complained}
percentage={
statusQuery.data.totalCounts.complained /
statusQuery.data.totalCounts.sent
}
/>
<DashboardItemCard
status={EmailStatus.CLICKED}
count={statusQuery.data.totalCounts.clicked}
percentage={
statusQuery.data.totalCounts.clicked /
statusQuery.data.totalCounts.sent
}
/>
<DashboardItemCard
status={EmailStatus.OPENED}
count={statusQuery.data.totalCounts.opened}
percentage={
statusQuery.data.totalCounts.opened /
statusQuery.data.totalCounts.sent
}
/>
</>
) : (
<>
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
</div>
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
</div>
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
</div>
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
</div>
<div className="h-[100px] w-[1/5] min-w-[200px] bg-secondary/10 border rounded-lg p-4 flex justify-center items-center gap-3 ">
<Spinner className="w-4 h-4" innerSvgClass="stroke-primary" />
</div>
</>
)}
</div>
{!statusQuery.isLoading && statusQuery.data ? (
<div className="w-full h-[400px] border shadow rounded-lg p-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={900}
height={300}
data={statusQuery.data.result}
margin={{
top: 20,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as Record<
| "sent"
| "delivered"
| "opened"
| "clicked"
| "bounced"
| "complained",
number
> & { date: string };
if (!data || data.sent === 0) return null;
return (
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
{data.date}
</p>
{data.delivered ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#40a02bcc] dark:bg-[#a6e3a1] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Delivered
</p>
<p className="text-xs font-mono">
{data.delivered}
</p>
</div>
) : null}
{data.bounced ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#d20f39cc] dark:bg-[#f38ba8] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Bounced
</p>
<p className="text-xs font-mono">{data.bounced}</p>
</div>
) : null}
{data.complained ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#df8e1dcc] dark:bg-[#F9E2AF] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Complained
</p>
<p className="text-xs font-mono">
{data.complained}
</p>
</div>
) : null}
{data.opened ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#8839efcc] dark:bg-[#cba6f7] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Opened
</p>
<p className="text-xs font-mono">{data.opened}</p>
</div>
) : null}
{data.clicked ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#04a5e5cc] dark:bg-[#93c5fd] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Clicked
</p>
<p className="text-xs font-mono">{data.clicked}</p>
</div>
) : null}
</div>
);
}}
cursor={false}
/>
{/* <Legend /> */}
<Bar
barSize={8}
dataKey="delivered"
stackId="a"
fill={currentColors.delivered}
/>
<Bar
dataKey="bounced"
stackId="a"
fill={currentColors.bounced}
/>
<Bar
dataKey="complained"
stackId="a"
fill={currentColors.complained}
/>
<Bar dataKey="opened" stackId="a" fill={currentColors.opened} />
<Bar
dataKey="clicked"
stackId="a"
fill={currentColors.clicked}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : null}
</div>
</div>
);
}
type DashboardItemCardProps = {
status: EmailStatus | "total";
count: number;
percentage: number;
};
const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
status,
count,
percentage,
}) => {
return (
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-lg p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
<div className=" capitalize">{status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
{count}
</div>
{status !== "total" ? (
<div className="text-sm pb-1">
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,62 @@
import React from "react";
import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs";
import { useUrlState } from "~/hooks/useUrlState";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@unsend/ui/src/select";
import { api } from "~/trpc/react";
interface DashboardFiltersProps {
days: string;
setDays: (days: string) => void;
domain: string | null;
setDomain: (domain: string | null) => void;
}
export default function DashboardFilters({
days,
setDays,
domain,
setDomain,
}: DashboardFiltersProps) {
const { data: domainsQuery } = api.domain.domains.useQuery();
const handleDomain = (val: string) => {
setDomain(val === "All Domain" ? null : val);
};
return (
<div className="flex gap-3">
<Select
value={domain ?? "All Domain"}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: "All Domain"}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domain" className="capitalize">
All Domain
</SelectItem>
{domainsQuery &&
domainsQuery.map((domain) => (
<SelectItem key={domain.id} value={domain.id.toString()}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
<TabsList>
<TabsTrigger value="7">7 Days</TabsTrigger>
<TabsTrigger value="30">30 Days</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,265 @@
import React from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
AreaChart,
Area,
} from "recharts";
import { EmailStatusIcon } from "../emails/email-status-badge";
import { EmailStatus } from "@prisma/client";
import { api } from "~/trpc/react";
import Spinner from "@unsend/ui/src/spinner";
import { useTheme } from "@unsend/ui";
import { EMAIL_COLORS } from "~/lib/constants/colors";
import { useColors } from "./hooks/useColors";
interface EmailChartProps {
days: number;
domain: string | null;
}
export default function EmailChart({ days, domain }: EmailChartProps) {
const domainId = domain ? Number(domain) : undefined;
const statusQuery = api.dashboard.emailTimeSeries.useQuery({
days: days,
domain: domainId,
});
const currentColors = useColors();
return (
<div className="flex flex-col gap-16">
{!statusQuery.isLoading && statusQuery.data ? (
<div className="w-full h-[450px] border shadow rounded-xl p-4">
<div>
{/* <div className="mb-4 text-sm">Emails</div> */}
<div className="flex gap-10">
<EmailChartItem
status={"total"}
count={statusQuery.data.totalCounts.sent}
percentage={100}
/>
<EmailChartItem
status={EmailStatus.DELIVERED}
count={statusQuery.data.totalCounts.delivered}
percentage={
statusQuery.data.totalCounts.delivered /
statusQuery.data.totalCounts.sent
}
/>
<EmailChartItem
status={EmailStatus.BOUNCED}
count={statusQuery.data.totalCounts.bounced}
percentage={
statusQuery.data.totalCounts.bounced /
statusQuery.data.totalCounts.sent
}
/>
<EmailChartItem
status={EmailStatus.COMPLAINED}
count={statusQuery.data.totalCounts.complained}
percentage={
statusQuery.data.totalCounts.complained /
statusQuery.data.totalCounts.sent
}
/>
<EmailChartItem
status={EmailStatus.CLICKED}
count={statusQuery.data.totalCounts.clicked}
percentage={
statusQuery.data.totalCounts.clicked /
statusQuery.data.totalCounts.sent
}
/>
<EmailChartItem
status={EmailStatus.OPENED}
count={statusQuery.data.totalCounts.opened}
percentage={
statusQuery.data.totalCounts.opened /
statusQuery.data.totalCounts.sent
}
/>
</div>
</div>
<ResponsiveContainer width="100%" height="80%">
<BarChart
width={900}
height={200}
data={statusQuery.data.result}
margin={{
top: 20,
right: 30,
left: 20,
bottom: 5,
}}
>
<XAxis
dataKey="date"
fontSize={12}
className="font-mono"
stroke={currentColors.xaxis}
/>
{/* <YAxis fontSize={12} className="font-mono" /> */}
<Tooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as Record<
| "sent"
| "delivered"
| "opened"
| "clicked"
| "bounced"
| "complained",
number
> & { date: string };
if (!data || data.sent === 0) return null;
return (
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
{data.date}
</p>
{data.delivered ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#40a02bcc] dark:bg-[#a6e3a1] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Delivered
</p>
<p className="text-xs font-mono">{data.delivered}</p>
</div>
) : null}
{data.bounced ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#d20f39cc] dark:bg-[#f38ba8] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Bounced
</p>
<p className="text-xs font-mono">{data.bounced}</p>
</div>
) : null}
{data.complained ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#df8e1dcc] dark:bg-[#F9E2AF] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Complained
</p>
<p className="text-xs font-mono">{data.complained}</p>
</div>
) : null}
{data.opened ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#8839efcc] dark:bg-[#cba6f7] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Opened
</p>
<p className="text-xs font-mono">{data.opened}</p>
</div>
) : null}
{data.clicked ? (
<div className="flex gap-2 items-center">
<div className="w-2.5 h-2.5 bg-[#04a5e5cc] dark:bg-[#93c5fd] rounded-[2px]"></div>
<p className="text-xs text-muted-foreground w-[70px]">
Clicked
</p>
<p className="text-xs font-mono">{data.clicked}</p>
</div>
) : null}
</div>
);
}}
cursor={false}
/>
{/* <Legend /> */}
<Bar
barSize={8}
dataKey="delivered"
stackId="a"
fill={currentColors.delivered}
/>
<Bar dataKey="bounced" stackId="a" fill={currentColors.bounced} />
<Bar
dataKey="complained"
stackId="a"
fill={currentColors.complained}
/>
<Bar dataKey="opened" stackId="a" fill={currentColors.opened} />
<Bar dataKey="clicked" stackId="a" fill={currentColors.clicked} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[450px]"> </div>
)}
</div>
);
}
type DashboardItemCardProps = {
status: EmailStatus | "total";
count: number;
percentage: number;
};
const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
status,
count,
percentage,
}) => {
return (
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
<div className=" capitalize">{status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
{count}
</div>
{status !== "total" ? (
<div className="text-sm pb-1">
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
</div>
) : null}
</div>
</div>
);
};
const EmailChartItem: React.FC<DashboardItemCardProps> = ({
status,
count,
percentage,
}) => {
const color = EMAIL_COLORS[status];
return (
<div className="flex gap-3 items-stretch font-mono">
{/* <div className={`${color} w-0.5 rounded-full`}></div> */}
<div>
<div className=" flex items-center gap-2">
<div className={`${color} w-2.5 h-2.5 rounded-[3px]`}></div>
{/* <div className={`${color} w-0.5 rounded-full`}></div> */}
<div className="text-xs uppercase text-muted-foreground ">
{status.toLowerCase()}
</div>
</div>
<div className="mt-1 -ml-0.5 ">
<span className="text-xl font-mono">{count}</span>
<span className="text-xs ml-2 font-mono">
{status !== "total"
? `(${count > 0 ? (percentage * 100).toFixed(0) : 0}%)`
: null}
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { useTheme } from "@unsend/ui";
export function useColors() {
const { resolvedTheme } = useTheme();
const lightColors = {
delivered: "#40a02b",
bounced: "#d20f39",
complained: "#df8e1d",
opened: "#8839ef",
clicked: "#04a5e5",
xaxis: "#6D6F84",
};
const darkColors = {
delivered: "#a6e3a1",
bounced: "#f38ba8",
complained: "#F9E2AF",
opened: "#cba6f7",
clicked: "#93c5fd",
xaxis: "#AAB1CD",
};
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
return currentColors;
}

View File

@@ -1,12 +1,31 @@
"use client";
import DashboardChart from "./dashboard-chart";
import EmailChart from "./email-chart";
import DashboardFilters from "./dashboard-filters";
import { useUrlState } from "~/hooks/useUrlState";
import { ReputationMetrics } from "./reputation-metrics";
export default function Dashboard() {
const [days, setDays] = useUrlState("days", "7");
const [domain, setDomain] = useUrlState("domain");
return (
<div>
<div className="w-full ">
<DashboardChart />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="font-semibold text-xl">Analytics</h1>
<DashboardFilters
days={days ?? "7"}
setDays={setDays}
domain={domain}
setDomain={setDomain}
/>
</div>
<div className=" space-y-12">
<EmailChart days={Number(days ?? "7")} domain={domain} />
<ReputationMetrics days={Number(days ?? "7")} domain={domain} />
</div>
</div>
</div>
);

View File

@@ -0,0 +1,402 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@unsend/ui/src/tooltip";
import {
CheckCircle2,
CheckCircle2Icon,
InfoIcon,
OctagonAlertIcon,
TriangleAlertIcon,
} from "lucide-react";
import {
Bar,
BarChart,
ReferenceLine,
ResponsiveContainer,
Tooltip as RechartsTooltip,
CartesianGrid,
YAxis,
} from "recharts";
import {
HARD_BOUNCE_RISK_RATE,
HARD_BOUNCE_WARNING_RATE,
COMPLAINED_WARNING_RATE,
COMPLAINED_RISK_RATE,
} from "~/lib/constants";
import { api } from "~/trpc/react";
import { useColors } from "./hooks/useColors";
interface ReputationMetricsProps {
days: number;
domain: string | null;
}
enum ACCOUNT_STATUS {
HEALTHY = "HEALTHY",
WARNING = "WARNING",
RISK = "RISK",
}
const CustomLabel = ({ value, stroke }: { value: string; stroke: string }) => {
return (
<text x={0} y={-5} fill={stroke} fontSize={12} textAnchor="start">
{value}
</text>
);
};
export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
const { data: metrics, isLoading } =
api.dashboard.reputationMetricsData.useQuery({
domain: domain ? Number(domain) : undefined,
});
const colors = useColors();
const bouncedMetric = metrics
? [
{
name: "Bounce Rate",
value: metrics.bounceRate,
},
]
: [];
const complaintMetric = metrics
? [
{
name: "Complaint Rate",
value: metrics.complaintRate,
},
]
: [];
const bounceStatus =
(metrics?.bounceRate ?? 0) > HARD_BOUNCE_RISK_RATE
? ACCOUNT_STATUS.RISK
: (metrics?.bounceRate ?? 0) > HARD_BOUNCE_WARNING_RATE
? ACCOUNT_STATUS.WARNING
: ACCOUNT_STATUS.HEALTHY;
const complaintStatus =
(metrics?.complaintRate ?? 0) > COMPLAINED_RISK_RATE
? ACCOUNT_STATUS.RISK
: (metrics?.complaintRate ?? 0) > COMPLAINED_WARNING_RATE
? ACCOUNT_STATUS.WARNING
: ACCOUNT_STATUS.HEALTHY;
return (
<TooltipProvider>
<div className="flex gap-10 w-full">
<div className="w-1/2 border rounded-xl shadow p-4">
<div className="flex justify-between">
<div className=" flex items-center gap-2">
<div className="text-muted-foreground">Bounce Rate</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="w-[300px]">
The percentage of emails sent from your account that resulted
in a hard bounce.
</TooltipContent>
</Tooltip>
</div>
<div></div>
</div>
<div className="flex items-baseline gap-4">
<div className="text-2xl mt-2">{metrics?.bounceRate}%</div>
<StatusBadge status={bounceStatus} />
</div>
{/* <div className="flex">
<StatusBadge status={ACCOUNT_STATUS.HEALTHY} />
</div> */}
<ResponsiveContainer width="100%" height={200}>
<BarChart
// width={350}
// height={100}
data={bouncedMetric}
margin={{
top: 20,
// right: 30,
left: -30,
bottom: 5,
}}
>
<YAxis
domain={[0, 15]}
ticks={[0, 5, 10, 15]}
fontSize={12}
tickFormatter={(value) => `${value}%`}
hide={false}
axisLine={false}
tickLine={false}
interval={0}
/>
<CartesianGrid
vertical={false}
strokeDasharray="3 3"
stroke={`${colors.xaxis}50`}
/>
<ReferenceLine
y={HARD_BOUNCE_WARNING_RATE}
stroke={`${colors.complained}A0`}
label={{
value: "",
position: "insideBottomLeft",
fill: colors.complained,
fontSize: 12,
}}
strokeDasharray="3 3"
/>
{/* <CartesianGrid vertical={false} strokeDasharray="3 3" /> */}
{/* <YAxis fontSize={12} /> */}
{/* <ReferenceLine
y={0}
stroke={colors.xaxis}
strokeDasharray="3 3"
/> */}
<ReferenceLine
y={HARD_BOUNCE_RISK_RATE}
stroke={`${colors.bounced}A0`}
label={{
value: ``,
position: "insideBottomLeft",
fill: colors.bounced,
fontSize: 12,
}}
strokeDasharray="3 3"
/>
<RechartsTooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as {
name: string;
value: number;
};
if (!data) return null;
return (
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
{data.name}
</p>
<div className="flex gap-2 items-center">
<div
className="w-2.5 h-2.5 rounded-[2px]"
style={{ background: colors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Current
</p>
<p className="text-xs font-mono">{data.value}%</p>
</div>
<div className="flex gap-2 items-center">
<div
className="w-2.5 h-2.5 rounded-[2px]"
style={{ background: colors.complained }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Warning at
</p>
<p className="text-xs font-mono">
{HARD_BOUNCE_WARNING_RATE}%
</p>
</div>
<div className="flex gap-2 items-center">
<div
className="w-2.5 h-2.5 rounded-[2px]"
style={{ background: colors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Risk at
</p>
<p className="text-xs font-mono">
{HARD_BOUNCE_RISK_RATE}%
</p>
</div>
</div>
);
}}
cursor={false}
/>
<Bar
barSize={150}
dataKey="value"
stackId="a"
fill={colors.clicked}
radius={[8, 8, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 border rounded-xl shadow p-4">
<div className=" flex items-center gap-2">
<div className=" text-muted-foreground">Complaint Rate</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="w-[300px]">
The percentage of emails sent from your account that resulted in
recipients reporting them as spam.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-baseline gap-4">
<div className="text-2xl mt-2">{metrics?.complaintRate}%</div>
<StatusBadge status={complaintStatus} />
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={complaintMetric}
margin={{
top: 20,
left: -30,
bottom: 5,
}}
>
<YAxis
domain={[0, 0.8]}
ticks={[0, 0.2, 0.4, 0.6, 0.8]}
fontSize={12}
tickFormatter={(value) => `${value}%`}
hide={false}
axisLine={false}
interval={0}
/>
<CartesianGrid
vertical={false}
strokeDasharray="3 3"
stroke={`${colors.xaxis}50`}
/>
<ReferenceLine
y={COMPLAINED_WARNING_RATE}
stroke={`${colors.complained}A0`}
label={{
value: "",
position: "insideBottomLeft",
fill: colors.complained,
fontSize: 12,
}}
strokeDasharray="3 3"
/>
{/* <ReferenceLine
y={0}
stroke={colors.xaxis}
strokeDasharray="3 3"
/> */}
<ReferenceLine
y={COMPLAINED_RISK_RATE}
stroke={`${colors.bounced}A0`}
label={{
value: ``,
position: "insideBottomLeft",
fill: colors.bounced,
fontSize: 12,
}}
strokeDasharray="3 3"
/>
<RechartsTooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as {
name: string;
value: number;
};
if (!data) return null;
return (
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
{data.name}
</p>
<div className="flex gap-2 items-center">
<div
className="w-2.5 h-2.5 rounded-[2px]"
style={{ background: colors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Current
</p>
<p className="text-xs font-mono">{data.value}%</p>
</div>
<div className="flex gap-2 items-center">
<div
className="w-2.5 h-2.5 rounded-[2px]"
style={{ background: colors.complained }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Warning at
</p>
<p className="text-xs font-mono">
{COMPLAINED_WARNING_RATE}%
</p>
</div>
<div className="flex gap-2 items-center">
<div
className="w-2.5 h-2.5 rounded-[2px]"
style={{ background: colors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Risk at
</p>
<p className="text-xs font-mono">
{COMPLAINED_RISK_RATE}%
</p>
</div>
</div>
);
}}
cursor={false}
/>
<Bar
barSize={150}
dataKey="value"
stackId="a"
fill={colors.clicked}
radius={[8, 8, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</TooltipProvider>
);
}
export const StatusBadge: React.FC<{ status: ACCOUNT_STATUS }> = ({
status,
}) => {
const className =
status === "HEALTHY"
? " text-success border-success"
: status === "WARNING"
? " text-warning border-warning"
: " text-destructive border-destructive";
const StatusIcon =
status === "HEALTHY"
? CheckCircle2Icon
: status === "WARNING"
? TriangleAlertIcon
: OctagonAlertIcon;
return (
<div
className={` capitalize text-xs ${className} flex gap-1 items-center rounded-lg`}
>
<StatusIcon className="h-3.5 w-3.5" />
{status.toLowerCase()}
</div>
);
};

View File

@@ -0,0 +1,18 @@
import { EmailStatus } from "@prisma/client";
export const EMAIL_COLORS: Record<EmailStatus | "total", string> = {
total: "bg-gray-400 dark:bg-gray-400",
DELIVERED: "bg-[#40a02b] dark:bg-[#a6e3a1]",
BOUNCED: "bg-[#d20f39] dark:bg-[#f38ba8]",
FAILED: "bg-[#d20f39] dark:bg-[#f38ba8]",
CLICKED: "bg-[#04a5e5] dark:bg-[#93c5fd]",
OPENED: "bg-[#8839ef] dark:bg-[#cba6f7]",
COMPLAINED: "bg-[#df8e1d] dark:bg-[#F9E2AF]",
DELIVERY_DELAYED: "bg-[#df8e1d] dark:bg-[#F9E2AF]",
SENT: "bg-gray-200 dark:bg-gray-400",
SCHEDULED: "bg-gray-200 dark:bg-gray-400",
QUEUED: "bg-gray-200 dark:bg-gray-400",
REJECTED: "bg-[#d20f39] dark:bg-[#f38ba8]",
RENDERING_FAILURE: "bg-[#d20f39] dark:bg-[#f38ba8]",
CANCELLED: "bg-gray-200 dark:bg-gray-400",
};

View File

@@ -1 +1,7 @@
export const DEFAULT_QUERY_LIMIT = 30;
/* Reputation constants */
export const HARD_BOUNCE_WARNING_RATE = 5;
export const HARD_BOUNCE_RISK_RATE = 10;
export const COMPLAINED_WARNING_RATE = 0.1;
export const COMPLAINED_RISK_RATE = 0.5;

View File

@@ -9,6 +9,7 @@ import { campaignRouter } from "./routers/campaign";
import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard";
/**
* This is the primary router for your server.
@@ -26,6 +27,7 @@ export const appRouter = createTRPCRouter({
template: templateRouter,
billing: billingRouter,
invitation: invitationRouter,
dashboard: dashboardRouter,
});
// export type definition of API

View File

@@ -0,0 +1,140 @@
import { Prisma } from "@prisma/client";
import { format, subDays } from "date-fns";
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { db } from "~/server/db";
export const dashboardRouter = createTRPCRouter({
emailTimeSeries: teamProcedure
.input(
z.object({
days: z.number().optional(),
domain: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { team } = ctx;
const days = input.days !== 7 ? 30 : 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0];
type DailyEmailUsage = {
date: string;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
};
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
SELECT
date,
SUM(sent)::integer AS sent,
SUM(delivered)::integer AS delivered,
SUM(opened)::integer AS opened,
SUM(clicked)::integer AS clicked,
SUM(bounced)::integer AS bounced,
SUM(complained)::integer AS complained
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
GROUP BY "date"
ORDER BY "date" ASC
`;
// Fill in any missing dates with 0 values
const filledResult: DailyEmailUsage[] = [];
const endDateObj = new Date();
for (let i = days; i > -1; i--) {
const dateStr = subDays(endDateObj, i)
.toISOString()
.split("T")[0] as string;
const existingData = result.find((r) => r.date === dateStr);
if (existingData) {
filledResult.push({
...existingData,
date: format(dateStr, "MMM dd"),
});
} else {
filledResult.push({
date: format(dateStr, "MMM dd"),
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
});
}
}
const totalCounts = result.reduce(
(acc, curr) => {
acc.sent += curr.sent;
acc.delivered += curr.delivered;
acc.opened += curr.opened;
acc.clicked += curr.clicked;
acc.bounced += curr.bounced;
acc.complained += curr.complained;
return acc;
},
{
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
}
);
return { result: filledResult, totalCounts };
}),
reputationMetricsData: teamProcedure
.input(
z.object({
domain: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { team } = ctx;
const reputations = await db.cumulatedMetrics.findMany({
where: {
teamId: team.id,
...(input.domain ? { domainId: input.domain } : {}),
},
});
const results = reputations.reduce(
(acc, curr) => {
acc.delivered += Number(curr.delivered);
acc.hardBounced += Number(curr.hardBounced);
acc.complained += Number(curr.complained);
return acc;
},
{ delivered: 0, hardBounced: 0, complained: 0 }
);
const resultWithRates = {
...results,
bounceRate: results.delivered
? (results.hardBounced / results.delivered) * 100
: 0,
complaintRate: results.delivered
? (results.complained / results.delivered) * 100
: 0,
};
return resultWithRates;
}),
});

View File

@@ -61,99 +61,6 @@ export const emailRouter = createTRPCRouter({
return { emails };
}),
dashboard: teamProcedure
.input(
z.object({
days: z.number().optional(),
domain: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { team } = ctx;
const days = input.days !== 7 ? 30 : 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0];
type DailyEmailUsage = {
date: string;
sent: number;
delivered: number;
opened: number;
clicked: number;
bounced: number;
complained: number;
};
const result = await db.$queryRaw<Array<DailyEmailUsage>>`
SELECT
date,
SUM(sent)::integer AS sent,
SUM(delivered)::integer AS delivered,
SUM(opened)::integer AS opened,
SUM(clicked)::integer AS clicked,
SUM(bounced)::integer AS bounced,
SUM(complained)::integer AS complained
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
${input.domain ? Prisma.sql`AND "domainId" = ${input.domain}` : Prisma.sql``}
GROUP BY "date"
ORDER BY "date" ASC
`;
// Fill in any missing dates with 0 values
const filledResult: DailyEmailUsage[] = [];
const endDateObj = new Date();
for (let i = days; i > -1; i--) {
const dateStr = subDays(endDateObj, i)
.toISOString()
.split("T")[0] as string;
const existingData = result.find((r) => r.date === dateStr);
if (existingData) {
filledResult.push({
...existingData,
date: format(dateStr, "MMM dd"),
});
} else {
filledResult.push({
date: format(dateStr, "MMM dd"),
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
});
}
}
const totalCounts = result.reduce(
(acc, curr) => {
acc.sent += curr.sent;
acc.delivered += curr.delivered;
acc.opened += curr.opened;
acc.clicked += curr.clicked;
acc.bounced += curr.bounced;
acc.complained += curr.complained;
return acc;
},
{
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
}
);
return { result: filledResult, totalCounts };
}),
getEmail: emailProcedure.query(async ({ input }) => {
const email = await db.email.findUnique({
where: {

View File

@@ -66,8 +66,6 @@ export async function parseSesHook(data: SesEvent) {
mailStatus === EmailStatus.BOUNCED &&
(mailData as SesBounce).bounceType === "Permanent";
console.log("mailStatus", mailStatus, "isHardBounced", isHardBounced);
if (
[
"DELIVERED",
@@ -109,6 +107,31 @@ export async function parseSesHook(data: SesEvent) {
...(isHardBounced ? { hardBounced: { increment: 1 } } : {}),
},
});
if (
isHardBounced ||
updateField === "complained" ||
updateField === "delivered"
) {
await db.cumulatedMetrics.upsert({
where: {
teamId_domainId: {
teamId: email.teamId,
domainId: email.domainId ?? 0,
},
},
update: {
[updateField]: {
increment: BigInt(1),
},
},
create: {
teamId: email.teamId,
domainId: email.domainId ?? 0,
[updateField]: BigInt(1),
},
});
}
}
if (email.campaignId) {

View File

@@ -61,6 +61,10 @@ const config = {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",

View File

@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-9 items-center justify-center rounded-md bg-muted p-0.5 text-muted-foreground",
className
)}
{...props}

View File

@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 overflow-hidden rounded-xl border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}

View File

@@ -29,6 +29,9 @@
--destructive: 347 62% 55%;
--destructive-foreground: 210 40% 98%;
--warning: 35 77% 49%;
--warning-foreground: 210 40% 98%;
--success: 142 49% 44%;
--success-foreground: 210 40% 98%;
@@ -55,7 +58,7 @@
}
.dark {
--background: 240 21% 15%;
--background: 240 21% 12%;
--foreground: 226 64% 88%;
--card: 222.2 84% 4.9%;
@@ -82,6 +85,9 @@
--success: 115 54% 76%;
--success-foreground: 210 40% 98%;
--warning: 41 86% 83%;
--warning-foreground: 210 40% 98%;
--border: 237 16% 23%;
--input: 217.2 32.6% 17.5%;
--ring: 217.2 32.6% 17.5%;
@@ -92,7 +98,7 @@
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 21% 12%;
--sidebar-background: 240 23% 9%;
--sidebar-foreground: 226 64% 88%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;