Skip to content

Commit 533d7ff

Browse files
committed
feat(analytics): updated flag usage analytics
1 parent 82aa2a4 commit 533d7ff

9 files changed

Lines changed: 298 additions & 270 deletions

File tree

apps/api/src/routes/project/route.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
} from "@/lib/validations/project";
1616
import { resolveRoleByProjectParams } from "@/middleware/resolve-role";
1717
import { verifyRole } from "@/middleware/verify-role";
18-
import type { DailyConversionData, TinybirdRow } from "@/types/analytics";
18+
import type { DailyConversionData } from "@/types/analytics";
1919
import type { RequestWithSession } from "@/types/request";
20-
import { pivotAnalyticsData } from "@/utils/analytics";
20+
import { fillMissingDates, pivotAnalyticsData } from "@/utils/analytics";
2121
import { sendProjectInviteEmail } from "@/utils/project";
2222

2323
const router = Router();
@@ -1367,6 +1367,7 @@ router.get(
13671367
async (req: RequestWithSession, res: Response) => {
13681368
const { projectId } = req.params;
13691369
const { timeRange = "7d" } = req.query;
1370+
13701371
let days: number;
13711372
if (timeRange === "30d") {
13721373
days = 30;
@@ -1400,30 +1401,31 @@ router.get(
14001401

14011402
const safeData = Array.isArray(data) ? data : [];
14021403

1404+
const dailyImpressions = fillMissingDates(
1405+
safeData.filter((r) => r.impressions !== null),
1406+
days
1407+
);
1408+
1409+
const flagDistribution = safeData.filter(
1410+
(r) => r.flag_key !== null && r.total !== null
1411+
);
1412+
1413+
const dailyTopFlagImpressions = pivotAnalyticsData(
1414+
safeData.filter((r) => r.flag_key && r.val && !r.variation_name),
1415+
"flag_key",
1416+
"val",
1417+
days
1418+
);
1419+
1420+
const dailyVariationUsage = safeData.filter(
1421+
(r) => r.variation_name !== null && r.val !== null
1422+
);
1423+
14031424
res.json({
1404-
dailyImpressions:
1405-
safeData.filter(
1406-
(r: DailyConversionData) => r.impressions !== undefined
1407-
) || [],
1408-
flagDistribution:
1409-
safeData.filter(
1410-
(r: DailyConversionData) => r.flag_key && r.total !== undefined
1411-
) || [],
1412-
dailyVariationUsageProjectWide: pivotAnalyticsData(
1413-
safeData.filter(
1414-
(r): r is TinybirdRow & { variation_name: string } =>
1415-
!!r.variation_name
1416-
),
1417-
"variation_name",
1418-
"val"
1419-
),
1420-
dailyTopFlagImpressions: pivotAnalyticsData(
1421-
safeData.filter(
1422-
(r): r is TinybirdRow & { flag_key: string } => !!r.flag_key
1423-
),
1424-
"flag_key",
1425-
"val"
1426-
),
1425+
dailyImpressions,
1426+
flagDistribution,
1427+
dailyVariationUsage,
1428+
dailyTopFlagImpressions,
14271429
});
14281430
} catch (error) {
14291431
console.error("Failed to get usage analytics:", error);

apps/api/src/utils/analytics.ts

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,75 @@
1-
/**
2-
* Transforms flat Tinybird rows into component-ready JSON
3-
* e.g [{date: '2023-01-01', variation: 'A', val: 10}]
4-
* -> [{date: '2023-01-01', A: 10}]
5-
*/
6-
export function pivotAnalyticsData<
7-
T extends { date: string },
8-
P extends keyof T,
9-
V extends keyof T,
10-
>(
11-
rows: T[],
12-
pivotKey: P,
13-
valueKey: V
14-
): Array<{ date: string } & Record<string, T[V]>> {
15-
if (!rows || rows.length === 0) {
16-
return [];
1+
import { eachDayOfInterval, format, subDays } from "date-fns";
2+
3+
type AnalyticsRow = {
4+
date?: string | null;
5+
[key: string]: unknown;
6+
};
7+
8+
type ChartDataNode = {
9+
date: string;
10+
[key: string]: string | number;
11+
};
12+
13+
export function pivotAnalyticsData(
14+
rows: AnalyticsRow[],
15+
pivotKey: string,
16+
valueKey: string,
17+
days: number
18+
): ChartDataNode[] {
19+
const endDate = new Date();
20+
const startDate = subDays(endDate, days - 1);
21+
const dateRange = eachDayOfInterval({ start: startDate, end: endDate });
22+
23+
const resultObj: Record<string, ChartDataNode> = {};
24+
25+
for (const day of dateRange) {
26+
const dateStr = format(day, "yyyy-MM-dd");
27+
resultObj[dateStr] = { date: dateStr };
1728
}
1829

19-
const grouped = rows.reduce(
20-
(acc, row) => {
30+
if (rows && Array.isArray(rows)) {
31+
for (const row of rows) {
2132
const date = row.date;
2233

23-
// The value of the pivotKey (e.g., 'Variation A') becomes a property name
24-
// We cast to string to ensure it's a valid object key
25-
const dynamicKey = String(row[pivotKey]);
26-
const value = row[valueKey];
34+
if (date && resultObj[date]) {
35+
const key = String(row[pivotKey]);
36+
37+
const value = Number(row[valueKey]) || 0;
2738

28-
if (!acc[date]) {
29-
acc[date] = { date } as { date: string } & Record<string, T[V]>;
39+
const currentVal = (resultObj[date][key] as number) || 0;
40+
resultObj[date][key] = currentVal + value;
3041
}
42+
}
43+
}
3144

32-
acc[date][dynamicKey] = value;
33-
return acc;
34-
},
35-
{} as Record<string, { date: string } & Record<string, T[V]>>
45+
return (Object.values(resultObj) as ChartDataNode[]).sort((a, b) =>
46+
a.date.localeCompare(b.date)
3647
);
48+
}
49+
50+
export function fillMissingDates(
51+
rows: AnalyticsRow[],
52+
days: number
53+
): Array<{ date: string; impressions: number }> {
54+
const endDate = new Date();
55+
const startDate = subDays(endDate, days - 1);
56+
const dateRange = eachDayOfInterval({ start: startDate, end: endDate });
57+
58+
const resultObj: Record<string, { date: string; impressions: number }> = {};
59+
60+
for (const day of dateRange) {
61+
const dateStr = format(day, "yyyy-MM-dd");
62+
resultObj[dateStr] = { date: dateStr, impressions: 0 };
63+
}
64+
65+
if (rows && Array.isArray(rows)) {
66+
for (const row of rows) {
67+
const date = row.date;
68+
if (date && resultObj[date]) {
69+
resultObj[date].impressions = Number(row.impressions) || 0;
70+
}
71+
}
72+
}
3773

38-
return Object.values(grouped).sort((a, b) => a.date.localeCompare(b.date));
74+
return Object.values(resultObj).sort((a, b) => a.date.localeCompare(b.date));
3975
}

0 commit comments

Comments
 (0)