Skip to content

Commit f99a6e0

Browse files
committed
feat: add StatCard component and integrate avg time metric
1 parent 87e5e19 commit f99a6e0

File tree

4 files changed

+115
-121
lines changed

4 files changed

+115
-121
lines changed

src/client/components/application/ApplicationStatsChart.tsx

+43-120
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { useGlobalRangeDate } from '../../hooks/useGlobalRangeDate';
66
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
77
import { DateFilter } from '../DateFilter';
88
import { Button } from '../ui/button';
9-
import { LuRefreshCw, LuArrowUp, LuArrowDown } from 'react-icons/lu';
9+
import { LuRefreshCw } from 'react-icons/lu';
1010
import { trpc } from '@/api/trpc';
1111
import { useCurrentWorkspaceId } from '@/store/user';
12+
import { StatCard } from './StatCard';
13+
import prettyMilliseconds from 'pretty-ms';
1214

1315
interface ApplicationStatsChartProps {
1416
applicationId: string;
@@ -40,8 +42,6 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
4042
date: item.date,
4143
events: item.eventCount,
4244
sessions: item.sessionCount,
43-
avgEventsPerSession: item.avgEventsPerSession,
44-
avgScreensPerSession: item.avgScreensPerSession,
4545
}));
4646
}, [data]);
4747

@@ -54,16 +54,8 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
5454
},
5555
sessions: {
5656
label: t('Sessions'),
57-
color: 'hsl(var(--chart-3))',
58-
},
59-
avgEventsPerSession: {
60-
label: t('Unique Events'),
6157
color: 'hsl(var(--chart-2))',
6258
},
63-
avgScreensPerSession: {
64-
label: t('Unique Screens'),
65-
color: 'hsl(var(--chart-4))',
66-
},
6759
};
6860
}, [t]);
6961

@@ -73,6 +65,7 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
7365
return {
7466
events: { total: 0, diff: 0 },
7567
sessions: { total: 0, diff: 0 },
68+
avgTime: { total: 0, diff: 0 },
7669
avgEventsPerSession: { total: 0, diff: 0 },
7770
avgScreensPerSession: { total: 0, diff: 0 },
7871
};
@@ -83,13 +76,16 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
8376
(acc, item) => {
8477
acc.events += item.eventCount;
8578
acc.sessions += item.sessionCount;
79+
acc.avgTime +=
80+
item.sessionCount > 0 ? item.totalTime / item.sessionCount : 0;
8681
acc.avgEventsPerSession += item.avgEventsPerSession;
8782
acc.avgScreensPerSession += item.avgScreensPerSession;
8883
return acc;
8984
},
9085
{
9186
events: 0,
9287
sessions: 0,
88+
avgTime: 0,
9389
avgEventsPerSession: 0,
9490
avgScreensPerSession: 0,
9591
}
@@ -100,13 +96,16 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
10096
(acc, item) => {
10197
acc.events += item.eventCount;
10298
acc.sessions += item.sessionCount;
99+
acc.avgTime +=
100+
item.sessionCount > 0 ? item.totalTime / item.sessionCount : 0;
103101
acc.avgEventsPerSession += item.avgEventsPerSession;
104102
acc.avgScreensPerSession += item.avgScreensPerSession;
105103
return acc;
106104
},
107105
{
108106
events: 0,
109107
sessions: 0,
108+
avgTime: 0,
110109
avgEventsPerSession: 0,
111110
avgScreensPerSession: 0,
112111
}
@@ -122,6 +121,10 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
122121
total: currentTotals.sessions,
123122
diff: currentTotals.sessions - previousTotals.sessions,
124123
},
124+
avgTime: {
125+
total: currentTotals.avgTime,
126+
diff: currentTotals.avgTime - previousTotals.avgTime,
127+
},
125128
avgEventsPerSession: {
126129
total: currentTotals.avgEventsPerSession,
127130
diff:
@@ -163,118 +166,38 @@ export const ApplicationStatsChart: React.FC<ApplicationStatsChartProps> =
163166
/>
164167
</CardContent>
165168

166-
<div className="grid grid-cols-2 border-t md:grid-cols-4">
167-
<div className="border-border border-r p-4">
168-
<div className="flex flex-col">
169-
<span className="text-muted-foreground text-sm">
170-
{t('Events')}
171-
</span>
172-
<div className="mt-1 flex items-center justify-between">
173-
<span className="text-2xl font-bold">
174-
{statsData.events.total.toLocaleString()}
175-
</span>
176-
{statsData.events.diff !== 0 && (
177-
<div
178-
className={`flex items-center ${statsData.events.diff > 0 ? 'text-green-500' : 'text-red-500'}`}
179-
>
180-
{statsData.events.diff > 0 ? (
181-
<LuArrowUp className="mr-1" />
182-
) : (
183-
<LuArrowDown className="mr-1" />
184-
)}
185-
<span>
186-
{Math.abs(statsData.events.diff).toLocaleString()}
187-
</span>
188-
</div>
189-
)}
190-
</div>
191-
</div>
192-
</div>
169+
<div className="flex flex-wrap">
170+
<StatCard
171+
label={t('Events')}
172+
curr={statsData.events.total}
173+
diff={statsData.events.diff}
174+
/>
193175

194-
<div className="border-border border-r p-4">
195-
<div className="flex flex-col">
196-
<span className="text-muted-foreground text-sm">
197-
{t('Sessions')}
198-
</span>
199-
<div className="mt-1 flex items-center justify-between">
200-
<span className="text-2xl font-bold">
201-
{statsData.sessions.total.toLocaleString()}
202-
</span>
203-
{statsData.sessions.diff !== 0 && (
204-
<div
205-
className={`flex items-center ${statsData.sessions.diff > 0 ? 'text-green-500' : 'text-red-500'}`}
206-
>
207-
{statsData.sessions.diff > 0 ? (
208-
<LuArrowUp className="mr-1" />
209-
) : (
210-
<LuArrowDown className="mr-1" />
211-
)}
212-
<span>
213-
{Math.abs(statsData.sessions.diff).toLocaleString()}
214-
</span>
215-
</div>
216-
)}
217-
</div>
218-
</div>
219-
</div>
176+
<StatCard
177+
label={t('Sessions')}
178+
curr={statsData.sessions.total}
179+
diff={statsData.sessions.diff}
180+
/>
220181

221-
<div className="border-border border-r p-4">
222-
<div className="flex flex-col">
223-
<span className="text-muted-foreground text-sm">
224-
{t('Avg Events / Session')}
225-
</span>
226-
<div className="mt-1 flex items-center justify-between">
227-
<span className="text-2xl font-bold">
228-
{statsData.avgEventsPerSession.total.toLocaleString()}
229-
</span>
230-
{statsData.avgEventsPerSession.diff !== 0 && (
231-
<div
232-
className={`flex items-center ${statsData.avgEventsPerSession.diff > 0 ? 'text-green-500' : 'text-red-500'}`}
233-
>
234-
{statsData.avgEventsPerSession.diff > 0 ? (
235-
<LuArrowUp className="mr-1" />
236-
) : (
237-
<LuArrowDown className="mr-1" />
238-
)}
239-
<span>
240-
{Math.abs(
241-
statsData.avgEventsPerSession.diff
242-
).toLocaleString()}
243-
</span>
244-
</div>
245-
)}
246-
</div>
247-
</div>
248-
</div>
182+
<StatCard
183+
label={t('Avg. Time')}
184+
curr={statsData.avgTime.total}
185+
diff={statsData.avgTime.diff}
186+
formatter={(value) => prettyMilliseconds(value)}
187+
/>
249188

250-
<div className="border-border p-4">
251-
<div className="flex flex-col">
252-
<span className="text-muted-foreground text-sm">
253-
{t('Avg Screens / Session')}
254-
</span>
255-
<div className="mt-1 flex items-center justify-between">
256-
<span className="text-2xl font-bold">
257-
{statsData.avgScreensPerSession.total.toLocaleString()}
258-
</span>
259-
{statsData.avgScreensPerSession.diff !== 0 && (
260-
<div
261-
className={`flex items-center ${statsData.avgScreensPerSession.diff > 0 ? 'text-green-500' : 'text-red-500'}`}
262-
>
263-
{statsData.avgScreensPerSession.diff > 0 ? (
264-
<LuArrowUp className="mr-1" />
265-
) : (
266-
<LuArrowDown className="mr-1" />
267-
)}
268-
<span>
269-
{Math.abs(
270-
statsData.avgScreensPerSession.diff
271-
).toLocaleString()}
272-
</span>
273-
</div>
274-
)}
275-
</div>
276-
</div>
277-
</div>
189+
<StatCard
190+
label={t('Avg. Events / Session')}
191+
curr={statsData.avgEventsPerSession.total}
192+
diff={statsData.avgEventsPerSession.diff}
193+
/>
194+
195+
<StatCard
196+
label={t('Avg. Screens / Session')}
197+
curr={statsData.avgScreensPerSession.total}
198+
diff={statsData.avgScreensPerSession.diff}
199+
borderRight={false}
200+
/>
278201
</div>
279202
</Card>
280203
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { cn } from '@/utils/style';
2+
import React from 'react';
3+
import { LuArrowUp, LuArrowDown } from 'react-icons/lu';
4+
5+
interface StatCardProps {
6+
label: string;
7+
curr: number;
8+
diff: number;
9+
formatter?: (value: number) => string;
10+
className?: string;
11+
borderRight?: boolean;
12+
}
13+
14+
export const StatCard: React.FC<StatCardProps> = React.memo((props) => {
15+
const {
16+
label,
17+
curr,
18+
diff,
19+
formatter,
20+
className = '',
21+
borderRight = true,
22+
} = props;
23+
24+
return (
25+
<div
26+
className={cn(
27+
'flex-1 border-t p-4',
28+
borderRight && 'border-border border-r',
29+
className
30+
)}
31+
>
32+
<div className="flex flex-col">
33+
<span className="text-muted-foreground text-sm">{label}</span>
34+
<div className="mt-1 flex items-center justify-between">
35+
<span className="text-2xl font-bold">
36+
{formatter ? formatter(curr) : curr.toLocaleString()}
37+
</span>
38+
{diff !== 0 && (
39+
<div
40+
className={`flex items-center ${diff > 0 ? 'text-green-500' : 'text-red-500'}`}
41+
>
42+
{diff > 0 ? (
43+
<LuArrowUp className="mr-1" />
44+
) : (
45+
<LuArrowDown className="mr-1" />
46+
)}
47+
<span>
48+
{formatter ? formatter(diff) : Math.abs(diff).toLocaleString()}
49+
</span>
50+
</div>
51+
)}
52+
</div>
53+
</div>
54+
</div>
55+
);
56+
});
57+
58+
StatCard.displayName = 'StatCard';

src/client/utils/common.ts

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export function formatNumber(n: number): string {
2626
});
2727
}
2828

29+
/**
30+
* @deprecated
31+
* maybe replace with package `pretty-ms`
32+
*/
2933
export function formatShortTime(val: number, formats = ['m', 's'], space = '') {
3034
const { days, hours, minutes, seconds, ms } = parseTime(val);
3135
let t = '';

src/server/model/application/event.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { prisma } from '../_client.js';
2-
import { BaseQueryFilters, getDateQuery } from '../../utils/prisma.js';
2+
import {
3+
BaseQueryFilters,
4+
getDateQuery,
5+
getTimestampIntervalQuery,
6+
} from '../../utils/prisma.js';
37
import { getDateArray } from '@tianji/shared';
48
import { z } from 'zod';
59

610
export const eventStatsQueryResultItemSchema = z.object({
711
date: z.string(),
812
eventCount: z.number(),
913
sessionCount: z.number(),
14+
totalTime: z.number(),
1015
avgEventsPerSession: z.number(),
1116
avgScreensPerSession: z.number(),
1217
});
@@ -38,6 +43,7 @@ export async function getApplicationEventStats(
3843
${getDateQuery('"ApplicationEvent"."createdAt"', unit, timezone)} date,
3944
COUNT(*) as "eventCount",
4045
COUNT(DISTINCT "sessionId") as "sessionCount",
46+
${getTimestampIntervalQuery('"ApplicationEvent"."createdAt"')} as "totalTime",
4147
ROUND(
4248
CAST(
4349
COUNT(DISTINCT CONCAT("sessionId", ':', "eventName")) AS DECIMAL
@@ -70,6 +76,7 @@ export async function getApplicationEventStats(
7076
${getDateQuery('"ApplicationEvent"."createdAt"', unit, timezone)} date,
7177
COUNT(*) as "eventCount",
7278
COUNT(DISTINCT "sessionId") as "sessionCount",
79+
${getTimestampIntervalQuery('"ApplicationEvent"."createdAt"')} as "totalTime",
7380
ROUND(
7481
CAST(
7582
COUNT(DISTINCT CONCAT("sessionId", ':', "eventName")) AS DECIMAL
@@ -98,6 +105,7 @@ export async function getApplicationEventStats(
98105
date: res.date,
99106
eventCount: Number(res.eventCount),
100107
sessionCount: Number(res.sessionCount),
108+
totalTime: Number(res.totalTime),
101109
avgEventsPerSession: Number(res.avgEventsPerSession),
102110
avgScreensPerSession: Number(res.avgScreensPerSession),
103111
})),
@@ -111,6 +119,7 @@ export async function getApplicationEventStats(
111119
date: res.date,
112120
eventCount: Number(res.eventCount),
113121
sessionCount: Number(res.sessionCount),
122+
totalTime: Number(res.totalTime),
114123
avgEventsPerSession: Number(res.avgEventsPerSession),
115124
avgScreensPerSession: Number(res.avgScreensPerSession),
116125
})),

0 commit comments

Comments
 (0)