-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Response time tracking analytics #939
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
5d2fa97
Add API for response time
elie222 93896ee
Merge branch 'main' into feat/response-time-analytics
elie222 6b48d85
fix up response time
elie222 0456674
optimize
elie222 7ed29a5
Merge branch 'main' into feat/response-time-analytics
elie222 9df983f
add ui for response time
elie222 d6ed08a
adjustments
elie222 b889993
fix exports
elie222 3b696ae
Merge branch 'main' into feat/response-time-analytics
elie222 dfbac27
Merge branch 'main' into feat/response-time-analytics
elie222 a5c5b11
fix migration
elie222 fada1c4
fixes
elie222 4639cb4
fix
elie222 fb7a4c7
fixes
elie222 2e8de50
select account for linking microsoft
elie222 e455c72
fix
elie222 2f68a16
delete unused route
elie222 aaf7353
delete dead code
elie222 8bcecf3
fixes
elie222 c68f844
fix internal api
elie222 4ecf3d2
response time in mins to avoid overflow
elie222 88e9daf
fix
elie222 7f0a625
fix
elie222 1d10e98
fix tests
elie222 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
241 changes: 241 additions & 0 deletions
241
apps/web/app/(app)/[emailAccountId]/stats/ResponseTimeAnalytics.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")} | ||
| </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`; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.