Improve project overview weekly users

This commit is contained in:
Konstantin Wohlwend 2026-05-12 11:45:22 -07:00
parent e61c70d3c1
commit efa2153d47
3 changed files with 72 additions and 14 deletions

View File

@ -111,6 +111,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- Always let me know about the tradeoffs and decisions you make while implementing a non-trivial change.
- Whenever you change the URL of a page in the docs (or remove one), add a redirect in the docs-mintlify/docs.json file to make sure we don't lose any SEO juice.
- When you made frontend (or docs, dashboard, demo, etc.) changes, and you have a browser MCP in your list of MCP tools, make sure to test the changes in the browser MCP.
- If you're using the browser to test the dashboard and need to sign in, use GitHub OAuth to sign in (by default it should redirect you to the mock OAuth provider page, where you can sign in with admin@example.com).
### Code-related
- Use ES6 maps instead of records wherever you can.

View File

@ -7,7 +7,8 @@ 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 WEEKLY_USERS_WINDOW_DAYS = 7;
const CHART_WINDOW_DAYS = 30;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
type ProjectWeeklyUsers = {
@ -19,8 +20,8 @@ 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.
// The query emits one synthetic row per project with day set 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);
@ -83,12 +84,13 @@ export const GET = createSmartRouteHandler({
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 since = new Date(todayUtc.getTime() - (CHART_WINDOW_DAYS - 1) * ONE_DAY_MS);
const weeklySince = new Date(todayUtc.getTime() - (WEEKLY_USERS_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) {
for (let i = 0; i < CHART_WINDOW_DAYS; i += 1) {
const day = new Date(since.getTime() + i * ONE_DAY_MS);
out.push({ date: day.toISOString().split("T")[0], activity: 0 });
}
@ -117,6 +119,7 @@ export const GET = createSmartRouteHandler({
projectIds,
branchId: DEFAULT_BRANCH_ID,
since: since.toISOString().slice(0, 19),
weeklySince: weeklySince.toISOString().slice(0, 19),
untilExclusive: untilExclusive.toISOString().slice(0, 19),
};
@ -126,7 +129,7 @@ export const GET = createSmartRouteHandler({
query: `
SELECT
project_id AS projectId,
toDate(event_at, 'UTC') AS day,
toString(toDate(event_at, 'UTC')) AS day,
uniqExact(assumeNotNull(user_id)) AS users
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
@ -136,7 +139,21 @@ 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 GROUPING SETS ((projectId), (projectId, day))
GROUP BY projectId, day
UNION ALL
SELECT
project_id AS projectId,
'1970-01-01' AS day,
uniqExact(assumeNotNull(user_id)) AS users
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 >= {weeklySince:DateTime}
AND event_at < {untilExclusive:DateTime}
AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0
GROUP BY projectId
`,
query_params: queryParams,
format: "JSONEachRow",

View File

@ -4,13 +4,35 @@ import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
import { useId } from 'react';
type DataPoint = { date: string, activity: number };
type ChartDataPoint = DataPoint & {
completeActivity: number | null,
incompleteActivity: number | null,
};
const CHART_HEIGHT = 56;
const EMPTY_BASELINE_COUNT = 30;
function formatDay(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' });
}
function getTodayUtcDateKey(): string {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
return today.toISOString().split("T")[0];
}
function toChartData(data: DataPoint[]): ChartDataPoint[] {
const todayKey = getTodayUtcDateKey();
const firstIncompleteIndex = data.findIndex((point) => point.date >= todayKey);
return data.map((point, index) => ({
...point,
completeActivity: firstIncompleteIndex === -1 || index < firstIncompleteIndex ? point.activity : null,
incompleteActivity: firstIncompleteIndex !== -1 && index >= Math.max(0, firstIncompleteIndex - 1) ? point.activity : null,
}));
}
function EmptyBaseline({ count }: { count: number }) {
return (
<svg
@ -54,7 +76,7 @@ export function ProjectWeeklyUsersMetric(props: {
users/wk
</span>
</div>
<EmptyBaseline count={7} />
<EmptyBaseline count={EMPTY_BASELINE_COUNT} />
</div>
);
}
@ -73,11 +95,13 @@ export function ProjectWeeklyUsersMetric(props: {
<span className="absolute right-0 top-0 text-[9px] uppercase tracking-[0.14em] text-destructive/80">
Failed to load
</span>
<EmptyBaseline count={7} />
<EmptyBaseline count={EMPTY_BASELINE_COUNT} />
</div>
);
}
const chartData = data ? toChartData(data) : 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">
@ -94,10 +118,10 @@ export function ProjectWeeklyUsersMetric(props: {
</span>
</div>
{hasActivity && data ? (
{hasActivity && chartData ? (
<div className="absolute inset-0 text-foreground/80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 22, right: 0, left: 0, bottom: 0 }}>
<AreaChart data={chartData} margin={{ top: 22, right: 0, left: 0, bottom: 0 }}>
<XAxis dataKey="date" hide />
<defs>
<linearGradient id={`weekly-users-fill-${gradId}`} x1="0" y1="0" x2="0" y2="1">
@ -118,22 +142,38 @@ export function ProjectWeeklyUsersMetric(props: {
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(), 'daily active users']}
formatter={(value: number, name: string) => [
value.toLocaleString(),
name,
]}
/>
<Area
type="monotone"
dataKey="activity"
dataKey="completeActivity"
name="daily active users"
stroke="currentColor"
strokeWidth={1.5}
fill={`url(#weekly-users-fill-${gradId})`}
isAnimationActive={false}
activeDot={{ r: 2.5, strokeWidth: 0, fill: 'currentColor' }}
/>
<Area
type="monotone"
dataKey="incompleteActivity"
name="daily active users (incomplete day)"
stroke="currentColor"
strokeWidth={1.5}
strokeDasharray="3 3"
fill="transparent"
isAnimationActive={false}
activeDot={{ r: 2.5, strokeWidth: 0, fill: 'currentColor' }}
connectNulls={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<EmptyBaseline count={data?.length ?? 7} />
<EmptyBaseline count={data?.length ?? EMPTY_BASELINE_COUNT} />
)}
</div>
);