Skip to content

Commit 227dac6

Browse files
authored
feat(dashboard): add weekly users metrics for projects (#1412)
- Introduced a new API endpoint to fetch weekly and daily user metrics for managed projects. - Updated the dashboard to utilize this new endpoint, replacing the previous daily active users data. - Created a new component to visualize weekly users metrics in the project cards. - Refactored existing components to accommodate the new data structure and ensure proper rendering of user activity charts. This change enhances the analytics capabilities of the dashboard, providing better insights into user engagement over time. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * New internal endpoint providing per-project weekly user totals and 7-day daily activity series. * **Updates** * Dashboard and project cards switched from DAU to weekly user metrics; main metric shows weekly users and label reads "users/wk". * Charts now display weekly-user-aware sparklines alongside daily activity. * **Tests** * Added unit tests covering weekly aggregation and daily-series merging. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 261d892 commit 227dac6

5 files changed

Lines changed: 252 additions & 69 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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", day: "1970-01-01", users: 4 },
27+
{ projectId: "__proto__", day: "1970-01-01", users: 7 },
28+
{ projectId: "missing-project", day: "1970-01-01", users: 99 },
29+
{ projectId: "project-a", day: "2026-05-01", users: 2 },
30+
{ projectId: "__proto__", day: "2026-05-02", users: 5 },
31+
{ projectId: "missing-project", day: "2026-05-01", users: 99 },
32+
],
33+
);
34+
35+
expect(Object.fromEntries(byProject)).toMatchInlineSnapshot(`
36+
{
37+
"__proto__": {
38+
"daily_users": [
39+
{
40+
"activity": 0,
41+
"date": "2026-05-01",
42+
},
43+
{
44+
"activity": 5,
45+
"date": "2026-05-02",
46+
},
47+
],
48+
"weekly_users": 7,
49+
},
50+
"project-a": {
51+
"daily_users": [
52+
{
53+
"activity": 2,
54+
"date": "2026-05-01",
55+
},
56+
{
57+
"activity": 0,
58+
"date": "2026-05-02",
59+
},
60+
],
61+
"weekly_users": 4,
62+
},
63+
}
64+
`);
65+
});
66+
});

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

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,46 @@ 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, day: string, users: number }[],
21+
) {
22+
// GROUPING SETS emits one rollup row per project with day defaulted to the
23+
// ClickHouse Date epoch ("1970-01-01"); those rows hold the weekly total.
24+
const dailyIndex = new Map<string, Map<string, number>>();
25+
for (const row of rows) {
26+
const project = byProject.get(row.projectId);
27+
if (project == null) {
28+
continue;
29+
}
30+
const dayKey = row.day.split("T")[0];
31+
if (dayKey === "1970-01-01") {
32+
project.weekly_users = Number(row.users);
33+
continue;
34+
}
35+
let m = dailyIndex.get(row.projectId);
36+
if (!m) {
37+
m = new Map();
38+
dailyIndex.set(row.projectId, m);
39+
}
40+
m.set(dayKey, Number(row.users));
41+
}
42+
43+
for (const [id, project] of byProject) {
44+
const m = dailyIndex.get(id);
45+
if (!m) continue;
46+
project.daily_users = project.daily_users.map((point) => ({
47+
date: point.date,
48+
activity: m.get(point.date) ?? 0,
49+
}));
50+
}
51+
}
52+
1353
export const GET = createSmartRouteHandler({
1454
metadata: { hidden: true },
1555
request: yupObject({
@@ -24,7 +64,10 @@ export const GET = createSmartRouteHandler({
2464
statusCode: yupNumber().oneOf([200]).defined(),
2565
bodyType: yupString().oneOf(["json"]).defined(),
2666
body: yupObject({
27-
projects: yupRecord(yupString().defined(), MetricsDataPointsSchema).defined(),
67+
projects: yupRecord(yupString().defined(), yupObject({
68+
weekly_users: yupNumber().integer().defined(),
69+
daily_users: MetricsDataPointsSchema,
70+
}).defined()).defined(),
2871
}).defined(),
2972
}),
3073
handler: async (req) => {
@@ -52,28 +95,39 @@ export const GET = createSmartRouteHandler({
5295
return out;
5396
};
5497

55-
const byProject: Record<string, { date: string, activity: number }[]> = {};
98+
const byProject = new Map<string, ProjectWeeklyUsers>();
5699
for (const id of projectIds) {
57-
byProject[id] = emptySeries();
100+
byProject.set(id, {
101+
weekly_users: 0,
102+
daily_users: emptySeries(),
103+
});
58104
}
105+
const projectsResponse = () => Object.fromEntries(byProject);
59106

60107
if (projectIds.length === 0) {
61108
return {
62109
statusCode: 200,
63110
bodyType: "json",
64-
body: { projects: byProject },
111+
body: { projects: projectsResponse() },
65112
};
66113
}
67114

68-
let rows: { projectId: string, day: string, dau: number }[] = [];
115+
const clickhouseClient = getClickhouseAdminClient();
116+
const queryParams = {
117+
projectIds,
118+
branchId: DEFAULT_BRANCH_ID,
119+
since: since.toISOString().slice(0, 19),
120+
untilExclusive: untilExclusive.toISOString().slice(0, 19),
121+
};
122+
123+
let rows: { projectId: string, day: string, users: number }[] = [];
69124
try {
70-
const clickhouseClient = getClickhouseAdminClient();
71125
const result = await clickhouseClient.query({
72126
query: `
73127
SELECT
74128
project_id AS projectId,
75-
toDate(event_at) AS day,
76-
uniqExact(assumeNotNull(user_id)) AS dau
129+
toDate(event_at, 'UTC') AS day,
130+
uniqExact(assumeNotNull(user_id)) AS users
77131
FROM analytics_internal.events
78132
WHERE event_type = '$token-refresh'
79133
AND project_id IN {projectIds:Array(String)}
@@ -82,55 +136,33 @@ export const GET = createSmartRouteHandler({
82136
AND event_at >= {since:DateTime}
83137
AND event_at < {untilExclusive:DateTime}
84138
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
85-
GROUP BY projectId, day
139+
GROUP BY GROUPING SETS ((projectId), (projectId, day))
86140
`,
87-
query_params: {
88-
projectIds,
89-
branchId: DEFAULT_BRANCH_ID,
90-
since: since.toISOString().slice(0, 19),
91-
untilExclusive: untilExclusive.toISOString().slice(0, 19),
92-
},
141+
query_params: queryParams,
93142
format: "JSONEachRow",
94143
});
95-
rows = await result.json();
144+
rows = await result.json<{ projectId: string, day: string, users: number }>();
96145
} catch (error) {
97146
const captureId = error instanceof ClickHouseError
98-
? "internal-projects-dau-clickhouse-error"
99-
: "internal-projects-dau-unexpected-error";
147+
? "internal-projects-weekly-users-clickhouse-error"
148+
: "internal-projects-weekly-users-unexpected-error";
100149
captureError(captureId, new StackAssertionError(
101-
"Failed to load projects DAU.",
150+
"Failed to load projects weekly users.",
102151
{ cause: error, projectCount: projectIds.length },
103152
));
104153
return {
105154
statusCode: 200,
106155
bodyType: "json",
107-
body: { projects: byProject },
156+
body: { projects: projectsResponse() },
108157
};
109158
}
110-
const index = new Map<string, Map<string, number>>();
111-
for (const row of rows) {
112-
const dayKey = row.day.split("T")[0];
113-
let m = index.get(row.projectId);
114-
if (!m) {
115-
m = new Map();
116-
index.set(row.projectId, m);
117-
}
118-
m.set(dayKey, Number(row.dau));
119-
}
120159

121-
for (const id of projectIds) {
122-
const m = index.get(id);
123-
if (!m) continue;
124-
byProject[id] = byProject[id].map((point) => ({
125-
date: point.date,
126-
activity: m.get(point.date) ?? 0,
127-
}));
128-
}
160+
applyProjectWeeklyUsersRows(byProject, rows);
129161

130162
return {
131163
statusCode: 200,
132164
bodyType: "json",
133-
body: { projects: byProject },
165+
body: { projects: projectsResponse() },
134166
};
135167
},
136168
});

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

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ export default function PageClient() {
7070
const [recentConfigProjectsError, setRecentConfigProjectsError] = useState(false);
7171
const [projectStatuses, setProjectStatuses] = useState<Map<string, ProjectOnboardingStatus>>(new Map());
7272
const [loadingProjectStatuses, setLoadingProjectStatuses] = useState(true);
73-
const [projectDau, setProjectDau] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
73+
const [projectWeeklyUsers, setProjectWeeklyUsers] = useState<Map<string, number>>(new Map());
74+
const [projectWeeklyUsersChart, setProjectWeeklyUsersChart] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
75+
const [loadingProjectWeeklyUsers, setLoadingProjectWeeklyUsers] = useState(true);
76+
const [projectWeeklyUsersError, setProjectWeeklyUsersError] = useState(false);
7477
const router = useRouter();
7578

7679
useEffect(() => {
@@ -124,34 +127,66 @@ export default function PageClient() {
124127

125128
useEffect(() => {
126129
let cancelled = false;
127-
runAsynchronously(async () => {
130+
runAsynchronouslyWithAlert(async () => {
131+
if (!cancelled) {
132+
setLoadingProjectWeeklyUsers(true);
133+
setProjectWeeklyUsersError(false);
134+
}
128135
try {
129-
const response = await appInternals.sendRequest("/internal/projects-dau", {}, "client");
136+
const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client");
130137
if (!response.ok) {
131-
console.warn("[projects-dau] request failed", response.status, await response.text());
132-
return;
138+
throw new Error(`Failed to load project weekly users: ${response.status} ${await response.text()}`);
133139
}
134140
const body = await response.json();
135-
if (body == null || typeof body !== "object" || !("projects" in body) || body.projects == null || typeof body.projects !== "object") {
136-
console.warn("[projects-dau] unexpected body", body);
137-
return;
141+
if (
142+
body == null ||
143+
typeof body !== "object" ||
144+
!("projects" in body) ||
145+
body.projects == null ||
146+
typeof body.projects !== "object" ||
147+
Array.isArray(body.projects)
148+
) {
149+
throw new Error("Failed to load project weekly users: response body did not include a projects object.");
138150
}
139-
const map = new Map<string, { date: string, activity: number }[]>();
140-
for (const [projectId, series] of Object.entries(body.projects as Record<string, unknown>)) {
141-
if (!Array.isArray(series)) continue;
151+
const weeklyUsersMap = new Map<string, number>();
152+
const weeklyUsersChartMap = new Map<string, { date: string, activity: number }[]>();
153+
for (const [projectId, value] of Object.entries(body.projects)) {
154+
if (value == null || typeof value !== "object") {
155+
continue;
156+
}
157+
const weeklyUsers = "weekly_users" in value ? value.weekly_users : undefined;
158+
if (typeof weeklyUsers === "number") {
159+
weeklyUsersMap.set(projectId, weeklyUsers);
160+
}
161+
const dailyUsers = "daily_users" in value ? value.daily_users : undefined;
162+
if (!Array.isArray(dailyUsers)) {
163+
continue;
164+
}
142165
const points: { date: string, activity: number }[] = [];
143-
for (const point of series) {
144-
if (point != null && typeof point === "object" && "date" in point && "activity" in point && typeof (point as any).date === "string" && typeof (point as any).activity === "number") {
145-
points.push({ date: (point as any).date, activity: (point as any).activity });
166+
for (const point of dailyUsers) {
167+
if (point != null && typeof point === "object" && "date" in point && "activity" in point) {
168+
const date = point.date;
169+
const activity = point.activity;
170+
if (typeof date === "string" && typeof activity === "number") {
171+
points.push({ date, activity });
172+
}
146173
}
147174
}
148-
map.set(projectId, points);
175+
weeklyUsersChartMap.set(projectId, points);
176+
}
177+
if (!cancelled) {
178+
setProjectWeeklyUsers(weeklyUsersMap);
179+
setProjectWeeklyUsersChart(weeklyUsersChartMap);
149180
}
181+
} catch (error) {
182+
if (!cancelled) {
183+
setProjectWeeklyUsersError(true);
184+
}
185+
throw error;
186+
} finally {
150187
if (!cancelled) {
151-
setProjectDau(map);
188+
setLoadingProjectWeeklyUsers(false);
152189
}
153-
} catch (e) {
154-
console.warn("[projects-dau] fetch error", e);
155190
}
156191
});
157192
return () => {
@@ -472,7 +507,10 @@ export default function PageClient() {
472507
project={project}
473508
href={projectHref}
474509
showIncompleteBadge={!loadingProjectStatuses && onboardingStatus !== "completed"}
475-
dau={projectDau.get(project.id)}
510+
weeklyUsers={projectWeeklyUsers.get(project.id)}
511+
weeklyUsersChart={projectWeeklyUsersChart.get(project.id)}
512+
weeklyUsersLoading={loadingProjectWeeklyUsers}
513+
weeklyUsersError={projectWeeklyUsersError}
476514
/>
477515
);
478516
})}

apps/dashboard/src/components/project-card.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { DesignBadge } from "@/components/design-components/badge";
33
import { DesignCard } from "@/components/design-components/card";
44
import { Link } from "@/components/link";
5-
import { ProjectDauSparkline } from "@/components/project-dau-sparkline";
5+
import { ProjectWeeklyUsersMetric } from "@/components/project-weekly-users-metric";
66
import { useFromNow } from '@/hooks/use-from-now';
77
import { FolderOpenIcon } from "@phosphor-icons/react";
88
import { AdminProject } from '@stackframe/stack';
@@ -12,7 +12,10 @@ export function ProjectCard(props: {
1212
project: AdminProject,
1313
href?: string,
1414
showIncompleteBadge?: boolean,
15-
dau?: { date: string, activity: number }[],
15+
weeklyUsers?: number,
16+
weeklyUsersChart?: { date: string, activity: number }[],
17+
weeklyUsersLoading?: boolean,
18+
weeklyUsersError?: boolean,
1619
}) {
1720
const createdAt = useFromNow(props.project.createdAt);
1821
const href = props.href ?? urlString`/projects/${props.project.id}`;
@@ -49,7 +52,12 @@ export function ProjectCard(props: {
4952
</div>
5053

5154
<div className="-mx-3 -mb-3 mt-3 overflow-hidden rounded-b-2xl border-t border-black/[0.08] dark:border-white/[0.06] px-3 pt-3 pb-3">
52-
<ProjectDauSparkline data={props.dau} />
55+
<ProjectWeeklyUsersMetric
56+
weeklyUsers={props.weeklyUsers}
57+
data={props.weeklyUsersChart}
58+
loading={props.weeklyUsersLoading}
59+
error={props.weeklyUsersError}
60+
/>
5361
</div>
5462
</DesignCard>
5563
</Link>

0 commit comments

Comments
 (0)