Skip to content

Commit 76e6712

Browse files
committed
feat(projects): implement applyProjectWeeklyUsersRows function and add tests
- Introduced the `applyProjectWeeklyUsersRows` function to process weekly and daily user metrics for projects. - Created a new test file to validate the functionality of the new helper, ensuring it correctly applies user data and handles unknown projects. - Updated the existing route to utilize the new function for better code organization and clarity. This change enhances the backend's ability to manage and analyze user engagement metrics for projects.
1 parent 12ccfae commit 76e6712

3 files changed

Lines changed: 201 additions & 117 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from "vitest";
2+
import { applyProjectWeeklyUsersRows } from "./route";
3+
4+
describe("internal projects weekly users helpers", () => {
5+
it("applies ClickHouse rows through a Map and skips unknown projects", () => {
6+
const byProject = new Map([
7+
["project-a", {
8+
weekly_users: 0,
9+
daily_users: [
10+
{ date: "2026-05-01", activity: 0 },
11+
{ date: "2026-05-02", activity: 0 },
12+
],
13+
}],
14+
["__proto__", {
15+
weekly_users: 0,
16+
daily_users: [
17+
{ date: "2026-05-01", activity: 0 },
18+
{ date: "2026-05-02", activity: 0 },
19+
],
20+
}],
21+
]);
22+
23+
applyProjectWeeklyUsersRows(
24+
byProject,
25+
[
26+
{ projectId: "project-a", weeklyUsers: 4 },
27+
{ projectId: "__proto__", weeklyUsers: 7 },
28+
{ projectId: "missing-project", weeklyUsers: 99 },
29+
],
30+
[
31+
{ projectId: "project-a", day: "2026-05-01", dailyUsers: 2 },
32+
{ projectId: "__proto__", day: "2026-05-02", dailyUsers: 5 },
33+
{ projectId: "missing-project", day: "2026-05-01", dailyUsers: 99 },
34+
],
35+
);
36+
37+
expect(Object.fromEntries(byProject)).toMatchInlineSnapshot(`
38+
{
39+
"__proto__": {
40+
"daily_users": [
41+
{
42+
"activity": 0,
43+
"date": "2026-05-01",
44+
},
45+
{
46+
"activity": 5,
47+
"date": "2026-05-02",
48+
},
49+
],
50+
"weekly_users": 7,
51+
},
52+
"project-a": {
53+
"daily_users": [
54+
{
55+
"activity": 2,
56+
"date": "2026-05-01",
57+
},
58+
{
59+
"activity": 0,
60+
"date": "2026-05-02",
61+
},
62+
],
63+
"weekly_users": 4,
64+
},
65+
}
66+
`);
67+
});
68+
});

apps/backend/src/app/api/latest/internal/projects-weekly-users/route.tsx

Lines changed: 101 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,48 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist
1010
const WINDOW_DAYS = 7;
1111
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
1212

13+
type ProjectWeeklyUsers = {
14+
weekly_users: number,
15+
daily_users: { date: string, activity: number }[],
16+
};
17+
18+
export function applyProjectWeeklyUsersRows(
19+
byProject: Map<string, ProjectWeeklyUsers>,
20+
rows: { projectId: string, weeklyUsers: number }[],
21+
dailyRows: { projectId: string, day: string, dailyUsers: number }[],
22+
) {
23+
for (const row of rows) {
24+
const project = byProject.get(row.projectId);
25+
if (project == null) {
26+
continue;
27+
}
28+
project.weekly_users = Number(row.weeklyUsers);
29+
}
30+
31+
const dailyIndex = new Map<string, Map<string, number>>();
32+
for (const row of dailyRows) {
33+
if (!byProject.has(row.projectId)) {
34+
continue;
35+
}
36+
const dayKey = row.day.split("T")[0];
37+
let m = dailyIndex.get(row.projectId);
38+
if (!m) {
39+
m = new Map();
40+
dailyIndex.set(row.projectId, m);
41+
}
42+
m.set(dayKey, Number(row.dailyUsers));
43+
}
44+
45+
for (const [id, project] of byProject) {
46+
const m = dailyIndex.get(id);
47+
if (!m) continue;
48+
project.daily_users = project.daily_users.map((point) => ({
49+
date: point.date,
50+
activity: m.get(point.date) ?? 0,
51+
}));
52+
}
53+
}
54+
1355
export const GET = createSmartRouteHandler({
1456
metadata: { hidden: true },
1557
request: yupObject({
@@ -55,76 +97,77 @@ export const GET = createSmartRouteHandler({
5597
return out;
5698
};
5799

58-
const byProject: Record<string, { weekly_users: number, daily_users: { date: string, activity: number }[] }> = {};
100+
const byProject = new Map<string, ProjectWeeklyUsers>();
59101
for (const id of projectIds) {
60-
byProject[id] = {
102+
byProject.set(id, {
61103
weekly_users: 0,
62104
daily_users: emptySeries(),
63-
};
105+
});
64106
}
107+
const projectsResponse = () => Object.fromEntries(byProject);
65108

66109
if (projectIds.length === 0) {
67110
return {
68111
statusCode: 200,
69112
bodyType: "json",
70-
body: { projects: byProject },
113+
body: { projects: projectsResponse() },
71114
};
72115
}
73116

117+
const clickhouseClient = getClickhouseAdminClient();
118+
const queryParams = {
119+
projectIds,
120+
branchId: DEFAULT_BRANCH_ID,
121+
since: since.toISOString().slice(0, 19),
122+
untilExclusive: untilExclusive.toISOString().slice(0, 19),
123+
};
124+
74125
let rows: { projectId: string, weeklyUsers: number }[] = [];
75126
let dailyRows: { projectId: string, day: string, dailyUsers: number }[] = [];
76127
try {
77-
const clickhouseClient = getClickhouseAdminClient();
78-
const result = await clickhouseClient.query({
79-
query: `
80-
SELECT
81-
project_id AS projectId,
82-
uniqExact(assumeNotNull(user_id)) AS weeklyUsers
83-
FROM analytics_internal.events
84-
WHERE event_type = '$token-refresh'
85-
AND project_id IN {projectIds:Array(String)}
86-
AND branch_id = {branchId:String}
87-
AND user_id IS NOT NULL
88-
AND event_at >= {since:DateTime}
89-
AND event_at < {untilExclusive:DateTime}
90-
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
91-
GROUP BY projectId
92-
`,
93-
query_params: {
94-
projectIds,
95-
branchId: DEFAULT_BRANCH_ID,
96-
since: since.toISOString().slice(0, 19),
97-
untilExclusive: untilExclusive.toISOString().slice(0, 19),
98-
},
99-
format: "JSONEachRow",
100-
});
101-
rows = await result.json();
102-
103-
const dailyResult = await clickhouseClient.query({
104-
query: `
105-
SELECT
106-
project_id AS projectId,
107-
toDate(event_at) AS day,
108-
uniqExact(assumeNotNull(user_id)) AS dailyUsers
109-
FROM analytics_internal.events
110-
WHERE event_type = '$token-refresh'
111-
AND project_id IN {projectIds:Array(String)}
112-
AND branch_id = {branchId:String}
113-
AND user_id IS NOT NULL
114-
AND event_at >= {since:DateTime}
115-
AND event_at < {untilExclusive:DateTime}
116-
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
117-
GROUP BY projectId, day
118-
`,
119-
query_params: {
120-
projectIds,
121-
branchId: DEFAULT_BRANCH_ID,
122-
since: since.toISOString().slice(0, 19),
123-
untilExclusive: untilExclusive.toISOString().slice(0, 19),
124-
},
125-
format: "JSONEachRow",
126-
});
127-
dailyRows = await dailyResult.json();
128+
const [weeklyResult, dailyResult] = await Promise.all([
129+
clickhouseClient.query({
130+
query: `
131+
SELECT
132+
project_id AS projectId,
133+
uniqExact(assumeNotNull(user_id)) AS weeklyUsers
134+
FROM analytics_internal.events
135+
WHERE event_type = '$token-refresh'
136+
AND project_id IN {projectIds:Array(String)}
137+
AND branch_id = {branchId:String}
138+
AND user_id IS NOT NULL
139+
AND event_at >= {since:DateTime}
140+
AND event_at < {untilExclusive:DateTime}
141+
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
142+
GROUP BY projectId
143+
`,
144+
query_params: queryParams,
145+
format: "JSONEachRow",
146+
}),
147+
clickhouseClient.query({
148+
query: `
149+
SELECT
150+
project_id AS projectId,
151+
toDate(event_at) AS day,
152+
uniqExact(assumeNotNull(user_id)) AS dailyUsers
153+
FROM analytics_internal.events
154+
WHERE event_type = '$token-refresh'
155+
AND project_id IN {projectIds:Array(String)}
156+
AND branch_id = {branchId:String}
157+
AND user_id IS NOT NULL
158+
AND event_at >= {since:DateTime}
159+
AND event_at < {untilExclusive:DateTime}
160+
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
161+
GROUP BY projectId, day
162+
`,
163+
query_params: queryParams,
164+
format: "JSONEachRow",
165+
}),
166+
]);
167+
[rows, dailyRows] = await Promise.all([
168+
weeklyResult.json<{ projectId: string, weeklyUsers: number }>(),
169+
dailyResult.json<{ projectId: string, day: string, dailyUsers: number }>(),
170+
]);
128171
} catch (error) {
129172
const captureId = error instanceof ClickHouseError
130173
? "internal-projects-weekly-users-clickhouse-error"
@@ -136,37 +179,16 @@ export const GET = createSmartRouteHandler({
136179
return {
137180
statusCode: 200,
138181
bodyType: "json",
139-
body: { projects: byProject },
182+
body: { projects: projectsResponse() },
140183
};
141184
}
142-
for (const row of rows) {
143-
byProject[row.projectId].weekly_users = Number(row.weeklyUsers);
144-
}
145185

146-
const dailyIndex = new Map<string, Map<string, number>>();
147-
for (const row of dailyRows) {
148-
const dayKey = row.day.split("T")[0];
149-
let m = dailyIndex.get(row.projectId);
150-
if (!m) {
151-
m = new Map();
152-
dailyIndex.set(row.projectId, m);
153-
}
154-
m.set(dayKey, Number(row.dailyUsers));
155-
}
156-
157-
for (const id of projectIds) {
158-
const m = dailyIndex.get(id);
159-
if (!m) continue;
160-
byProject[id].daily_users = byProject[id].daily_users.map((point) => ({
161-
date: point.date,
162-
activity: m.get(point.date) ?? 0,
163-
}));
164-
}
186+
applyProjectWeeklyUsersRows(byProject, rows, dailyRows);
165187

166188
return {
167189
statusCode: 200,
168190
bodyType: "json",
169-
body: { projects: byProject },
191+
body: { projects: projectsResponse() },
170192
};
171193
},
172194
});

apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -124,49 +124,43 @@ export default function PageClient() {
124124
useEffect(() => {
125125
let cancelled = false;
126126
runAsynchronously(async () => {
127-
try {
128-
const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client");
129-
if (!response.ok) {
130-
console.warn("[projects-weekly-users] request failed", response.status, await response.text());
131-
return;
127+
const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client");
128+
if (!response.ok) {
129+
throw new Error(`Failed to load project weekly users: ${response.status} ${await response.text()}`);
130+
}
131+
const body = await response.json();
132+
if (body == null || typeof body !== "object" || !("projects" in body) || body.projects == null || typeof body.projects !== "object") {
133+
throw new Error("Failed to load project weekly users: response body did not include a projects object.");
134+
}
135+
const weeklyUsersMap = new Map<string, number>();
136+
const weeklyUsersChartMap = new Map<string, { date: string, activity: number }[]>();
137+
for (const [projectId, value] of Object.entries(body.projects)) {
138+
if (value == null || typeof value !== "object") {
139+
continue;
132140
}
133-
const body = await response.json();
134-
if (body == null || typeof body !== "object" || !("projects" in body) || body.projects == null || typeof body.projects !== "object") {
135-
console.warn("[projects-weekly-users] unexpected body", body);
136-
return;
141+
const weeklyUsers = "weekly_users" in value ? value.weekly_users : undefined;
142+
if (typeof weeklyUsers === "number") {
143+
weeklyUsersMap.set(projectId, weeklyUsers);
137144
}
138-
const weeklyUsersMap = new Map<string, number>();
139-
const weeklyUsersChartMap = new Map<string, { date: string, activity: number }[]>();
140-
for (const [projectId, value] of Object.entries(body.projects)) {
141-
if (value == null || typeof value !== "object") {
142-
continue;
143-
}
144-
const weeklyUsers = "weekly_users" in value ? value.weekly_users : undefined;
145-
if (typeof weeklyUsers === "number") {
146-
weeklyUsersMap.set(projectId, weeklyUsers);
147-
}
148-
const dailyUsers = "daily_users" in value ? value.daily_users : undefined;
149-
if (!Array.isArray(dailyUsers)) {
150-
continue;
151-
}
152-
const points: { date: string, activity: number }[] = [];
153-
for (const point of dailyUsers) {
154-
if (point != null && typeof point === "object" && "date" in point && "activity" in point) {
155-
const date = point.date;
156-
const activity = point.activity;
157-
if (typeof date === "string" && typeof activity === "number") {
158-
points.push({ date, activity });
159-
}
145+
const dailyUsers = "daily_users" in value ? value.daily_users : undefined;
146+
if (!Array.isArray(dailyUsers)) {
147+
continue;
148+
}
149+
const points: { date: string, activity: number }[] = [];
150+
for (const point of dailyUsers) {
151+
if (point != null && typeof point === "object" && "date" in point && "activity" in point) {
152+
const date = point.date;
153+
const activity = point.activity;
154+
if (typeof date === "string" && typeof activity === "number") {
155+
points.push({ date, activity });
160156
}
161157
}
162-
weeklyUsersChartMap.set(projectId, points);
163-
}
164-
if (!cancelled) {
165-
setProjectWeeklyUsers(weeklyUsersMap);
166-
setProjectWeeklyUsersChart(weeklyUsersChartMap);
167158
}
168-
} catch (e) {
169-
console.warn("[projects-weekly-users] fetch error", e);
159+
weeklyUsersChartMap.set(projectId, points);
160+
}
161+
if (!cancelled) {
162+
setProjectWeeklyUsers(weeklyUsersMap);
163+
setProjectWeeklyUsersChart(weeklyUsersChartMap);
170164
}
171165
});
172166
return () => {

0 commit comments

Comments
 (0)