diff --git a/apps/backend/src/app/api/latest/internal/projects-dau/route.tsx b/apps/backend/src/app/api/latest/internal/projects-dau/route.tsx deleted file mode 100644 index 6700632049..0000000000 --- a/apps/backend/src/app/api/latest/internal/projects-dau/route.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { ClickHouseError } from "@clickhouse/client"; -import { getClickhouseAdminClient } from "@/lib/clickhouse"; -import { listManagedProjectIds } from "@/lib/projects"; -import { DEFAULT_BRANCH_ID } from "@/lib/tenancies"; -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { KnownErrors } from "@stackframe/stack-shared"; -import { MetricsDataPointsSchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; -import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; -const WINDOW_DAYS = 7; -const ONE_DAY_MS = 24 * 60 * 60 * 1000; - -export const GET = createSmartRouteHandler({ - metadata: { hidden: true }, - request: yupObject({ - auth: yupObject({ - type: clientOrHigherAuthTypeSchema.defined(), - tenancy: adaptSchema.defined(), - user: adaptSchema, - project: adaptSchema.defined(), - }), - }), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["json"]).defined(), - body: yupObject({ - projects: yupRecord(yupString().defined(), MetricsDataPointsSchema).defined(), - }).defined(), - }), - handler: async (req) => { - if (!req.auth.user) { - throw new KnownErrors.UserAuthenticationRequired(); - } - if (req.auth.project.id !== "internal") { - throw new KnownErrors.ExpectedInternalProject(); - } - - const projectIds = await listManagedProjectIds(req.auth.user); - - const now = new Date(); - const todayUtc = new Date(now); - todayUtc.setUTCHours(0, 0, 0, 0); - const since = new Date(todayUtc.getTime() - (WINDOW_DAYS - 1) * ONE_DAY_MS); - const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); - - const emptySeries = () => { - const out: { date: string, activity: number }[] = []; - for (let i = 0; i < WINDOW_DAYS; i += 1) { - const day = new Date(since.getTime() + i * ONE_DAY_MS); - out.push({ date: day.toISOString().split("T")[0], activity: 0 }); - } - return out; - }; - - const byProject: Record = {}; - for (const id of projectIds) { - byProject[id] = emptySeries(); - } - - if (projectIds.length === 0) { - return { - statusCode: 200, - bodyType: "json", - body: { projects: byProject }, - }; - } - - let rows: { projectId: string, day: string, dau: 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 - FROM analytics_internal.events - WHERE event_type = '$token-refresh' - AND project_id IN {projectIds:Array(String)} - AND branch_id = {branchId:String} - AND user_id IS NOT NULL - AND event_at >= {since:DateTime} - AND event_at < {untilExclusive:DateTime} - AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0 - GROUP BY projectId, day - `, - query_params: { - projectIds, - branchId: DEFAULT_BRANCH_ID, - since: since.toISOString().slice(0, 19), - untilExclusive: untilExclusive.toISOString().slice(0, 19), - }, - format: "JSONEachRow", - }); - rows = await result.json(); - } catch (error) { - const captureId = error instanceof ClickHouseError - ? "internal-projects-dau-clickhouse-error" - : "internal-projects-dau-unexpected-error"; - captureError(captureId, new StackAssertionError( - "Failed to load projects DAU.", - { cause: error, projectCount: projectIds.length }, - )); - return { - statusCode: 200, - bodyType: "json", - body: { projects: byProject }, - }; - } - const index = new Map>(); - 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, - })); - } - - return { - statusCode: 200, - bodyType: "json", - body: { projects: byProject }, - }; - }, -}); diff --git a/apps/backend/src/app/api/latest/internal/projects-weekly-users/route.test.tsx b/apps/backend/src/app/api/latest/internal/projects-weekly-users/route.test.tsx new file mode 100644 index 0000000000..6eb0075b13 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/projects-weekly-users/route.test.tsx @@ -0,0 +1,68 @@ +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", weeklyUsers: 4 }, + { projectId: "__proto__", weeklyUsers: 7 }, + { projectId: "missing-project", weeklyUsers: 99 }, + ], + [ + { projectId: "project-a", day: "2026-05-01", dailyUsers: 2 }, + { projectId: "__proto__", day: "2026-05-02", dailyUsers: 5 }, + { projectId: "missing-project", day: "2026-05-01", dailyUsers: 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, + }, + } + `); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/projects-weekly-users/route.tsx b/apps/backend/src/app/api/latest/internal/projects-weekly-users/route.tsx new file mode 100644 index 0000000000..35886f24ec --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/projects-weekly-users/route.tsx @@ -0,0 +1,194 @@ +import { ClickHouseError } from "@clickhouse/client"; +import { getClickhouseAdminClient } from "@/lib/clickhouse"; +import { listManagedProjectIds } from "@/lib/projects"; +import { DEFAULT_BRANCH_ID } from "@/lib/tenancies"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { MetricsDataPointsSchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +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, + rows: { projectId: string, weeklyUsers: number }[], + dailyRows: { projectId: string, day: string, dailyUsers: number }[], +) { + for (const row of rows) { + const project = byProject.get(row.projectId); + if (project == null) { + continue; + } + project.weekly_users = Number(row.weeklyUsers); + } + + const dailyIndex = new Map>(); + for (const row of dailyRows) { + if (!byProject.has(row.projectId)) { + continue; + } + const dayKey = row.day.split("T")[0]; + let m = dailyIndex.get(row.projectId); + if (!m) { + m = new Map(); + dailyIndex.set(row.projectId, m); + } + m.set(dayKey, Number(row.dailyUsers)); + } + + 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({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema, + project: adaptSchema.defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + projects: yupRecord(yupString().defined(), yupObject({ + weekly_users: yupNumber().integer().defined(), + daily_users: MetricsDataPointsSchema, + }).defined()).defined(), + }).defined(), + }), + handler: async (req) => { + if (!req.auth.user) { + throw new KnownErrors.UserAuthenticationRequired(); + } + if (req.auth.project.id !== "internal") { + throw new KnownErrors.ExpectedInternalProject(); + } + + const projectIds = await listManagedProjectIds(req.auth.user); + + const now = new Date(); + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - (WINDOW_DAYS - 1) * ONE_DAY_MS); + const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); + + const emptySeries = () => { + const out: { date: string, activity: number }[] = []; + for (let i = 0; i < WINDOW_DAYS; i += 1) { + const day = new Date(since.getTime() + i * ONE_DAY_MS); + out.push({ date: day.toISOString().split("T")[0], activity: 0 }); + } + return out; + }; + + const byProject = new Map(); + for (const id of projectIds) { + 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: projectsResponse() }, + }; + } + + 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, weeklyUsers: number }[] = []; + let dailyRows: { projectId: string, day: string, dailyUsers: number }[] = []; + try { + const [weeklyResult, dailyResult] = await Promise.all([ + clickhouseClient.query({ + query: ` + SELECT + project_id AS projectId, + uniqExact(assumeNotNull(user_id)) AS weeklyUsers + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id IN {projectIds:Array(String)} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0 + GROUP BY projectId + `, + query_params: queryParams, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + project_id AS projectId, + toDate(event_at, 'UTC') AS day, + uniqExact(assumeNotNull(user_id)) AS dailyUsers + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id IN {projectIds:Array(String)} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0 + GROUP BY projectId, day + `, + query_params: queryParams, + format: "JSONEachRow", + }), + ]); + [rows, dailyRows] = await Promise.all([ + weeklyResult.json<{ projectId: string, weeklyUsers: number }>(), + dailyResult.json<{ projectId: string, day: string, dailyUsers: number }>(), + ]); + } catch (error) { + const captureId = error instanceof ClickHouseError + ? "internal-projects-weekly-users-clickhouse-error" + : "internal-projects-weekly-users-unexpected-error"; + captureError(captureId, new StackAssertionError( + "Failed to load projects weekly users.", + { cause: error, projectCount: projectIds.length }, + )); + return { + statusCode: 200, + bodyType: "json", + body: { projects: projectsResponse() }, + }; + } + + applyProjectWeeklyUsersRows(byProject, rows, dailyRows); + + return { + statusCode: 200, + bodyType: "json", + body: { projects: projectsResponse() }, + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 66d36f8e2e..24ed26e919 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -68,7 +68,8 @@ export default function PageClient() { const [openingConfigFile, setOpeningConfigFile] = useState(false); const [projectStatuses, setProjectStatuses] = useState>(new Map()); const [loadingProjectStatuses, setLoadingProjectStatuses] = useState(true); - const [projectDau, setProjectDau] = useState>(new Map()); + const [projectWeeklyUsers, setProjectWeeklyUsers] = useState>(new Map()); + const [projectWeeklyUsersChart, setProjectWeeklyUsersChart] = useState>(new Map()); const router = useRouter(); useEffect(() => { @@ -123,33 +124,43 @@ export default function PageClient() { useEffect(() => { let cancelled = false; runAsynchronously(async () => { - try { - const response = await appInternals.sendRequest("/internal/projects-dau", {}, "client"); - if (!response.ok) { - console.warn("[projects-dau] request failed", response.status, await response.text()); - return; + const response = await appInternals.sendRequest("/internal/projects-weekly-users", {}, "client"); + if (!response.ok) { + 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") { + throw new Error("Failed to load project weekly users: response body did not include a projects object."); + } + const weeklyUsersMap = new Map(); + const weeklyUsersChartMap = new Map(); + for (const [projectId, value] of Object.entries(body.projects)) { + if (value == null || typeof value !== "object") { + continue; } - 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; + 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 map = new Map(); - for (const [projectId, series] of Object.entries(body.projects as Record)) { - if (!Array.isArray(series)) 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 }); + const points: { date: string, activity: number }[] = []; + 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); } - if (!cancelled) { - setProjectDau(map); - } - } catch (e) { - console.warn("[projects-dau] fetch error", e); + weeklyUsersChartMap.set(projectId, points); + } + if (!cancelled) { + setProjectWeeklyUsers(weeklyUsersMap); + setProjectWeeklyUsersChart(weeklyUsersChartMap); } }); return () => { @@ -370,7 +381,8 @@ 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)} /> ); })} diff --git a/apps/dashboard/src/components/project-card.tsx b/apps/dashboard/src/components/project-card.tsx index 8d9918cdca..c4b4132da9 100644 --- a/apps/dashboard/src/components/project-card.tsx +++ b/apps/dashboard/src/components/project-card.tsx @@ -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,8 @@ export function ProjectCard(props: { project: AdminProject, href?: string, showIncompleteBadge?: boolean, - dau?: { date: string, activity: number }[], + weeklyUsers?: number, + weeklyUsersChart?: { date: string, activity: number }[], }) { const createdAt = useFromNow(props.project.createdAt); const href = props.href ?? urlString`/projects/${props.project.id}`; @@ -49,7 +50,7 @@ export function ProjectCard(props: {
- +
diff --git a/apps/dashboard/src/components/project-dau-sparkline.tsx b/apps/dashboard/src/components/project-weekly-users-metric.tsx similarity index 85% rename from apps/dashboard/src/components/project-dau-sparkline.tsx rename to apps/dashboard/src/components/project-weekly-users-metric.tsx index 7b50270d9d..9241ad423e 100644 --- a/apps/dashboard/src/components/project-dau-sparkline.tsx +++ b/apps/dashboard/src/components/project-weekly-users-metric.tsx @@ -33,10 +33,11 @@ function EmptyBaseline({ count }: { count: number }) { ); } -export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) { +export function ProjectWeeklyUsersMetric(props: { weeklyUsers: number | undefined, data: DataPoint[] | undefined }) { + 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, ''); return ( @@ -48,10 +49,10 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) { (hasActivity ? 'text-foreground' : 'text-muted-foreground/50') } > - {total.toLocaleString()} + {weeklyUsers.toLocaleString()} - /wk + users/wk @@ -61,7 +62,7 @@ export function ProjectDauSparkline(props: { data: DataPoint[] | undefined }) { - + @@ -79,14 +80,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']} />