Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
56 changes: 43 additions & 13 deletions apps/web/app/(app)/[emailAccountId]/stats/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ interface BarChartProps {
dataKeys?: string[];
xAxisKey?: string;
xAxisFormatter?: (value: string) => string;
yAxisFormatter?: (value: number) => string;
tooltipLabelFormatter?: (value: string | number) => string;
tooltipValueFormatter?: (value: number) => string;
activeCharts?: string[];
period?: "day" | "week" | "month" | "year";
}
Expand All @@ -27,10 +30,13 @@ export function BarChart({
dataKeys,
xAxisKey = "date",
xAxisFormatter,
yAxisFormatter,
tooltipLabelFormatter,
tooltipValueFormatter,
activeCharts,
period,
}: BarChartProps) {
const defaultFormatter = (value: any) => {
const defaultFormatter = (value: string) => {
const date = new Date(value);

if (period === "year") {
Expand Down Expand Up @@ -101,27 +107,47 @@ export function BarChart({
minTickGap={32}
tickFormatter={formatter}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={yAxisFormatter}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const data = payload[0];
const date = new Date(data.payload[xAxisKey]);
const xValue = data.payload[xAxisKey];

let dateFormat: Intl.DateTimeFormatOptions;
if (period === "year") {
dateFormat = { year: "numeric" };
} else if (period === "month") {
dateFormat = { month: "short", year: "numeric" };
// Use custom formatter if provided, otherwise try date formatting with fallback
let label: string;
if (tooltipLabelFormatter) {
label = tooltipLabelFormatter(xValue);
} else {
dateFormat = { month: "short", day: "numeric", year: "numeric" };
const date = new Date(xValue);
if (Number.isNaN(date.getTime())) {
// Fallback for non-date values
label = String(xValue);
} else {
let dateFormat: Intl.DateTimeFormatOptions;
if (period === "year") {
dateFormat = { year: "numeric" };
} else if (period === "month") {
dateFormat = { month: "short", year: "numeric" };
} else {
dateFormat = {
month: "short",
day: "numeric",
year: "numeric",
};
}
label = date.toLocaleDateString("en-US", dateFormat);
}
}

return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<p className="mb-2 font-medium">
{date.toLocaleDateString("en-US", dateFormat)}
</p>
<p className="mb-2 font-medium">{label}</p>
{payload.map((entry) => (
<div
key={entry.dataKey}
Expand All @@ -137,7 +163,11 @@ export function BarChart({
<span className="text-muted-foreground">
{config[entry.dataKey as keyof typeof config]?.label}:
</span>
<span className="ml-auto font-medium">{entry.value}</span>
<span className="ml-auto font-medium">
{tooltipValueFormatter
? tooltipValueFormatter(entry.value as number)
: entry.value}
</span>
</div>
))}
</div>
Expand Down
241 changes: 241 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/stats/ResponseTimeAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"use client";

import { useMemo } from "react";
import type { DateRange } from "react-day-picker";
import { Clock, TrendingDown, TrendingUp, Timer } from "lucide-react";
import { useOrgSWR } from "@/hooks/useOrgSWR";
import { LoadingContent } from "@/components/LoadingContent";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CardBasic } from "@/components/ui/card";
import { getDateRangeParams } from "./params";
import { BarChart } from "./BarChart";
import type { ChartConfig } from "@/components/ui/chart";
import { COLORS } from "@/utils/colors";
import { cn } from "@/utils";
import type {
GetResponseTimeResponse,
ResponseTimeParams,
} from "@/app/api/user/stats/response-time/route";
import { isDefined } from "@/utils/types";
import { pluralize } from "@/utils/string";

interface ResponseTimeAnalyticsProps {
dateRange?: DateRange;
refreshInterval: number;
}

export function ResponseTimeAnalytics({
dateRange,
refreshInterval,
}: ResponseTimeAnalyticsProps) {
const params: ResponseTimeParams = getDateRangeParams(dateRange);

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

const distributionData = useMemo(() => {
if (!data?.distribution) return [];
return [
{ group: "< 1 hour", count: data.distribution.lessThan1Hour },
{ group: "1-4 hours", count: data.distribution.oneToFourHours },
{ group: "4-24 hours", count: data.distribution.fourTo24Hours },
{ group: "1-3 days", count: data.distribution.oneToThreeDays },
{ group: "3-7 days", count: data.distribution.threeToSevenDays },
{ group: "> 7 days", count: data.distribution.moreThan7Days },
];
}, [data]);
const trendData = useMemo(() => {
if (!data?.trend) return [];
return data.trend
.map((item) =>
item
? {
date: item.period,
median: item.medianResponseTime,
}
: null,
)
.filter(isDefined);
}, [data]);

const distributionChartConfig: ChartConfig = {
count: { label: "Emails", color: COLORS.analytics.blue },
};

const trendChartConfig: ChartConfig = {
median: { label: "Median Response Time", color: COLORS.analytics.purple },
};

return (
<LoadingContent
loading={isLoading}
error={error}
loadingComponent={<Skeleton className="h-[400px] rounded" />}
>
{data && (
<div className="space-y-4">
{data.emailsAnalyzed > 0 && (
<p className="text-muted-foreground text-sm">
Response time data based on last {data.emailsAnalyzed}{" "}
{pluralize(data.emailsAnalyzed, "email")}
Comment thread
elie222 marked this conversation as resolved.
</p>
)}

<div className="grid gap-2 sm:gap-4 grid-cols-3">
<SummaryCard
title="Median Response"
value={formatTime(data.summary.medianResponseTime)}
icon={<Clock className="h-4 w-4" />}
comparison={data.summary.previousPeriodComparison}
/>
<SummaryCard
title="Average Response"
value={formatTime(data.summary.averageResponseTime)}
icon={<Timer className="h-4 w-4" />}
/>
<SummaryCard
title="Within 1 Hour"
value={`${data.summary.within1Hour}%`}
icon={<TrendingUp className="h-4 w-4" />}
/>
</div>

{/* Distribution Chart */}
{distributionData.some((d) => d.count > 0) && (
<CardBasic>
<p>Response Time Distribution</p>
<div className="mt-4">
<BarChart
data={distributionData}
config={distributionChartConfig}
dataKeys={["count"]}
xAxisKey="group"
xAxisFormatter={(value) => value}
tooltipLabelFormatter={(value) => String(value)}
/>
</div>
</CardBasic>
)}

{/* Trend Chart */}
{trendData.length > 0 && (
<CardBasic>
<p>Weekly Response Time Trend</p>
<div className="mt-4">
<BarChart
data={trendData}
config={trendChartConfig}
dataKeys={["median"]}
xAxisKey="date"
xAxisFormatter={(value) => value}
yAxisFormatter={formatTimeShort}
tooltipValueFormatter={formatTime}
/>
</div>
</CardBasic>
)}

{/* Empty state */}
{!distributionData.some((d) => d.count > 0) &&
trendData.length === 0 && (
<CardBasic>
<p>Response Time Analytics</p>
<div className="mt-4 h-32 flex items-center justify-center text-muted-foreground">
<p>No response time data available for this period.</p>
</div>
</CardBasic>
)}
</div>
)}
</LoadingContent>
);
}

function SummaryCard({
title,
value,
icon,
comparison,
}: {
title: string;
value: string;
icon: React.ReactNode;
comparison?: {
medianResponseTime: number;
percentChange: number;
} | null;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<span className="text-muted-foreground">{icon}</span>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{comparison && (
<p
className={cn(
"text-xs mt-1 flex items-center gap-1",
comparison.percentChange < 0
? "text-green-600"
: comparison.percentChange > 0
? "text-red-600"
: "text-muted-foreground",
)}
>
{comparison.percentChange < 0 ? (
<TrendingDown className="h-3 w-3" />
) : comparison.percentChange > 0 ? (
<TrendingUp className="h-3 w-3" />
) : null}
{comparison.percentChange === 0
? "No change"
: `${Math.abs(comparison.percentChange)}% ${comparison.percentChange < 0 ? "faster" : "slower"}`}
<span className="text-muted-foreground ml-1">vs previous</span>
</p>
)}
</CardContent>
</Card>
);
}

function formatTime(minutes: number): string {
if (minutes === 0) return "0m";
if (minutes < 60) return `${Math.round(minutes)}m`;
if (minutes < 1440) {
let hours = Math.floor(minutes / 60);
let mins = Math.round(minutes % 60);
// Carry over if rounded minutes equals 60
if (mins === 60) {
hours += 1;
mins = 0;
}
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
let days = Math.floor(minutes / 1440);
let hours = Math.round((minutes % 1440) / 60);
// Carry over if rounded hours equals 24
if (hours === 24) {
days += 1;
hours = 0;
}
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
}

// Shorter format for Y-axis labels
function formatTimeShort(minutes: number): string {
if (minutes === 0) return "0";
if (minutes < 60) return `${Math.round(minutes)}m`;
if (minutes < 1440) {
const hours = Math.round(minutes / 60);
return `${hours}h`;
}
const days = Math.round(minutes / 1440);
return `${days}d`;
}
5 changes: 5 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/stats/Stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { StatsOnboarding } from "@/app/(app)/[emailAccountId]/stats/StatsOnboard
import { useStatLoader } from "@/providers/StatLoaderProvider";
import { EmailActionsAnalytics } from "@/app/(app)/[emailAccountId]/stats/EmailActionsAnalytics";
import { RuleStatsChart } from "./RuleStatsChart";
import { ResponseTimeAnalytics } from "./ResponseTimeAnalytics";
import { PageHeading } from "@/components/Typography";
import { PageWrapper } from "@/components/PageWrapper";
import { useOrgAccess } from "@/hooks/useOrgAccess";
Expand Down Expand Up @@ -122,6 +123,10 @@ export function Stats() {
dateRange={dateRange}
refreshInterval={refreshInterval}
/>
<ResponseTimeAnalytics
dateRange={dateRange}
refreshInterval={refreshInterval}
/>
<RuleStatsChart
dateRange={dateRange}
title="Assistant processed emails"
Expand Down
Loading
Loading