Add dashboard

This commit is contained in:
KMKoushik
2024-05-02 21:47:03 +10:00
parent a9143be5b6
commit 2d4babe618
4 changed files with 329 additions and 83 deletions

View File

@@ -1,85 +1,223 @@
import { AreaChart, Area, XAxis, YAxis, Tooltip } from "recharts"; import React from "react";
import {
const data = [ BarChart,
{ Bar,
name: "Page A", XAxis,
uv: 4000, YAxis,
pv: 2400, Tooltip,
amt: 2400, ResponsiveContainer,
}, } from "recharts";
{ import { EmailStatusIcon } from "../emails/email-status-badge";
name: "Page B", import { EmailStatus } from "@prisma/client";
uv: 3000, import { api } from "~/trpc/react";
pv: 1398, import Spinner from "@unsend/ui/src/spinner";
amt: 2210, import { Tabs, TabsList, TabsTrigger } from "@unsend/ui/src/tabs";
}, import { useUrlState } from "~/hooks/useUrlState";
{
name: "Page C",
uv: 2000,
pv: 9800,
amt: 2290,
},
{
name: "Page D",
uv: 2780,
pv: 3908,
amt: 2000,
},
{
name: "Page E",
uv: 1890,
pv: 4800,
amt: 2181,
},
{
name: "Page F",
uv: 2390,
pv: 3800,
amt: 2500,
},
{
name: "Page G",
uv: 3490,
pv: 4300,
amt: 2100,
},
];
export default function DashboardChart() { export default function DashboardChart() {
const [days, setDays] = useUrlState("days", "7");
const statusQuery = api.email.dashboard.useQuery({ days: Number(days) });
return ( return (
<AreaChart <div>
width={900} <div className="flex justify-between items-center">
height={250} <h1 className="font-bold text-lg">Dashboard</h1>
data={data} <Tabs
margin={{ top: 10, right: 30, left: 0, bottom: 0 }} value={days || "7"}
onValueChange={(value) => setDays(value)}
className=""
> >
<defs> <TabsList>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1"> <TabsTrigger value="7">7 Days</TabsTrigger>
<stop offset="5%" stopColor="#8884d8" stopOpacity={0.8} /> <TabsTrigger value="30">30 Days</TabsTrigger>
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} /> </TabsList>
</linearGradient> </Tabs>
<linearGradient id="colorPv" x1="0" y1="0" x2="0" y2="1"> </div>
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} /> <div className="flex flex-col gap-8 mt-8">
</linearGradient> <div className="flex flex-wrap gap-2">
</defs> {!statusQuery.isLoading && statusQuery.data ? (
<XAxis dataKey="name" /> <>
<YAxis /> <DashboardItemCard
<Tooltip /> status={"total"}
<Area count={statusQuery.data.totalCount}
type="monotone" percentage={100}
dataKey="uv"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
/> />
<Area <DashboardItemCard
type="monotone" status={EmailStatus.DELIVERED}
dataKey="pv" count={statusQuery.data.emailStatusCounts.DELIVERED.count}
stroke="#82ca9d" percentage={
fillOpacity={1} statusQuery.data.emailStatusCounts.DELIVERED.percentage
fill="url(#colorPv)" }
/> />
</AreaChart> <DashboardItemCard
status={EmailStatus.BOUNCED}
count={statusQuery.data.emailStatusCounts.BOUNCED.count}
percentage={
statusQuery.data.emailStatusCounts.BOUNCED.percentage
}
/>
<DashboardItemCard
status={EmailStatus.COMPLAINED}
count={statusQuery.data.emailStatusCounts.COMPLAINED.count}
percentage={
statusQuery.data.emailStatusCounts.COMPLAINED.percentage
}
/>
<DashboardItemCard
status={EmailStatus.CLICKED}
count={statusQuery.data.emailStatusCounts.CLICKED.count}
percentage={
statusQuery.data.emailStatusCounts.CLICKED.percentage
}
/>
<DashboardItemCard
status={EmailStatus.OPENED}
count={statusQuery.data.emailStatusCounts.OPENED.count}
percentage={
statusQuery.data.emailStatusCounts.OPENED.percentage
}
/>
</>
) : (
<>
<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 rounded-lg p-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart
width={900}
height={300}
data={statusQuery.data.emailDailyStatusCounts}
margin={{
top: 20,
right: 30,
left: 20,
bottom: 5,
}}
>
{/* <CartesianGrid strokeDasharray="3 3" /> */}
<XAxis dataKey="name" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as Record<
EmailStatus,
number
> & { name: string };
if (
!data ||
(!data.BOUNCED &&
!data.COMPLAINED &&
!data.DELIVERED &&
!data.OPENED &&
!data.CLICKED)
)
return null;
return (
<div className=" bg-black border shadow-lg p-2 rounded flex flex-col gap-4 px-4">
<p className="text-sm text-muted-foreground">
{data.name}
</p>
{data.DELIVERED ? (
<div>
<p className="text-sm text-[#10b981]">Delivered</p>
<p className="text-xs">{data.DELIVERED} emails</p>
</div>
) : null}
{data.BOUNCED ? (
<div>
<p className="text-sm text-[#ef4444]">Bounced</p>
<p className="text-xs">{data.BOUNCED} emails</p>
</div>
) : null}
{data.COMPLAINED ? (
<div>
<p className="text-sm text-[#eab308]">Complained</p>
<p className="text-xs">{data.COMPLAINED} emails</p>
</div>
) : null}
{data.OPENED ? (
<div>
<p className="text-sm text-[#6366f1]">Opened</p>
<p className="text-xs">{data.OPENED} emails</p>
</div>
) : null}
{data.CLICKED ? (
<div>
<p className="text-sm text-[#06b6d4]">Clicked</p>
<p className="text-xs">{data.CLICKED} emails</p>
</div>
) : null}
</div>
);
}}
cursor={false}
/>
{/* <Legend /> */}
<Bar
barSize={8}
dataKey="DELIVERED"
stackId="a"
fill="#10b981"
/>
<Bar dataKey="BOUNCED" stackId="a" fill="#ef4444" />
<Bar dataKey="COMPLAINED" stackId="a" fill="#eab308" />
<Bar dataKey="OPENED" stackId="a" fill="#6366f1" />
<Bar dataKey="CLICKED" stackId="a" fill="#06b6d4" />
</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 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-primary font-light text-2xl font-mono">
{count}
</div>
{status !== "total" ? (
<div className="text-sm pb-1">{percentage}%</div>
) : null}
</div>
</div>
);
};

View File

@@ -5,8 +5,7 @@ import DashboardChart from "./dashboard-chart";
export default function Dashboard() { export default function Dashboard() {
return ( return (
<div> <div>
Dashboard <div className="w-full ">
<div className="mx-auto flex justify-center item-center mt-[30vh]">
<DashboardChart /> <DashboardChart />
</div> </div>
</div> </div>

View File

@@ -69,10 +69,13 @@ export default async function AuthenticatedDashboardLayout({
<div className="flex min-h-screen w-full h-full"> <div className="flex min-h-screen w-full h-full">
<div className="hidden bg-muted/20 md:block md:w-[280px]"> <div className="hidden bg-muted/20 md:block md:w-[280px]">
<div className="flex h-full max-h-screen flex-col gap-2"> <div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 items-center px-4 lg:h-[60px] lg:px-6"> <div className="flex h-14 gap-4 items-center px-4 lg:h-[60px] lg:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold"> <Link href="/" className="flex items-center gap-2 font-semibold">
<span className=" text-lg">Unsend</span> <span className=" text-lg">Unsend</span>
</Link> </Link>
<span className="text-[10px] text-muted-foreground bg-muted p-0.5 px-2 rounded-full">
Early access
</span>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4"> <nav className="grid items-start px-2 text-sm font-medium lg:px-4">

View File

@@ -1,4 +1,5 @@
import { EmailStatus } from "@prisma/client"; import { EmailStatus } from "@prisma/client";
import { format, subDays } from "date-fns";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc"; import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
@@ -51,6 +52,111 @@ export const emailRouter = createTRPCRouter({
return { emails, totalPage: Math.ceil(count / limit) }; return { emails, totalPage: Math.ceil(count / limit) };
}), }),
dashboard: teamProcedure
.input(
z.object({
days: z.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { team } = ctx;
const daysInMs = (input.days || 7) * 24 * 60 * 60 * 1000;
const rawEmailStatusCounts = await db.email.findMany({
where: {
teamId: team.id,
createdAt: {
gt: new Date(Date.now() - daysInMs),
},
},
select: {
latestStatus: true,
createdAt: true,
},
});
const totalCount = rawEmailStatusCounts.length;
const emailStatusCounts = rawEmailStatusCounts.reduce(
(acc, cur) => {
acc[cur.latestStatus] = {
count: (acc[cur.latestStatus]?.count || 0) + 1,
percentage:
(((acc[cur.latestStatus]?.count || 0) + 1) / totalCount) * 100,
};
return acc;
},
{
DELIVERED: { count: 0, percentage: 0 },
COMPLAINED: { count: 0, percentage: 0 },
OPENED: { count: 0, percentage: 0 },
CLICKED: { count: 0, percentage: 0 },
BOUNCED: { count: 0, percentage: 0 },
} as Record<EmailStatus, { count: number; percentage: number }>
);
const dateRecord: Record<
string,
Record<
"DELIVERED" | "COMPLAINED" | "OPENED" | "CLICKED" | "BOUNCED",
number
>
> = {};
const currentDate = new Date();
for (let i = 0; i < (input.days || 7); i++) {
const actualDate = subDays(currentDate, i);
dateRecord[format(actualDate, "MMM dd")] = {
DELIVERED: 0,
COMPLAINED: 0,
OPENED: 0,
CLICKED: 0,
BOUNCED: 0,
};
}
const _emailDailyStatusCounts = rawEmailStatusCounts.reduce(
(acc, { latestStatus, createdAt }) => {
const day = format(createdAt, "MMM dd");
console.log(day);
if (
!day ||
![
"DELIVERED",
"COMPLAINED",
"OPENED",
"CLICKED",
"BOUNCED",
].includes(latestStatus)
) {
return acc;
}
acc[day]![
latestStatus as
| "DELIVERED"
| "COMPLAINED"
| "OPENED"
| "CLICKED"
| "BOUNCED"
]++;
return acc;
},
dateRecord
);
const emailDailyStatusCounts = Object.entries(_emailDailyStatusCounts)
.reverse()
.map(([date, counts]) => ({
name: date,
...counts,
}));
return { emailStatusCounts, totalCount, emailDailyStatusCounts };
}),
getEmail: teamProcedure getEmail: teamProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {