mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Improve project overview weekly users
This commit is contained in:
parent
e61c70d3c1
commit
efa2153d47
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user