Skip to content

Commit 08c7426

Browse files
committed
Refactor: update project weekly users handling in API and dashboard components
- Modified the `applyProjectWeeklyUsersRows` function to unify the handling of weekly and daily user metrics, simplifying the input structure. - Updated the API query to return a combined result set for weekly and daily users, improving data retrieval efficiency. - Enhanced the dashboard components to manage loading states and error handling for weekly user data, providing a better user experience. - Adjusted the `ProjectWeeklyUsersMetric` component to display loading and error states appropriately. These changes improve the clarity and reliability of user metrics across the application.
1 parent 4eaa772 commit 08c7426

5 files changed

Lines changed: 141 additions & 98 deletions

File tree

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,12 @@ describe("internal projects weekly users helpers", () => {
2323
applyProjectWeeklyUsersRows(
2424
byProject,
2525
[
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 },
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 },
3432
],
3533
);
3634

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

Lines changed: 30 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,27 @@ type ProjectWeeklyUsers = {
1717

1818
export function applyProjectWeeklyUsersRows(
1919
byProject: Map<string, ProjectWeeklyUsers>,
20-
rows: { projectId: string, weeklyUsers: number }[],
21-
dailyRows: { projectId: string, day: string, dailyUsers: number }[],
20+
rows: { projectId: string, day: string, users: number }[],
2221
) {
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>>();
2325
for (const row of rows) {
2426
const project = byProject.get(row.projectId);
2527
if (project == null) {
2628
continue;
2729
}
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)) {
30+
const dayKey = row.day.split("T")[0];
31+
if (dayKey === "1970-01-01") {
32+
project.weekly_users = Number(row.users);
3433
continue;
3534
}
36-
const dayKey = row.day.split("T")[0];
3735
let m = dailyIndex.get(row.projectId);
3836
if (!m) {
3937
m = new Map();
4038
dailyIndex.set(row.projectId, m);
4139
}
42-
m.set(dayKey, Number(row.dailyUsers));
40+
m.set(dayKey, Number(row.users));
4341
}
4442

4543
for (const [id, project] of byProject) {
@@ -122,52 +120,28 @@ export const GET = createSmartRouteHandler({
122120
untilExclusive: untilExclusive.toISOString().slice(0, 19),
123121
};
124122

125-
let rows: { projectId: string, weeklyUsers: number }[] = [];
126-
let dailyRows: { projectId: string, day: string, dailyUsers: number }[] = [];
123+
let rows: { projectId: string, day: string, users: number }[] = [];
127124
try {
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, 'UTC') 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-
]);
125+
const result = await clickhouseClient.query({
126+
query: `
127+
SELECT
128+
project_id AS projectId,
129+
toDate(event_at, 'UTC') AS day,
130+
uniqExact(assumeNotNull(user_id)) AS users
131+
FROM analytics_internal.events
132+
WHERE event_type = '$token-refresh'
133+
AND project_id IN {projectIds:Array(String)}
134+
AND branch_id = {branchId:String}
135+
AND user_id IS NOT NULL
136+
AND event_at >= {since:DateTime}
137+
AND event_at < {untilExclusive:DateTime}
138+
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
139+
GROUP BY GROUPING SETS ((projectId), (projectId, day))
140+
`,
141+
query_params: queryParams,
142+
format: "JSONEachRow",
143+
});
144+
rows = await result.json<{ projectId: string, day: string, users: number }>();
171145
} catch (error) {
172146
const captureId = error instanceof ClickHouseError
173147
? "internal-projects-weekly-users-clickhouse-error"
@@ -183,7 +157,7 @@ export const GET = createSmartRouteHandler({
183157
};
184158
}
185159

186-
applyProjectWeeklyUsersRows(byProject, rows, dailyRows);
160+
applyProjectWeeklyUsersRows(byProject, rows);
187161

188162
return {
189163
statusCode: 200,

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

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export default function PageClient() {
7070
const [loadingProjectStatuses, setLoadingProjectStatuses] = useState(true);
7171
const [projectWeeklyUsers, setProjectWeeklyUsers] = useState<Map<string, number>>(new Map());
7272
const [projectWeeklyUsersChart, setProjectWeeklyUsersChart] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
73+
const [loadingProjectWeeklyUsers, setLoadingProjectWeeklyUsers] = useState(true);
74+
const [projectWeeklyUsersError, setProjectWeeklyUsersError] = useState(false);
7375
const router = useRouter();
7476

7577
useEffect(() => {
@@ -123,44 +125,66 @@ export default function PageClient() {
123125

124126
useEffect(() => {
125127
let cancelled = false;
126-
runAsynchronously(async () => {
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.");
128+
runAsynchronouslyWithAlert(async () => {
129+
if (!cancelled) {
130+
setLoadingProjectWeeklyUsers(true);
131+
setProjectWeeklyUsersError(false);
134132
}
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;
140-
}
141-
const weeklyUsers = "weekly_users" in value ? value.weekly_users : undefined;
142-
if (typeof weeklyUsers === "number") {
143-
weeklyUsersMap.set(projectId, weeklyUsers);
133+
try {
134+
const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client");
135+
if (!response.ok) {
136+
throw new Error(`Failed to load project weekly users: ${response.status} ${await response.text()}`);
144137
}
145-
const dailyUsers = "daily_users" in value ? value.daily_users : undefined;
146-
if (!Array.isArray(dailyUsers)) {
147-
continue;
138+
const body = await response.json();
139+
if (
140+
body == null ||
141+
typeof body !== "object" ||
142+
!("projects" in body) ||
143+
body.projects == null ||
144+
typeof body.projects !== "object" ||
145+
Array.isArray(body.projects)
146+
) {
147+
throw new Error("Failed to load project weekly users: response body did not include a projects object.");
148148
}
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 });
149+
const weeklyUsersMap = new Map<string, number>();
150+
const weeklyUsersChartMap = new Map<string, { date: string, activity: number }[]>();
151+
for (const [projectId, value] of Object.entries(body.projects)) {
152+
if (value == null || typeof value !== "object") {
153+
continue;
154+
}
155+
const weeklyUsers = "weekly_users" in value ? value.weekly_users : undefined;
156+
if (typeof weeklyUsers === "number") {
157+
weeklyUsersMap.set(projectId, weeklyUsers);
158+
}
159+
const dailyUsers = "daily_users" in value ? value.daily_users : undefined;
160+
if (!Array.isArray(dailyUsers)) {
161+
continue;
162+
}
163+
const points: { date: string, activity: number }[] = [];
164+
for (const point of dailyUsers) {
165+
if (point != null && typeof point === "object" && "date" in point && "activity" in point) {
166+
const date = point.date;
167+
const activity = point.activity;
168+
if (typeof date === "string" && typeof activity === "number") {
169+
points.push({ date, activity });
170+
}
156171
}
157172
}
173+
weeklyUsersChartMap.set(projectId, points);
174+
}
175+
if (!cancelled) {
176+
setProjectWeeklyUsers(weeklyUsersMap);
177+
setProjectWeeklyUsersChart(weeklyUsersChartMap);
178+
}
179+
} catch (error) {
180+
if (!cancelled) {
181+
setProjectWeeklyUsersError(true);
182+
}
183+
throw error;
184+
} finally {
185+
if (!cancelled) {
186+
setLoadingProjectWeeklyUsers(false);
158187
}
159-
weeklyUsersChartMap.set(projectId, points);
160-
}
161-
if (!cancelled) {
162-
setProjectWeeklyUsers(weeklyUsersMap);
163-
setProjectWeeklyUsersChart(weeklyUsersChartMap);
164188
}
165189
});
166190
return () => {
@@ -383,6 +407,8 @@ export default function PageClient() {
383407
showIncompleteBadge={!loadingProjectStatuses && onboardingStatus !== "completed"}
384408
weeklyUsers={projectWeeklyUsers.get(project.id)}
385409
weeklyUsersChart={projectWeeklyUsersChart.get(project.id)}
410+
weeklyUsersLoading={loadingProjectWeeklyUsers}
411+
weeklyUsersError={projectWeeklyUsersError}
386412
/>
387413
);
388414
})}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export function ProjectCard(props: {
1414
showIncompleteBadge?: boolean,
1515
weeklyUsers?: number,
1616
weeklyUsersChart?: { date: string, activity: number }[],
17+
weeklyUsersLoading?: boolean,
18+
weeklyUsersError?: boolean,
1719
}) {
1820
const createdAt = useFromNow(props.project.createdAt);
1921
const href = props.href ?? urlString`/projects/${props.project.id}`;
@@ -50,7 +52,12 @@ export function ProjectCard(props: {
5052
</div>
5153

5254
<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">
53-
<ProjectWeeklyUsersMetric weeklyUsers={props.weeklyUsers} data={props.weeklyUsersChart} />
55+
<ProjectWeeklyUsersMetric
56+
weeklyUsers={props.weeklyUsers}
57+
data={props.weeklyUsersChart}
58+
loading={props.weeklyUsersLoading}
59+
error={props.weeklyUsersError}
60+
/>
5461
</div>
5562
</DesignCard>
5663
</Link>

apps/dashboard/src/components/project-weekly-users-metric.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,51 @@ function EmptyBaseline({ count }: { count: number }) {
3333
);
3434
}
3535

36-
export function ProjectWeeklyUsersMetric(props: { weeklyUsers: number | undefined, data: DataPoint[] | undefined }) {
36+
export function ProjectWeeklyUsersMetric(props: {
37+
weeklyUsers: number | undefined,
38+
data: DataPoint[] | undefined,
39+
loading?: boolean,
40+
error?: boolean,
41+
}) {
3742
const weeklyUsers = props.weeklyUsers ?? 0;
3843
const data = props.data;
3944
const dailyTotal = data?.reduce((sum, d) => sum + d.activity, 0) ?? 0;
4045
const hasActivity = weeklyUsers > 0 || dailyTotal > 0;
4146
const gradId = useId().replace(/:/g, '');
4247

48+
if (props.loading && props.weeklyUsers === undefined) {
49+
return (
50+
<div className="relative w-full" style={{ height: CHART_HEIGHT }}>
51+
<div className="absolute left-0 top-0 z-10 flex items-baseline gap-1">
52+
<span className="h-[18px] w-10 animate-pulse rounded bg-foreground/10" aria-hidden="true" />
53+
<span className="text-[9px] uppercase tracking-[0.14em] text-muted-foreground/60">
54+
users/wk
55+
</span>
56+
</div>
57+
<EmptyBaseline count={7} />
58+
</div>
59+
);
60+
}
61+
62+
if (props.error && props.weeklyUsers === undefined) {
63+
return (
64+
<div className="relative w-full" style={{ height: CHART_HEIGHT }}>
65+
<div className="absolute left-0 top-0 z-10 flex items-baseline gap-1">
66+
<span className="text-lg font-semibold tabular-nums leading-none text-muted-foreground/50">
67+
68+
</span>
69+
<span className="text-[9px] uppercase tracking-[0.14em] text-muted-foreground/60">
70+
users/wk
71+
</span>
72+
</div>
73+
<span className="absolute right-0 top-0 text-[9px] uppercase tracking-[0.14em] text-destructive/80">
74+
Failed to load
75+
</span>
76+
<EmptyBaseline count={7} />
77+
</div>
78+
);
79+
}
80+
4381
return (
4482
<div className="relative w-full" style={{ height: CHART_HEIGHT }}>
4583
<div className="absolute left-0 top-0 z-10 flex items-baseline gap-1">

0 commit comments

Comments
 (0)