Skip to content

Commit 12ccfae

Browse files
committed
feat(dashboard): add weekly users metrics for projects
- 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.
1 parent 7a54e82 commit 12ccfae

4 files changed

Lines changed: 96 additions & 40 deletions

File tree

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: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export const GET = createSmartRouteHandler({
2424
statusCode: yupNumber().oneOf([200]).defined(),
2525
bodyType: yupString().oneOf(["json"]).defined(),
2626
body: yupObject({
27-
projects: yupRecord(yupString().defined(), MetricsDataPointsSchema).defined(),
27+
projects: yupRecord(yupString().defined(), yupObject({
28+
weekly_users: yupNumber().integer().defined(),
29+
daily_users: MetricsDataPointsSchema,
30+
}).defined()).defined(),
2831
}).defined(),
2932
}),
3033
handler: async (req) => {
@@ -52,9 +55,12 @@ export const GET = createSmartRouteHandler({
5255
return out;
5356
};
5457

55-
const byProject: Record<string, { date: string, activity: number }[]> = {};
58+
const byProject: Record<string, { weekly_users: number, daily_users: { date: string, activity: number }[] }> = {};
5659
for (const id of projectIds) {
57-
byProject[id] = emptySeries();
60+
byProject[id] = {
61+
weekly_users: 0,
62+
daily_users: emptySeries(),
63+
};
5864
}
5965

6066
if (projectIds.length === 0) {
@@ -65,15 +71,41 @@ export const GET = createSmartRouteHandler({
6571
};
6672
}
6773

68-
let rows: { projectId: string, day: string, dau: number }[] = [];
74+
let rows: { projectId: string, weeklyUsers: number }[] = [];
75+
let dailyRows: { projectId: string, day: string, dailyUsers: number }[] = [];
6976
try {
7077
const clickhouseClient = getClickhouseAdminClient();
7178
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({
72104
query: `
73105
SELECT
74106
project_id AS projectId,
75107
toDate(event_at) AS day,
76-
uniqExact(assumeNotNull(user_id)) AS dau
108+
uniqExact(assumeNotNull(user_id)) AS dailyUsers
77109
FROM analytics_internal.events
78110
WHERE event_type = '$token-refresh'
79111
AND project_id IN {projectIds:Array(String)}
@@ -92,13 +124,13 @@ export const GET = createSmartRouteHandler({
92124
},
93125
format: "JSONEachRow",
94126
});
95-
rows = await result.json();
127+
dailyRows = await dailyResult.json();
96128
} catch (error) {
97129
const captureId = error instanceof ClickHouseError
98-
? "internal-projects-dau-clickhouse-error"
99-
: "internal-projects-dau-unexpected-error";
130+
? "internal-projects-weekly-users-clickhouse-error"
131+
: "internal-projects-weekly-users-unexpected-error";
100132
captureError(captureId, new StackAssertionError(
101-
"Failed to load projects DAU.",
133+
"Failed to load projects weekly users.",
102134
{ cause: error, projectCount: projectIds.length },
103135
));
104136
return {
@@ -107,21 +139,25 @@ export const GET = createSmartRouteHandler({
107139
body: { projects: byProject },
108140
};
109141
}
110-
const index = new Map<string, Map<string, number>>();
111142
for (const row of rows) {
143+
byProject[row.projectId].weekly_users = Number(row.weeklyUsers);
144+
}
145+
146+
const dailyIndex = new Map<string, Map<string, number>>();
147+
for (const row of dailyRows) {
112148
const dayKey = row.day.split("T")[0];
113-
let m = index.get(row.projectId);
149+
let m = dailyIndex.get(row.projectId);
114150
if (!m) {
115151
m = new Map();
116-
index.set(row.projectId, m);
152+
dailyIndex.set(row.projectId, m);
117153
}
118-
m.set(dayKey, Number(row.dau));
154+
m.set(dayKey, Number(row.dailyUsers));
119155
}
120156

121157
for (const id of projectIds) {
122-
const m = index.get(id);
158+
const m = dailyIndex.get(id);
123159
if (!m) continue;
124-
byProject[id] = byProject[id].map((point) => ({
160+
byProject[id].daily_users = byProject[id].daily_users.map((point) => ({
125161
date: point.date,
126162
activity: m.get(point.date) ?? 0,
127163
}));

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

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export default function PageClient() {
6868
const [openingConfigFile, setOpeningConfigFile] = useState(false);
6969
const [projectStatuses, setProjectStatuses] = useState<Map<string, ProjectOnboardingStatus>>(new Map());
7070
const [loadingProjectStatuses, setLoadingProjectStatuses] = useState(true);
71-
const [projectDau, setProjectDau] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
71+
const [projectWeeklyUsers, setProjectWeeklyUsers] = useState<Map<string, number>>(new Map());
72+
const [projectWeeklyUsersChart, setProjectWeeklyUsersChart] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
7273
const router = useRouter();
7374

7475
useEffect(() => {
@@ -124,32 +125,48 @@ export default function PageClient() {
124125
let cancelled = false;
125126
runAsynchronously(async () => {
126127
try {
127-
const response = await appInternals.sendRequest("/internal/projects-dau", {}, "client");
128+
const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client");
128129
if (!response.ok) {
129-
console.warn("[projects-dau] request failed", response.status, await response.text());
130+
console.warn("[projects-weekly-users] request failed", response.status, await response.text());
130131
return;
131132
}
132133
const body = await response.json();
133134
if (body == null || typeof body !== "object" || !("projects" in body) || body.projects == null || typeof body.projects !== "object") {
134-
console.warn("[projects-dau] unexpected body", body);
135+
console.warn("[projects-weekly-users] unexpected body", body);
135136
return;
136137
}
137-
const map = new Map<string, { date: string, activity: number }[]>();
138-
for (const [projectId, series] of Object.entries(body.projects as Record<string, unknown>)) {
139-
if (!Array.isArray(series)) continue;
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+
}
140152
const points: { date: string, activity: number }[] = [];
141-
for (const point of series) {
142-
if (point != null && typeof point === "object" && "date" in point && "activity" in point && typeof (point as any).date === "string" && typeof (point as any).activity === "number") {
143-
points.push({ date: (point as any).date, activity: (point as any).activity });
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+
}
144160
}
145161
}
146-
map.set(projectId, points);
162+
weeklyUsersChartMap.set(projectId, points);
147163
}
148164
if (!cancelled) {
149-
setProjectDau(map);
165+
setProjectWeeklyUsers(weeklyUsersMap);
166+
setProjectWeeklyUsersChart(weeklyUsersChartMap);
150167
}
151168
} catch (e) {
152-
console.warn("[projects-dau] fetch error", e);
169+
console.warn("[projects-weekly-users] fetch error", e);
153170
}
154171
});
155172
return () => {
@@ -355,7 +372,8 @@ export default function PageClient() {
355372
project={project}
356373
href={projectHref}
357374
showIncompleteBadge={!loadingProjectStatuses && onboardingStatus !== "completed"}
358-
dau={projectDau.get(project.id)}
375+
weeklyUsers={projectWeeklyUsers.get(project.id)}
376+
weeklyUsersChart={projectWeeklyUsersChart.get(project.id)}
359377
/>
360378
);
361379
})}

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

Lines changed: 4 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,8 @@ 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 }[],
1617
}) {
1718
const createdAt = useFromNow(props.project.createdAt);
1819
const href = props.href ?? urlString`/projects/${props.project.id}`;
@@ -49,7 +50,7 @@ export function ProjectCard(props: {
4950
</div>
5051

5152
<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} />
53+
<ProjectWeeklyUsersMetric weeklyUsers={props.weeklyUsers} data={props.weeklyUsersChart} />
5354
</div>
5455
</DesignCard>
5556
</Link>

apps/dashboard/src/components/project-dau-sparkline.tsx renamed to apps/dashboard/src/components/project-weekly-users-metric.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ function EmptyBaseline({ count }: { count: number }) {
3333
);
3434
}
3535

36-
export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
36+
export function ProjectWeeklyUsersMetric(props: { weeklyUsers: number | undefined, data: DataPoint[] | undefined }) {
37+
const weeklyUsers = props.weeklyUsers ?? 0;
3738
const data = props.data;
38-
const total = data?.reduce((sum, d) => sum + d.activity, 0) ?? 0;
39-
const hasActivity = total > 0;
39+
const dailyTotal = data?.reduce((sum, d) => sum + d.activity, 0) ?? 0;
40+
const hasActivity = weeklyUsers > 0 || dailyTotal > 0;
4041
const gradId = useId().replace(/:/g, '');
4142

4243
return (
@@ -48,10 +49,10 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
4849
(hasActivity ? 'text-foreground' : 'text-muted-foreground/50')
4950
}
5051
>
51-
{total.toLocaleString()}
52+
{weeklyUsers.toLocaleString()}
5253
</span>
5354
<span className="text-[9px] uppercase tracking-[0.14em] text-muted-foreground/60">
54-
/wk
55+
users/wk
5556
</span>
5657
</div>
5758

@@ -61,7 +62,7 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
6162
<AreaChart data={data} margin={{ top: 22, right: 0, left: 0, bottom: 0 }}>
6263
<XAxis dataKey="date" hide />
6364
<defs>
64-
<linearGradient id={`dau-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
65+
<linearGradient id={`weekly-users-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
6566
<stop offset="0%" stopColor="currentColor" stopOpacity={0.28} />
6667
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
6768
</linearGradient>
@@ -79,14 +80,14 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
7980
labelStyle={{ color: 'hsl(var(--muted-foreground))', marginBottom: 1, fontSize: 10 }}
8081
itemStyle={{ color: 'hsl(var(--foreground))', padding: 0 }}
8182
labelFormatter={(label: string) => formatDay(label)}
82-
formatter={(value: number) => [value.toLocaleString(), 'active']}
83+
formatter={(value: number) => [value.toLocaleString(), 'daily active users']}
8384
/>
8485
<Area
8586
type="monotone"
8687
dataKey="activity"
8788
stroke="currentColor"
8889
strokeWidth={1.5}
89-
fill={`url(#dau-fill-${gradId})`}
90+
fill={`url(#weekly-users-fill-${gradId})`}
9091
isAnimationActive={false}
9192
activeDot={{ r: 2.5, strokeWidth: 0, fill: 'currentColor' }}
9293
/>

0 commit comments

Comments
 (0)