feat: better dashboard chart (#378)

* dashbaord ui stuff

* graph stuff

* stuff
This commit is contained in:
KM Koushik
2026-03-15 18:28:17 +11:00
committed by GitHub
parent 9a306b1d59
commit 4307670822
5 changed files with 174 additions and 158 deletions
@@ -29,33 +29,38 @@ export default function DashboardFilters({
}; };
return ( return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select value={domain ?? "All Domains"} onValueChange={(val) => handleDomain(val)}> <Select
<SelectTrigger className="w-full sm:w-[180px]"> value={domain ?? "All Domains"}
{domain ? domainsQuery?.find((d) => d.id === Number(domain))?.name : "All Domains"} onValueChange={(val) => handleDomain(val)}
</SelectTrigger> >
<SelectContent> <SelectTrigger className="w-full sm:w-[180px]">
<SelectItem value="All Domains" className="capitalize"> {domain
All Domains ? domainsQuery?.find((d) => d.id === Number(domain))?.name
</SelectItem> : "All Domains"}
{domainsQuery && </SelectTrigger>
domainsQuery.map((domain) => ( <SelectContent>
<SelectItem key={domain.id} value={domain.id.toString()}> <SelectItem value="All Domains" className="capitalize">
{domain.name} All Domains
</SelectItem> </SelectItem>
))} {domainsQuery &&
</SelectContent> domainsQuery.map((domain) => (
</Select> <SelectItem key={domain.id} value={domain.id.toString()}>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}> {domain.name}
<TabsList className="w-full sm:w-auto"> </SelectItem>
<TabsTrigger value="7" className="flex-1 sm:flex-none"> ))}
7 Days </SelectContent>
</TabsTrigger> </Select>
<TabsTrigger value="30" className="flex-1 sm:flex-none"> <Tabs value={days || "30"} onValueChange={(value) => setDays(value)}>
30 Days <TabsList className="w-full sm:w-auto">
</TabsTrigger> <TabsTrigger value="7" className="flex-1 sm:flex-none">
</TabsList> 7 Days
</Tabs> </TabsTrigger>
</div> <TabsTrigger value="30" className="flex-1 sm:flex-none">
); 30 Days
</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
} }
@@ -32,16 +32,18 @@ const STACK_ORDER: string[] = [
] as const; ] as const;
type StackKey = (typeof STACK_ORDER)[number]; type StackKey = (typeof STACK_ORDER)[number];
function createRoundedTopShape(
function createRoundedTopShape(currentKey: StackKey) { currentKey: StackKey,
const currentIndex = STACK_ORDER.indexOf(currentKey); visibleStackOrder: StackKey[],
) {
const currentIndex = visibleStackOrder.indexOf(currentKey);
return (props: any) => { return (props: any) => {
const payload = props.payload as const payload = props.payload as
| Partial<Record<StackKey, number>> | Partial<Record<StackKey, number>>
| undefined; | undefined;
let hasAbove = false; let hasAbove = false;
for (let i = currentIndex + 1; i < STACK_ORDER.length; i++) { for (let i = currentIndex + 1; i < visibleStackOrder.length; i++) {
const key = STACK_ORDER[i]; const key = visibleStackOrder[i];
const val = key ? (payload?.[key] ?? 0) : 0; const val = key ? (payload?.[key] ?? 0) : 0;
if (val > 0) { if (val > 0) {
hasAbove = true; hasAbove = true;
@@ -55,6 +57,7 @@ function createRoundedTopShape(currentKey: StackKey) {
} }
export default function EmailChart({ days, domain }: EmailChartProps) { export default function EmailChart({ days, domain }: EmailChartProps) {
const [selectedMetrics, setSelectedMetrics] = React.useState<StackKey[]>([]);
const domainId = domain ? Number(domain) : undefined; const domainId = domain ? Number(domain) : undefined;
const statusQuery = api.dashboard.emailTimeSeries.useQuery({ const statusQuery = api.dashboard.emailTimeSeries.useQuery({
days: days, days: days,
@@ -63,6 +66,32 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
const currentColors = useColors(); const currentColors = useColors();
const metricMeta: Record<StackKey, { label: string; color: string }> = {
delivered: { label: "Delivered", color: currentColors.delivered },
bounced: { label: "Bounced", color: currentColors.bounced },
complained: { label: "Complained", color: currentColors.complained },
opened: { label: "Opened", color: currentColors.opened },
clicked: { label: "Clicked", color: currentColors.clicked },
};
const visibleMetrics =
selectedMetrics.length === 0
? STACK_ORDER
: STACK_ORDER.filter((key) => selectedMetrics.includes(key));
const toggleMetric = (metric: StackKey) => {
setSelectedMetrics((prev) => {
const exists = prev.includes(metric);
if (exists) {
return prev.filter((key) => key !== metric);
}
const nextSet = new Set([...prev, metric]);
return STACK_ORDER.filter((key) => nextSet.has(key));
});
};
return ( return (
<div className="flex flex-col gap-16"> <div className="flex flex-col gap-16">
{!statusQuery.isLoading && statusQuery.data ? ( {!statusQuery.isLoading && statusQuery.data ? (
@@ -75,6 +104,8 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
status={"total"} status={"total"}
count={statusQuery.data.totalCounts.sent} count={statusQuery.data.totalCounts.sent}
percentage={100} percentage={100}
isActive={selectedMetrics.length === 0}
isClickable={false}
/> />
<EmailChartItem <EmailChartItem
status={EmailStatus.DELIVERED} status={EmailStatus.DELIVERED}
@@ -83,6 +114,11 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
statusQuery.data.totalCounts.delivered / statusQuery.data.totalCounts.delivered /
statusQuery.data.totalCounts.sent statusQuery.data.totalCounts.sent
} }
isActive={
selectedMetrics.length === 0 ||
selectedMetrics.includes("delivered")
}
onClick={() => toggleMetric("delivered")}
/> />
<EmailChartItem <EmailChartItem
status={EmailStatus.BOUNCED} status={EmailStatus.BOUNCED}
@@ -91,6 +127,11 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
statusQuery.data.totalCounts.bounced / statusQuery.data.totalCounts.bounced /
statusQuery.data.totalCounts.sent statusQuery.data.totalCounts.sent
} }
isActive={
selectedMetrics.length === 0 ||
selectedMetrics.includes("bounced")
}
onClick={() => toggleMetric("bounced")}
/> />
<EmailChartItem <EmailChartItem
status={EmailStatus.COMPLAINED} status={EmailStatus.COMPLAINED}
@@ -99,6 +140,11 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
statusQuery.data.totalCounts.complained / statusQuery.data.totalCounts.complained /
statusQuery.data.totalCounts.sent statusQuery.data.totalCounts.sent
} }
isActive={
selectedMetrics.length === 0 ||
selectedMetrics.includes("complained")
}
onClick={() => toggleMetric("complained")}
/> />
<EmailChartItem <EmailChartItem
status={EmailStatus.CLICKED} status={EmailStatus.CLICKED}
@@ -107,6 +153,11 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
statusQuery.data.totalCounts.clicked / statusQuery.data.totalCounts.clicked /
statusQuery.data.totalCounts.sent statusQuery.data.totalCounts.sent
} }
isActive={
selectedMetrics.length === 0 ||
selectedMetrics.includes("clicked")
}
onClick={() => toggleMetric("clicked")}
/> />
<EmailChartItem <EmailChartItem
status={EmailStatus.OPENED} status={EmailStatus.OPENED}
@@ -115,6 +166,11 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
statusQuery.data.totalCounts.opened / statusQuery.data.totalCounts.opened /
statusQuery.data.totalCounts.sent statusQuery.data.totalCounts.sent
} }
isActive={
selectedMetrics.length === 0 ||
selectedMetrics.includes("opened")
}
onClick={() => toggleMetric("opened")}
/> />
</div> </div>
</div> </div>
@@ -135,6 +191,9 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
fontSize={12} fontSize={12}
className="font-mono" className="font-mono"
stroke={currentColors.xaxis} stroke={currentColors.xaxis}
tick={{ fill: currentColors.xaxis, fillOpacity: 0.65 }}
axisLine={false}
tickLine={false}
/> />
{/* <YAxis fontSize={12} className="font-mono" /> */} {/* <YAxis fontSize={12} className="font-mono" /> */}
<Tooltip <Tooltip
@@ -154,11 +213,10 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
if (!data) return null; if (!data) return null;
const hasAnyData = const hasAnyData =
(data.delivered || 0) > 0 || visibleMetrics.reduce(
(data.bounced || 0) > 0 || (sum, key) => sum + (data[key] || 0),
(data.complained || 0) > 0 || 0,
(data.opened || 0) > 0 || ) > 0;
(data.clicked || 0) > 0;
if (!hasAnyData) return null; if (!hasAnyData) return null;
@@ -167,105 +225,43 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{data.date} {data.date}
</p> </p>
{data.delivered ? ( {visibleMetrics.map((metricKey) => {
<div className="flex gap-2 items-center"> const metricValue = data[metricKey] || 0;
if (!metricValue) return null;
return (
<div <div
className="w-2.5 h-2.5 rounded-[2px]" key={metricKey}
style={{ backgroundColor: currentColors.delivered }} className="flex gap-2 items-center"
></div> >
<p className="text-xs text-muted-foreground w-[70px]"> <div
Delivered className="w-2.5 h-2.5 rounded-[2px]"
</p> style={{
<p className="text-xs font-mono">{data.delivered}</p> backgroundColor: metricMeta[metricKey].color,
</div> }}
) : null} ></div>
{data.bounced ? ( <p className="text-xs text-muted-foreground w-[70px]">
<div className="flex gap-2 items-center"> {metricMeta[metricKey].label}
<div </p>
className="w-2.5 h-2.5 rounded-[2px]" <p className="text-xs font-mono">{metricValue}</p>
style={{ backgroundColor: currentColors.bounced }} </div>
></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 rounded-[2px]"
style={{
backgroundColor: currentColors.complained,
}}
></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 rounded-[2px]"
style={{ backgroundColor: currentColors.opened }}
></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 rounded-[2px]"
style={{ backgroundColor: currentColors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
Clicked
</p>
<p className="text-xs font-mono">{data.clicked}</p>
</div>
) : null}
</div> </div>
); );
}} }}
cursor={false} cursor={false}
/> />
{/* <Legend /> */} {visibleMetrics.map((metricKey) => (
<Bar <Bar
barSize={8} key={metricKey}
dataKey="delivered" barSize={20}
stackId="a" dataKey={metricKey}
fill={currentColors.delivered} stackId="a"
shape={createRoundedTopShape("delivered")} fill={metricMeta[metricKey].color}
/> shape={createRoundedTopShape(metricKey, visibleMetrics)}
<Bar />
dataKey="bounced" ))}
stackId="a"
fill={currentColors.bounced}
shape={createRoundedTopShape("bounced")}
/>
<Bar
dataKey="complained"
stackId="a"
fill={currentColors.complained}
shape={createRoundedTopShape("complained")}
/>
<Bar
dataKey="opened"
stackId="a"
fill={currentColors.opened}
shape={createRoundedTopShape("opened")}
/>
<Bar
dataKey="clicked"
stackId="a"
fill={currentColors.clicked}
shape={createRoundedTopShape("clicked")}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -280,6 +276,9 @@ type DashboardItemCardProps = {
status: EmailStatus | "total"; status: EmailStatus | "total";
count: number; count: number;
percentage: number; percentage: number;
onClick?: () => void;
isActive?: boolean;
isClickable?: boolean;
}; };
const DashboardItemCard: React.FC<DashboardItemCardProps> = ({ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
@@ -311,6 +310,9 @@ const EmailChartItem: React.FC<DashboardItemCardProps> = ({
status, status,
count, count,
percentage, percentage,
onClick,
isActive = false,
isClickable = true,
}) => { }) => {
const currentColors = useColors(); const currentColors = useColors();
@@ -333,7 +335,17 @@ const EmailChartItem: React.FC<DashboardItemCardProps> = ({
}; };
return ( return (
<div className="flex gap-3 items-stretch font-mono"> <button
type="button"
onClick={onClick}
disabled={!isClickable}
aria-pressed={isClickable ? isActive : undefined}
className={`flex gap-3 items-stretch font-mono transition-opacity ${
isClickable ? "cursor-pointer" : "cursor-default"
} ${isActive ? "opacity-100" : "opacity-45 hover:opacity-100"} ${
isClickable ? "" : "pointer-events-none"
}`}
>
<div> <div>
<div className=" flex items-center gap-2"> <div className=" flex items-center gap-2">
<div <div
@@ -354,6 +366,6 @@ const EmailChartItem: React.FC<DashboardItemCardProps> = ({
</span> </span>
</div> </div>
</div> </div>
</div> </button>
); );
}; };
@@ -7,7 +7,7 @@ import { useUrlState } from "~/hooks/useUrlState";
import { ReputationMetrics } from "./reputation-metrics"; import { ReputationMetrics } from "./reputation-metrics";
export default function Dashboard() { export default function Dashboard() {
const [days, setDays] = useUrlState("days", "7"); const [days, setDays] = useUrlState("days", "30");
const [domain, setDomain] = useUrlState("domain"); const [domain, setDomain] = useUrlState("domain");
return ( return (
@@ -16,16 +16,16 @@ export default function Dashboard() {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<H1>Analytics</H1> <H1>Analytics</H1>
<DashboardFilters <DashboardFilters
days={days ?? "7"} days={days ?? "30"}
setDays={setDays} setDays={setDays}
domain={domain} domain={domain}
setDomain={setDomain} setDomain={setDomain}
/> />
</div> </div>
<div className=" space-y-12"> <div className=" space-y-12">
<EmailChart days={Number(days ?? "7")} domain={domain} /> <EmailChart days={Number(days ?? "30")} domain={domain} />
<ReputationMetrics days={Number(days ?? "7")} domain={domain} /> <ReputationMetrics days={Number(days ?? "30")} domain={domain} />
</div> </div>
</div> </div>
</div> </div>
@@ -3,15 +3,15 @@ import { format, subDays } from "date-fns";
import { Prisma, Team } from "@prisma/client"; import { Prisma, Team } from "@prisma/client";
type EmailTimeSeries = { type EmailTimeSeries = {
days?: number; days?: number;
domain?: number domain?: number;
team: Team team: Team;
}; };
export async function emailTimeSeries(input: EmailTimeSeries) { export async function emailTimeSeries(input: EmailTimeSeries) {
const days = input.days !== 7 ? 30 : 7; const days = input.days !== 7 ? 30 : 7;
const { domain, team } = input const { domain, team } = input;
const startDate = new Date(); const startDate = new Date();
startDate.setDate(startDate.getDate() - days); startDate.setDate(startDate.getDate() - days);
const isoStartDate = startDate.toISOString().split("T")[0]; const isoStartDate = startDate.toISOString().split("T")[0];
@@ -87,22 +87,21 @@ export async function emailTimeSeries(input: EmailTimeSeries) {
clicked: 0, clicked: 0,
bounced: 0, bounced: 0,
complained: 0, complained: 0,
} },
); );
return { result: filledResult, totalCounts }; return { result: filledResult, totalCounts };
} }
type ReputationMetricsData = { type ReputationMetricsData = {
domain?: number domain?: number;
team: Team team: Team;
}; };
export async function reputationMetricsData(input: ReputationMetricsData) { export async function reputationMetricsData(input: ReputationMetricsData) {
const { domain, team } = input const { domain, team } = input;
const reputations = await db.cumulatedMetrics.findMany({ const reputations = await db.cumulatedMetrics.findMany({
where: { where: {
teamId: team.id, teamId: team.id,
...(domain ? { domainId: domain } : {}), ...(domain ? { domainId: domain } : {}),
@@ -116,7 +115,7 @@ export async function reputationMetricsData(input: ReputationMetricsData) {
acc.complained += Number(curr.complained); acc.complained += Number(curr.complained);
return acc; return acc;
}, },
{ delivered: 0, hardBounced: 0, complained: 0 } { delivered: 0, hardBounced: 0, complained: 0 },
); );
const resultWithRates = { const resultWithRates = {
+7 -7
View File
@@ -122,10 +122,10 @@
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
--sidebar-background: 225 3% 94%; --sidebar-background: 220 2% 96%;
--sidebar-foreground: 240 5.3% 26.1%; --sidebar-foreground: 234 16% 35%;
--sidebar-primary: 240 5.9% 10%; --sidebar-primary: 167 34% 20%;
--sidebar-primary-foreground: 0 0% 98%; --sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 240 11% 88%; --sidebar-accent: 240 11% 88%;
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 240 11% 88%; --sidebar-border: 240 11% 88%;
@@ -182,10 +182,10 @@
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
--sidebar-background: 240 23% 9%; --sidebar-background: 240 21% 12%;
--sidebar-foreground: 226 64% 88%; --sidebar-foreground: 226 64% 88%;
--sidebar-primary: 224.3 76.3% 48%; --sidebar-primary: 167 64% 94%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 240 23% 9%;
--sidebar-accent: 237 17% 20%; --sidebar-accent: 237 17% 20%;
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 21% 15%; --sidebar-border: 240 21% 15%;