mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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 -->
This commit is contained in:
parent
261d8923d4
commit
227dac6567
@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyProjectWeeklyUsersRows } from "./route";
|
||||
|
||||
describe("internal projects weekly users helpers", () => {
|
||||
it("applies ClickHouse rows through a Map and skips unknown projects", () => {
|
||||
const byProject = new Map([
|
||||
["project-a", {
|
||||
weekly_users: 0,
|
||||
daily_users: [
|
||||
{ date: "2026-05-01", activity: 0 },
|
||||
{ date: "2026-05-02", activity: 0 },
|
||||
],
|
||||
}],
|
||||
["__proto__", {
|
||||
weekly_users: 0,
|
||||
daily_users: [
|
||||
{ date: "2026-05-01", activity: 0 },
|
||||
{ date: "2026-05-02", activity: 0 },
|
||||
],
|
||||
}],
|
||||
]);
|
||||
|
||||
applyProjectWeeklyUsersRows(
|
||||
byProject,
|
||||
[
|
||||
{ projectId: "project-a", day: "1970-01-01", users: 4 },
|
||||
{ projectId: "__proto__", day: "1970-01-01", users: 7 },
|
||||
{ projectId: "missing-project", day: "1970-01-01", users: 99 },
|
||||
{ projectId: "project-a", day: "2026-05-01", users: 2 },
|
||||
{ projectId: "__proto__", day: "2026-05-02", users: 5 },
|
||||
{ projectId: "missing-project", day: "2026-05-01", users: 99 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(Object.fromEntries(byProject)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"__proto__": {
|
||||
"daily_users": [
|
||||
{
|
||||
"activity": 0,
|
||||
"date": "2026-05-01",
|
||||
},
|
||||
{
|
||||
"activity": 5,
|
||||
"date": "2026-05-02",
|
||||
},
|
||||
],
|
||||
"weekly_users": 7,
|
||||
},
|
||||
"project-a": {
|
||||
"daily_users": [
|
||||
{
|
||||
"activity": 2,
|
||||
"date": "2026-05-01",
|
||||
},
|
||||
{
|
||||
"activity": 0,
|
||||
"date": "2026-05-02",
|
||||
},
|
||||
],
|
||||
"weekly_users": 4,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@ -10,6 +10,46 @@ import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist
|
||||
const WINDOW_DAYS = 7;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type ProjectWeeklyUsers = {
|
||||
weekly_users: number,
|
||||
daily_users: { date: string, activity: number }[],
|
||||
};
|
||||
|
||||
export function applyProjectWeeklyUsersRows(
|
||||
byProject: Map<string, ProjectWeeklyUsers>,
|
||||
rows: { projectId: string, day: string, users: number }[],
|
||||
) {
|
||||
// GROUPING SETS emits one rollup row per project with day defaulted to the
|
||||
// ClickHouse Date epoch ("1970-01-01"); those rows hold the weekly total.
|
||||
const dailyIndex = new Map<string, Map<string, number>>();
|
||||
for (const row of rows) {
|
||||
const project = byProject.get(row.projectId);
|
||||
if (project == null) {
|
||||
continue;
|
||||
}
|
||||
const dayKey = row.day.split("T")[0];
|
||||
if (dayKey === "1970-01-01") {
|
||||
project.weekly_users = Number(row.users);
|
||||
continue;
|
||||
}
|
||||
let m = dailyIndex.get(row.projectId);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
dailyIndex.set(row.projectId, m);
|
||||
}
|
||||
m.set(dayKey, Number(row.users));
|
||||
}
|
||||
|
||||
for (const [id, project] of byProject) {
|
||||
const m = dailyIndex.get(id);
|
||||
if (!m) continue;
|
||||
project.daily_users = project.daily_users.map((point) => ({
|
||||
date: point.date,
|
||||
activity: m.get(point.date) ?? 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = createSmartRouteHandler({
|
||||
metadata: { hidden: true },
|
||||
request: yupObject({
|
||||
@ -24,7 +64,10 @@ export const GET = createSmartRouteHandler({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
projects: yupRecord(yupString().defined(), MetricsDataPointsSchema).defined(),
|
||||
projects: yupRecord(yupString().defined(), yupObject({
|
||||
weekly_users: yupNumber().integer().defined(),
|
||||
daily_users: MetricsDataPointsSchema,
|
||||
}).defined()).defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
handler: async (req) => {
|
||||
@ -52,28 +95,39 @@ export const GET = createSmartRouteHandler({
|
||||
return out;
|
||||
};
|
||||
|
||||
const byProject: Record<string, { date: string, activity: number }[]> = {};
|
||||
const byProject = new Map<string, ProjectWeeklyUsers>();
|
||||
for (const id of projectIds) {
|
||||
byProject[id] = emptySeries();
|
||||
byProject.set(id, {
|
||||
weekly_users: 0,
|
||||
daily_users: emptySeries(),
|
||||
});
|
||||
}
|
||||
const projectsResponse = () => Object.fromEntries(byProject);
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { projects: byProject },
|
||||
body: { projects: projectsResponse() },
|
||||
};
|
||||
}
|
||||
|
||||
let rows: { projectId: string, day: string, dau: number }[] = [];
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
const queryParams = {
|
||||
projectIds,
|
||||
branchId: DEFAULT_BRANCH_ID,
|
||||
since: since.toISOString().slice(0, 19),
|
||||
untilExclusive: untilExclusive.toISOString().slice(0, 19),
|
||||
};
|
||||
|
||||
let rows: { projectId: string, day: string, users: number }[] = [];
|
||||
try {
|
||||
const clickhouseClient = getClickhouseAdminClient();
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
project_id AS projectId,
|
||||
toDate(event_at) AS day,
|
||||
uniqExact(assumeNotNull(user_id)) AS dau
|
||||
toDate(event_at, 'UTC') AS day,
|
||||
uniqExact(assumeNotNull(user_id)) AS users
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id IN {projectIds:Array(String)}
|
||||
@ -82,55 +136,33 @@ export const GET = createSmartRouteHandler({
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
|
||||
GROUP BY projectId, day
|
||||
GROUP BY GROUPING SETS ((projectId), (projectId, day))
|
||||
`,
|
||||
query_params: {
|
||||
projectIds,
|
||||
branchId: DEFAULT_BRANCH_ID,
|
||||
since: since.toISOString().slice(0, 19),
|
||||
untilExclusive: untilExclusive.toISOString().slice(0, 19),
|
||||
},
|
||||
query_params: queryParams,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
rows = await result.json();
|
||||
rows = await result.json<{ projectId: string, day: string, users: number }>();
|
||||
} catch (error) {
|
||||
const captureId = error instanceof ClickHouseError
|
||||
? "internal-projects-dau-clickhouse-error"
|
||||
: "internal-projects-dau-unexpected-error";
|
||||
? "internal-projects-weekly-users-clickhouse-error"
|
||||
: "internal-projects-weekly-users-unexpected-error";
|
||||
captureError(captureId, new StackAssertionError(
|
||||
"Failed to load projects DAU.",
|
||||
"Failed to load projects weekly users.",
|
||||
{ cause: error, projectCount: projectIds.length },
|
||||
));
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { projects: byProject },
|
||||
body: { projects: projectsResponse() },
|
||||
};
|
||||
}
|
||||
const index = new Map<string, Map<string, number>>();
|
||||
for (const row of rows) {
|
||||
const dayKey = row.day.split("T")[0];
|
||||
let m = index.get(row.projectId);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
index.set(row.projectId, m);
|
||||
}
|
||||
m.set(dayKey, Number(row.dau));
|
||||
}
|
||||
|
||||
for (const id of projectIds) {
|
||||
const m = index.get(id);
|
||||
if (!m) continue;
|
||||
byProject[id] = byProject[id].map((point) => ({
|
||||
date: point.date,
|
||||
activity: m.get(point.date) ?? 0,
|
||||
}));
|
||||
}
|
||||
applyProjectWeeklyUsersRows(byProject, rows);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: { projects: byProject },
|
||||
body: { projects: projectsResponse() },
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -70,7 +70,10 @@ export default function PageClient() {
|
||||
const [recentConfigProjectsError, setRecentConfigProjectsError] = useState(false);
|
||||
const [projectStatuses, setProjectStatuses] = useState<Map<string, ProjectOnboardingStatus>>(new Map());
|
||||
const [loadingProjectStatuses, setLoadingProjectStatuses] = useState(true);
|
||||
const [projectDau, setProjectDau] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
|
||||
const [projectWeeklyUsers, setProjectWeeklyUsers] = useState<Map<string, number>>(new Map());
|
||||
const [projectWeeklyUsersChart, setProjectWeeklyUsersChart] = useState<Map<string, { date: string, activity: number }[]>>(new Map());
|
||||
const [loadingProjectWeeklyUsers, setLoadingProjectWeeklyUsers] = useState(true);
|
||||
const [projectWeeklyUsersError, setProjectWeeklyUsersError] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@ -124,34 +127,66 @@ export default function PageClient() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
runAsynchronously(async () => {
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
if (!cancelled) {
|
||||
setLoadingProjectWeeklyUsers(true);
|
||||
setProjectWeeklyUsersError(false);
|
||||
}
|
||||
try {
|
||||
const response = await appInternals.sendRequest("/internal/projects-dau", {}, "client");
|
||||
const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client");
|
||||
if (!response.ok) {
|
||||
console.warn("[projects-dau] request failed", response.status, await response.text());
|
||||
return;
|
||||
throw new Error(`Failed to load project weekly users: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (body == null || typeof body !== "object" || !("projects" in body) || body.projects == null || typeof body.projects !== "object") {
|
||||
console.warn("[projects-dau] unexpected body", body);
|
||||
return;
|
||||
if (
|
||||
body == null ||
|
||||
typeof body !== "object" ||
|
||||
!("projects" in body) ||
|
||||
body.projects == null ||
|
||||
typeof body.projects !== "object" ||
|
||||
Array.isArray(body.projects)
|
||||
) {
|
||||
throw new Error("Failed to load project weekly users: response body did not include a projects object.");
|
||||
}
|
||||
const map = new Map<string, { date: string, activity: number }[]>();
|
||||
for (const [projectId, series] of Object.entries(body.projects as Record<string, unknown>)) {
|
||||
if (!Array.isArray(series)) continue;
|
||||
const weeklyUsersMap = new Map<string, number>();
|
||||
const weeklyUsersChartMap = new Map<string, { date: string, activity: number }[]>();
|
||||
for (const [projectId, value] of Object.entries(body.projects)) {
|
||||
if (value == null || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const weeklyUsers = "weekly_users" in value ? value.weekly_users : undefined;
|
||||
if (typeof weeklyUsers === "number") {
|
||||
weeklyUsersMap.set(projectId, weeklyUsers);
|
||||
}
|
||||
const dailyUsers = "daily_users" in value ? value.daily_users : undefined;
|
||||
if (!Array.isArray(dailyUsers)) {
|
||||
continue;
|
||||
}
|
||||
const points: { date: string, activity: number }[] = [];
|
||||
for (const point of series) {
|
||||
if (point != null && typeof point === "object" && "date" in point && "activity" in point && typeof (point as any).date === "string" && typeof (point as any).activity === "number") {
|
||||
points.push({ date: (point as any).date, activity: (point as any).activity });
|
||||
for (const point of dailyUsers) {
|
||||
if (point != null && typeof point === "object" && "date" in point && "activity" in point) {
|
||||
const date = point.date;
|
||||
const activity = point.activity;
|
||||
if (typeof date === "string" && typeof activity === "number") {
|
||||
points.push({ date, activity });
|
||||
}
|
||||
}
|
||||
}
|
||||
map.set(projectId, points);
|
||||
weeklyUsersChartMap.set(projectId, points);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setProjectDau(map);
|
||||
setProjectWeeklyUsers(weeklyUsersMap);
|
||||
setProjectWeeklyUsersChart(weeklyUsersChartMap);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setProjectWeeklyUsersError(true);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingProjectWeeklyUsers(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[projects-dau] fetch error", e);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@ -472,7 +507,10 @@ export default function PageClient() {
|
||||
project={project}
|
||||
href={projectHref}
|
||||
showIncompleteBadge={!loadingProjectStatuses && onboardingStatus !== "completed"}
|
||||
dau={projectDau.get(project.id)}
|
||||
weeklyUsers={projectWeeklyUsers.get(project.id)}
|
||||
weeklyUsersChart={projectWeeklyUsersChart.get(project.id)}
|
||||
weeklyUsersLoading={loadingProjectWeeklyUsers}
|
||||
weeklyUsersError={projectWeeklyUsersError}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { DesignBadge } from "@/components/design-components/badge";
|
||||
import { DesignCard } from "@/components/design-components/card";
|
||||
import { Link } from "@/components/link";
|
||||
import { ProjectDauSparkline } from "@/components/project-dau-sparkline";
|
||||
import { ProjectWeeklyUsersMetric } from "@/components/project-weekly-users-metric";
|
||||
import { useFromNow } from '@/hooks/use-from-now';
|
||||
import { FolderOpenIcon } from "@phosphor-icons/react";
|
||||
import { AdminProject } from '@stackframe/stack';
|
||||
@ -12,7 +12,10 @@ export function ProjectCard(props: {
|
||||
project: AdminProject,
|
||||
href?: string,
|
||||
showIncompleteBadge?: boolean,
|
||||
dau?: { date: string, activity: number }[],
|
||||
weeklyUsers?: number,
|
||||
weeklyUsersChart?: { date: string, activity: number }[],
|
||||
weeklyUsersLoading?: boolean,
|
||||
weeklyUsersError?: boolean,
|
||||
}) {
|
||||
const createdAt = useFromNow(props.project.createdAt);
|
||||
const href = props.href ?? urlString`/projects/${props.project.id}`;
|
||||
@ -49,7 +52,12 @@ export function ProjectCard(props: {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<ProjectDauSparkline data={props.dau} />
|
||||
<ProjectWeeklyUsersMetric
|
||||
weeklyUsers={props.weeklyUsers}
|
||||
data={props.weeklyUsersChart}
|
||||
loading={props.weeklyUsersLoading}
|
||||
error={props.weeklyUsersError}
|
||||
/>
|
||||
</div>
|
||||
</DesignCard>
|
||||
</Link>
|
||||
|
||||
@ -33,12 +33,51 @@ function EmptyBaseline({ count }: { count: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
|
||||
export function ProjectWeeklyUsersMetric(props: {
|
||||
weeklyUsers: number | undefined,
|
||||
data: DataPoint[] | undefined,
|
||||
loading?: boolean,
|
||||
error?: boolean,
|
||||
}) {
|
||||
const weeklyUsers = props.weeklyUsers ?? 0;
|
||||
const data = props.data;
|
||||
const total = data?.reduce((sum, d) => sum + d.activity, 0) ?? 0;
|
||||
const hasActivity = total > 0;
|
||||
const dailyTotal = data?.reduce((sum, d) => sum + d.activity, 0) ?? 0;
|
||||
const hasActivity = weeklyUsers > 0 || dailyTotal > 0;
|
||||
const gradId = useId().replace(/:/g, '');
|
||||
|
||||
if (props.loading && props.weeklyUsers === undefined) {
|
||||
return (
|
||||
<div className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||
<div className="absolute left-0 top-0 z-10 flex items-baseline gap-1">
|
||||
<span className="h-[18px] w-10 animate-pulse rounded bg-foreground/10" aria-hidden="true" />
|
||||
<span className="text-[9px] uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||
users/wk
|
||||
</span>
|
||||
</div>
|
||||
<EmptyBaseline count={7} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.error && props.weeklyUsers === undefined) {
|
||||
return (
|
||||
<div className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||
<div className="absolute left-0 top-0 z-10 flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold tabular-nums leading-none text-muted-foreground/50">
|
||||
—
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||
users/wk
|
||||
</span>
|
||||
</div>
|
||||
<span className="absolute right-0 top-0 text-[9px] uppercase tracking-[0.14em] text-destructive/80">
|
||||
Failed to load
|
||||
</span>
|
||||
<EmptyBaseline count={7} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||
<div className="absolute left-0 top-0 z-10 flex items-baseline gap-1">
|
||||
@ -48,10 +87,10 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
|
||||
(hasActivity ? 'text-foreground' : 'text-muted-foreground/50')
|
||||
}
|
||||
>
|
||||
{total.toLocaleString()}
|
||||
{weeklyUsers.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||
/wk
|
||||
users/wk
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -61,7 +100,7 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
|
||||
<AreaChart data={data} margin={{ top: 22, right: 0, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="date" hide />
|
||||
<defs>
|
||||
<linearGradient id={`dau-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<linearGradient id={`weekly-users-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity={0.28} />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
@ -79,14 +118,14 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) {
|
||||
labelStyle={{ color: 'hsl(var(--muted-foreground))', marginBottom: 1, fontSize: 10 }}
|
||||
itemStyle={{ color: 'hsl(var(--foreground))', padding: 0 }}
|
||||
labelFormatter={(label: string) => formatDay(label)}
|
||||
formatter={(value: number) => [value.toLocaleString(), 'active']}
|
||||
formatter={(value: number) => [value.toLocaleString(), 'daily active users']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="activity"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#dau-fill-${gradId})`}
|
||||
fill={`url(#weekly-users-fill-${gradId})`}
|
||||
isAnimationActive={false}
|
||||
activeDot={{ r: 2.5, strokeWidth: 0, fill: 'currentColor' }}
|
||||
/>
|
||||
Loading…
Reference in New Issue
Block a user