diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts index c578f77235..82f5fbbe00 100644 --- a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -5,6 +5,7 @@ import { getTools, validateToolNames } from "@/lib/ai/tools"; import { listManagedProjectIds } from "@/lib/projects"; import { SmartResponse } from "@/route-handlers/smart-response"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { validateImageAttachments } from "@stackframe/stack-shared/dist/ai/image-limits"; import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { Json } from "@stackframe/stack-shared/dist/utils/json"; @@ -31,7 +32,6 @@ export const POST = createSmartRouteHandler({ const isAuthenticated = fullReq.auth != null; const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages, projectId } = body; - // Verify user has access to the target project if (projectId != null) { if (fullReq.auth?.project.id !== "internal") { throw new StatusError(StatusError.Forbidden, "You do not have access to this project"); @@ -46,12 +46,31 @@ export const POST = createSmartRouteHandler({ } } + const imageValidationResult = validateImageAttachments(messages); + if (!imageValidationResult.ok) { + throw new StatusError(StatusError.BadRequest, imageValidationResult.reason); + } + const model = selectModel(quality, speed, isAuthenticated); const systemPrompt = getFullSystemPrompt(systemPromptId); const tools = await getTools(toolNames, { auth: fullReq.auth, targetProjectId: projectId }); const toolsArg = Object.keys(tools).length > 0 ? tools : undefined; const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai"; - const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5; + // create-dashboard now does an inspection loop (queryAnalytics) before calling updateDashboard, + // so it needs room for ~3 exploratory queries + the final tool call + some retry slack. + const isCreateDashboard = systemPromptId === "create-dashboard"; + // build-analytics-query aims for one-shot queries with complete schema + // knowledge, but needs a few steps for retries on errors or follow-ups. + const isBuildAnalyticsQuery = systemPromptId === "build-analytics-query"; + const stepLimit = toolsArg == null + ? 1 + : isDocsOrSearch + ? 50 + : isCreateDashboard + ? 12 + : isBuildAnalyticsQuery + ? 5 + : 5; if (mode === "stream") { const result = streamText({ diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.test.ts b/apps/backend/src/app/api/latest/internal/metrics/route.test.ts new file mode 100644 index 0000000000..9be2a91ad2 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/metrics/route.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { getMetricsWindowBounds, isMetricsRevenueInvoiceStatus } from "./route"; + +describe("internal metrics helpers", () => { + it("only counts paid and succeeded invoices as revenue", () => { + expect(isMetricsRevenueInvoiceStatus("paid")).toBe(true); + expect(isMetricsRevenueInvoiceStatus("succeeded")).toBe(true); + expect(isMetricsRevenueInvoiceStatus("failed")).toBe(false); + expect(isMetricsRevenueInvoiceStatus("uncollectible")).toBe(false); + expect(isMetricsRevenueInvoiceStatus(null)).toBe(false); + }); + + it("derives a single UTC-aligned rolling window from one clock", () => { + const { todayUtc, since, untilExclusive } = getMetricsWindowBounds(new Date("2026-04-13T23:59:59.999Z")); + + expect(todayUtc.toISOString()).toBe("2026-04-13T00:00:00.000Z"); + expect(since.toISOString()).toBe("2026-03-14T00:00:00.000Z"); + expect(untilExclusive.toISOString()).toBe("2026-04-14T00:00:00.000Z"); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/metrics/route.tsx b/apps/backend/src/app/api/latest/internal/metrics/route.tsx index d6f2ad6192..2c1162cfac 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1,28 +1,69 @@ +import { Prisma } from "@/generated/prisma/client"; +import { EmailOutboxSimpleStatus } from "@/generated/prisma/enums"; import { getClickhouseAdminClient } from "@/lib/clickhouse"; +import { ClickHouseError } from "@clickhouse/client"; +import { ActivitySplit, buildSplitFromDailyEntitySets } from "@/lib/metrics-activity-split"; import { Tenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy, PrismaClientTransaction, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import yup from 'yup'; +import { + type MetricsDataPoint, + MetricsAnalyticsOverviewSchema, + MetricsAuthOverviewSchema, + MetricsDataPointsSchema as DataPointsSchema, + MetricsEmailOverviewSchema, + MetricsLoginMethodEntrySchema, + MetricsPaymentsOverviewSchema, + MetricsRecentUserSchema, +} from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud"; -type DataPoints = yup.InferType; +type DataPoints = MetricsDataPoint[]; const MAX_USERS_FOR_COUNTRY_SAMPLE = 10_000; +const METRICS_WINDOW_DAYS = 30; +const METRICS_WINDOW_MS = METRICS_WINDOW_DAYS * 24 * 60 * 60 * 1000; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +export const METRICS_REVENUE_INVOICE_STATUSES = ["paid", "succeeded"] as const; +const METRICS_REVENUE_INVOICE_STATUSES_SQL = Prisma.raw( + METRICS_REVENUE_INVOICE_STATUSES.map((status) => `'${status}'`).join(", "), +); +const METRICS_REVENUE_INVOICE_STATUS_SET = new Set(METRICS_REVENUE_INVOICE_STATUSES); -const DataPointsSchema = yupArray(yupObject({ - date: yupString().defined(), - activity: yupNumber().defined(), -}).defined()).defined(); +export function isMetricsRevenueInvoiceStatus(status: string | null | undefined): boolean { + return status != null && METRICS_REVENUE_INVOICE_STATUS_SET.has(status); +} + +export function getMetricsWindowBounds(now: Date): { + todayUtc: Date, + since: Date, + untilExclusive: Date, +} { + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + return { + todayUtc, + since: new Date(todayUtc.getTime() - METRICS_WINDOW_MS), + untilExclusive: new Date(todayUtc.getTime() + ONE_DAY_MS), + }; +} function formatClickhouseDateTimeParam(date: Date): string { // ClickHouse DateTime params are passed as "YYYY-MM-DDTHH:MM:SS" (no timezone); treat them as UTC. return date.toISOString().slice(0, 19); } -async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransaction, includeAnonymous: boolean = false): Promise> { +function normalizeUuidFromEvent(value: string): string | null { + const normalized = value.trim().toLowerCase(); + return isUuid(normalized) ? normalized : null; +} + +async function loadUsersByCountry(tenancy: Tenancy, includeAnonymous: boolean = false): Promise> { const clickhouseClient = getClickhouseAdminClient(); const res = await clickhouseClient.query({ query: ` @@ -106,8 +147,8 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date, includeAnonymous: boo async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false) { const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); - const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); - const untilExclusive = new Date(todayUtc.getTime() + 24 * 60 * 60 * 1000); + const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); + const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); const clickhouseClient = getClickhouseAdminClient(); const result = await clickhouseClient.query({ @@ -140,13 +181,13 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou const dauByDay = new Map(); for (const row of rows) { // ClickHouse returns dates/datetimes without timezone, treat as UTC. - const dayKey = new Date(row.day + 'Z').toISOString().split('T')[0]; + const dayKey = row.day.split('T')[0]; dauByDay.set(dayKey, Number(row.dau)); } const out: DataPoints = []; - for (let i = 0; i <= 30; i += 1) { - const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) { + const day = new Date(since.getTime() + i * ONE_DAY_MS); const dayKey = day.toISOString().split('T')[0]; out.push({ date: dayKey, @@ -156,6 +197,163 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou return out; } +async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAnonymous: boolean): Promise { + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); + const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); + const clickhouseClient = getClickhouseAdminClient(); + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + + const userRows = await clickhouseClient.query({ + query: ` + SELECT + toDate(event_at) AS day, + assumeNotNull(user_id) AS user_id + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) + GROUP BY day, user_id + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }).then((result) => result.json() as Promise<{ day: string, user_id: string }[]>); + + const sanitizedUserRows = userRows.flatMap((row) => { + const userId = normalizeUuidFromEvent(row.user_id); + if (userId == null) { + return []; + } + return [{ ...row, user_id: userId }]; + }); + + const activeUserIds = [...new Set(sanitizedUserRows.map((row) => row.user_id))]; + const users: { projectUserId: string, signedUpAtOrCreatedAt: Date }[] = activeUserIds.length === 0 + ? [] + : await prisma.$replica().$queryRaw<{ projectUserId: string, signedUpAtOrCreatedAt: Date }[]>` + SELECT + "projectUserId"::text AS "projectUserId", + COALESCE("signedUpAt", "createdAt") AS "signedUpAtOrCreatedAt" + FROM ${sqlQuoteIdent(schema)}."ProjectUser" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "projectUserId" IN (${Prisma.join(activeUserIds.map((id) => Prisma.sql`${id}::UUID`))}) + ${includeAnonymous ? Prisma.empty : Prisma.sql`AND "isAnonymous" = false`} + `; + + const orderedDays: string[] = []; + const idsByDay = new Map>(); + for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) { + const date = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0]; + orderedDays.push(date); + idsByDay.set(date, new Set()); + } + for (const row of sanitizedUserRows) { + const day = row.day.split('T')[0]; + const daySet = idsByDay.get(day); + if (daySet) { + daySet.add(row.user_id); + } + } + + const createdDayByUserId = new Map( + users.map((user) => [user.projectUserId, user.signedUpAtOrCreatedAt.toISOString().split('T')[0]]) + ); + + return buildSplitFromDailyEntitySets({ + orderedDays, + entityIdsByDay: idsByDay, + createdDayByEntityId: createdDayByUserId, + }); +} + +async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise { + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); + const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); + const clickhouseClient = getClickhouseAdminClient(); + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + + const teamRows = await clickhouseClient.query({ + query: ` + SELECT + toDate(event_at) AS day, + assumeNotNull(team_id) AS team_id + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND team_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + GROUP BY day, team_id + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + }, + format: "JSONEachRow", + }).then((result) => result.json() as Promise<{ day: string, team_id: string }[]>); + + const sanitizedTeamRows = teamRows.flatMap((row) => { + const teamId = normalizeUuidFromEvent(row.team_id); + if (teamId == null) { + return []; + } + return [{ ...row, team_id: teamId }]; + }); + + const activeTeamIds = [...new Set(sanitizedTeamRows.map((row) => row.team_id))]; + const teams: { teamId: string, createdAt: Date }[] = activeTeamIds.length === 0 + ? [] + : await prisma.$replica().$queryRaw<{ teamId: string, createdAt: Date }[]>` + SELECT "teamId"::text AS "teamId", "createdAt" + FROM ${sqlQuoteIdent(schema)}."Team" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "teamId" IN (${Prisma.join(activeTeamIds.map((id) => Prisma.sql`${id}::UUID`))}) + `; + + const orderedDays: string[] = []; + const idsByDay = new Map>(); + for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) { + const date = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0]; + orderedDays.push(date); + idsByDay.set(date, new Set()); + } + for (const row of sanitizedTeamRows) { + const day = row.day.split('T')[0]; + const daySet = idsByDay.get(day); + if (daySet) { + daySet.add(row.team_id); + } + } + + const createdDayByTeamId = new Map( + teams.map((team) => [team.teamId, team.createdAt.toISOString().split('T')[0]]) + ); + + return buildSplitFromDailyEntitySets({ + orderedDays, + entityIdsByDay: idsByDay, + createdDayByEntityId: createdDayByTeamId, + }); +} + async function loadLoginMethods(tenancy: Tenancy): Promise<{ method: string, count: number }[]> { const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); @@ -199,6 +397,783 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boole return dbUsers.map((user) => userPrismaToCrud(user, tenancy.config)); } +async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise { + const { since, untilExclusive } = getMetricsWindowBounds(now); + + const clickhouseClient = getClickhouseAdminClient(); + try { + const result = await clickhouseClient.query({ + query: ` + SELECT + assumeNotNull(user_id) AS user_id + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) + GROUP BY user_id + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }); + const rows: { user_id: string }[] = await result.json(); + const uniqueUserIds = new Set(); + for (const row of rows) { + const normalizedUserId = normalizeUuidFromEvent(row.user_id); + if (normalizedUserId != null) { + uniqueUserIds.add(normalizedUserId); + } + } + return uniqueUserIds.size; + } catch (error) { + // Only swallow real ClickHouse errors (e.g. project hasn't enabled + // analytics yet, transient query failure). Anything else is a programming + // bug and should propagate to the smart route handler. + if (!(error instanceof ClickHouseError)) { + throw error; + } + captureError("internal-metrics-load-monthly-active-users-failed", new StackAssertionError( + "Failed to load monthly active users for internal metrics.", + { + cause: error, + projectId: tenancy.project.id, + branchId: tenancy.branchId, + }, + )); + return 0; + } +} + + +async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise> { + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + const { since } = getMetricsWindowBounds(now); + + const rows = await prisma.$replica().$queryRaw<{ day: string, new_cents: bigint }[]>` + SELECT + TO_CHAR("createdAt"::date, 'YYYY-MM-DD') AS day, + COALESCE(SUM("amountTotal"), 0)::bigint AS new_cents + FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "amountTotal" IS NOT NULL + AND "status" IN (${METRICS_REVENUE_INVOICE_STATUSES_SQL}) + AND "createdAt" >= ${since} + GROUP BY day + ORDER BY day + `; + + const revenueByDay = new Map(); + for (const row of rows) { + revenueByDay.set(row.day, Number(row.new_cents)); + } + + const dailyRevenue: Array<{ date: string, new_cents: number, refund_cents: number }> = []; + for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { + const day = new Date(since.getTime() + i * ONE_DAY_MS); + const key = day.toISOString().split('T')[0]; + dailyRevenue.push({ date: key, new_cents: revenueByDay.get(key) ?? 0, refund_cents: 0 }); + } + + return dailyRevenue; +} + +async function loadPaymentsOverview(tenancy: Tenancy, now: Date) { + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + + const { since: thirtyDaysAgo } = getMetricsWindowBounds(now); + + const [ + subscriptionsByStatus, + aggregates, + dailySubscriptionRows, + ] = await Promise.all([ + prisma.$replica().$queryRaw<{ status: string, cnt: number }[]>` + SELECT "status"::text AS status, COUNT(*)::int AS cnt + FROM ${sqlQuoteIdent(schema)}."Subscription" + WHERE "tenancyId" = ${tenancy.id}::UUID + GROUP BY "status" + `, + prisma.$replica().$queryRaw<[{ + active_subscription_count: number, + total_one_time_purchases: number, + revenue_cents: bigint, + total_subscription_invoices: number, + successful_subscription_invoices: number, + mrr_cents: bigint, + }]>` + SELECT + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."Subscription" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "status" = 'active'::"SubscriptionStatus") AS active_subscription_count, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."OneTimePurchase" + WHERE "tenancyId" = ${tenancy.id}::UUID) AS total_one_time_purchases, + (SELECT COALESCE(SUM("amountTotal"), 0)::bigint + FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "amountTotal" IS NOT NULL + AND "status" IN (${METRICS_REVENUE_INVOICE_STATUSES_SQL})) AS revenue_cents, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" + WHERE "tenancyId" = ${tenancy.id}::UUID) AS total_subscription_invoices, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "status" IN (${METRICS_REVENUE_INVOICE_STATUSES_SQL})) AS successful_subscription_invoices, + (SELECT COALESCE(SUM("amountTotal"), 0)::bigint + FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "amountTotal" IS NOT NULL + AND "status" IN (${METRICS_REVENUE_INVOICE_STATUSES_SQL}) + AND "createdAt" >= ${thirtyDaysAgo}) AS mrr_cents + `, + // Daily subscription signups for the last 30 days + prisma.$replica().$queryRaw<{ day: string, cnt: number }[]>` + SELECT + TO_CHAR("createdAt"::date, 'YYYY-MM-DD') AS day, + COUNT(*)::int AS cnt + FROM ${sqlQuoteIdent(schema)}."Subscription" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "createdAt" >= ${thirtyDaysAgo} + GROUP BY day + ORDER BY day + `, + ]); + + const subsByStatusMap = new Map(); + for (const group of subscriptionsByStatus) { + subsByStatusMap.set(group.status.toLowerCase(), Number(group.cnt)); + } + const subsByStatus = Object.fromEntries(subsByStatusMap); + + const activeSubscriptionCount = Number(aggregates[0].active_subscription_count); + const totalOneTimePurchases = Number(aggregates[0].total_one_time_purchases); + const invoiceRevenueCents = Number(aggregates[0].revenue_cents); + const totalSubscriptionInvoices = Number(aggregates[0].total_subscription_invoices); + const successfulSubscriptionInvoices = Number(aggregates[0].successful_subscription_invoices); + const mrrCents = Number(aggregates[0].mrr_cents); + + const recentByDay = new Map(); + for (const row of dailySubscriptionRows) { + recentByDay.set(row.day, Number(row.cnt)); + } + const dailySubscriptions: DataPoints = []; + for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { + const day = new Date(thirtyDaysAgo.getTime() + i * ONE_DAY_MS); + const key = day.toISOString().split('T')[0]; + dailySubscriptions.push({ date: key, activity: recentByDay.get(key) ?? 0 }); + } + + // MRR proxy: trailing-30-day paid invoice revenue. This is an approximation + // (it conflates one-time and recurring revenue and ignores billing cadence) + // but it is derived from real data instead of a hardcoded per-subscription rate. + const totalOrders = totalOneTimePurchases + totalSubscriptionInvoices; + const checkoutConversionRate = totalOrders > 0 + ? Number((((successfulSubscriptionInvoices + totalOneTimePurchases) / totalOrders) * 100).toFixed(2)) + : 0; + + return { + subscriptions_by_status: subsByStatus, + active_subscription_count: activeSubscriptionCount, + total_one_time_purchases: totalOneTimePurchases, + daily_subscriptions: dailySubscriptions, + revenue_cents: invoiceRevenueCents, + mrr_cents: mrrCents, + total_orders: totalOrders, + checkout_conversion_rate: checkoutConversionRate, + }; +} + +// ── Email Aggregates ───────────────────────────────────────────────────────── + +async function loadEmailOverview(tenancy: Tenancy, now: Date) { + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + const { since: thirtyDaysAgo } = getMetricsWindowBounds(now); + + const [ + counts, + recentEmails, + emailsByDayAndStatus, + ] = await Promise.all([ + // Single scan: per-status counts + delivered/bounced/clicked/finishedSending counts + prisma.$replica().$queryRaw<[{ + ok_count: number, + error_count: number, + in_progress_count: number, + delivered_count: number, + bounced_count: number, + clicked_count: number, + finished_sending_count: number, + }]>` + SELECT + COUNT(*) FILTER (WHERE "simpleStatus" = 'OK'::"EmailOutboxSimpleStatus")::int AS ok_count, + COUNT(*) FILTER (WHERE "simpleStatus" = 'ERROR'::"EmailOutboxSimpleStatus")::int AS error_count, + COUNT(*) FILTER (WHERE "simpleStatus" = 'IN_PROGRESS'::"EmailOutboxSimpleStatus")::int AS in_progress_count, + COUNT(*) FILTER (WHERE "deliveredAt" IS NOT NULL)::int AS delivered_count, + COUNT(*) FILTER (WHERE "bouncedAt" IS NOT NULL)::int AS bounced_count, + COUNT(*) FILTER (WHERE "clickedAt" IS NOT NULL)::int AS clicked_count, + COUNT(*) FILTER (WHERE "finishedSendingAt" IS NOT NULL)::int AS finished_sending_count + FROM ${sqlQuoteIdent(schema)}."EmailOutbox" + WHERE "tenancyId" = ${tenancy.id}::UUID + `, + prisma.$replica().$queryRaw<{ + id: string, + createdAt: Date, + simpleStatus: string, + status: string, + renderedSubject: string | null, + }[]>` + SELECT + "id"::text AS id, + "createdAt", + "simpleStatus"::text AS "simpleStatus", + "status"::text AS "status", + "renderedSubject" + FROM ${sqlQuoteIdent(schema)}."EmailOutbox" + WHERE "tenancyId" = ${tenancy.id}::UUID + ORDER BY "createdAt" DESC + LIMIT ${RECENT_LIST_PAGE_SIZE} + `, + // Per-day per-simpleStatus counts for the last 30 days + prisma.$replica().$queryRaw<{ day: string, status: string, cnt: number }[]>` + SELECT + TO_CHAR("createdAt"::date, 'YYYY-MM-DD') AS day, + "simpleStatus"::text AS status, + COUNT(*)::int AS cnt + FROM ${sqlQuoteIdent(schema)}."EmailOutbox" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "createdAt" >= ${thirtyDaysAgo} + GROUP BY day, "simpleStatus" + ORDER BY day + `, + ]); + + const deliveredCount = Number(counts[0].delivered_count); + const bouncedCount = Number(counts[0].bounced_count); + const clickedCount = Number(counts[0].clicked_count); + const finishedSendingCount = Number(counts[0].finished_sending_count); + + // Match the original groupBy behavior: only include statuses that actually + // have at least one row, mirroring what Prisma's groupBy used to return. + const emailsByStatus: Record = {}; + const okCount = Number(counts[0].ok_count); + const errorCount = Number(counts[0].error_count); + const inProgressCount = Number(counts[0].in_progress_count); + if (okCount > 0) emailsByStatus.ok = okCount; + if (errorCount > 0) emailsByStatus.error = errorCount; + if (inProgressCount > 0) emailsByStatus['in-progress'] = inProgressCount; + + // Daily email sends for last 30 days + const emailByDay = new Map(); + for (const row of emailsByDayAndStatus) { + emailByDay.set(row.day, (emailByDay.get(row.day) ?? 0) + Number(row.cnt)); + } + const dailyEmails: DataPoints = []; + for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { + const day = new Date(thirtyDaysAgo.getTime() + i * ONE_DAY_MS); + const key = day.toISOString().split('T')[0]; + dailyEmails.push({ date: key, activity: emailByDay.get(key) ?? 0 }); + } + + const totalEmails = Object.values(emailsByStatus).reduce((a, b) => a + b, 0); + const denom = finishedSendingCount > 0 ? finishedSendingCount : 1; + const deliverabilityRate = Number((Math.min(deliveredCount / denom, 1) * 100).toFixed(2)); + const bounceRate = Number((Math.min(bouncedCount / denom, 1) * 100).toFixed(2)); + const clickRate = Number((Math.min(clickedCount / denom, 1) * 100).toFixed(2)); + + // Build per-day per-status breakdown for stacked bar chart + type DayStatusCounts = { date: string, ok: number, error: number, in_progress: number }; + const dayStatusMap = new Map(); + for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { + const key = new Date(thirtyDaysAgo.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0]; + dayStatusMap.set(key, { ok: 0, error: 0, in_progress: 0 }); + } + for (const row of emailsByDayAndStatus) { + const entry = dayStatusMap.get(row.day); + if (entry == null) continue; + const count = Number(row.cnt); + // Exhaustive switch over EmailOutboxSimpleStatus — adding a new enum + // value will fail typecheck here so we can't silently miscount. + const status = row.status as EmailOutboxSimpleStatus; + switch (status) { + case 'OK': { + entry.ok += count; + break; + } + case 'ERROR': { + entry.error += count; + break; + } + case 'IN_PROGRESS': { + entry.in_progress += count; + break; + } + default: { + const _exhaustiveCheck: never = status; + captureError("internal-metrics-unknown-email-simple-status", new StackAssertionError( + `Unknown EmailOutboxSimpleStatus value: ${String(_exhaustiveCheck)}`, + { status: _exhaustiveCheck }, + )); + } + } + } + const dailyEmailsByStatus: DayStatusCounts[] = [...dayStatusMap.entries()].map(([date, counts]) => ({ + date, + ...counts, + })); + + return { + emails_by_status: emailsByStatus, + total_emails: totalEmails, + daily_emails: dailyEmails, + daily_emails_by_status: dailyEmailsByStatus, + emails_sent: finishedSendingCount, + recent_emails: recentEmails.map((email) => ({ + id: email.id, + status: email.status, + subject: email.renderedSubject ?? '(no subject)', + created_at_millis: email.createdAt.getTime(), + })), + deliverability_status: { + delivered: deliveredCount, + bounced: bouncedCount, + error: emailsByStatus['error'] ?? 0, + in_progress: emailsByStatus['in-progress'] ?? 0, + }, + deliverability_rate: deliverabilityRate, + bounce_rate: bounceRate, + click_rate: clickRate, + }; +} + +// ── Web Analytics Aggregates ───────────────────────────────────────────────── + +type SessionReplayAggregates = { + total: number, + recent: number, + avgSessionSeconds: number, + totalRevenueCents: number, +}; + +async function loadSessionReplayAggregates(tenancy: Tenancy, since: Date): Promise { + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + const result = await prisma.$replica().$queryRaw<[{ + total: number, + recent: number, + avg_ms: number | null, + total_revenue_cents: bigint, + }]>` + SELECT + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."SessionReplay" + WHERE "tenancyId" = ${tenancy.id}::UUID) AS total, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."SessionReplay" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "startedAt" >= ${since}) AS recent, + (SELECT AVG(GREATEST(0, EXTRACT(EPOCH FROM ("lastEventAt" - "startedAt")) * 1000)) + FROM ${sqlQuoteIdent(schema)}."SessionReplay" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "startedAt" >= ${since}) AS avg_ms, + (SELECT COALESCE(SUM("amountTotal"), 0)::bigint + FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "amountTotal" IS NOT NULL + AND "status" IN (${METRICS_REVENUE_INVOICE_STATUSES_SQL})) AS total_revenue_cents + `; + + const row = result[0]; + const avgSessionSeconds = Number(((Number(row.avg_ms ?? 0)) / 1000).toFixed(1)); + return { + total: Number(row.total), + recent: Number(row.recent), + avgSessionSeconds, + totalRevenueCents: Number(row.total_revenue_cents), + }; +} + +async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymous: boolean) { + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); + const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); + + const clickhouseClient = getClickhouseAdminClient(); + + // Session replay aggregates come from Postgres and have nothing to do with + // ClickHouse availability. Run them in parallel with the ClickHouse queries + // but keep them outside the ClickHouse-only try/catch so a postgres failure + // never gets misattributed to "analytics not enabled". + const replayPromise = loadSessionReplayAggregates(tenancy, since); + + let clickhouseAggregates: { + dailyPageViews: DataPoints, + dailyClicks: DataPoints, + dailyVisitors: DataPoints, + visitors: number, + onlineLive: number, + topReferrers: { referrer: string, visitors: number }[], + topRegion: { country_code: string | null, region_code: string | null, count: number } | null, + } | null = null; + + try { + const analyticsUserJoin = ` + LEFT JOIN ( + SELECT + user_id, + argMax(JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8'), event_at) AS latest_is_anonymous + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at < {untilExclusive:DateTime} + GROUP BY user_id + ) AS token_refresh_users + ON e.user_id = token_refresh_users.user_id + `; + const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(JSONExtract(toJSONString(e.data), 'is_anonymous', 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)"; + const [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult] = await Promise.all([ + // Combined daily aggregates: page-view count, click count, and unique + // visitors per day — one scan over the page-view/click event types. + clickhouseClient.query({ + query: ` + SELECT + toDate(e.event_at) AS day, + countIf( + e.event_type = '$page-view' + AND e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS pv, + countIf( + e.event_type = '$click' + AND e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS cl, + uniqExactIf( + assumeNotNull(e.user_id), + e.event_type = '$page-view' + AND e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS visitors + FROM analytics_internal.events AS e + ${analyticsUserJoin} + WHERE e.event_type IN ('$page-view', '$click') + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} + GROUP BY day + ORDER BY day ASC + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + uniqExactIf( + assumeNotNull(e.user_id), + e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS visitors + FROM analytics_internal.events AS e + ${analyticsUserJoin} + WHERE e.event_type = '$page-view' + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.user_id IS NOT NULL + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + nullIf(CAST(e.data.referrer, 'String'), '') AS referrer, + uniqExactIf( + assumeNotNull(e.user_id), + e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS visitors + FROM analytics_internal.events AS e + ${analyticsUserJoin} + WHERE e.event_type = '$page-view' + AND e.project_id = {projectId:String} + AND e.branch_id = {branchId:String} + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} + GROUP BY referrer + HAVING visitors > 0 + ORDER BY visitors DESC + LIMIT ${TOP_REFERRERS_PAGE_SIZE} + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + CAST(data.ip_info.country_code, 'Nullable(String)') AS country_code, + CAST(data.ip_info.region_code, 'Nullable(String)') AS region_code, + uniqExactIf( + assumeNotNull(user_id), + user_id IS NOT NULL + AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) + ) AS visitors + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + GROUP BY country_code, region_code + HAVING visitors > 0 + ORDER BY visitors DESC + LIMIT 1 + `, + query_params: { + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + uniqExact(assumeNotNull(user_id)) AS online + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IS NOT NULL + AND event_at >= {onlineSince:DateTime} + AND event_at < {untilExclusive:DateTime} + AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) + `, + query_params: { + onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), + ]); + + const dailyEventRows: { day: string, pv: number, cl: number, visitors: number }[] = await dailyEventResult.json(); + const pvByDay = new Map(); + const clByDay = new Map(); + const visitorByDay = new Map(); + for (const row of dailyEventRows) { + const key = row.day.split('T')[0]; + pvByDay.set(key, Number(row.pv)); + clByDay.set(key, Number(row.cl)); + visitorByDay.set(key, Number(row.visitors)); + } + const totalVisitorRows: { visitors: number }[] = await totalVisitorResult.json(); + const visitors = Number(totalVisitorRows[0]?.visitors ?? 0); + + const dailyPageViews: DataPoints = []; + const dailyClicks: DataPoints = []; + const dailyVisitors: DataPoints = []; + for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { + const day = new Date(since.getTime() + i * ONE_DAY_MS); + const key = day.toISOString().split('T')[0]; + dailyPageViews.push({ date: key, activity: pvByDay.get(key) ?? 0 }); + dailyClicks.push({ date: key, activity: clByDay.get(key) ?? 0 }); + dailyVisitors.push({ date: key, activity: visitorByDay.get(key) ?? 0 }); + } + + const referrers: { referrer: string | null, visitors: number }[] = await referrerResult.json(); + const topRegionRows: { country_code: string | null, region_code: string | null, visitors: number }[] = await topRegionResult.json(); + const onlineRows: { online: number }[] = await onlineResult.json(); + + clickhouseAggregates = { + dailyPageViews, + dailyClicks, + dailyVisitors, + visitors, + onlineLive: Number(onlineRows[0]?.online ?? 0), + topReferrers: referrers.map((row) => ({ + referrer: row.referrer ?? '(direct)', + visitors: Number(row.visitors), + })), + topRegion: topRegionRows[0] ? { + country_code: topRegionRows[0].country_code, + region_code: topRegionRows[0].region_code, + count: Number(topRegionRows[0].visitors), + } : null, + }; + } catch (error) { + // Only swallow real ClickHouse errors — that's the "analytics not enabled + // for this project" path. Anything else is a real bug and should propagate. + if (!(error instanceof ClickHouseError)) { + throw error; + } + captureError("internal-metrics-analytics-overview-clickhouse-fallback", new StackAssertionError( + "Falling back to empty analytics overview due to ClickHouse query failure.", + { + cause: error, + projectId: tenancy.project.id, + branchId: tenancy.branchId, + }, + )); + // Leave clickhouseAggregates as null — handled in the response builder below. + } + + // Postgres-backed session replay query has its own error surface — let it + // propagate naturally so we don't conflate it with "clickhouse missing". + const replayResult = await replayPromise; + + // daily_revenue is intentionally not populated here — it is owned by + // payments_overview (real invoice data) and stitched into analytics_overview + // by the response builder so the dashboard can keep reading it from a single + // location. + if (clickhouseAggregates == null) { + return { + daily_page_views: [] as DataPoints, + daily_clicks: [] as DataPoints, + daily_visitors: [] as DataPoints, + daily_revenue: [] as Array<{ date: string, new_cents: number, refund_cents: number }>, + total_revenue_cents: replayResult.totalRevenueCents, + total_replays: replayResult.total, + recent_replays: replayResult.recent, + visitors: 0, + avg_session_seconds: replayResult.avgSessionSeconds, + online_live: 0, + revenue_per_visitor: 0, + top_referrers: [], + top_region: null, + }; + } + + return { + daily_page_views: clickhouseAggregates.dailyPageViews, + daily_clicks: clickhouseAggregates.dailyClicks, + daily_visitors: clickhouseAggregates.dailyVisitors, + daily_revenue: [] as Array<{ date: string, new_cents: number, refund_cents: number }>, + total_revenue_cents: replayResult.totalRevenueCents, + total_replays: replayResult.total, + recent_replays: replayResult.recent, + visitors: clickhouseAggregates.visitors, + avg_session_seconds: replayResult.avgSessionSeconds, + online_live: clickhouseAggregates.onlineLive, + revenue_per_visitor: clickhouseAggregates.visitors > 0 + ? Number(((replayResult.totalRevenueCents / 100) / clickhouseAggregates.visitors).toFixed(2)) + : 0, + top_referrers: clickhouseAggregates.topReferrers, + top_region: clickhouseAggregates.topRegion, + }; +} + +// ── Auth Extra Aggregates ──────────────────────────────────────────────────── + +async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) { + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); + + const [counts, dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([ + prisma.$replica().$queryRaw<[{ + total_users: number, + verified_non_anonymous_users: number, + anonymous_users: number, + total_teams: number, + }]>` + SELECT + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."ProjectUser" + WHERE "tenancyId" = ${tenancy.id}::UUID) AS total_users, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."ProjectUser" pu + WHERE pu."tenancyId" = ${tenancy.id}::UUID + AND pu."isAnonymous" = false + AND EXISTS ( + SELECT 1 FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc + WHERE cc."tenancyId" = pu."tenancyId" + AND cc."projectUserId" = pu."projectUserId" + AND cc."type" = 'EMAIL'::"ContactChannelType" + AND cc."isVerified" = true + )) AS verified_non_anonymous_users, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."ProjectUser" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "isAnonymous" = true) AS anonymous_users, + (SELECT COUNT(*)::int + FROM ${sqlQuoteIdent(schema)}."Team" + WHERE "tenancyId" = ${tenancy.id}::UUID) AS total_teams + `, + loadDailyActiveUsersSplit(tenancy, now, includeAnonymous), + loadDailyActiveTeamsSplit(tenancy, now), + loadMonthlyActiveUsers(tenancy, now, includeAnonymous), + ]); + + const totalUsers = Number(counts[0].total_users); + const verifiedNonAnonymousUsers = Number(counts[0].verified_non_anonymous_users); + const anonymousUsers = Number(counts[0].anonymous_users); + const totalTeams = Number(counts[0].total_teams); + const nonAnonymousTotal = totalUsers - anonymousUsers; + // total_users_filtered respects the includeAnonymous query flag so the + // handler can use it directly without a separate count round trip. + const totalUsersFiltered = includeAnonymous ? totalUsers : nonAnonymousTotal; + + // verified_users / unverified_users always count non-anonymous users only, + // so they never overlap with anonymous_users (which is its own bucket). + // Adding all three always equals totalUsers, regardless of includeAnonymous. + return { + verified_users: verifiedNonAnonymousUsers, + unverified_users: nonAnonymousTotal - verifiedNonAnonymousUsers, + anonymous_users: anonymousUsers, + total_teams: totalTeams, + mau, + daily_active_users_split: dailyActiveUsersSplit, + daily_active_teams_split: dailyActiveTeamsSplit, + total_users_filtered: totalUsersFiltered, + }; +} + +const RECENT_LIST_PAGE_SIZE = 100; +const TOP_REFERRERS_PAGE_SIZE = 100; + export const GET = createSmartRouteHandler({ metadata: { hidden: true, @@ -219,40 +1194,46 @@ export const GET = createSmartRouteHandler({ total_users: yupNumber().integer().defined(), daily_users: DataPointsSchema, daily_active_users: DataPointsSchema, - // TODO: Narrow down the types further - users_by_country: yupMixed().defined(), - recently_registered: yupMixed().defined(), - recently_active: yupMixed().defined(), - login_methods: yupMixed().defined(), + users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(), + // recently_registered/active are CRUD User objects passed through from + // usersCrudHandlers. Validated against MetricsRecentUserSchema, which + // covers the fields the dashboard reads — extra fields from + // UsersCrud["Admin"]["Read"] flow through. + recently_registered: yupArray(MetricsRecentUserSchema).defined(), + recently_active: yupArray(MetricsRecentUserSchema).defined(), + login_methods: yupArray(MetricsLoginMethodEntrySchema).defined(), + auth_overview: MetricsAuthOverviewSchema, + payments_overview: MetricsPaymentsOverviewSchema, + email_overview: MetricsEmailOverviewSchema, + analytics_overview: MetricsAnalyticsOverviewSchema, }).defined(), }), handler: async (req) => { const now = new Date(); const includeAnonymous = req.query.include_anonymous === "true"; - const prisma = await getPrismaClientForTenancy(req.auth.tenancy); - const [ - totalUsers, dailyUsers, dailyActiveUsers, usersByCountry, recentlyRegistered, recentlyActive, - loginMethods + loginMethods, + authOverview, + paymentsOverview, + emailOverview, + analyticsOverview, + dailyRevenue, ] = await Promise.all([ - prisma.projectUser.count({ - where: { tenancyId: req.auth.tenancy.id, ...(includeAnonymous ? {} : { isAnonymous: false }) }, - }), loadTotalUsers(req.auth.tenancy, now, includeAnonymous), loadDailyActiveUsers(req.auth.tenancy, now, includeAnonymous), - loadUsersByCountry(req.auth.tenancy, prisma, includeAnonymous), + loadUsersByCountry(req.auth.tenancy, includeAnonymous), usersCrudHandlers.adminList({ tenancy: req.auth.tenancy, query: { order_by: 'signed_up_at', desc: "true", - limit: 5, + limit: RECENT_LIST_PAGE_SIZE, include_anonymous: includeAnonymous ? "true" : "false", }, allowedErrorTypes: [ @@ -261,8 +1242,19 @@ export const GET = createSmartRouteHandler({ }).then(res => res.items), loadRecentlyActiveUsers(req.auth.tenancy, includeAnonymous), loadLoginMethods(req.auth.tenancy), + loadAuthOverview(req.auth.tenancy, includeAnonymous, now), + loadPaymentsOverview(req.auth.tenancy, now), + loadEmailOverview(req.auth.tenancy, now), + loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous), + loadDailyRevenue(req.auth.tenancy, now), ] as const); + const totalUsers = authOverview.total_users_filtered; + + // Stitch real daily revenue (from paid invoices) into analytics_overview so + // the dashboard can read it from a single location. + const finalAnalyticsOverview = { ...analyticsOverview, daily_revenue: dailyRevenue }; + return { statusCode: 200, bodyType: "json", @@ -274,6 +1266,10 @@ export const GET = createSmartRouteHandler({ recently_registered: recentlyRegistered, recently_active: recentlyActive, login_methods: loginMethods, + auth_overview: authOverview, + payments_overview: paymentsOverview, + email_overview: emailOverview, + analytics_overview: finalAnalyticsOverview, } }; }, diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index ce054aa29a..7a66a4d3d7 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -43,6 +43,7 @@ export type SystemPromptId = | "email-assistant-draft" | "create-dashboard" | "run-query" + | "build-analytics-query" | "rewrite-template-source"; /** @@ -76,27 +77,35 @@ Run a ClickHouse SQL query against the project's analytics database. Only SELECT Available tables: **events** - User activity events -- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed +- event_type: LowCardinality(String) - ONLY: $page-view, $click, $token-refresh - event_at: DateTime64(3, 'UTC') - When the event occurred -- data: JSON - Additional event data -- user_id: Nullable(String) - Associated user ID -- team_id: Nullable(String) - Associated team ID +- data: JSON - MUST use toString() before extracting: JSONExtractString(toString(data), 'key') +- user_id: Nullable(String) - Always populated (no nulls) +- team_id: Nullable(String) - Always NULL, never use - created_at: DateTime64(3, 'UTC') - When the record was created +Event data payloads: +- $page-view: {is_anonymous, path, referrer} +- $click: {is_anonymous, selector} +- $token-refresh: {is_anonymous, refresh_token_id, ip_info: {country_code, city_name, region_code, is_trusted, latitude, longitude, tz_identifier, ip}} + **users** - User profiles - id: UUID - User ID - display_name: Nullable(String) - User's display name - primary_email: Nullable(String) - User's primary email - primary_email_verified: UInt8 - Whether email is verified (0/1) - signed_up_at: DateTime64(3, 'UTC') - When user signed up -- client_metadata: JSON - Client-side metadata -- client_read_only_metadata: JSON - Read-only client metadata -- server_metadata: JSON - Server-side metadata +- client_metadata: JSON - Typically empty +- client_read_only_metadata: JSON - Typically empty +- server_metadata: JSON - Typically empty - is_anonymous: UInt8 - Whether user is anonymous (0/1) SQL QUERY GUIDELINES: - Only SELECT queries are allowed (no INSERT, UPDATE, DELETE) +- JSON extraction REQUIRES toString(): JSONExtractString(toString(data), 'key') +- Nested JSON uses dot notation: JSONExtractString(toString(data), 'ip_info.country_code') - Always use LIMIT to avoid returning too many rows (default to LIMIT 100) +- Use relative date ranges: now() - INTERVAL X DAY - Use appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. - For counting, use COUNT(*) or COUNT(DISTINCT column) - Example queries: @@ -490,6 +499,51 @@ RUNTIME CONTRACT (HARD RULES) No import/export/require statements. No external networking calls. +──────────────────────────────────────── +HOOK SAFETY (HARD RULES — VIOLATING THIS CRASHES THE DASHBOARD) +──────────────────────────────────────── +React throws "Minified React error #310" (also: #300, #301, #321) when hooks are called in a +different order between renders. This is the #1 source of dashboard runtime crashes. You MUST +follow these rules without exception: + +1. **ALL hooks go at the TOP of the Dashboard component**, before ANY conditional returns, + ANY \`if\`, ANY ternary, ANY loop, ANY early \`return\`. +2. **Hooks are called UNCONDITIONALLY on every render.** Never wrap a hook in \`if\`, never call + one inside a \`.map()\` or \`.forEach()\`, never skip one because a variable is null. +3. **Put loading / error / empty early returns AFTER every hook has run**, not before. +4. **Do not call hooks inside event handlers, effects, or helper functions** defined inside the + component body. Hooks only go directly in the component function body. +5. Before finishing the code, mentally re-order your hooks and confirm the count and order are + identical on every possible render path. + +CANONICAL BAD EXAMPLE (crashes with React error #310): + function Dashboard() { + const [users, setUsers] = React.useState(null); + if (!users) { + return ; // ← early return BEFORE the next hook + } + const [filter, setFilter] = React.useState(""); // ← this hook is skipped on first render + React.useEffect(() => { ... }, []); // ← and this one + return
...
; + } + +CANONICAL GOOD EXAMPLE: + function Dashboard() { + // All hooks first. Unconditional. Same count every render. + const [users, setUsers] = React.useState(null); + const [filter, setFilter] = React.useState(""); + const [error, setError] = React.useState(null); + React.useEffect(() => { ... }, []); + + // Conditional rendering AFTER all hooks: + if (error) return ; + if (!users) return ; + return
...
; + } + +If you catch yourself writing \`if (...) return ...\` anywhere above a \`React.useXxx\` call, +STOP and move the return below every hook. + ──────────────────────────────────────── EDITING BEHAVIOR (when existing code is provided) ──────────────────────────────────────── @@ -517,121 +571,104 @@ Teams: Project: - stackServerApp.getProject() → Promise +Analytics (ClickHouse): +- stackServerApp.queryAnalytics({ query }) → Promise<{ result: Record[], query_id: string }> + Use this for event trends, counts, distributions, and any aggregate that SDK list methods cannot express. + See the CLICKHOUSE ANALYTICS section below for schema and examples. Test your query with the + queryAnalytics TOOL during your reasoning loop BEFORE embedding it in the dashboard. + Important: - Use camelCase options (includeAnonymous) - The SDK handles auth/retries/errors; still show graceful UI states ──────────────────────────────────────── -CHART RULES (RECHARTS REQUIRED) +LAYOUT & DESIGN RULES ──────────────────────────────────────── -- Every dashboard MUST include at least one chart. -- Choose chart types that match the question: - - Trends over time → LineChart / AreaChart - - Comparisons/top-N → BarChart - - Distributions → PieChart (or BarChart if many categories) -- Always wrap charts in ResponsiveContainer. -- Use XAxis/YAxis + Tooltip; include CartesianGrid when useful. -- If the query is time-series, ALWAYS show a time-series chart. - -Do not overwhelm: 1–2 charts maximum. - -──────────────────────────────────────── -LAYOUT & DESIGN RULES (PRACTICAL) -──────────────────────────────────────── -Use this container baseline: -
- - -Header: -- Clear title that matches the question -- Optional Refresh button using DashboardUI.DesignButton (disabled while loading) - -Metrics: -- 2–4 DashboardUI.DesignMetricCard in a CSS grid: -
-- Use the trend prop to show up/down/neutral direction -- Keep titles short - -Charts: -- Wrap Recharts in DashboardUI.DesignChartCard + DashboardUI.DesignChartContainer -- Always use DashboardUI.DesignChartTooltipContent and DashboardUI.DesignChartLegendContent -- Use DashboardUI.getDesignChartColor(index) for consistent colors - -Tables (optional): -- Use DashboardUI.DesignTable with sub-components -- Only include if it helps answer the question - -Loading & Errors: -- Always show DashboardUI.DesignSkeleton while loading -- Disable interactions during loading -- If an error happens, show a small, user-friendly message in the UI (non-technical) -- Use DashboardUI.DesignEmptyState when there is no data to display - -DASHBOARD UI COMPONENTS: See the DashboardUI type definitions provided in context. -All accessed as DashboardUI.. Light/dark mode is automatic. -AVAILABLE DASHBOARD UI COMPONENTS (via DashboardUI.*) -──────────────────────────────────────── -All components are accessed as DashboardUI.. No imports needed. -Light and dark mode are handled automatically via CSS variables. - -METRIC CARDS (use for KPI / big numbers): - - -GENERAL-PURPOSE CARD (for text-only content like titles, descriptions, headings): - - Any content goes here - - Text-only cards (bodyOnly variant) are automatically transparent in dark mode. - Do NOT add padding (p-6, p-5, etc.) to DesignCard className — the component already has built-in padding. - -CHART COMPONENTS (wrapping Recharts): - - - - - - - } /> - } /> - - - - - - chartConfig format: { [dataKey]: { label: "Human Name", color: DashboardUI.getDesignChartColor(index) } } - - TABLE: - - - - Name - Email - - - - {rows.map(row => ( - - {row.name} - {row.email} - - ))} - - - -OTHER COMPONENTS: - - - - - - +You have FULL FREEDOM over the page layout. Use standard JSX with Tailwind utility classes +(flexbox, CSS grid, spacing, typography) — the DashboardUI components handle light/dark mode, +glassmorphism, and typography automatically. + +Container baseline: +
+ +Organizing principles (NOT component specifics — see each component's JSDoc for those): +- Every dashboard MUST include at least one chart. +- 2–4 metric cards max in a header row; use a CSS grid like + \`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4\`. +- 1–2 charts max in the main content area. Don't pack the page. +- Put interactive elements (tables, filters) below the overview section. +- Always render loading, error, AND empty states. A blank chart is a bug. +- Prefer skeletons over spinners for loading (see DesignSkeleton JSDoc). +- User-facing error messages are short and non-technical. Log details with + \`console.error('[Dashboard] :', err)\` — do NOT surface React error + codes, stack traces, or raw exception strings to the user. + +For any component-specific question (props, state shape, examples, gotchas), read the JSDoc +on the component you're using. The JSDoc is included in the type definitions delivered with +this prompt — it is the source of truth, not this section. + +DASHBOARD UI COMPONENTS +──────────────────────────────────────── +\`React\`, \`DashboardUI\`, \`Recharts\`, and \`stackServerApp\` are pre-injected globals +in the sandbox — no \`import\` / \`require\` / \`export\` statements, ever. Reference them +directly (e.g. \`React.useState\`, \`DashboardUI.DataGrid\`). Light / dark mode, +glassmorphic surfaces, and typography are handled automatically by the components. + +THE JSDOC IS LOAD-BEARING — READ IT BEFORE YOU WRITE CODE +───────────────────────────────────────────────────────── +The FULL usage contract for every DashboardUI.* component (mental model, canonical +pattern, prop rules, runnable examples, common mistakes) lives in a JSDoc block on +the component itself. Those JSDoc blocks are injected into your context alongside +the TypeScript types. BEFORE you write a single line against any component, locate +its JSDoc in the "DashboardUI component documentation" block and read it. The bare +type signatures are NOT sufficient — the JSDoc is where the load-bearing rules live +(e.g. "DataGrid.rows is NEVER your raw array; use useDataSource"). If the JSDoc and +this prompt disagree, the JSDoc wins — it ships with the component and is always +up to date. + +"Fully controlled" — what it means on DashboardUI components +───────────────────────────────────────────────────────────── +Components that expose \`state\` + \`onChange\` (DataGrid, AnalyticsChart) are fully +controlled: the component holds no internal state. You store the full state object +in a \`React.useState\` hook, pass the current value as \`state\`, and pass the setter +directly as \`onChange\`. The component calls your setter with the NEXT complete state +object whenever anything changes (sort, search, zoom, etc.) — it never merges, never +partial-updates. Rules: + +1. Keep data and state in SEPARATE hooks. Never combine them into one \`useState\`. +2. Pass the raw setter to \`onChange\`: \`onChange={setGridState}\`. Do not wrap it. +3. Always initialize state from the component's \`create*State\` / \`DEFAULT_STATE\` + helper. Never hand-assemble the state object. +4. Read each component's JSDoc for its exact state shape — do NOT guess fields. + +Quick map of what to use when: + +- KPI / big number → DashboardUI.DesignMetricCard +- Grouping / section wrapper → DashboardUI.DesignCard +- Chart chrome (title + body) → DashboardUI.DesignChartCard +- Time-series chart → DashboardUI.AnalyticsChart (inside a DesignChartCard) +- Static ranking / distribution → DashboardUI.DesignChartCard + DashboardUI.DesignChartContainer + raw Recharts.* +- Small static list (< 20 rows) → DashboardUI.DesignTable + DesignTableHeader / Row / Head / Body / Cell +- Interactive / large table → DashboardUI.DataGrid + DashboardUI.useDataSource + DashboardUI.createDefaultDataGridState +- Status pills / tags → DashboardUI.DesignBadge +- Buttons → DashboardUI.DesignButton +- Empty / zero-result placeholder → DashboardUI.DesignEmptyState +- Loading placeholder → DashboardUI.DesignSkeleton (NEVER a spinner or "Loading..." text) +- Progress / quota bar → DashboardUI.DesignProgressBar +- Divider line → DashboardUI.DesignSeparator + +Chart decision tree: + + 1. Is the x-axis a timestamp? → AnalyticsChart (area / line / bar / compare / segmented). + 2. Is it a breakdown / distribution you want to show as a pie? → AnalyticsChart with \`view: "pie"\`. + 3. Is it a static ranking, horizontal bar chart, or something Recharts has but AnalyticsChart doesn't? + → Raw Recharts inside DesignChartCard + DesignChartContainer. + 4. Is it a single number? → DesignMetricCard, not a chart. + +Do NOT reach for raw Recharts as a default. AnalyticsChart handles zoom, tooltips, +annotations, formatting, and dark-mode palette automatically; rebuilding those on +top of Recharts is wasted work. ──────────────────────────────────────── LAYOUT ──────────────────────────────────────── @@ -641,6 +678,13 @@ Example: function Dashboard() { const [loading, setLoading] = React.useState(true); const [users, setUsers] = React.useState(null); + const [chartData, setChartData] = React.useState([]); + const [chartState, setChartState] = React.useState({ + ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, + layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => + l.kind === "compare" ? { ...l, visible: false } : l + ), + }); const [error, setError] = React.useState(null); const [showControls] = React.useState(!!window.__showControls); const [chatOpen, setChatOpen] = React.useState(!!window.__chatOpen); @@ -677,19 +721,22 @@ Example: window.dashboardNavigate('/users')} className="cursor-pointer hover:bg-foreground/[0.02] transition-colors hover:transition-none" />
- - {/* Recharts chart here */} - + {/* data and state are SEPARATE hooks — onChange receives AnalyticsChartState directly */} +
); } ──────────────────────────────────────── -RECHARTS (via Recharts.*) +RECHARTS (via Recharts.*) — FALLBACK ONLY ──────────────────────────────────────── -Use via Recharts.* — always wrap in DashboardUI.DesignChartContainer: -- Recharts.LineChart, Recharts.BarChart, Recharts.AreaChart, Recharts.PieChart +Use raw Recharts ONLY for non-time-series visuals (static bar rankings, pie charts). +For any time-series data, use DashboardUI.AnalyticsChart instead (see above). + +Available via Recharts.* — always wrap in DashboardUI.DesignChartContainer: +- Recharts.BarChart, Recharts.PieChart (most common fallback uses) +- Recharts.LineChart, Recharts.AreaChart (prefer AnalyticsChart for these) - Recharts.XAxis, Recharts.YAxis, Recharts.CartesianGrid - Recharts.Line, Recharts.Bar, Recharts.Area, Recharts.Cell - Recharts.ResponsiveContainer (used internally by DesignChartContainer — do NOT wrap again) @@ -703,11 +750,19 @@ TYPE DEFINITIONS The type definitions for the Stack SDK and dashboard UI components will be provided in the user messages. Use them to determine available fields, methods, prop types, and variants. -CLICKHOUSE (queryAnalytics only) -Available tables: +CLICKHOUSE ANALYTICS +Two ways to use ClickHouse: + +1. **queryAnalytics TOOL (reasoning loop, inspection)** — use this BEFORE writing code, to look at real data. +2. **stackServerApp.queryAnalytics({ query }) at RUNTIME (embedded in the dashboard TSX)** — use this INSIDE + the Dashboard component to fetch live aggregates for charts/tables. Returns \`{ result: Record[], query_id: string }\`. + +Project + branch filtering is AUTOMATIC in both cases. Do NOT add \`WHERE project_id = ...\`. + +Available tables (same schema in both contexts): events: -- event_type: LowCardinality(String) ($token-refresh only) +- event_type: LowCardinality(String) ($token-refresh only, today) - event_at: DateTime64(3, 'UTC') - data: JSON - user_id: Nullable(String) @@ -725,6 +780,98 @@ users (limited fields): - server_metadata: JSON - is_anonymous: UInt8 (0/1) +──────────────────────────────────────── +INSPECTION LOOP — USE SPARINGLY +──────────────────────────────────────── +\`queryAnalytics\` is an inspection tool that lets you look at real data before building the dashboard. + +DEFAULT TO SKIPPING INSPECTION. Only inspect if ONE of these is true: +- You need to know the scale/shape of the data to pick the right chart type or normalization. +- The user's question implies a segmentation (by region/plan/provider) and you need to check what segments exist. +- You need to know the JSON keys in \`data\` / \`*_metadata\` columns before writing JSONExtract. +- The user said the previous dashboard was "wrong", "off", or "not scaled well" — inspect to fix deterministically. + +BUDGET: ≤ 2 queries per turn. Make each query count — prefer aggregates that reveal multiple +dimensions at once (e.g. combine counts, date ranges, and segments in one query). + +INSPECTION QUERY DISCIPLINE (when you do inspect): +- ALWAYS include \`LIMIT\` (≤ 20 for row samples). Results are TRUNCATED to 50 rows for you. +- PREFER aggregates (\`count\`, \`sum\`, \`min\`, \`max\`, \`avg\`, \`quantile\`, \`GROUP BY\`) over \`SELECT *\`. +- Keep queries FAST. Add a time filter (\`event_at > now() - INTERVAL ...\`) where it helps. +- Do NOT paste query results verbatim into the dashboard text. Use them to inform design only. +- On a query error (unknown column, missing JSON key), DO NOT fall back to fabricated data. + See DATA HONESTY below. + +INSPECTION QUERY EXAMPLES (reference, not a checklist): + -- Scale check before a dual-axis chart: + SELECT count() AS signups, sum(toFloat64OrZero(JSONExtractString(data, 'amount_usd'))) AS revenue + FROM events WHERE event_at > now() - INTERVAL 30 DAY + + -- Segment existence before "by region": + SELECT JSONExtractString(client_metadata, 'region') AS region, count() + FROM users GROUP BY region ORDER BY count() DESC LIMIT 10 + + -- Project age to pick a default window: + SELECT min(signed_up_at), max(signed_up_at), count() FROM users + +──────────────────────────────────────── +DATA HONESTY (HARD RULE — NEVER FABRICATE DATA) +──────────────────────────────────────── +You MUST only use fields that actually exist in the SDK types or the ClickHouse schema. You +MUST NOT invent synthetic/placeholder/mock data to fill in gaps, EVER. A dashboard showing fake +numbers is worse than one that admits the data is missing. + +If the user asks for a metric the data cannot answer: + +1. **Substitute**: pick the closest REAL metric and name it honestly in the UI. For example, if + the user asks for "revenue by region" but there is no revenue field and no region field, + build "signups by day" (or another real metric) and include a \`DashboardUI.DesignCard\` or + subtitle briefly saying: "Revenue and region aren't tracked yet — showing signup activity + instead." + +2. **Degrade**: if there is genuinely nothing relevant to show for part of the ask, render + \`DashboardUI.DesignEmptyState\` with a non-technical message explaining what's missing + (e.g. "No revenue data yet — connect payments to see this chart"). + +3. **Ship what works**: always ship a working dashboard. Do not block on the missing piece — + build the parts you CAN build, and be explicit about the parts you can't. + +FORBIDDEN: +- Hardcoding arrays like \`[{ region: 'US', revenue: 1200 }, ...]\` with made-up values. +- Using \`Math.random()\` or seeded generators to produce "realistic-looking" data. +- Inventing field names on real records (e.g. \`user.subscriptionPlan\` when that doesn't exist). +- Silently fudging math so a chart "looks right" — if the data is wrong, fix the data source, + don't cook the numbers. + +If you are not sure whether a field exists, either (a) check the SDK type definitions already +provided in your context, or (b) run ONE inspection query to confirm. Do not guess. + +──────────────────────────────────────── +RUNTIME QUERIES IN THE GENERATED DASHBOARD TSX +──────────────────────────────────────── +For dashboards backed by ClickHouse aggregates (event trends, counts by segment, etc.), +embed the query in the dashboard itself so it fetches live data at runtime: + + const [rows, setRows] = React.useState(null); + const [error, setError] = React.useState(null); + React.useEffect(() => { + stackServerApp.queryAnalytics({ + query: "SELECT toStartOfDay(event_at) AS day, count() AS n FROM events WHERE event_at > now() - INTERVAL 30 DAY GROUP BY day ORDER BY day" + }) + .then(res => setRows(res.result)) + .catch(err => { console.error('[Dashboard] query failed', err); setError('Failed to load analytics'); }); + }, []); + +Rules: +- The query string must be valid ClickHouse SQL that you have already TESTED via the queryAnalytics TOOL during inspection. +- Always handle loading + error + empty states. +- \`res.result\` is an array of plain objects — map it to Point[] for AnalyticsChart (preferred) or \`data\` for Recharts. +- Do NOT hardcode sample/mock data in place of a real query. +- CRITICAL: ClickHouse returns ALL values as strings. You MUST cast numeric columns with Number(): + res.result.map(r => ({ ts: new Date(r.day).getTime(), values: { primary: Number(r.count) } })) + Forgetting Number() causes NaN in charts and broken rendering. Always cast. +- For segment arrays, cast every element: segments = res.result.map(r => [Number(r.a), Number(r.b)]) + ──────────────────────────────────────── NAVIGATION API (postMessage-based) ──────────────────────────────────────── @@ -758,20 +905,18 @@ BACK & EDIT CONTROLS (conditional) PRIMARY OBJECTIVE ──────────────────────────────────────── Build a dashboard that directly answers THE USER'S SPECIFIC QUESTION. - A "generic analytics dashboard" is wrong. -Every card, chart, and table must exist only because it helps answer the query. + +Every card, chart, and table must exist because it helps answer the query. ──────────────────────────────────────── DASHBOARD REQUIREMENTS (HARD RULES) ──────────────────────────────────────── 1) Read the user's query carefully. Build ONLY what answers it. -2) The dashboard MUST include at least one Recharts chart that visualizes the answer. +2) The dashboard MUST include at least one chart (prefer AnalyticsChart for time-series). - Text-only dashboards are not allowed. -3) Keep it concise: - - 2–4 metric cards - - 1–2 charts - - Optional: a small table ONLY if it adds decision-useful detail +3) 2–4 metric cards, 1–2 charts. + - Optional: small tables when they add decision-useful detail 4) Never show technical details in the UI: - No API names, method names, SDK details, types, or implementation notes. 5) Use professional, clean design: @@ -795,17 +940,53 @@ If the user's intent is slightly ambiguous, infer the most useful dashboard and EXAMPLES (MENTAL MODEL, NOT UI TEXT) ──────────────────────────────────────── Query: "how many users do I have?" -→ Total users card, verified card, anonymous card, signup trend chart +→ Total users card, verified card, anonymous card, signup trend AnalyticsChart Query: "what users came from oauth providers?" -→ OAuth vs email cards, provider distribution chart (Google/GitHub/etc.) +→ OAuth vs email cards, provider distribution Recharts.PieChart (non-time-series) Query: "show me user growth over time" -→ Total users card, net-new in period card, growth rate card, line chart +→ Total users card, net-new in period card, growth rate card, AnalyticsChart (area) with compare layer showing previous period Query: "which teams have the most users?" → Total teams card, avg users per team card, bar chart of top teams +Query: "full analytics overview" +→ Total users card, growth rate card, verified % card, AnalyticsChart: signups over time + +──────────────────────────────────────── +PRE-EMIT CHECKLIST (RUN THIS IN YOUR HEAD BEFORE CALLING updateDashboard) +──────────────────────────────────────── +Before you call updateDashboard, silently walk through these four checks. If any fails, fix it +FIRST and re-run the list. + + [1] HOOK ORDER — Are all \`React.useState\` / \`React.useEffect\` / \`React.useCallback\` calls at + the top of the Dashboard component, before every \`if\` / early \`return\` / conditional? + If no, move them up. This prevents React error #310. Also check that any variable + referenced inside a hook initializer (e.g. \`useState(() => foo(columns))\`) is declared + ABOVE that hook — a TDZ error looks like a hook-order crash but isn't one. + + [2] DATA HONESTY — Does every field the code references actually exist in the SDK types or + ClickHouse schema shown in context? No made-up field names, no hardcoded sample arrays, + no \`Math.random()\` data. If something is missing, substitute or degrade — don't fabricate. + + [3] SCALE / TYPE MATCH — If the chart combines multiple metrics on one axis, are their ranges + actually compatible? If not, use a dual axis, a different chart, or split into two charts. + (This is the single most common "still not scaled well" failure mode.) + + [4] SEGMENT INTEGRITY — For every layer with \`segmented: true\`: + - segments.length === data.length (one row per Point) + - segments[i].length === segmentSeries.length (one value per category) + - segments[i] values sum to data[i].values[layerId] (rows sum to the layer total) + - If using explicit palette: palette light/dark arrays have same length as segmentSeries + Missing any of these → chart renders incorrectly (gaps, overflow, or wrong colors). + + [5] EMPTY / ERROR STATES — Does the code handle loading, error, AND empty-data paths with + \`DashboardUI.DesignSkeleton\` / \`DashboardUI.DesignEmptyState\` / a user-friendly error + message? A blank chart is a bug. + +All five pass → emit the tool call. Any fail → fix, re-check, emit. + You MUST call the updateDashboard tool with the complete source code. NEVER output code directly in the chat. `, @@ -817,42 +998,188 @@ You are helping users query their Stack Auth project's analytics data using Clic **Available Tables:** **events** - User activity events -- event_type: LowCardinality(String) - $token-refresh is the only valid event_type right now, it occurs whenever an access token is refreshed +- event_type: LowCardinality(String) - ONLY: $page-view, $click, $token-refresh - event_at: DateTime64(3, 'UTC') - When the event occurred -- data: JSON - Additional event data -- user_id: Nullable(String) - Associated user ID -- team_id: Nullable(String) - Associated team ID +- data: JSON - MUST use toString() before extracting: JSONExtractString(toString(data), 'key') +- user_id: Nullable(String) - Always populated (no nulls) +- team_id: Nullable(String) - Always NULL, never use - created_at: DateTime64(3, 'UTC') - When the record was created +Event data payloads: +- $page-view: {is_anonymous, path, referrer} +- $click: {is_anonymous, selector} +- $token-refresh: {is_anonymous, refresh_token_id, ip_info: {country_code, city_name, region_code, is_trusted, latitude, longitude, tz_identifier, ip}} + **users** - User profiles - id: UUID - User ID - display_name: Nullable(String) - User's display name - primary_email: Nullable(String) - User's primary email - primary_email_verified: UInt8 - Whether email is verified (0/1) - signed_up_at: DateTime64(3, 'UTC') - When user signed up -- client_metadata: JSON - Client-side metadata -- client_read_only_metadata: JSON - Read-only client metadata -- server_metadata: JSON - Server-side metadata +- client_metadata: JSON - Typically empty +- client_read_only_metadata: JSON - Typically empty +- server_metadata: JSON - Typically empty - is_anonymous: UInt8 - Whether user is anonymous (0/1) **SQL Query Guidelines:** - Only SELECT queries are allowed (no INSERT, UPDATE, DELETE) - Project filtering is automatic - you don't need WHERE project_id = ... +- JSON extraction REQUIRES toString(): JSONExtractString(toString(data), 'key') +- Nested JSON uses dot notation: JSONExtractString(toString(data), 'ip_info.country_code') - Always use LIMIT to avoid returning too many rows (default to LIMIT 100) -- Use appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. -- For counting, use COUNT(*) or COUNT(DISTINCT column) +- Use relative date ranges: now() - INTERVAL X DAY +- Use date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. +- For counting, use count() or count(DISTINCT column) **Example Queries:** -- Count users: \`SELECT COUNT(*) FROM users\` +- Count users: \`SELECT count() FROM users\` - Recent signups: \`SELECT * FROM users ORDER BY signed_up_at DESC LIMIT 10\` -- Events today: \`SELECT COUNT(*) FROM events WHERE toDate(event_at) = today()\` -- Event types: \`SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10\` +- Events today: \`SELECT count() FROM events WHERE toDate(event_at) = today()\` +- Page views by path: \`SELECT JSONExtractString(toString(data), 'path') as path, count() as views FROM events WHERE event_type = '$page-view' GROUP BY path ORDER BY views DESC LIMIT 20\` **Focus:** - Help users write efficient, correct ClickHouse SQL queries - Explain query results clearly - Suggest relevant queries based on user questions - Use the queryAnalytics tool to execute queries and return results +`, + + "build-analytics-query": ` +## Context: Analytics Query Builder + +You are a ClickHouse SQL expert helping the user build queries that drive a data grid on the Stack Auth analytics page. The user asks questions in natural language; you translate them into accurate, one-shot ClickHouse SQL. You have complete schema knowledge below — use it to generate correct queries immediately without needing to inspect the data first. + +**HARD RULE — how the tool works:** +Call \`queryAnalytics\` with your SQL query. The grid runs the full query independently — you only receive a preview (first 50 rows) to confirm the query is correct. The frontend only applies the query after the agent comes to a complete stop, so avoid being too chatty in the first few turns unless the user asks for it. +1. Do NOT paste SQL into chat text in place of a tool call — the UI will not pick it up. +2. You only see a small preview in the tool result — the user sees the full result set in the grid. +3. Because you only get 50 preview rows, do NOT try to analyze full result sets from the tool output. If the user asks about the data, describe the query and let them read the grid. +4. The grid wraps your query as a subquery: \`SELECT * FROM () LIMIT 50 OFFSET ...\` and paginates via infinite scroll. Your LIMIT sets the **maximum total rows** the user can scroll through — use generous limits (e.g. 1000 for aggregates) so the grid can paginate the full result. + +### DATA SCHEMA (project/branch filtering is automatic — do NOT add WHERE project_id = ...) + +**users** table: +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| display_name | Nullable(String) | Typically populated | +| primary_email | Nullable(String) | Usually present | +| primary_email_verified | UInt8 (0/1) | Primary user segmentation axis | +| signed_up_at | DateTime64(3, 'UTC') | High-resolution timestamp | +| is_anonymous | UInt8 (0/1) | Rare; mostly testing | +| client_metadata | JSON | Typically empty {} | +| server_metadata | JSON | Typically empty {} | +| client_read_only_metadata | JSON | Typically empty {} | +| restricted_by_admin | UInt8 (0/1) | Rare; administrative flag | + +Key insights: Metadata fields are sparse/empty — don't expect rich structures. Email verification is the primary segmentation. Anonymous users are negligible. + +**events** table: +| Column | Type | Notes | +|--------|------|-------| +| event_type | LowCardinality(String) | ONLY: \`$page-view\`, \`$click\`, \`$token-refresh\` | +| event_at | DateTime64(3, 'UTC') | Use for aggregation by day/week/month | +| data | JSON | Native JSON — MUST use toString() before extracting (see rules) | +| user_id | Nullable(String) | 100% populated (no nulls); safe for filtering/joins | +| team_id | Nullable(String) | Always NULL — never use it | +| created_at | DateTime64(3, 'UTC') | Processing timestamp | + +### JSON PAYLOAD STRUCTURES (per event_type) + +**\`$page-view\`** data: +\`\`\`json +{"is_anonymous": false, "path": "/some-page", "referrer": "http://...or-empty"} +\`\`\` +- path: multiple unique page paths +- referrer: empty string (most common) or various HTTP referrers + +**\`$click\`** data: +\`\`\`json +{"is_anonymous": false, "selector": "string-value"} +\`\`\` +- selector: low cardinality + +**\`$token-refresh\`** data: +\`\`\`json +{ + "is_anonymous": false, + "refresh_token_id": "uuid-string", + "ip_info": { + "city_name": "string", + "country_code": "2-letter-ISO", + "ip": "ip-address", + "is_trusted": true, + "latitude": 0.0, + "longitude": 0.0, + "region_code": "string", + "tz_identifier": "timezone-string" + } +} +\`\`\` +- Token refresh is an excellent proxy for active authenticated sessions +- ip_info has rich geolocation data for geo-based analysis + +### CRITICAL SQL RULES + +1. **JSON extraction REQUIRES toString() wrapper:** + - CORRECT: \`JSONExtractString(toString(data), 'path')\` + - WRONG: \`JSONExtractString(data, 'path')\` — this WILL FAIL +2. **Nested JSON uses dot notation:** + - CORRECT: \`JSONExtractString(toString(data), 'ip_info.country_code')\` + - WRONG: \`JSONExtractString(data, 'ip_info')['country_code']\` +3. SELECT queries only — no INSERT / UPDATE / DELETE / DDL +4. ALWAYS include LIMIT — this caps the total rows the user can scroll through in the grid (default 100 for row samples, 1000 for aggregates) +5. Use relative date ranges: \`now() - INTERVAL X DAY\` +6. team_id is always NULL — never filter on it +7. Metadata fields are almost always empty — safe to ignore +8. Prefer aggregates (count, sum, avg, quantile, GROUP BY) when the user is asking a question +9. Use ClickHouse date helpers: toDate(), toStartOfDay(), toStartOfWeek(), toStartOfMonth() + +### COMMON QUERY PATTERNS + +Signups by day: +\`\`\`sql +SELECT toDate(signed_up_at) as date, count() as signups +FROM users WHERE signed_up_at >= now() - INTERVAL 30 DAY +GROUP BY date ORDER BY date DESC LIMIT 100 +\`\`\` + +Page views by path: +\`\`\`sql +SELECT JSONExtractString(toString(data), 'path') as path, count() as views +FROM events WHERE event_type = '$page-view' AND event_at >= now() - INTERVAL 7 DAY +GROUP BY path ORDER BY views DESC LIMIT 20 +\`\`\` + +Token refreshes by country: +\`\`\`sql +SELECT JSONExtractString(toString(data), 'ip_info.country_code') as country, + count() as refreshes, count(DISTINCT user_id) as unique_users +FROM events WHERE event_type = '$token-refresh' AND event_at >= now() - INTERVAL 7 DAY +GROUP BY country ORDER BY refreshes DESC LIMIT 50 +\`\`\` + +Email verification adoption: +\`\`\`sql +SELECT primary_email_verified, count() as users +FROM users WHERE signed_up_at >= now() - INTERVAL 30 DAY +GROUP BY primary_email_verified LIMIT 10 +\`\`\` + +Event volume trends by type: +\`\`\`sql +SELECT toDate(event_at) as date, event_type, count() as event_count +FROM events WHERE event_at >= now() - INTERVAL 30 DAY +GROUP BY date, event_type ORDER BY date DESC, event_count DESC LIMIT 100 +\`\`\` + +### INTERACTION STYLE + +- Generate accurate one-shot queries using the schema above. Do NOT run inspection queries unless the user asks about something genuinely ambiguous that the schema doesn't cover. +- Keep chat messages short — the user sees the grid directly. +- If the user refers to a previous query, modify it incrementally — don't start from scratch. +- If \`queryAnalytics\` returns an error, adjust and retry. Do NOT invent columns or fabricate data. +- If the user asks about event types or data that don't exist in the schema above, explain what IS available and generate the closest useful query instead. `, "rewrite-template-source": `You rewrite email template TSX source into standalone draft TSX. diff --git a/apps/backend/src/lib/ai/schema.ts b/apps/backend/src/lib/ai/schema.ts index e27a5abe27..3da71d7b21 100644 --- a/apps/backend/src/lib/ai/schema.ts +++ b/apps/backend/src/lib/ai/schema.ts @@ -15,6 +15,7 @@ export const requestBodySchema = yupObject({ "email-assistant-draft", "create-dashboard", "run-query", + "build-analytics-query", "rewrite-template-source" ]).defined(), messages: yupArray( diff --git a/apps/backend/src/lib/ai/tools/sql-query.ts b/apps/backend/src/lib/ai/tools/sql-query.ts index 3025e03e90..37054d02b9 100644 --- a/apps/backend/src/lib/ai/tools/sql-query.ts +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -9,19 +9,21 @@ export const SQL_QUERY_RESULT_MAX_CHARS = 50_000; export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectId?: string | null) { if (auth == null) { - // Return null or throw - analytics queries require authentication return null; } const projectId = targetProjectId ?? auth.tenancy.project.id; const branchId = targetProjectId ? "main" : auth.tenancy.branchId; + // Max rows returned to the model (backstop if LIMIT is missing). + const MAX_ROWS_FOR_AI = 50; + return tool({ - description: "Run a ClickHouse SQL query against the project's analytics database. Only SELECT queries are allowed. Project filtering is automatic.", + description: `Set and validate a ClickHouse SQL query for the analytics data grid. The grid runs the full query independently — you only receive a preview of the first ${MAX_ROWS_FOR_AI} rows to confirm correctness. Only SELECT queries are allowed. Project filtering is automatic. Always include a LIMIT clause.`, inputSchema: z.object({ query: z .string() - .describe("The ClickHouse SQL query to execute. Only SELECT queries are allowed. Always include LIMIT clause."), + .describe("The ClickHouse SQL query to execute. Only SELECT queries are allowed. Always include a LIMIT clause unless the system prompt tells you to do otherwise."), }), execute: async ({ query }: { query: string }) => { const client = getClickhouseExternalClient(); @@ -41,7 +43,18 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI format: "JSONEachRow", }); const rows = await resultSet.json[]>(); - const response = { success: true as const, rowCount: rows.length, result: rows }; + const truncated = rows.length > MAX_ROWS_FOR_AI; + const returnedRows = truncated ? rows.slice(0, MAX_ROWS_FOR_AI) : rows; + const response = { + success: true as const, + rowCount: returnedRows.length, + totalRows: rows.length, + truncated, + ...(truncated + ? { truncationNote: `Only the first ${MAX_ROWS_FOR_AI} of ${rows.length} rows are shown. Add LIMIT or aggregate to see the rest.` } + : {}), + result: returnedRows, + }; const serialized = JSON.stringify(response); if (serialized.length > SQL_QUERY_RESULT_MAX_CHARS) { return { diff --git a/apps/backend/src/lib/metrics-activity-split.ts b/apps/backend/src/lib/metrics-activity-split.ts new file mode 100644 index 0000000000..f8e6eecf53 --- /dev/null +++ b/apps/backend/src/lib/metrics-activity-split.ts @@ -0,0 +1,244 @@ +import type { MetricsActivitySplit } from "@stackframe/stack-shared/dist/interface/admin-metrics"; + +export type ActivitySplit = MetricsActivitySplit; + +export function createEmptySplitSeries(days: string[]): ActivitySplit { + const emptySeries = days.map((date) => ({ date, activity: 0 })); + return { + total: emptySeries.map((item) => ({ ...item })), + new: emptySeries.map((item) => ({ ...item })), + retained: emptySeries.map((item) => ({ ...item })), + reactivated: emptySeries.map((item) => ({ ...item })), + }; +} + +/** + * Bucket each day's active entity ids into new / retained / reactivated counts. + * + * Classification rules (in order): + * 1. createdDay === current day → new + * 2. entity was active on the immediately previous day → retained + * 3. entity was active earlier in the window → reactivated + * 4. createdDay falls inside the window → new (gap-day case) + * 5. otherwise (created before window, or unknown) → reactivated + * (avoids inflating "new" for pre-existing entities first seen inside the window) + */ +export function buildSplitFromDailyEntitySets(options: { + orderedDays: string[], + entityIdsByDay: Map>, + createdDayByEntityId?: Map, +}): ActivitySplit { + const { orderedDays, entityIdsByDay, createdDayByEntityId } = options; + const split = createEmptySplitSeries(orderedDays); + const windowStart = orderedDays[0]; + const previouslySeen = new Set(); + let previousDaySet = new Set(); + + for (let i = 0; i < orderedDays.length; i += 1) { + const day = orderedDays[i]; + const currentDaySet = entityIdsByDay.get(day) ?? new Set(); + let newCount = 0; + let retainedCount = 0; + let reactivatedCount = 0; + + for (const entityId of currentDaySet) { + const createdDay = createdDayByEntityId?.get(entityId); + if (createdDay === day) { + newCount += 1; + } else if (previousDaySet.has(entityId)) { + retainedCount += 1; + } else if (previouslySeen.has(entityId)) { + reactivatedCount += 1; + } else if (createdDay != null && createdDay >= windowStart) { + // Created within the window on a different day, but not active on the + // immediately previous day — treat as new (gap-day case). + newCount += 1; + } else { + // Either created before the window started, or createdDay is unknown. + // Either way, we cannot legitimately bucket this as "new" — count as + // reactivated to avoid inflating new-user metrics for pre-existing + // entities first seen inside the window. + reactivatedCount += 1; + } + } + + split.total[i].activity = currentDaySet.size; + split.new[i].activity = newCount; + split.retained[i].activity = retainedCount; + split.reactivated[i].activity = reactivatedCount; + + for (const entityId of currentDaySet) { + previouslySeen.add(entityId); + } + previousDaySet = currentDaySet; + } + + return split; +} + +if (import.meta.vitest) { + const { test, expect, describe } = import.meta.vitest; + + // Three-day window for the simple cases below. + const days = ['2026-04-01', '2026-04-02', '2026-04-03']; + + describe("buildSplitFromDailyEntitySets", () => { + test("classifies a user active only today as new when their createdDay === today", () => { + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set()], + ['2026-04-02', new Set()], + ['2026-04-03', new Set(['user-a'])], + ]), + createdDayByEntityId: new Map([['user-a', '2026-04-03']]), + }); + expect(split.new.map((d) => d.activity)).toEqual([0, 0, 1]); + expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.total.map((d) => d.activity)).toEqual([0, 0, 1]); + }); + + test("classifies a user active on consecutive days as new on day 1 and retained on day 2", () => { + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set(['user-a'])], + ['2026-04-02', new Set(['user-a'])], + ['2026-04-03', new Set()], + ]), + createdDayByEntityId: new Map([['user-a', '2026-04-01']]), + }); + expect(split.new.map((d) => d.activity)).toEqual([1, 0, 0]); + expect(split.retained.map((d) => d.activity)).toEqual([0, 1, 0]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]); + }); + + test("classifies a user with a gap day as new then reactivated", () => { + // Active day 1, missing day 2, active day 3 → reactivated on day 3 + // because they were previously seen but not on the immediately previous day. + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set(['user-a'])], + ['2026-04-02', new Set()], + ['2026-04-03', new Set(['user-a'])], + ]), + createdDayByEntityId: new Map([['user-a', '2026-04-01']]), + }); + expect(split.new.map((d) => d.activity)).toEqual([1, 0, 0]); + expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 1]); + }); + + test("classifies a user created in-window with a gap before first activity as new (rule 4)", () => { + // createdDay is 2026-04-02 but they're not active on 2026-04-02. + // First active 2026-04-03 → bucket as new (created within window), + // not reactivated, because they have never been seen before. + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set()], + ['2026-04-02', new Set()], + ['2026-04-03', new Set(['user-a'])], + ]), + createdDayByEntityId: new Map([['user-a', '2026-04-02']]), + }); + expect(split.new.map((d) => d.activity)).toEqual([0, 0, 1]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]); + }); + + test("classifies a user created before the window as reactivated, never new (rule 5)", () => { + // createdDay is 2026-03-15 (before window). They are first seen on 2026-04-02. + // Should bucket as reactivated to avoid inflating new-user metrics with + // pre-existing entities that just happened to log in inside the window. + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set()], + ['2026-04-02', new Set(['user-a'])], + ['2026-04-03', new Set(['user-a'])], + ]), + createdDayByEntityId: new Map([['user-a', '2026-03-15']]), + }); + expect(split.new.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 1]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 1, 0]); + }); + + test("treats unknown createdDay as 'never new' (rule 5 fallback)", () => { + // user-a is active in the window but has no createdDay record. The + // function should not bucket them as new — falls into reactivated. + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set()], + ['2026-04-02', new Set(['user-a'])], + ['2026-04-03', new Set()], + ]), + createdDayByEntityId: new Map(), + }); + expect(split.new.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 1, 0]); + }); + + test("handles multiple users with different classification on the same day", () => { + // Day 3: + // - user-a created day 3 → new + // - user-b active day 2 + day 3 → retained + // - user-c active day 1 then day 3 (gap) → reactivated + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map([ + ['2026-04-01', new Set(['user-c'])], + ['2026-04-02', new Set(['user-b'])], + ['2026-04-03', new Set(['user-a', 'user-b', 'user-c'])], + ]), + createdDayByEntityId: new Map([ + ['user-a', '2026-04-03'], + ['user-b', '2026-04-02'], + ['user-c', '2026-04-01'], + ]), + }); + expect(split.total[2].activity).toBe(3); + expect(split.new[2].activity).toBe(1); // user-a + expect(split.retained[2].activity).toBe(1); // user-b + expect(split.reactivated[2].activity).toBe(1); // user-c + }); + + test("returns all-zero series when no entities are active", () => { + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map(), + createdDayByEntityId: new Map(), + }); + expect(split.total.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.new.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.retained.map((d) => d.activity)).toEqual([0, 0, 0]); + expect(split.reactivated.map((d) => d.activity)).toEqual([0, 0, 0]); + }); + + test("preserves the orderedDays date list across all four series", () => { + const split = buildSplitFromDailyEntitySets({ + orderedDays: days, + entityIdsByDay: new Map(), + }); + const dateList = days; + expect(split.total.map((d) => d.date)).toEqual(dateList); + expect(split.new.map((d) => d.date)).toEqual(dateList); + expect(split.retained.map((d) => d.date)).toEqual(dateList); + expect(split.reactivated.map((d) => d.date)).toEqual(dateList); + }); + }); + + describe("createEmptySplitSeries", () => { + test("returns independent series objects (not aliased)", () => { + const split = createEmptySplitSeries(['2026-04-01', '2026-04-02']); + split.new[0].activity = 5; + // Mutating .new should not bleed into .total/.retained/.reactivated. + expect(split.total[0].activity).toBe(0); + expect(split.retained[0].activity).toBe(0); + expect(split.reactivated[0].activity).toBe(0); + }); + }); +} diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index b9e3f85e21..8000166ef5 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -16,9 +16,50 @@ import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects'; import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; +import { createHash } from 'node:crypto'; const EXPLORATORY_TEAM_DISPLAY_NAME = 'Exploratory Research and Insight Partnership With Very Long Collaborative Name For Testing'; +/** + * Derive a stable v4-shaped UUID from a namespaced string so seed re-runs + * upsert into existing rows instead of creating duplicates. + */ +function deterministicUuid(namespace: string): string { + const hex = createHash('sha256').update(namespace).digest('hex'); + const a = hex.slice(0, 8); + const b = hex.slice(8, 12); + const c = '4' + hex.slice(13, 16); + const d = ((parseInt(hex.slice(16, 17), 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20); + const e = hex.slice(20, 32); + return `${a}-${b}-${c}-${d}-${e}`; +} + +/** Mulberry32 — small, fast, deterministic PRNG. */ +function deterministicPrng(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6D2B79F5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Convert a string into a deterministic 32-bit seed for `deterministicPrng`. */ +function seedFromString(input: string): number { + const hex = createHash('sha256').update(input).digest('hex').slice(0, 8); + return parseInt(hex, 16) >>> 0; +} + +function daysAgo(d: number, h: number = 12): Date { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - d); + date.setHours(h, 0, 0, 0); + return date; +} + // ============= Types ============= type TeamSeed = { @@ -39,6 +80,7 @@ type UserSeed = { primaryEmailVerified: boolean, isAnonymous: boolean, oauthProviders: UserSeedOauthProvider[], + createdAt?: Date, }; type SeedDummyTeamsOptions = { @@ -88,6 +130,17 @@ type SessionActivityEventSeedOptions = { userEmailToId: Map, }; +type BulkActivityRegion = { + country: string, + region: string, + city: string, + lat: number, + lon: number, + tz: string, + weight: number, + ipPrefix: string, +}; + type SeedDummyProjectOptions = { projectId?: string, ownerTeamId: string, @@ -119,6 +172,7 @@ const userSeeds: UserSeed[] = [ oauthProviders: [ { providerId: 'github', accountId: 'amelia-chen-gh' }, ], + createdAt: daysAgo(28, 9), }, { email: 'leo.park@dummy.dev', @@ -126,6 +180,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: false, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(28, 15), }, { displayName: 'Some-long-display-name with-middle-name with-last-name', @@ -137,6 +192,7 @@ const userSeeds: UserSeed[] = [ { providerId: 'google', accountId: 'isla-rodriguez-google' }, { providerId: 'microsoft', accountId: 'isla-rodriguez-msft' }, ], + createdAt: daysAgo(25, 10), }, { displayName: 'Al', @@ -145,6 +201,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: true, oauthProviders: [], + createdAt: daysAgo(25, 16), }, { displayName: 'Priya Narang', @@ -155,6 +212,7 @@ const userSeeds: UserSeed[] = [ oauthProviders: [ { providerId: 'spotify', accountId: 'priya-narang-spotify' }, ], + createdAt: daysAgo(23, 8), }, { displayName: 'Jonas Richter', @@ -164,6 +222,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(21, 14), }, { displayName: 'Chioma Mensah', @@ -175,6 +234,7 @@ const userSeeds: UserSeed[] = [ oauthProviders: [ { providerId: 'google', accountId: 'chioma-mensah-google' }, ], + createdAt: daysAgo(21, 17), }, { displayName: 'Nia Holloway', @@ -183,6 +243,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(18, 11), }, { displayName: 'Mateo Silva', @@ -193,6 +254,7 @@ const userSeeds: UserSeed[] = [ oauthProviders: [ { providerId: 'github', accountId: 'mateo-silva-gh' }, ], + createdAt: daysAgo(15, 9), }, { displayName: 'Harper Lin', @@ -201,6 +263,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(12, 13), }, { displayName: 'Zara Malik', @@ -210,6 +273,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(9, 10), }, { displayName: 'Luca Bennett', @@ -218,6 +282,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: false, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(6, 16), }, { displayName: 'Evelyn Brooks', @@ -227,6 +292,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(4, 8), }, { displayName: 'Theo Fischer', @@ -238,6 +304,7 @@ const userSeeds: UserSeed[] = [ oauthProviders: [ { providerId: 'microsoft', accountId: 'theo-fischer-msft' }, ], + createdAt: daysAgo(3, 11), }, { email: 'naomi.patel@dummy.dev', @@ -245,6 +312,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: false, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(1, 9), }, { displayName: 'Kai Romero', @@ -253,6 +321,7 @@ const userSeeds: UserSeed[] = [ primaryEmailVerified: true, isAnonymous: false, oauthProviders: [], + createdAt: daysAgo(1, 15), }, ]; @@ -272,6 +341,16 @@ const DUMMY_SEED_IDS = { ameliaSeatPack: '0b696a83-c54e-4a74-ae47-3ac5a4db49e6', launchCouncilUpfront: '10766081-37fd-410c-8b2e-1c3351e2d364', }, + invoices: { + growthMonthly1: 'e1a2b3c4-d5e6-4f78-9a0b-1c2d3e4f5a60', + growthMonthly2: 'f2b3c4d5-e6f7-4890-ab1c-2d3e4f5a6b71', + growthMonthly3: 'a3c4d5e6-f7a8-4901-bc2d-3e4f5a6b7c82', + growthMonthly4: 'b4d5e6f7-a8b9-4012-cd3e-4f5a6b7c8d93', + growthMonthly5: 'c5e6f7a8-b9c0-4123-de4f-5a6b7c8d9ea4', + starterCreation: 'd6f7a8b9-c0d1-4234-ef50-6a7b8c9d0fb5', + legacyPaid1: 'e7a8b9c0-d1e2-4345-a061-7b8c9d0e1ac6', + legacyPaid2: 'f8b9c0d1-e2f3-4456-b172-8c9d0e1f2bd7', + }, emails: { welcomeAmelia: 'af8cfd90-8912-4bf7-93a7-20ff2be54767', passkeyMilo: 'd534d777-5aa2-4014-a198-6484bbadcbf2', @@ -355,6 +434,13 @@ async function seedDummyUsers(options: SeedDummyUsersOptions): Promise { + bulkSeed = (bulkSeed * 1664525 + 1013904223) & 0x7fffffff; + return bulkSeed / 0x7fffffff; + }; + + // Per-day sign-up counts (day 0 = 30 days ago, day 29 = yesterday) + // Pattern: gradual growth with realistic variance and weekend dips + const dailySignUpCounts = [ + 1, 0, 2, 1, 3, 0, 1, // week 1 (low, starting out) + 2, 3, 1, 2, 4, 1, 0, // week 2 (picking up) + 3, 2, 4, 3, 2, 5, 1, // week 3 (steady growth) + 4, 3, 5, 2, 6, 3, 2, 4, // week 4+ (peak recent activity) + ]; + + let bulkIndex = 0; + for (let dayOffset = 0; dayOffset < dailySignUpCounts.length; dayOffset++) { + const count = dailySignUpCounts[dayOffset]; + const dayBack = dailySignUpCounts.length - dayOffset; + + for (let j = 0; j < count; j++) { + const fnIdx = Math.floor(bulkRand() * bulkFirstNames.length); + const lnIdx = Math.floor(bulkRand() * bulkLastNames.length); + const firstName = bulkFirstNames[fnIdx]!; + const lastName = bulkLastNames[lnIdx]!; + const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}.bulk${bulkIndex}@dummy.dev`; + const displayName = `${firstName} ${lastName}`; + const hour = 8 + Math.floor(bulkRand() * 12); + const bulkCreatedAt = daysAgo(dayBack, hour); + const hasOauth = bulkRand() > 0.6; + const oauthProvider = hasOauth + ? [{ providerId: bulkOauthProviders[Math.floor(bulkRand() * bulkOauthProviders.length)]!, accountId: `${email}-oauth` }] + : []; + + const existing = await prisma.projectUser.findFirst({ + where: { + tenancyId: tenancy.id, + contactChannels: { some: { type: 'EMAIL', value: email } }, + }, + select: { projectUserId: true }, + }); + + let bulkUserId: string; + if (!existing) { + const created = await usersCrudHandlers.adminCreate({ + tenancy, + data: { + display_name: displayName, + primary_email: email, + primary_email_auth_enabled: true, + primary_email_verified: bulkRand() > 0.3, + otp_auth_enabled: false, + is_anonymous: false, + oauth_providers: oauthProvider.map((p) => ({ + id: p.providerId, + account_id: p.accountId, + email, + })), + profile_image_url: null, + }, + }); + bulkUserId = created.id; + } else { + bulkUserId = existing.projectUserId; + } + await prisma.projectUser.updateMany({ + where: { tenancyId: tenancy.id, projectUserId: bulkUserId }, + data: { createdAt: bulkCreatedAt }, + }); + userEmailToId.set(email, bulkUserId); + + bulkIndex++; + } + } + return userEmailToId; } @@ -452,6 +630,31 @@ function buildDummyPaymentsSetup(): PaymentsSetup { }, }, }, + 'regression-addon': { + displayName: 'Regression Add-on', + productLineId: 'add_ons', + customerType: 'user', + serverOnly: false, + stackable: true, + prices: { + monthly: { + USD: '199', + interval: monthlyInterval as any, + serverOnly: false, + }, + }, + includedItems: { + snapshot_credits: { + quantity: 500, + repeat: monthlyInterval as any, + expires: 'when-repeated', + }, + }, + isAddOnTo: { + 'starter': true, + 'growth': true, + }, + }, }; const paymentsBranchOverride = { @@ -460,6 +663,10 @@ function buildDummyPaymentsSetup(): PaymentsSetup { displayName: 'Workspace Plans', customerType: 'team', }, + add_ons: { + displayName: 'Add-ons', + customerType: 'team', + }, }, items: { studio_seats: { @@ -474,6 +681,10 @@ function buildDummyPaymentsSetup(): PaymentsSetup { displayName: 'Automation Minutes', customerType: 'user', }, + snapshot_credits: { + displayName: 'Snapshot Credits', + customerType: 'user', + }, }, products: paymentsProducts, }; @@ -788,6 +999,116 @@ async function seedDummyTransactions(options: TransactionsSeedOptions) { }, }); } + + type InvoiceSeed = { + id: string, + stripeSubscriptionId: string, + stripeInvoiceId: string, + isSubscriptionCreationInvoice: boolean, + status: string, + amountTotal: number, + createdAt: Date, + }; + + const invoiceSeeds: InvoiceSeed[] = [ + { + id: DUMMY_SEED_IDS.invoices.growthMonthly1, + stripeSubscriptionId: 'sub_growth_designsystems', + stripeInvoiceId: 'in_growth_ds_001', + isSubscriptionCreationInvoice: true, + status: 'paid', + amountTotal: 12900, + createdAt: daysAgo(25, 10), + }, + { + id: DUMMY_SEED_IDS.invoices.growthMonthly2, + stripeSubscriptionId: 'sub_growth_designsystems', + stripeInvoiceId: 'in_growth_ds_002', + isSubscriptionCreationInvoice: false, + status: 'paid', + amountTotal: 12900, + createdAt: daysAgo(18, 10), + }, + { + id: DUMMY_SEED_IDS.invoices.growthMonthly3, + stripeSubscriptionId: 'sub_growth_designsystems', + stripeInvoiceId: 'in_growth_ds_003', + isSubscriptionCreationInvoice: false, + status: 'paid', + amountTotal: 12900, + createdAt: daysAgo(11, 10), + }, + { + id: DUMMY_SEED_IDS.invoices.growthMonthly4, + stripeSubscriptionId: 'sub_growth_designsystems', + stripeInvoiceId: 'in_growth_ds_004', + isSubscriptionCreationInvoice: false, + status: 'paid', + amountTotal: 12900, + createdAt: daysAgo(4, 10), + }, + { + id: DUMMY_SEED_IDS.invoices.growthMonthly5, + stripeSubscriptionId: 'sub_growth_designsystems', + stripeInvoiceId: 'in_growth_ds_005', + isSubscriptionCreationInvoice: false, + status: 'succeeded', + amountTotal: 15900, + createdAt: daysAgo(1, 14), + }, + { + id: DUMMY_SEED_IDS.invoices.starterCreation, + stripeSubscriptionId: 'sub_starter_prototype', + stripeInvoiceId: 'in_starter_proto_001', + isSubscriptionCreationInvoice: true, + status: 'paid', + amountTotal: 0, + createdAt: daysAgo(20, 8), + }, + { + id: DUMMY_SEED_IDS.invoices.legacyPaid1, + stripeSubscriptionId: 'sub_legacy_enterprise_alpha', + stripeInvoiceId: 'in_legacy_ent_001', + isSubscriptionCreationInvoice: true, + status: 'paid', + amountTotal: 49900, + createdAt: daysAgo(28, 9), + }, + { + id: DUMMY_SEED_IDS.invoices.legacyPaid2, + stripeSubscriptionId: 'sub_legacy_enterprise_alpha', + stripeInvoiceId: 'in_legacy_ent_002', + isSubscriptionCreationInvoice: false, + status: 'paid', + amountTotal: 49900, + createdAt: daysAgo(14, 9), + }, + ]; + + for (const invoice of invoiceSeeds) { + await prisma.subscriptionInvoice.upsert({ + where: { + tenancyId_id: { + tenancyId, + id: invoice.id, + }, + }, + update: { + status: invoice.status, + amountTotal: invoice.amountTotal, + }, + create: { + tenancyId, + id: invoice.id, + stripeSubscriptionId: invoice.stripeSubscriptionId, + stripeInvoiceId: invoice.stripeInvoiceId, + isSubscriptionCreationInvoice: invoice.isSubscriptionCreationInvoice, + status: invoice.status, + amountTotal: invoice.amountTotal, + createdAt: invoice.createdAt, + }, + }); + } } async function seedDummyEmails(options: EmailSeedOptions) { @@ -907,134 +1228,491 @@ const sessionActivityLocations = [ { countryCode: 'CH', regionCode: 'ZH', cityName: 'Zurich', latitude: 47.3769, longitude: 8.5417, tzIdentifier: 'Europe/Zurich' }, ]; +// ── Bulk activity seed fixtures ───────────────────────────────────────────── + +const BULK_ACTIVITY_REGIONS: BulkActivityRegion[] = [ + // North America + { country: 'US', region: 'CA', city: 'San Francisco', lat: 37.7749, lon: -122.4194, tz: 'America/Los_Angeles', weight: 18, ipPrefix: '104.16' }, + { country: 'US', region: 'NY', city: 'New York', lat: 40.7128, lon: -74.0060, tz: 'America/New_York', weight: 14, ipPrefix: '23.56' }, + { country: 'US', region: 'TX', city: 'Austin', lat: 30.2672, lon: -97.7431, tz: 'America/Chicago', weight: 6, ipPrefix: '68.54' }, + { country: 'US', region: 'WA', city: 'Seattle', lat: 47.6062, lon: -122.3321, tz: 'America/Los_Angeles', weight: 5, ipPrefix: '52.10' }, + { country: 'CA', region: 'ON', city: 'Toronto', lat: 43.6532, lon: -79.3832, tz: 'America/Toronto', weight: 5, ipPrefix: '99.240' }, + { country: 'CA', region: 'BC', city: 'Vancouver', lat: 49.2827, lon: -123.1207, tz: 'America/Vancouver', weight: 3, ipPrefix: '206.75' }, + { country: 'MX', region: 'CMX', city: 'Mexico City', lat: 19.4326, lon: -99.1332, tz: 'America/Mexico_City', weight: 2, ipPrefix: '189.148' }, + // Europe + { country: 'GB', region: 'ENG', city: 'London', lat: 51.5074, lon: -0.1278, tz: 'Europe/London', weight: 10, ipPrefix: '90.196' }, + { country: 'DE', region: 'BE', city: 'Berlin', lat: 52.5200, lon: 13.4050, tz: 'Europe/Berlin', weight: 7, ipPrefix: '91.64' }, + { country: 'FR', region: 'IDF', city: 'Paris', lat: 48.8566, lon: 2.3522, tz: 'Europe/Paris', weight: 5, ipPrefix: '82.64' }, + { country: 'NL', region: 'NH', city: 'Amsterdam', lat: 52.3676, lon: 4.9041, tz: 'Europe/Amsterdam', weight: 3, ipPrefix: '145.14' }, + { country: 'ES', region: 'MD', city: 'Madrid', lat: 40.4168, lon: -3.7038, tz: 'Europe/Madrid', weight: 3, ipPrefix: '85.55' }, + { country: 'IT', region: 'LAZ', city: 'Rome', lat: 41.9028, lon: 12.4964, tz: 'Europe/Rome', weight: 2, ipPrefix: '93.41' }, + { country: 'PL', region: 'MZ', city: 'Warsaw', lat: 52.2297, lon: 21.0122, tz: 'Europe/Warsaw', weight: 2, ipPrefix: '178.42' }, + { country: 'SE', region: 'AB', city: 'Stockholm', lat: 59.3293, lon: 18.0686, tz: 'Europe/Stockholm', weight: 2, ipPrefix: '81.229' }, + { country: 'IE', region: 'D', city: 'Dublin', lat: 53.3498, lon: -6.2603, tz: 'Europe/Dublin', weight: 2, ipPrefix: '185.2' }, + // Asia-Pacific + { country: 'IN', region: 'KA', city: 'Bangalore', lat: 12.9716, lon: 77.5946, tz: 'Asia/Kolkata', weight: 9, ipPrefix: '157.48' }, + { country: 'IN', region: 'MH', city: 'Mumbai', lat: 19.0760, lon: 72.8777, tz: 'Asia/Kolkata', weight: 4, ipPrefix: '14.140' }, + { country: 'JP', region: '13', city: 'Tokyo', lat: 35.6762, lon: 139.6503, tz: 'Asia/Tokyo', weight: 5, ipPrefix: '126.209' }, + { country: 'SG', region: '01', city: 'Singapore', lat: 1.3521, lon: 103.8198, tz: 'Asia/Singapore', weight: 3, ipPrefix: '165.21' }, + { country: 'AU', region: 'NSW', city: 'Sydney', lat: -33.8688, lon: 151.2093, tz: 'Australia/Sydney', weight: 3, ipPrefix: '203.2' }, + { country: 'KR', region: '11', city: 'Seoul', lat: 37.5665, lon: 126.9780, tz: 'Asia/Seoul', weight: 2, ipPrefix: '211.34' }, + { country: 'CN', region: 'SH', city: 'Shanghai', lat: 31.2304, lon: 121.4737, tz: 'Asia/Shanghai', weight: 2, ipPrefix: '114.88' }, + { country: 'ID', region: 'JK', city: 'Jakarta', lat: -6.2088, lon: 106.8456, tz: 'Asia/Jakarta', weight: 1, ipPrefix: '103.47' }, + // South America / MEA + { country: 'BR', region: 'SP', city: 'São Paulo', lat: -23.5505, lon: -46.6333, tz: 'America/Sao_Paulo', weight: 3, ipPrefix: '177.66' }, + { country: 'AR', region: 'C', city: 'Buenos Aires', lat: -34.6037, lon: -58.3816, tz: 'America/Argentina/Buenos_Aires', weight: 1, ipPrefix: '181.45' }, + { country: 'ZA', region: 'GT', city: 'Johannesburg', lat: -26.2041, lon: 28.0473, tz: 'Africa/Johannesburg', weight: 1, ipPrefix: '41.76' }, + { country: 'AE', region: 'DU', city: 'Dubai', lat: 25.2048, lon: 55.2708, tz: 'Asia/Dubai', weight: 1, ipPrefix: '94.200' }, + { country: 'NG', region: 'LA', city: 'Lagos', lat: 6.5244, lon: 3.3792, tz: 'Africa/Lagos', weight: 1, ipPrefix: '102.89' }, +]; + +const BULK_ACTIVITY_REGION_WEIGHT_TOTAL = BULK_ACTIVITY_REGIONS.reduce((sum, r) => sum + r.weight, 0); + +function pickBulkActivityRegion(rand: () => number): BulkActivityRegion { + const roll = rand() * BULK_ACTIVITY_REGION_WEIGHT_TOTAL; + let acc = 0; + for (const r of BULK_ACTIVITY_REGIONS) { + acc += r.weight; + if (roll < acc) return r; + } + return BULK_ACTIVITY_REGIONS[BULK_ACTIVITY_REGIONS.length - 1]!; +} + +const BULK_FIRST_NAMES = [ + 'Alex', 'Jordan', 'Taylor', 'Morgan', 'Riley', 'Quinn', 'Avery', 'Dakota', + 'Casey', 'Hayden', 'Cameron', 'Rowan', 'Sage', 'Blake', 'Emery', 'Skyler', + 'Reese', 'Peyton', 'Eden', 'Finley', 'Kendall', 'Aubrey', 'Drew', 'Jesse', + 'Parker', 'Robin', 'Sydney', 'River', 'Harley', 'Milan', 'Aarav', 'Yuki', + 'Mateo', 'Nia', 'Omar', 'Priya', 'Kai', 'Luca', 'Zara', 'Ines', 'Noa', +]; +const BULK_LAST_NAMES = [ + 'Kim', 'Liu', 'Patel', 'Garcia', 'Brown', 'Davis', 'Wilson', 'Martinez', + 'Anderson', 'Thomas', 'Jackson', 'White', 'Harris', 'Clark', 'Lewis', + 'Robinson', 'Walker', 'Young', 'Allen', 'Scott', 'Adams', 'Nelson', 'Hill', + 'Moore', 'Hall', 'King', 'Wright', 'Green', 'Baker', 'Turner', 'Okafor', + 'Suzuki', 'Schneider', 'Dubois', 'Rossi', 'Nakamura', 'Silva', 'Ivanov', +]; +const BULK_OAUTH_PROVIDERS = ['google', 'github', 'microsoft']; + +const BULK_REFERRERS = [ + { url: 'https://www.google.com/', weight: 32 }, + { url: 'https://github.com/', weight: 18 }, + { url: 'https://twitter.com/', weight: 12 }, + { url: 'https://www.producthunt.com/', weight: 8 }, + { url: '', weight: 20 }, // direct traffic + { url: 'https://news.ycombinator.com/', weight: 6 }, + { url: 'https://www.reddit.com/', weight: 4 }, +]; +const BULK_REFERRER_WEIGHT_TOTAL = BULK_REFERRERS.reduce((sum, r) => sum + r.weight, 0); + +function pickBulkReferrer(rand: () => number): string { + const roll = rand() * BULK_REFERRER_WEIGHT_TOTAL; + let acc = 0; + for (const r of BULK_REFERRERS) { + acc += r.weight; + if (roll < acc) return r.url; + } + return ''; +} + +const BULK_PAGE_PATHS = [ + '/', '/pricing', '/docs', '/docs/getting-started', '/docs/api-reference', + '/blog', '/blog/announcing-v2', '/about', '/contact', '/changelog', + '/dashboard', '/settings', '/settings/profile', '/settings/billing', + '/integrations', '/features', '/enterprise', +]; + +function bulkFakeIp(prefix: string, rand: () => number): string { + const c = Math.floor(rand() * 256); + const d = Math.floor(rand() * 254) + 1; + return `${prefix}.${c}.${d}`; +} + +function bulkRandomTimestampOnDay(now: Date, daysAgo: number, rand: () => number): Date { + const ts = new Date(now); + ts.setUTCDate(ts.getUTCDate() - daysAgo); + const hour = 8 + Math.floor(rand() * 14); + ts.setUTCHours(hour, Math.floor(rand() * 60), Math.floor(rand() * 60), Math.floor(rand() * 1000)); + return ts; +} + +function distributeBulkSignups(count: number, days: number, rand: () => number, now: Date): number[] { + const dayWeights: number[] = []; + for (let d = 0; d < days; d++) { + const ramp = 0.5 + (d / Math.max(1, days - 1)); + const jitter = 0.75 + rand() * 0.5; + const date = new Date(now); + date.setUTCDate(date.getUTCDate() - (days - 1 - d)); + const dow = date.getUTCDay(); + const weekend = (dow === 0 || dow === 6) ? 0.65 : 1.0; + dayWeights.push(ramp * jitter * weekend); + } + const total = dayWeights.reduce((a, b) => a + b, 0); + const offsets: number[] = []; + for (let d = 0; d < days; d++) { + const share = Math.round((dayWeights[d]! / total) * count); + const daysAgoOffset = days - 1 - d; + for (let i = 0; i < share; i++) offsets.push(daysAgoOffset); + } + while (offsets.length < count) offsets.push(Math.floor(rand() * days)); + while (offsets.length > count) offsets.pop(); + return offsets; +} + +function formatClickhouseTimestamp(date: Date): string { + return date.toISOString().replace('T', ' ').slice(0, 23); +} + async function seedDummySessionActivityEvents(options: SessionActivityEventSeedOptions) { const { tenancyId, projectId, userEmailToId } = options; - const now = new Date(); - const twoMonthsAgo = new Date(now); + // Anchor on midnight today so the seeded window is stable across re-runs + // within the same day. Across days the window legitimately shifts forward. + const todayUtc = new Date(); + todayUtc.setUTCHours(0, 0, 0, 0); + const twoMonthsAgo = new Date(todayUtc); twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); + const windowMs = todayUtc.getTime() - twoMonthsAgo.getTime(); const userEmails = Array.from(userEmailToId.keys()); - const ipInfoBatch: Prisma.EventIpInfoCreateManyInput[] = []; - const eventBatch: Prisma.EventCreateManyInput[] = []; - const clickhouseBatch: Array<{ - event_type: string, - event_at: Date, - data: Record, - project_id: string, - branch_id: string, - user_id: string | null, - team_id: string | null, - refresh_token_id: string | null, - session_replay_id: string | null, - session_replay_segment_id: string | null, - }> = []; + console.log(`Seeding session activity events for ${userEmails.length} users...`); for (const email of userEmails) { const userId = userEmailToId.get(email); if (!userId) continue; - const eventCount = 15 + Math.floor(Math.random() * 11); + // Per-user seeded PRNG so event count, timestamps, and locations are + // deterministic across re-runs. Deterministic IDs mean upserts hit the + // same rows instead of duplicating them. + const userRand = deterministicPrng(seedFromString(`session-events:${tenancyId}:${userId}`)); + const eventCount = 15 + Math.floor(userRand() * 11); // 15-25 events for (let i = 0; i < eventCount; i++) { - const randomTime = new Date( - twoMonthsAgo.getTime() + Math.random() * (now.getTime() - twoMonthsAgo.getTime()) - ); - - const location = sessionActivityLocations[Math.floor(Math.random() * sessionActivityLocations.length)]; - const sessionId = `session-${userId.substring(0, 8)}-${i.toString().padStart(3, '0')}-${randomTime.getTime().toString(36)}`; - const ipAddress = `${10 + Math.floor(Math.random() * 200)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}`; - const refreshTokenId = `seed-refresh-${generateUuid()}`; - - const ipInfoId = generateUuid(); - const eventId = generateUuid(); - - ipInfoBatch.push({ - id: ipInfoId, - ip: ipAddress, - countryCode: location.countryCode, - regionCode: location.regionCode, - cityName: location.cityName, - latitude: location.latitude, - longitude: location.longitude, - tzIdentifier: location.tzIdentifier, - createdAt: randomTime, - updatedAt: randomTime, + const randomTime = new Date(twoMonthsAgo.getTime() + userRand() * windowMs); + const location = sessionActivityLocations[Math.floor(userRand() * sessionActivityLocations.length)]!; + const sessionId = `session-${userId.substring(0, 8)}-${i.toString().padStart(3, '0')}`; + const ipAddress = `${10 + Math.floor(userRand() * 200)}.${Math.floor(userRand() * 256)}.${Math.floor(userRand() * 256)}.${Math.floor(userRand() * 256)}`; + const refreshTokenId = deterministicUuid(`session-events-refresh-token:${tenancyId}:${userId}:${i}`); + + const ipInfoId = deterministicUuid(`event-ip-info:${tenancyId}:${userId}:${i}`); + const eventId = deterministicUuid(`event:${tenancyId}:${userId}:${i}`); + + await globalPrismaClient.eventIpInfo.upsert({ + where: { id: ipInfoId }, + update: { + ip: ipAddress, + countryCode: location.countryCode, + regionCode: location.regionCode, + cityName: location.cityName, + latitude: location.latitude, + longitude: location.longitude, + tzIdentifier: location.tzIdentifier, + updatedAt: randomTime, + }, + create: { + id: ipInfoId, + ip: ipAddress, + countryCode: location.countryCode, + regionCode: location.regionCode, + cityName: location.cityName, + latitude: location.latitude, + longitude: location.longitude, + tzIdentifier: location.tzIdentifier, + createdAt: randomTime, + updatedAt: randomTime, + }, }); - eventBatch.push({ - id: eventId, - systemEventTypeIds: ['$session-activity', '$user-activity', '$project-activity', '$project'], - data: { - projectId, - branchId: DEFAULT_BRANCH_ID, - userId, - sessionId, - isAnonymous: false, + await globalPrismaClient.event.upsert({ + where: { id: eventId }, + update: { + systemEventTypeIds: ['$session-activity', '$user-activity', '$project-activity', '$project'], + data: { + projectId, + branchId: DEFAULT_BRANCH_ID, + userId, + sessionId, + isAnonymous: false, + }, + isEndUserIpInfoGuessTrusted: true, + endUserIpInfoGuessId: ipInfoId, + isWide: false, + eventStartedAt: randomTime, + eventEndedAt: randomTime, + updatedAt: randomTime, + }, + create: { + id: eventId, + systemEventTypeIds: ['$session-activity', '$user-activity', '$project-activity', '$project'], + data: { + projectId, + branchId: DEFAULT_BRANCH_ID, + userId, + sessionId, + isAnonymous: false, + }, + isEndUserIpInfoGuessTrusted: true, + endUserIpInfoGuessId: ipInfoId, + isWide: false, + eventStartedAt: randomTime, + eventEndedAt: randomTime, + createdAt: randomTime, + updatedAt: randomTime, }, - isEndUserIpInfoGuessTrusted: true, - endUserIpInfoGuessId: ipInfoId, - isWide: false, - eventStartedAt: randomTime, - eventEndedAt: randomTime, - createdAt: randomTime, - updatedAt: randomTime, }); // Also create $token-refresh events for ClickHouse (used by globe + analytics) - clickhouseBatch.push({ + const clickhouseUrl = getEnvVariable("STACK_CLICKHOUSE_URL", ""); + if (clickhouseUrl) { + const clickhouseClient = getClickhouseAdminClient(); + await clickhouseClient.insert({ + table: "analytics_internal.events", + values: [{ + event_type: '$token-refresh', + event_at: randomTime, + data: { + refresh_token_id: refreshTokenId, + is_anonymous: false, + ip_info: { + ip: ipAddress, + is_trusted: true, + country_code: location.countryCode, + region_code: location.regionCode, + city_name: location.cityName, + latitude: location.latitude, + longitude: location.longitude, + tz_identifier: location.tzIdentifier, + }, + }, + project_id: projectId, + branch_id: DEFAULT_BRANCH_ID, + user_id: userId, + team_id: null, + refresh_token_id: refreshTokenId, + session_replay_id: null, + session_replay_segment_id: null, + }], + format: "JSONEachRow", + clickhouse_settings: { + date_time_input_format: "best_effort", + }, + }); + } + } + } + + console.log('Finished seeding session activity events'); +} + +/** + * Seeds the dummy project with a bulk batch of fake user sign-ups and + * realistic activity data spread across recent history and various + * geographic regions. Populates: + * + * 1. ProjectUser rows with back-dated signedUpAt/createdAt + * 2. $token-refresh events in ClickHouse with geolocated ip_info + * 3. $page-view events in ClickHouse for daily visitors/page views/referrers + * 4. $click events in ClickHouse for the clicks chart + */ +async function seedBulkSignupsAndActivity(options: { + tenancy: Tenancy, + prisma: PrismaClientTransaction, + count?: number, + days?: number, +}) { + const count = options.count ?? 500; + const days = options.days ?? 60; + const now = new Date(); + const rand = deterministicPrng(0xC0FFEE); + const { tenancy, prisma } = options; + const clickhouse = getClickhouseAdminClient(); + + console.log(`[seed-activity] Target: ${count} users across ${days} days in project "${tenancy.project.id}" branch "${tenancy.branchId}"`); + + const dayOffsets = distributeBulkSignups(count, days, rand, now); + const clickhouseRows: Array> = []; + + let created = 0; + let updated = 0; + + const userActivity: Array<{ userId: string, signupDaysAgo: number, region: BulkActivityRegion }> = []; + + for (let i = 0; i < count; i++) { + const firstName = BULK_FIRST_NAMES[Math.floor(rand() * BULK_FIRST_NAMES.length)]!; + const lastName = BULK_LAST_NAMES[Math.floor(rand() * BULK_LAST_NAMES.length)]!; + const displayName = `${firstName} ${lastName}`; + const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}.signupseed${i}@dummy.dev`; + const signedUpAt = bulkRandomTimestampOnDay(now, dayOffsets[i]!, rand); + const region = pickBulkActivityRegion(rand); + const hasOauth = rand() > 0.55; + const oauthProvider = hasOauth + ? [{ id: BULK_OAUTH_PROVIDERS[Math.floor(rand() * BULK_OAUTH_PROVIDERS.length)]!, account_id: `${email}-oauth`, email }] + : []; + + const existing = await prisma.projectUser.findFirst({ + where: { + tenancyId: tenancy.id, + contactChannels: { some: { type: 'EMAIL', value: email } }, + }, + select: { projectUserId: true }, + }); + + let userId: string; + if (existing) { + userId = existing.projectUserId; + updated++; + } else { + const createdUser = await usersCrudHandlers.adminCreate({ + tenancy, + data: { + display_name: displayName, + primary_email: email, + primary_email_auth_enabled: true, + primary_email_verified: rand() > 0.25, + otp_auth_enabled: false, + is_anonymous: false, + oauth_providers: oauthProvider, + profile_image_url: null, + }, + }); + userId = createdUser.id; + created++; + } + + await prisma.projectUser.updateMany({ + where: { tenancyId: tenancy.id, projectUserId: userId }, + data: { createdAt: signedUpAt, signedUpAt }, + }); + + userActivity.push({ userId, signupDaysAgo: dayOffsets[i]!, region }); + + const ipInfoForUser = { + ip: bulkFakeIp(region.ipPrefix, rand), + is_trusted: true, + country_code: region.country, + region_code: region.region, + city_name: region.city, + latitude: region.lat, + longitude: region.lon, + tz_identifier: region.tz, + }; + + clickhouseRows.push({ + event_type: '$token-refresh', + event_at: formatClickhouseTimestamp(signedUpAt), + data: { + refresh_token_id: generateUuid(), + is_anonymous: false, + ip_info: ipInfoForUser, + }, + project_id: tenancy.project.id, + branch_id: tenancy.branchId, + user_id: userId, + team_id: null, + }); + + if ((i + 1) % 100 === 0) { + console.log(`[seed-activity] ${i + 1}/${count} users processed (${created} new, ${updated} updated)`); + } + } + + console.log(`[seed-activity] Generating multi-day activity events for ${userActivity.length} users...`); + + for (const { userId, signupDaysAgo, region } of userActivity) { + if (signupDaysAgo === 0) continue; + const isReturning = rand() < 0.7; + if (!isReturning) continue; + + const returnVisits = 2 + Math.floor(rand() * 7); + const ipInfo = { + ip: bulkFakeIp(region.ipPrefix, rand), + is_trusted: true, + country_code: region.country, + region_code: region.region, + city_name: region.city, + latitude: region.lat, + longitude: region.lon, + tz_identifier: region.tz, + }; + + for (let v = 0; v < returnVisits; v++) { + const visitDaysAgo = Math.floor(rand() * signupDaysAgo); + const visitTime = bulkRandomTimestampOnDay(now, visitDaysAgo, rand); + + clickhouseRows.push({ event_type: '$token-refresh', - event_at: randomTime, + event_at: formatClickhouseTimestamp(visitTime), data: { - refresh_token_id: refreshTokenId, + refresh_token_id: generateUuid(), is_anonymous: false, - ip_info: { - ip: ipAddress, - is_trusted: true, - country_code: location.countryCode, - region_code: location.regionCode, - city_name: location.cityName, - latitude: location.latitude, - longitude: location.longitude, - tz_identifier: location.tzIdentifier, - }, + ip_info: ipInfo, }, - project_id: projectId, - branch_id: DEFAULT_BRANCH_ID, + project_id: tenancy.project.id, + branch_id: tenancy.branchId, user_id: userId, team_id: null, - refresh_token_id: refreshTokenId, - session_replay_id: null, - session_replay_segment_id: null, }); + + const pageViewCount = 1 + Math.floor(rand() * 4); + for (let p = 0; p < pageViewCount; p++) { + const pvOffset = Math.floor(rand() * 3600) * 1000; + const pvTime = new Date(visitTime.getTime() + pvOffset); + clickhouseRows.push({ + event_type: '$page-view', + event_at: formatClickhouseTimestamp(pvTime), + data: { + path: BULK_PAGE_PATHS[Math.floor(rand() * BULK_PAGE_PATHS.length)], + referrer: p === 0 ? pickBulkReferrer(rand) : '', + is_anonymous: false, + }, + project_id: tenancy.project.id, + branch_id: tenancy.branchId, + user_id: userId, + team_id: null, + }); + } + + if (rand() < 0.4) { + const clickOffset = Math.floor(rand() * 1800) * 1000; + const clickTime = new Date(visitTime.getTime() + clickOffset); + clickhouseRows.push({ + event_type: '$click', + event_at: formatClickhouseTimestamp(clickTime), + data: { + selector: 'button.cta-primary', + is_anonymous: false, + }, + project_id: tenancy.project.id, + branch_id: tenancy.branchId, + user_id: userId, + team_id: null, + }); + } } } - // Batch insert into Postgres - await globalPrismaClient.eventIpInfo.createMany({ - data: ipInfoBatch, - skipDuplicates: true, - }); - - await globalPrismaClient.event.createMany({ - data: eventBatch, - skipDuplicates: true, - }); - - // Batch insert into ClickHouse for analytics/globe - const clickhouseUrl = getEnvVariable("STACK_CLICKHOUSE_URL", ""); - if (clickhouseUrl) { - const clickhouseClient = getClickhouseAdminClient(); - await clickhouseClient.insert({ - table: "analytics_internal.events", - values: clickhouseBatch, - format: "JSONEachRow", + console.log(`[seed-activity] Flushing ${clickhouseRows.length} events to ClickHouse...`); + const BATCH = 500; + for (let i = 0; i < clickhouseRows.length; i += BATCH) { + const batch = clickhouseRows.slice(i, i + BATCH); + await clickhouse.insert({ + table: 'analytics_internal.events', + values: batch, + format: 'JSONEachRow', clickhouse_settings: { - date_time_input_format: "best_effort", + date_time_input_format: 'best_effort', + async_insert: 1, }, }); } + + const tokenRefreshCount = clickhouseRows.filter(r => r.event_type === '$token-refresh').length; + const pageViewCount = clickhouseRows.filter(r => r.event_type === '$page-view').length; + const clickCount = clickhouseRows.filter(r => r.event_type === '$click').length; + + console.log(`[seed-activity] Done. created=${created} updated=${updated}`); + console.log(`[seed-activity] Events: $token-refresh=${tokenRefreshCount} $page-view=${pageViewCount} $click=${clickCount} total=${clickhouseRows.length}`); } /** @@ -1214,6 +1892,11 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis }), ]); + await seedBulkSignupsAndActivity({ + tenancy: dummyTenancy, + prisma: dummyPrisma, + }); + return projectId; } @@ -1221,63 +1904,59 @@ async function seedDummySessionReplays({ prisma, tenancyId, userEmailToId, + targetSessionReplayCount = 250, }: { prisma: PrismaClientTransaction, tenancyId: string, userEmailToId: Map, + targetSessionReplayCount?: number, }) { - const now = new Date(); - const usersToReplay = [ - 'amelia.chen@dummy.dev', - 'mateo.silva@dummy.dev', - 'priya.narang@dummy.dev', - ]; - - for (const email of usersToReplay) { - const userId = userEmailToId.get(email); - if (!userId) continue; - - const replayId = generateUuid(); - const batchId = generateUuid(); - const chunkId = generateUuid(); - const segmentId = generateUuid(); - const browserSessionId = generateUuid(); - const refreshTokenId = generateUuid(); - - // Each replay started 1-7 days ago, lasted ~8 seconds - const daysAgo = 1 + Math.floor(Math.random() * 7); - const startedAt = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); - const lastEventAt = new Date(startedAt.getTime() + 8000); - - await prisma.sessionReplay.upsert({ - where: { tenancyId_id: { tenancyId, id: replayId } }, - update: {}, - create: { - id: replayId, - tenancyId, - projectUserId: userId, - refreshTokenId, - startedAt, - lastEventAt, - }, - }); + const userIds = Array.from(userEmailToId.values()); + if (userIds.length === 0) { + throw new Error('Cannot seed session replays: no dummy project users exist'); + } - await prisma.sessionReplayChunk.upsert({ - where: { tenancyId_sessionReplayId_batchId: { tenancyId, sessionReplayId: replayId, batchId } }, - update: {}, - create: { - id: chunkId, - tenancyId, - sessionReplayId: replayId, - batchId, - sessionReplaySegmentId: segmentId, - browserSessionId, - s3Key: `preview://${replayId}/${batchId}`, - eventCount: 8, - byteLength: 0, - firstEventAt: startedAt, - lastEventAt, - }, + // Anchor on midnight today so the seeded window is stable across re-runs + // within the same day. + const todayUtc = new Date(); + todayUtc.setUTCHours(0, 0, 0, 0); + const twoWeeksAgo = new Date(todayUtc); + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); + const windowMs = todayUtc.getTime() - twoWeeksAgo.getTime(); + + // Single seeded PRNG keyed off tenancy so the whole replay set is + // deterministic across re-runs and identical IDs upsert in place. + const rand = deterministicPrng(seedFromString(`session-replays:${tenancyId}`)); + + const seeds: Prisma.SessionReplayCreateManyInput[] = []; + for (let i = 0; i < targetSessionReplayCount; i++) { + const startedAt = new Date(twoWeeksAgo.getTime() + rand() * windowMs); + const durationMs = 10_000 + Math.floor(rand() * (20 * 60 * 1000)); // 10s..20m + const lastEventAt = new Date(startedAt.getTime() + durationMs); + const projectUserId = userIds[Math.floor(rand() * userIds.length)]!; + + seeds.push({ + tenancyId, + refreshTokenId: deterministicUuid(`session-replay-refresh-token:${tenancyId}:${i}`), + projectUserId, + id: deterministicUuid(`session-replay:${tenancyId}:${i}`), + startedAt, + lastEventAt, }); } + + // Delete existing deterministic IDs first, then bulk-insert (Prisma createMany + // doesn't support upsert, so we delete+recreate to refresh timestamps). + const seedIds = seeds.map((s) => s.id!); + await prisma.sessionReplay.deleteMany({ + where: { + tenancyId, + id: { in: seedIds }, + }, + }); + await prisma.sessionReplay.createMany({ + data: seeds, + }); + + console.log(`Seeded ${targetSessionReplayCount} session replays`); } diff --git a/apps/dashboard/DESIGN-GUIDE.md b/apps/dashboard/DESIGN-GUIDE.md index 43f05c8fd9..7923f49fdb 100644 --- a/apps/dashboard/DESIGN-GUIDE.md +++ b/apps/dashboard/DESIGN-GUIDE.md @@ -11,7 +11,7 @@ If this guide conflicts with older examples in the codebase, follow this guide. Always prefer components from `apps/dashboard/src/components/design-components`. -- Do not build new ad-hoc visual primitives (for example custom `GlassCard`, custom badge pills, custom pill toggles, custom list rows) if a design-components component exists. +- Do not build new ad-hoc visual primitives (for example custom `GlassCard`, custom `ChartCard`, custom badge pills, custom pill toggles, custom list rows) if a design-components component exists. - If the desired UI can be achieved by tweaking/customizing/extending a design-components component, do that instead of creating a page-local alternative. - In all cases, default to design-components first; only use a non-design-components approach when there is absolutely no viable way to achieve the result with design-components. - Use `@/components/ui/*` primitives only when no design-components equivalent exists, or when the design-components component intentionally wraps the primitive. @@ -29,6 +29,7 @@ Use this when implementing a new dashboard UI quickly: 1. Need a section container/card? - Use `DesignCard`. + - For chart-heavy analytics surfaces (especially Recharts tooltips/overflow), use `DesignAnalyticsCard`. 2. Need user-facing status/info/warning/error message? - Use `DesignAlert`. 3. Need small semantic label (sent, failed, queued, active)? @@ -253,6 +254,36 @@ Default recommendation: - for dashboard sections, use `glassmorphic` style (either explicit or via nesting context) - use `gradient="default"` unless there is semantic reason for colored tint +### 4.1.1 `DesignAnalyticsCard` (and chart helpers) + +File: `apps/dashboard/src/components/design-components/analytics-card.tsx` + +Use for: + +- chart-heavy analytics shells on overview and metrics surfaces +- cards where chart tooltips need to escape clipping/stacking issues +- previously duplicated glass analytics wrappers (`ChartCard`, `GlassCard` clones) + +Exports: + +- `DesignAnalyticsCard` +- `DesignAnalyticsCardHeader` +- `DesignChartLegend` +- `useInfiniteListWindow` +- `DesignInfiniteScrollList` + +`DesignAnalyticsCard` props: + +- `gradient`: `"blue" | "cyan" | "purple" | "green" | "orange" | "slate"` +- `className` + +Rules: + +- prefer `DesignAnalyticsCard` over local chart wrappers for overview/analytics cards +- keep chart implementation local (Recharts config, data transforms), but keep shell/legend/list plumbing shared +- use `DesignChartLegend` instead of hand-rolled dot/label legend rows when layout matches +- use `useInfiniteListWindow` for incremental scrolling lists in analytics/list tabs + ### 4.2 `DesignAlert` File: `apps/dashboard/src/components/design-components/alert.tsx` @@ -551,6 +582,20 @@ Reference surfaces: Current pattern in these pages often uses custom card/header/pill components. New and refactored code should standardize to design-components primitives as follows. +### 5.0 `/projects/[projectId]/(overview)` analytics surfaces + +Use: + +- chart/list shells: `DesignAnalyticsCard` +- compact chart headers: `DesignAnalyticsCardHeader` +- stacked chart legends: `DesignChartLegend` +- incremental list rendering: `useInfiniteListWindow` (or `DesignInfiniteScrollList` where it fits) + +Avoid: + +- page-local `ChartCard` wrappers +- duplicated `IntersectionObserver` list window logic per card + ### 5.1 `/projects/[projectId]/emails` Use: @@ -774,7 +819,7 @@ Use this checklist before opening a dashboard UI PR: ## 10) Anti-Patterns (Do Not Introduce) -- Creating local `GlassCard` components instead of `DesignCard`. +- Creating local `GlassCard`/`ChartCard` components instead of `DesignCard` or `DesignAnalyticsCard`. - Creating local status pills instead of `DesignBadge`. - Creating local segmented/pill selectors instead of `DesignPillToggle`. - Using raw `Alert`/`Button` in standard dashboard surfaces where `DesignAlert`/`DesignButton` should be used. @@ -786,7 +831,7 @@ Use this checklist before opening a dashboard UI PR: When touching existing email/project pages, migrate in this order: -1. Cards/surfaces (`DesignCard`) +1. Cards/surfaces (`DesignCard` / `DesignAnalyticsCard` for chart-heavy shells) 2. Alerts (`DesignAlert`) 3. Badges (`DesignBadge`) 4. Toggles/tabs (`DesignPillToggle` / `DesignCategoryTabs`) @@ -806,3 +851,4 @@ Whenever a new reusable visual pattern is introduced in dashboard features: - then document the component contract and preferred usage here - avoid introducing permanent page-local UI primitives that duplicate design-components behavior + diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index 9c58911db4..45703700d4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -1,18 +1,6 @@ "use client"; import { CodeBlock } from "@/components/code-block"; -import { - CursorBlastEffect, - DesignAlert, - DesignBadge, - type DesignBadgeColor, - type DesignBadgeContentMode, - DesignButton, - DesignCard, - DesignCategoryTabs, - DesignInput, - DesignPillToggle, -} from "@stackframe/dashboard-ui-components"; import { DesignDataTable, DesignEditableGrid, @@ -23,7 +11,9 @@ import { DesignSelectorDropdown, DesignUserList, } from "@/components/design-components"; +import { DesignAnalyticsCard, DesignAnalyticsCardHeader, DesignChartLegend } from "@/components/design-components/analytics-card"; import { DataTableColumnHeader, SearchToolbarItem, Typography } from "@/components/ui"; +import { cn } from "@/lib/utils"; import { CheckCircle, Cube, @@ -39,6 +29,24 @@ import { Tag, Trash, } from "@phosphor-icons/react"; +import { + CursorBlastEffect, + DataGrid, + useDataSource, + type DataGridColumnDef, + type DataGridPaginationMode, + type DataGridSelectionMode, + createDefaultDataGridState, + DesignAlert, + DesignBadge, + type DesignBadgeColor, + type DesignBadgeContentMode, + DesignButton, + DesignCard, + DesignCategoryTabs, + DesignInput, + DesignPillToggle, +} from "@stackframe/dashboard-ui-components"; import { ColumnDef } from "@tanstack/react-table"; import { useMemo, useRef, useState } from "react"; @@ -46,11 +54,13 @@ import { useMemo, useRef, useState } from "react"; type ComponentId = | "alert" + | "analytics-card" | "badge" | "button" | "card" | "category-tabs" | "cursor-blast" + | "data-grid" | "data-table" | "editable-grid" | "input" @@ -62,11 +72,13 @@ type ComponentId = const COMPONENT_LIST: Array<{ value: ComponentId, label: string }> = [ { value: "alert", label: "Alert" }, + { value: "analytics-card", label: "Analytics Card" }, { value: "badge", label: "Badge" }, { value: "button", label: "Button" }, { value: "card", label: "Card" }, { value: "category-tabs", label: "Category Tabs" }, { value: "cursor-blast", label: "Cursor Blast Effect" }, + { value: "data-grid", label: "Data Grid" }, { value: "data-table", label: "Data Table" }, { value: "editable-grid", label: "Editable Grid" }, { value: "input", label: "Input" }, @@ -112,8 +124,8 @@ function isSize3(v: string): v is Size3 { function PropField({ label, children }: { label: string, children: React.ReactNode }) { return ( -
- +
+ {label} {children} @@ -199,6 +211,122 @@ const DEMO_USERS = [ { name: "Alan Turing", email: "alan@example.com", time: "Active 5h ago", color: "cyan" as const }, ]; +const DEMO_ANALYTICS_POINTS = [ + { date: "Feb 28", new: 31, retained: 51, reactivated: 7, visitors: 1260, revenueCents: 18200, movingAvg: 89, highlightedAvg: 96 }, + { date: "Mar 01", new: 34, retained: 54, reactivated: 8, visitors: 1330, revenueCents: 19600, movingAvg: 92, highlightedAvg: 97 }, + { date: "Mar 02", new: 37, retained: 57, reactivated: 9, visitors: 1390, revenueCents: 20800, movingAvg: 94, highlightedAvg: 98 }, + { date: "Mar 03", new: 40, retained: 59, reactivated: 10, visitors: 1450, revenueCents: 21900, movingAvg: 97, highlightedAvg: 99 }, + { date: "Mar 04", new: 42, retained: 58, reactivated: 11, visitors: 1510, revenueCents: 22800, movingAvg: 97, highlightedAvg: 101 }, + { date: "Mar 05", new: 37, retained: 61, reactivated: 9, visitors: 1470, revenueCents: 22400, movingAvg: 98, highlightedAvg: 102 }, + { date: "Mar 06", new: 45, retained: 64, reactivated: 12, visitors: 1620, revenueCents: 24300, movingAvg: 101, highlightedAvg: 104 }, + { date: "Mar 07", new: 49, retained: 66, reactivated: 10, visitors: 1675, revenueCents: 25700, movingAvg: 104, highlightedAvg: 105 }, + { date: "Mar 08", new: 43, retained: 63, reactivated: 8, visitors: 1590, revenueCents: 23600, movingAvg: 102, highlightedAvg: 104 }, + { date: "Mar 09", new: 52, retained: 70, reactivated: 13, visitors: 1740, revenueCents: 26900, movingAvg: 108, highlightedAvg: 107 }, + { date: "Mar 10", new: 46, retained: 68, reactivated: 12, visitors: 1710, revenueCents: 26200, movingAvg: 109, highlightedAvg: 108 }, + { date: "Mar 11", new: 55, retained: 74, reactivated: 15, visitors: 1835, revenueCents: 28400, movingAvg: 113, highlightedAvg: 110 }, +]; + +// ─── Data Grid demo data ──────────────────────────────────────────────────── + +type DemoGridUser = { + id: string, + name: string, + email: string, + role: "admin" | "editor" | "viewer", + status: "active" | "inactive" | "pending", + signUps: number, +}; + +const DEMO_GRID_USERS: DemoGridUser[] = [ + { id: "1", name: "Alice Anderson", email: "alice@company.io", role: "admin", status: "active", signUps: 1240 }, + { id: "2", name: "Bob Brown", email: "bob@gmail.com", role: "editor", status: "active", signUps: 870 }, + { id: "3", name: "Carol Chen", email: "carol@outlook.com", role: "viewer", status: "pending", signUps: 310 }, + { id: "4", name: "David Davis", email: "david@company.io", role: "editor", status: "inactive", signUps: 2100 }, + { id: "5", name: "Eve Evans", email: "eve@hey.com", role: "admin", status: "active", signUps: 4500 }, + { id: "6", name: "Frank Fisher", email: "frank@gmail.com", role: "viewer", status: "active", signUps: 95 }, + { id: "7", name: "Grace Garcia", email: "grace@company.io", role: "editor", status: "pending", signUps: 1800 }, + { id: "8", name: "Hank Harris", email: "hank@outlook.com", role: "viewer", status: "active", signUps: 620 }, +]; + +const DEMO_GRID_COLUMNS: DataGridColumnDef[] = [ + { + id: "name", + header: "Name", + accessor: "name", + width: 180, + type: "string", + renderCell: ({ value }) => ( +
+
+ {String(value).charAt(0).toUpperCase()} +
+ {String(value)} +
+ ), + }, + { id: "email", header: "Email", accessor: "email", width: 200, type: "string" }, + { + id: "role", + header: "Role", + accessor: "role", + width: 120, + type: "singleSelect", + valueOptions: [ + { value: "admin", label: "Admin" }, + { value: "editor", label: "Editor" }, + { value: "viewer", label: "Viewer" }, + ], + renderCell: ({ value }) => { + const colors: Record = { + admin: "bg-purple-500/10 text-purple-600 dark:text-purple-400 ring-1 ring-purple-500/20", + editor: "bg-blue-500/10 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/20", + viewer: "bg-foreground/[0.04] text-muted-foreground ring-1 ring-foreground/[0.06]", + }; + return ( + + {String(value)} + + ); + }, + }, + { + id: "status", + header: "Status", + accessor: "status", + width: 110, + type: "singleSelect", + valueOptions: [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "pending", label: "Pending" }, + ], + renderCell: ({ value }) => { + const dot: Record = { + active: "bg-emerald-500", + inactive: "bg-foreground/20", + pending: "bg-amber-500", + }; + return ( +
+
+ {String(value)} +
+ ); + }, + }, + { + id: "signUps", + header: "Sign-ups", + accessor: "signUps", + width: 110, + type: "number", + align: "right", + renderCell: ({ value }) => ( + {Number(value).toLocaleString()} + ), + }, +]; + // ─── Main ──────────────────────────────────────────────────────────────────── export default function PageClient() { @@ -209,6 +337,19 @@ export default function PageClient() { const [alertTitle, setAlertTitle] = useState("Order placed"); const [alertDesc, setAlertDesc] = useState("Your order has been confirmed."); + // Analytics Card + const [analyticsCardGradient, setAnalyticsCardGradient] = useState<"blue" | "cyan" | "purple" | "green" | "orange" | "slate">("blue"); + const [analyticsCardShowHeader, setAnalyticsCardShowHeader] = useState(true); + const [analyticsCardShowLegend, setAnalyticsCardShowLegend] = useState(true); + const [analyticsCardType, setAnalyticsCardType] = useState<"none" | "line" | "bar" | "stacked-bar" | "composed" | "donut">("stacked-bar"); + const [analyticsCardTooltipType, setAnalyticsCardTooltipType] = useState<"none" | "default" | "stacked" | "composed" | "visitors" | "revenue" | "donut">("stacked"); + const [analyticsCardHighlightMode, setAnalyticsCardHighlightMode] = useState<"none" | "bar-segment" | "series-hover" | "dot-hover" | "mixed">("bar-segment"); + const [analyticsCardMovingAverage, setAnalyticsCardMovingAverage] = useState(true); + const [analyticsCardSevenDayAverage, setAnalyticsCardSevenDayAverage] = useState(true); + const [analyticsCardMovingAverageDataKey, setAnalyticsCardMovingAverageDataKey] = useState("movingAvg"); + const [analyticsCardSevenDayAverageDataKey, setAnalyticsCardSevenDayAverageDataKey] = useState("highlightedAvg"); + const [analyticsCardHoveredIndex, setAnalyticsCardHoveredIndex] = useState(null); + // Badge const [badgeLabel, setBadgeLabel] = useState("In stock"); const [badgeColor, setBadgeColor] = useState("green"); @@ -245,6 +386,13 @@ export default function PageClient() { const [blastRageWindow, setBlastRageWindow] = useState(600); const [blastRageRadius, setBlastRageRadius] = useState(60); + // Data Grid + const [dgSelectionMode, setDgSelectionMode] = useState("none"); + const [dgRowHeight, setDgRowHeight] = useState(44); + const [dgShowToolbar, setDgShowToolbar] = useState(true); + const [dgState, setDgState] = useState(() => createDefaultDataGridState(DEMO_GRID_COLUMNS)); + const dgData = useDataSource({ data: DEMO_GRID_USERS, columns: DEMO_GRID_COLUMNS, getRowId: (r: DemoGridUser) => r.id, sorting: dgState.sorting, quickSearch: dgState.quickSearch, pagination: dgState.pagination, paginationMode: "client" }); + // Data Table const [tableClickableRows, setTableClickableRows] = useState(false); const [tableLastRowClick, setTableLastRowClick] = useState(""); @@ -443,6 +591,196 @@ export default function PageClient() {
); } + if (selected === "analytics-card") { + const demoLegendItems = [ + { key: "new", label: "New", color: "hsl(152, 38%, 52%)" }, + { key: "retained", label: "Retained", color: "hsl(221, 42%, 55%)" }, + { key: "reactivated", label: "Reactivated", color: "hsl(36, 55%, 58%)" }, + ]; + const maxTotal = Math.max( + ...DEMO_ANALYTICS_POINTS.map((point) => point.new + point.retained + point.reactivated), + 1, + ); + const hoveredIndex = analyticsCardHoveredIndex ?? 0; + const hoveredPoint = DEMO_ANALYTICS_POINTS[hoveredIndex] ?? DEMO_ANALYTICS_POINTS[0]; + const tooltipLeftPercent = ((hoveredIndex + 0.5) / DEMO_ANALYTICS_POINTS.length) * 100; + const movingAverageValue = hoveredPoint[analyticsCardMovingAverageDataKey as keyof typeof hoveredPoint]; + const sevenDayAverageValue = hoveredPoint[analyticsCardSevenDayAverageDataKey as keyof typeof hoveredPoint]; + const movingAverageIsNumber = typeof movingAverageValue === "number"; + const sevenDayAverageIsNumber = typeof sevenDayAverageValue === "number"; + const showTooltip = analyticsCardTooltipType !== "none" && analyticsCardType !== "none"; + return ( +
+ + {analyticsCardShowHeader && ( + + )} + {analyticsCardShowLegend && ( + + )} +
+
setAnalyticsCardHoveredIndex(null)} + > + {analyticsCardType === "none" && ( +
+ + No chart selected + +
+ )} + {analyticsCardType === "donut" && ( +
+
setAnalyticsCardHoveredIndex(0)} + > +
+
+
+ )} + {analyticsCardType !== "none" && analyticsCardType !== "donut" && ( +
+ {DEMO_ANALYTICS_POINTS.map((point, index) => { + const total = point.new + point.retained + point.reactivated; + const totalHeightPercent = Math.max((total / maxTotal) * 100, 12); + const retainedPercent = (point.retained / total) * 100; + const newPercent = (point.new / total) * 100; + const reactivatedPercent = (point.reactivated / total) * 100; + const isHovered = hoveredIndex === index; + const dimBySeriesHover = (analyticsCardHighlightMode === "series-hover" || analyticsCardHighlightMode === "mixed") + && analyticsCardHoveredIndex !== null + && !isHovered; + return ( +
setAnalyticsCardHoveredIndex(index)} + > +
+ {analyticsCardType === "stacked-bar" && ( +
+
+
+
+
+ )} + {analyticsCardType === "bar" && ( +
+ )} + {analyticsCardType === "line" && ( +
+
+
+ )} + {analyticsCardType === "composed" && ( +
+
+
+
+ )} +
+ {point.date.slice(-2)} +
+ ); + })} +
+ )} + {showTooltip && analyticsCardHoveredIndex !== null && ( +
+
{hoveredPoint.date}
+ {(analyticsCardTooltipType === "stacked" || analyticsCardTooltipType === "composed" || analyticsCardTooltipType === "default") && ( +
+
+ New + {hoveredPoint.new} +
+
+ Retained + {hoveredPoint.retained} +
+
+ Reactivated + {hoveredPoint.reactivated} +
+
+ )} + {analyticsCardTooltipType === "visitors" && ( +
+ Visitors + {hoveredPoint.visitors.toLocaleString()} +
+ )} + {analyticsCardTooltipType === "revenue" && ( +
+ Revenue + ${Math.round(hoveredPoint.revenueCents / 100)} +
+ )} + {analyticsCardTooltipType === "donut" && ( +
+ Segment share + 35% / 43% / 22% +
+ )} +
+ )} +
+
+ + {analyticsCardMovingAverageDataKey}:{" "} + {analyticsCardMovingAverage + ? (movingAverageIsNumber ? movingAverageValue : "invalid key") + : "off"} + + + {analyticsCardSevenDayAverageDataKey}:{" "} + {analyticsCardSevenDayAverage + ? (sevenDayAverageIsNumber ? sevenDayAverageValue : "invalid key") + : "off"} + +
+
+ +
+ ); + } if (selected === "badge") { const badgeIconProp = badgeContentMode === "icon" ? CheckCircle @@ -543,6 +881,25 @@ export default function PageClient() {
); } + if (selected === "data-grid") { + return ( +
+ + columns={DEMO_GRID_COLUMNS} + rows={dgData.rows} + getRowId={(row) => row.id} + totalRowCount={dgData.totalRowCount} + isLoading={dgData.isLoading} + state={dgState} + onChange={setDgState} + selectionMode={dgSelectionMode} + rowHeight={dgRowHeight} + toolbar={dgShowToolbar ? undefined : false} + maxHeight={400} + /> +
+ ); + } if (selected === "data-table") { return (
@@ -791,6 +1148,142 @@ export default function PageClient() {
); } + if (selected === "analytics-card") { + return ( +
+ + { + if (v === "blue" || v === "cyan" || v === "purple" || v === "green" || v === "orange" || v === "slate") { + setAnalyticsCardGradient(v); + return; + } + throw new Error(`Unknown analytics card gradient "${v}"`); + }} + options={[ + { value: "blue", label: "Blue" }, + { value: "cyan", label: "Cyan" }, + { value: "purple", label: "Purple" }, + { value: "green", label: "Green" }, + { value: "orange", label: "Orange" }, + { value: "slate", label: "Slate" }, + ]} + size="sm" + /> + + + { + if (v === "none" || v === "line" || v === "bar" || v === "stacked-bar" || v === "composed" || v === "donut") { + setAnalyticsCardType(v); + return; + } + throw new Error(`Unknown analytics chart type "${v}"`); + }} + options={[ + { value: "none", label: "None" }, + { value: "line", label: "Line" }, + { value: "bar", label: "Bar" }, + { value: "stacked-bar", label: "Stacked Bar" }, + { value: "composed", label: "Composed" }, + { value: "donut", label: "Donut" }, + ]} + size="sm" + /> + + + { + if (v === "none" || v === "default" || v === "stacked" || v === "composed" || v === "visitors" || v === "revenue" || v === "donut") { + setAnalyticsCardTooltipType(v); + return; + } + throw new Error(`Unknown analytics tooltip type "${v}"`); + }} + options={[ + { value: "none", label: "None" }, + { value: "default", label: "Default" }, + { value: "stacked", label: "Stacked" }, + { value: "composed", label: "Composed" }, + { value: "visitors", label: "Visitors" }, + { value: "revenue", label: "Revenue" }, + { value: "donut", label: "Donut" }, + ]} + size="sm" + /> + + + { + if (v === "none" || v === "bar-segment" || v === "series-hover" || v === "dot-hover" || v === "mixed") { + setAnalyticsCardHighlightMode(v); + return; + } + throw new Error(`Unknown analytics highlight mode "${v}"`); + }} + options={[ + { value: "none", label: "None" }, + { value: "bar-segment", label: "Bar Segment" }, + { value: "series-hover", label: "Series Hover" }, + { value: "dot-hover", label: "Dot Hover" }, + { value: "mixed", label: "Mixed" }, + ]} + size="sm" + /> + + + setAnalyticsCardShowHeader(v === "yes")} + size="sm" + /> + + + setAnalyticsCardShowLegend(v === "yes")} + size="sm" + /> + + + setAnalyticsCardMovingAverage(v === "yes")} + size="sm" + /> + + + setAnalyticsCardSevenDayAverage(v === "yes")} + size="sm" + /> + + + setAnalyticsCardMovingAverageDataKey(e.target.value)} + /> + + + setAnalyticsCardSevenDayAverageDataKey(e.target.value)} + /> + +
+ ); + } if (selected === "badge") { return (
@@ -1052,6 +1545,44 @@ export default function PageClient() {
); } + if (selected === "data-grid") { + return ( +
+ + { + if (v === "none" || v === "single" || v === "multiple") { + setDgSelectionMode(v); + return; + } + throw new Error(`Unknown selection mode "${v}"`); + }} + options={[ + { value: "none", label: "None" }, + { value: "single", label: "Single" }, + { value: "multiple", label: "Multiple" }, + ]} + size="sm" + /> + + + { + const n = Number(e.target.value); + if (!Number.isNaN(n) && n >= 24) setDgRowHeight(n); + }} + /> + + + + +
+ ); + } if (selected === "data-table") { return (
@@ -1419,6 +1950,30 @@ export default function PageClient() { title="${escapeAttr(alertTitle)}" description="${escapeAttr(alertDesc)}" />`; + } + if (selected === "analytics-card") { + const headerSnippet = analyticsCardShowHeader + ? `\n ` + : ""; + const legendSnippet = analyticsCardShowLegend + ? `\n ` + : ""; + return `${headerSnippet}${legendSnippet} + {/* chart content */} +`; } if (selected === "badge") { const iconProp = badgeContentMode === "icon" @@ -1484,6 +2039,19 @@ export default function PageClient() { rageClickThreshold={${blastRageThreshold}} rageClickWindowMs={${blastRageWindow}} rageClickRadiusPx={${blastRageRadius}} +/>`; + } + if (selected === "data-grid") { + return ` row.id} + state={gridState} + onChange={setGridState} + selectionMode="${dgSelectionMode}" + rowHeight={${dgRowHeight}} + toolbar={${dgShowToolbar}} + maxHeight={400} />`; } if (selected === "data-table") { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx index 0a0a1dc710..6e70e1c4b2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -168,17 +168,20 @@ function GlobeSectionInner({ countryData, totalUsers, children }: {countryData: // - Canvas width 350: Hide globe // - Canvas width 355: zoom = 360 // - Canvas width 500: zoom = 309 - // Formula: zoom = 484 - 0.35 * width (for width >= 355) + // - Canvas width >= 500: zoom stays at 309 so the globe keeps a constant + // visual fill ratio on widescreens instead of growing without bound and + // overflowing the canvas. const canvasWidth = globeContainerSize?.width ?? 0; const GLOBE_MIN_WIDTH = 350; const shouldShowGlobe = canvasWidth >= GLOBE_MIN_WIDTH; // Calculate zoom based on width - // For widths >= 355, use linear formula: zoom = 484 - 0.35 * width + // For widths >= 355, use linear formula clamped to a minimum distance. // For widths between 350-355, use 360 (same as at 355px) + const MIN_CAMERA_DISTANCE = 309; // matches the value at width = 500 const cameraDistance = canvasWidth >= 355 - ? 484 - 0.35 * canvasWidth + ? Math.max(MIN_CAMERA_DISTANCE, 484 - 0.35 * canvasWidth) : 360; // For 350-355 range, use 360 // Calculate border size using exact same formula structure as cameraDistance @@ -349,7 +352,7 @@ function GlobeSectionInner({ countryData, totalUsers, children }: {countryData: }, []); return ( -
+
) => { if (!active || !payload?.length) return null; const data = payload[0].payload as DataPoint; - const date = new Date(data.date); + const date = parseChartDate(data.date); const formattedDate = !isNaN(date.getTime()) ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : data.date; return ( -
+
{formattedDate} @@ -68,18 +84,144 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => { }; // Helper function to filter datapoints by time range -function filterDatapointsByTimeRange(datapoints: DataPoint[], timeRange: TimeRange): DataPoint[] { +function getDateKey(date: Date): string { + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function normalizeToLocalDay(date: Date): Date { + const normalizedDate = new Date(date); + normalizedDate.setHours(0, 0, 0, 0); + return normalizedDate; +} + +function parseChartDate(dateValue: string): Date { + if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) { + const [year, month, day] = dateValue.split("-").map(Number); + return new Date(year, month - 1, day); + } + + const parsed = new Date(dateValue); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Unsupported chart date format: ${dateValue}`); + } + return parsed; +} + +function formatDateRangeLabel(range: CustomDateRange | null): string { + if (range == null) { + return "Pick date range"; + } + + const fromLabel = range.from.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const toLabel = range.to.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + + return `${fromLabel} - ${toLabel}`; +} + +function filterPointsByTimeRange( + datapoints: T[], + timeRange: TimeRange, + customDateRange: CustomDateRange | null = null, +): T[] { if (timeRange === '7d') { return datapoints.slice(-7); } if (timeRange === '30d') { return datapoints.slice(-30); } + if (timeRange === 'custom') { + if (customDateRange == null) { + return datapoints; + } + + const fromKey = getDateKey(customDateRange.from); + const toKey = getDateKey(customDateRange.to); + + return datapoints.filter((point) => point.date >= fromKey && point.date <= toKey); + } return datapoints; } +export function filterDatapointsByTimeRange( + datapoints: DataPoint[], + timeRange: TimeRange, + customDateRange: CustomDateRange | null = null, +): DataPoint[] { + return filterPointsByTimeRange(datapoints, timeRange, customDateRange); +} + +export function filterStackedDatapointsByTimeRange( + datapoints: T[], + timeRange: TimeRange, + customDateRange: CustomDateRange | null = null, +): T[] { + return filterPointsByTimeRange(datapoints, timeRange, customDateRange); +} + +function getHoveredDataIndex(activeTooltipIndex: unknown, dataLength: number): number | null { + const parsedIndex = typeof activeTooltipIndex === "number" + ? activeTooltipIndex + : typeof activeTooltipIndex === "string" + ? Number(activeTooltipIndex) + : NaN; + + if (!Number.isInteger(parsedIndex) || parsedIndex < 0 || parsedIndex >= dataLength) { + return null; + } + + return parsedIndex; +} + +function updateHoveredIndexFromChartState( + state: unknown, + dataLength: number, + setHoveredIndex: (index: number | null) => void, +) { + if (typeof state !== "object" || state === null || !("activeTooltipIndex" in state)) { + setHoveredIndex(null); + return; + } + + setHoveredIndex(getHoveredDataIndex(state.activeTooltipIndex, dataLength)); +} + +function getActiveCoordinateX(state: unknown): number | null { + if (typeof state !== "object" || state === null || !("activeCoordinate" in state)) { + return null; + } + + const activeCoordinate = state.activeCoordinate; + if ( + typeof activeCoordinate !== "object" || + activeCoordinate === null || + !("x" in activeCoordinate) || + typeof activeCoordinate.x !== "number" || + !Number.isFinite(activeCoordinate.x) + ) { + return null; + } + + return activeCoordinate.x; +} + +function getDimmedOpacity( + baseOpacity: number, + index: number, + hoveredIndex: number | null, + dimFactor = 0.22, +) { + if (hoveredIndex == null) { + return baseOpacity; + } + + return hoveredIndex === index ? baseOpacity : Math.max(baseOpacity * dimFactor, 0.08); +} + // Shared BarChart component to reduce duplication -function ActivityBarChart({ +export function ActivityBarChart({ datapoints, config, height, @@ -90,6 +232,9 @@ function ActivityBarChart({ height?: number, compact?: boolean, }) { + const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + return ( updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} > {datapoints.map((entry, index) => { - const isWeekendDay = isWeekend(new Date(entry.date)); + const isWeekendDay = isWeekend(parseChartDate(entry.date)); + const baseOpacity = isWeekendDay ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; return ( ); })} @@ -150,13 +302,13 @@ function ActivityBarChart({ tickLine={false} tickMargin={compact ? 4 : 8} axisLine={false} - interval="equidistantPreserveStart" + interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"} tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10, }} tickFormatter={(value) => { - const date = new Date(value); + const date = parseChartDate(value); if (!isNaN(date.getTime())) { const month = date.toLocaleDateString("en-US", { month: "short", @@ -172,172 +324,981 @@ function ActivityBarChart({ ); } -export type GradientColor = "blue" | "purple" | "green" | "orange" | "slate" | "cyan"; +// ── Stacked bar chart (for DAU/DAT new · reactivated · retained splits) ────── -export function ChartCard({ - children, - className, - gradientColor = "blue" -}: { - children: React.ReactNode, - className?: string, - gradientColor?: GradientColor, -}) { - const designCardGradients: Record = { - blue: "blue", - purple: "purple", - green: "green", - orange: "orange", - slate: "default", - cyan: "cyan", - }; +export type StackedDataPoint = { + date: string, + new: number, + reactivated: number, + retained: number, +}; - return ( - <> - - div]:h-full [&>div]:min-h-0 [&>div]:flex [&>div]:flex-col [&>div]:overflow-visible", - className - )} - > -
- {children} -
-
- - ); -} +const stackedChartConfig: ChartConfig = { + retained: { label: "Retained", color: "hsl(221, 42%, 55%)" }, + reactivated: { label: "Reactivated", color: "hsl(36, 55%, 58%)" }, + new: { label: "New", color: "hsl(152, 38%, 52%)" }, +}; -export function TimeRangeToggle({ - timeRange, - onTimeRangeChange, -}: { - timeRange: TimeRange, - onTimeRangeChange: (range: TimeRange) => void, -}) { - const options: { id: TimeRange, label: string }[] = [ - { id: '7d', label: '7d' }, - { id: '30d', label: '30d' }, - { id: 'all', label: 'All' }, +const StackedTooltip = ({ active, payload }: TooltipProps) => { + if (!active || !payload?.length) return null; + + const row = payload[0].payload as StackedDataPoint & { avg7d?: number }; + const date = parseChartDate(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : row.date; + + const segments: Array<{ key: keyof typeof stackedChartConfig, value: number }> = [ + { key: 'retained', value: row.retained }, + { key: 'reactivated', value: row.reactivated }, + { key: 'new', value: row.new }, ]; + const nonZeroSegments = segments.filter((segment) => segment.value > 0); + const total = row.retained + row.reactivated + row.new; return ( - { - if (selectedId === '7d' || selectedId === '30d' || selectedId === 'all') { - onTimeRangeChange(selectedId); - return; - } - throw new Error(`Unsupported time range selected: ${selectedId}`); - }} - /> +
+
+
+ + {formattedDate} + + {total} total +
+ {nonZeroSegments.map((seg) => ( +
+ + + {stackedChartConfig[seg.key].label} + + + {seg.value.toLocaleString()} + +
+ ))} + {row.avg7d != null && ( +
+
+ 7-day avg + + {Math.round(row.avg7d).toLocaleString()} + +
+
+ )} +
+
); +}; + +// Trailing moving average that ignores zero-value days. +// This keeps trend direction intuitive while avoiding "floating" artifacts. +function rollingAvg(values: number[], windowSize: number): (number | null)[] { + return values.map((val, i) => { + if (val === 0) return null; + const start = Math.max(0, i - windowSize + 1); + const slice = values.slice(start, i + 1).filter(v => v > 0); + if (slice.length === 0) return null; + return slice.reduce((s, v) => s + v, 0) / slice.length; + }); } -export function TabbedMetricsCard({ - config, - chartData, - listData, - listTitle, - gradientColor = "blue", - projectId, - router, +function sevenDayAvg(values: number[], index: number): number { + const start = Math.max(0, index - 6); + const slice = values.slice(start, index + 1); + return slice.reduce((s, v) => s + v, 0) / slice.length; +} + +export function StackedBarChartDisplay({ + datapoints, height, compact = false, - timeRange, - totalAllTime, - showTotal = false, }: { - config: LineChartDisplayConfig, - chartData: DataPoint[], - listData: UserListItem[], - listTitle: string, - gradientColor?: GradientColor, - projectId: string, - router: ReturnType, + datapoints: StackedDataPoint[], height?: number, compact?: boolean, - timeRange: TimeRange, - totalAllTime?: number, - showTotal?: boolean, }) { - const [view, setView] = useState<'chart' | 'list'>('chart'); - - const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange); - - // Calculate total for the selected time range - const total = filteredDatapoints.reduce((sum, point) => sum + point.activity, 0); + const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); - // For "all" time range, use totalAllTime if provided (which includes data beyond 30 days) - const displayTotal = timeRange === 'all' && totalAllTime !== undefined ? totalAllTime : total; + const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); + const totals = datapoints.map(p => p.new + p.retained + p.reactivated); + const avgValues = rollingAvg(totals, windowSize); + const sevenDayAvgs = totals.map((_, i) => sevenDayAvg(totals, i)); + const chartData = datapoints.map((p, i) => { + const total = totals[i]; + const rawAvg = avgValues[i]; + const movingAvg = total > 0 && rawAvg != null + ? Math.min(total * 0.9, Math.max(total * 0.35, rawAvg)) + : null; + const isHighlightedAvg = hoveredIndex != null && i <= hoveredIndex && i >= hoveredIndex - 6; + return { + ...p, + movingAvg, + avg7d: sevenDayAvgs[i], + highlightedAvg: isHighlightedAvg ? movingAvg : null, + }; + }); - const hoverAccentColors: Record = { - blue: "hover:bg-blue-500/[0.06]", - purple: "hover:bg-purple-500/[0.06]", - green: "hover:bg-emerald-500/[0.06]", - orange: "hover:bg-orange-500/[0.06]", - slate: "hover:bg-slate-500/[0.04]", - cyan: "hover:bg-cyan-500/[0.06]", + const movingAvgConfig: ChartConfig = { + ...stackedChartConfig, + movingAvg: { label: "Moving avg", color: "hsl(var(--foreground))" }, }; - const hoverAccentClass = hoverAccentColors[gradientColor]; - const tabsGradient: "blue" | "cyan" | "purple" | "green" | "orange" | "default" = gradientColor === "slate" ? "default" : gradientColor; - return ( - -
- { - if (selectedId === "chart" || selectedId === "list") { - setView(selectedId); - return; + + updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > + + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: hoveredIndex == null ? 0.08 : 0.12, radius: 4 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + + {hoveredIndex != null && ( + + )} + + { + const date = parseChartDate(value); + if (!isNaN(date.getTime())) { + return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; } - throw new Error(`Unsupported metrics tab selected: ${selectedId}`); + return value; }} - showBadge={false} - size="sm" - glassmorphic={false} - gradient={tabsGradient} - className="flex-1 min-w-0 border-0 [&>button]:rounded-none [&>button]:px-3 [&>button]:py-3.5 [&>button]:text-xs" /> + + + ); +} - {view === 'chart' && showTotal && ( - - {displayTotal.toLocaleString()} +// ── Combined bar+line analytics chart ───────────────────────────────────────── + +export type ComposedDataPoint = { + date: string, + new_cents: number, + refund_cents: number, + visitors: number, + dau: number, + _showVisitors?: boolean, + _showRevenue?: boolean, +}; + +export type VisitorsHoverDataPoint = { + date: string, + page_views: number, + movingAvg?: number | null, + avg7d?: number, + highlightedAvg?: number | null, + top_countries?: Array<{ country_code: string, count: number }>, +}; + +export type RevenueHoverDataPoint = { + date: string, + new_cents: number, + refund_cents: number, + movingAvg?: number | null, + avg7d?: number, + highlightedAvg?: number | null, +}; + +type HighlightDotProps = { + cx?: number, + cy?: number, + fill?: string, +}; + +const composedChartConfig: ChartConfig = { + dau: { + label: "Daily Active Users", + theme: { light: "hsl(152, 38%, 52%)", dark: "hsl(152, 38%, 62%)" }, + }, + visitors: { + label: "Unique Visitors", + theme: { light: "hsl(210, 84%, 64%)", dark: "hsl(210, 84%, 72%)" }, + }, + revenue: { + label: "Revenue", + theme: { light: "hsl(268, 82%, 66%)", dark: "hsl(268, 82%, 74%)" }, + }, +}; + +function ComposedTooltip({ active, payload }: TooltipProps) { + if (!active || !payload?.length) return null; + + const row = payload[0]?.payload as ComposedDataPoint | undefined; + if (!row) return null; + + const date = parseChartDate(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }) + : row.date; + + const visitorsEnabled = row._showVisitors !== false; + const revenueEnabled = row._showRevenue !== false; + const revenueDollars = (row.new_cents / 100); + const revenuePerVisitor = visitorsEnabled && revenueEnabled && row.visitors > 0 ? (revenueDollars / row.visitors) : null; + + return ( +
+
+ + {formattedDate} + + +
+
+ + Daily active users +
+ + {row.dau.toLocaleString()} - )} -
+
- {config.description && view === 'chart' && ( -
- {config.description} +
+
+ + Unique visitors +
+ + {visitorsEnabled ? row.visitors.toLocaleString() : "—"} +
- )} -
+
+
+ + Revenue +
+ + {revenueEnabled ? `$${revenueDollars.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—"} + +
+ +
+
+ Revenue/visitor + + {revenuePerVisitor != null ? `$${revenuePerVisitor.toFixed(2)}` : "—"} + +
+
+
+
+ ); +} + +function HighlightedLineDot({ cx, cy, fill }: HighlightDotProps) { + if (cx == null || cy == null || fill == null) { + return null; + } + + return ( + + + + + + + ); +} + +export function ComposedAnalyticsChart({ + datapoints, + showVisitors = true, + showRevenue = true, + height, + compact = false, +}: { + datapoints: ComposedDataPoint[], + showVisitors?: boolean, + showRevenue?: boolean, + height?: number, + compact?: boolean, +}) { + const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [hoveredX, setHoveredX] = useState(null); + const taggedDatapoints = useMemo( + () => datapoints.map(d => ({ ...d, _showVisitors: showVisitors, _showRevenue: showRevenue })), + [datapoints, showVisitors, showRevenue], + ); + const maxVisitors = Math.max(...datapoints.map(d => Math.max(showVisitors ? d.visitors : 0, d.dau)), 1); + const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1); + const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5); + const revenueTicks = niceAxisTicks(Math.ceil(maxRevenueCents * 1.15), 5); + const visitorsMax = visitorTicks[visitorTicks.length - 1] ?? maxVisitors; + const revenueMax = revenueTicks[revenueTicks.length - 1] ?? maxRevenueCents; + + return ( + + { + updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex); + setHoveredX(getActiveCoordinateX(state)); + }} + onMouseLeave={() => { + setHoveredIndex(null); + setHoveredX(null); + }} + > + + + + + + + {hoveredX != null && ( + <> + + + + + + + + + + + )} + + + } + cursor={{ stroke: "hsl(var(--foreground))", strokeOpacity: hoveredIndex == null ? 0.32 : 0.62, strokeWidth: hoveredIndex == null ? 1 : 1.5 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + : false} + isAnimationActive={false} + /> + {showVisitors && hoveredIndex != null && hoveredX != null && ( + } + isAnimationActive={false} + strokeLinecap="round" + strokeLinejoin="round" + style={{ clipPath: `url(#visitors-highlight-clip-${id})` }} + legendType="none" + /> + )} + } + isAnimationActive={false} + /> + {hoveredIndex != null && hoveredX != null && ( + } + isAnimationActive={false} + strokeLinecap="round" + strokeLinejoin="round" + style={{ clipPath: `url(#dau-highlight-clip-${id})` }} + legendType="none" + /> + )} + : false} + isAnimationActive={false} + /> + {showRevenue && hoveredIndex != null && hoveredX != null && ( + } + isAnimationActive={false} + strokeLinecap="round" + strokeLinejoin="round" + style={{ clipPath: `url(#revenue-highlight-clip-${id})` }} + legendType="none" + /> + )} + + + { + const date = parseChartDate(value); + if (!isNaN(date.getTime())) { + return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; + } + return value; + }} + /> + + + ); +} + +function niceAxisTicks(maxValue: number, count: number): number[] { + if (maxValue <= 0) return [0]; + const rough = maxValue / (count - 1); + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); + const residual = rough / magnitude; + let step: number; + if (residual <= 1.5) { + step = magnitude; + } else if (residual <= 3) { + step = 2 * magnitude; + } else if (residual <= 7) { + step = 5 * magnitude; + } else { + step = 10 * magnitude; + } + const ticks: number[] = []; + for (let v = 0; v <= maxValue + step * 0.5; v += step) { + ticks.push(Math.round(v)); + } + return ticks; +} + +export type GradientColor = "blue" | "purple" | "green" | "orange" | "slate" | "cyan"; + +/** + * Thin wrapper kept for backward compatibility within this file. + * All new code should use DesignAnalyticsCard directly. + */ +export function ChartCard({ + children, + className, + gradientColor = "blue", + chart, +}: { + children: React.ReactNode, + className?: string, + gradientColor?: GradientColor, + chart?: DesignAnalyticsChartConfig, +}) { + return ( + + {children} + + ); +} + +export function TimeRangeToggle({ + timeRange, + onTimeRangeChange, + customDateRange = null, + onCustomDateRangeChange, +}: { + timeRange: TimeRange, + onTimeRangeChange: (range: TimeRange) => void, + customDateRange?: CustomDateRange | null, + onCustomDateRangeChange?: (range: CustomDateRange) => void, +}) { + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const supportsCustomDateRange = onCustomDateRangeChange != null; + const customDateRangeHandler = onCustomDateRangeChange; + + const options: { id: TimeRange, label: string }[] = [ + { id: '7d', label: '7d' }, + { id: '30d', label: '30d' }, + { id: 'all', label: 'All' }, + ...(supportsCustomDateRange ? [{ id: 'custom' as const, label: 'Custom' }] : []), + ]; + + const selectedRange = customDateRange == null ? undefined : { + from: customDateRange.from, + to: customDateRange.to, + }; + const latestSelectableDate = normalizeToLocalDay(new Date()); + + return ( + + +
+ { + if ( + selectedId === '7d' || + selectedId === '30d' || + selectedId === 'all' || + selectedId === 'custom' + ) { + if (selectedId === "custom" && !supportsCustomDateRange) { + throw new Error("Custom range selected but onCustomDateRangeChange is not provided"); + } + + if (selectedId === "custom") { + if (customDateRangeHandler == null) { + throw new Error("Custom range selected but onCustomDateRangeChange is not provided"); + } + if (customDateRange == null) { + const to = latestSelectableDate; + const from = new Date(to); + from.setDate(from.getDate() - 6); + customDateRangeHandler({ from, to }); + } + onTimeRangeChange("custom"); + setIsCalendarOpen(true); + return; + } + + setIsCalendarOpen(false); + onTimeRangeChange(selectedId); + return; + } + throw new Error(`Unsupported time range selected: ${selectedId}`); + }} + /> +
+
+ {supportsCustomDateRange && ( + + { + if (nextRange?.from == null || nextRange.to == null) { + return; + } + + const normalizedFrom = normalizeToLocalDay(nextRange.from); + const normalizedTo = normalizeToLocalDay(nextRange.to); + const normalizedRange = normalizedFrom <= normalizedTo + ? { from: normalizedFrom, to: normalizedTo } + : { from: normalizedTo, to: normalizedFrom }; + + if (customDateRangeHandler == null) { + throw new Error("Custom date range update handler is missing"); + } + customDateRangeHandler(normalizedRange); + onTimeRangeChange("custom"); + setIsCalendarOpen(false); + }} + numberOfMonths={1} + defaultMonth={customDateRange?.from} + disabled={{ after: latestSelectableDate }} + className="!p-0 !border-0" + classNames={{ + months: "relative", + month: "space-y-3", + month_caption: "flex justify-center items-center h-8", + caption_label: "text-sm font-semibold text-foreground", + nav: "absolute inset-x-0 top-0 flex items-center justify-between", + button_previous: cn( + "h-8 w-8 rounded-lg", + "inline-flex items-center justify-center", + "text-muted-foreground hover:text-foreground", + "hover:bg-foreground/[0.05]", + "transition-all duration-150 hover:transition-none", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + ), + button_next: cn( + "h-8 w-8 rounded-lg", + "inline-flex items-center justify-center", + "text-muted-foreground hover:text-foreground", + "hover:bg-foreground/[0.05]", + "transition-all duration-150 hover:transition-none", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + ), + month_grid: "w-full border-collapse mt-4", + weekdays: "flex gap-1", + weekday: cn( + "w-9 h-9 p-0 text-[11px] font-medium", + "text-muted-foreground/80", + "flex items-center justify-center", + ), + week: "flex gap-1 mt-1", + day: cn( + "relative w-9 h-9 p-0 text-center text-sm", + "flex items-center justify-center", + "focus-within:relative focus-within:z-20", + ), + day_button: cn( + "h-9 w-9 p-0 text-sm font-normal rounded-lg", + "inline-flex items-center justify-center", + "text-foreground", + "hover:bg-foreground/[0.05] hover:text-foreground", + "transition-all duration-150 hover:transition-none", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "aria-selected:opacity-100", + ), + selected: "[&>button]:text-foreground", + range_start: cn( + "range_start", + "[&>button]:bg-white/95 dark:[&>button]:bg-white/95", + "[&>button]:text-background dark:[&>button]:text-background", + "[&>button]:shadow-sm [&>button]:ring-1", + "[&>button]:ring-black/[0.12] dark:[&>button]:ring-white/[0.08]", + "[&>button]:hover:bg-white [&>button]:hover:text-background", + ), + range_end: cn( + "range_end", + "[&>button]:bg-white/95 dark:[&>button]:bg-white/95", + "[&>button]:text-background dark:[&>button]:text-background", + "[&>button]:shadow-sm [&>button]:ring-1", + "[&>button]:ring-black/[0.12] dark:[&>button]:ring-white/[0.08]", + "[&>button]:hover:bg-white [&>button]:hover:text-background", + ), + range_middle: cn( + "[&>button]:bg-foreground/[0.05] dark:[&>button]:bg-white/[0.06]", + "[&>button]:text-foreground [&>button]:shadow-none [&>button]:ring-0", + "[&>button]:hover:bg-foreground/[0.08] dark:[&>button]:hover:bg-white/[0.08]", + ), + outside: "text-muted-foreground/30 aria-selected:text-muted-foreground/50", + disabled: cn( + "pointer-events-none text-muted-foreground/45 dark:text-muted-foreground/30", + "[&>button]:text-muted-foreground/45 dark:[&>button]:text-muted-foreground/30", + "[&>button]:opacity-60 dark:[&>button]:opacity-40", + "[&>button]:bg-foreground/[0.02] dark:[&>button]:bg-transparent", + "[&>button]:cursor-not-allowed", + "[&>button]:hover:bg-foreground/[0.02] dark:[&>button]:hover:bg-transparent", + "[&>button]:hover:text-muted-foreground/45 dark:[&>button]:hover:text-muted-foreground/30", + ), + hidden: "invisible", + }} + /> + + )} +
+ ); +} + +export function TabbedMetricsCard({ + config, + chartData, + stackedChartData, + listData, + listTitle, + gradientColor = "blue", + projectId, + router, + height, + compact = false, + timeRange, + customDateRange = null, + totalAllTime, + showTotal = false, + stackedLegendItems, +}: { + config: LineChartDisplayConfig, + chartData: DataPoint[], + stackedChartData?: StackedDataPoint[], + listData: UserListItem[], + listTitle: string, + gradientColor?: GradientColor, + projectId: string, + router: ReturnType, + height?: number, + compact?: boolean, + timeRange: TimeRange, + customDateRange?: CustomDateRange | null, + totalAllTime?: number, + showTotal?: boolean, + stackedLegendItems?: Array<{ key: string, label: string, color: string }>, +}) { + const [view, setView] = useState<'chart' | 'list'>('chart'); + + const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange, customDateRange); + const filteredStackedDatapoints = stackedChartData ? filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange) : null; + + // Calculate total for the selected time range + const total = filteredDatapoints.reduce((sum, point) => sum + point.activity, 0); + + // For "all" time range, use totalAllTime if provided (which includes data beyond 30 days) + const displayTotal = timeRange === 'all' && totalAllTime !== undefined ? totalAllTime : total; + + const hoverAccentColors: Record = { + blue: "hover:bg-blue-500/[0.06]", + purple: "hover:bg-purple-500/[0.06]", + green: "hover:bg-emerald-500/[0.06]", + orange: "hover:bg-orange-500/[0.06]", + slate: "hover:bg-slate-500/[0.04]", + cyan: "hover:bg-cyan-500/[0.06]", + }; + + const hoverAccentClass = hoverAccentColors[gradientColor]; + const tabsGradient: "blue" | "cyan" | "purple" | "green" | "orange" | "default" = gradientColor === "slate" ? "default" : gradientColor; + + const listWindow = useInfiniteListWindow(listData.length, view === "list" ? "list" : "chart", view === "list"); + + return ( + +
+ { + if (selectedId === "chart" || selectedId === "list") { + setView(selectedId); + return; + } + throw new Error(`Unsupported metrics tab selected: ${selectedId}`); + }} + showBadge={false} + size="sm" + glassmorphic={false} + gradient={tabsGradient} + className="flex-1 min-w-0 border-0 [&>button]:rounded-none [&>button]:px-3 [&>button]:py-3.5 [&>button]:text-xs" + /> + + {view === 'chart' && showTotal && ( + + {displayTotal.toLocaleString()} + + )} +
+ + {config.description && view === 'chart' && ( +
+ {config.description} +
+ )} + + {filteredStackedDatapoints != null && view === 'chart' && ( + + )} + +
{view === 'chart' ? ( - filteredDatapoints.length === 0 ? ( + filteredStackedDatapoints != null ? ( + filteredStackedDatapoints.length === 0 ? ( +
+ + No data available for this period + +
+ ) : ( + + ) + ) : filteredDatapoints.length === 0 ? (
No data available for this period @@ -352,7 +1313,7 @@ export function TabbedMetricsCard({ /> ) ) : ( -
+
{listData.length === 0 ? (
@@ -360,44 +1321,63 @@ export function TabbedMetricsCard({
) : ( -
- {listData.map((user) => ( - - ))} + {timeLabel && ( +
+ {timeLabel} +
+ )} + + ); + })} + {listWindow.hasMore && ( +
+ + Loading more... + +
+ )}
)}
@@ -415,6 +1395,7 @@ export function LineChartDisplay({ compact = false, gradientColor = "blue", timeRange, + customDateRange = null, }: { config: LineChartDisplayConfig, datapoints: DataPoint[], @@ -423,11 +1404,20 @@ export function LineChartDisplay({ compact?: boolean, gradientColor?: GradientColor, timeRange: TimeRange, + customDateRange?: CustomDateRange | null, }) { - const filteredDatapoints = filterDatapointsByTimeRange(datapoints, timeRange); + const filteredDatapoints = filterDatapointsByTimeRange(datapoints, timeRange, customDateRange); return ( - +
@@ -450,12 +1440,427 @@ export function LineChartDisplay({
) : ( - + + )} +
+ + ); +} + +// ── StatCard ───────────────────────────────────────────────────────────────── + +export type StatCardProps = { + label: string, + value: number | string, + delta?: number, + deltaLabel?: string, + icon?: React.ReactNode, + gradientColor?: GradientColor, + className?: string, + compact?: boolean, +}; + +export function StatCard({ + label, + value, + delta, + deltaLabel, + icon, + gradientColor = "blue", + className, + compact = false, +}: StatCardProps) { + const isPositive = delta !== undefined && delta > 0; + const isNegative = delta !== undefined && delta < 0; + + return ( + +
+
+ + {label} + + {icon && ( + {icon} + )} +
+
+
+ {typeof value === 'number' ? value.toLocaleString() : value} +
+ {delta !== undefined && ( +
+ {isPositive ? '↑' : isNegative ? '↓' : '→'} + + {isPositive ? '+' : ''}{delta}{deltaLabel ?? ''} + +
+ )} +
+
+
+ ); +} + +// ── RankedListCard ──────────────────────────────────────────────────────────── + +export type RankedListItem = { + label: string, + count: number, + color?: string, +}; + +export function RankedListCard({ + title, + items, + gradientColor = "blue", + className, + compact = false, + emptyMessage = 'No data available', +}: { + title: string, + items: RankedListItem[], + gradientColor?: GradientColor, + className?: string, + compact?: boolean, + emptyMessage?: string, +}) { + const max = Math.max(...items.map(i => i.count), 1); + + return ( + +
+ {title} +
+
+ {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + items.map((item, i) => ( +
+ {i + 1} +
+
+ {item.label} + {item.count.toLocaleString()} +
+
+
+
+
+
+ )) + )} +
+ + ); +} + +// ── StatusBreakdownCard ─────────────────────────────────────────────────────── + +export type StatusBreakdownItem = { + label: string, + count: number, + color: string, +}; + +export function StatusBreakdownCard({ + title, + items, + gradientColor = "blue", + className, + compact = false, + emptyMessage = 'No data', +}: { + title: string, + items: StatusBreakdownItem[], + gradientColor?: GradientColor, + className?: string, + compact?: boolean, + emptyMessage?: string, +}) { + const total = items.reduce((s, i) => s + i.count, 0); + + return ( + +
+
+ {title} + {total > 0 && ( + {total.toLocaleString()} + )} +
+
+
+ {items.length === 0 || total === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + <> + {/* Stacked bar */} +
+ {items.filter(i => i.count > 0).map((item, idx) => ( +
+ ))} +
+ {/* Legend */} +
+ {items.filter(i => i.count > 0).map((item, idx) => ( +
+
+
+ {item.label} +
+
+ + {((item.count / total) * 100).toFixed(0)}% + + + {item.count.toLocaleString()} + +
+
+ ))} +
+ + )} +
+ + ); +} + +// ── AlertListCard ───────────────────────────────────────────────────────────── + +export type AlertItem = { + label: string, + detail?: string, + severity: 'error' | 'warning' | 'info', +}; + +export function AlertListCard({ + title, + alerts, + gradientColor = "orange", + className, + compact = false, + emptyMessage = 'No issues detected', +}: { + title: string, + alerts: AlertItem[], + gradientColor?: GradientColor, + className?: string, + compact?: boolean, + emptyMessage?: string, +}) { + const severityColors = { + error: 'bg-red-500/[0.12] text-red-600 dark:text-red-400 ring-red-500/20', + warning: 'bg-amber-500/[0.12] text-amber-600 dark:text-amber-400 ring-amber-500/20', + info: 'bg-blue-500/[0.08] text-blue-600 dark:text-blue-400 ring-blue-500/20', + }; + const severityDot = { + error: 'bg-red-500', + warning: 'bg-amber-500', + info: 'bg-blue-500', + }; + + return ( + +
+
+ {title} + {alerts.length > 0 && ( + a.severity === 'error') ? severityColors.error + : alerts.some(a => a.severity === 'warning') ? severityColors.warning + : severityColors.info + )}> + {alerts.length} + + )} +
+
+
+ {alerts.length === 0 ? ( +
+ + {emptyMessage} +
+ ) : ( + alerts.map((alert, idx) => ( +
+
+
+
{alert.label}
+ {alert.detail && ( +
{alert.detail}
+ )} +
+
+ )) + )} +
+ + ); +} + +// ── CorrelationCard ─────────────────────────────────────────────────────────── + +export type CorrelationSeries = { + key: string, + label: string, + color: string, + dataPoints: DataPoint[], +}; + +export function CorrelationCard({ + title, + series, + gradientColor = "purple", + className, + height = 180, + compact = false, + timeRange, + customDateRange = null, +}: { + title: string, + series: CorrelationSeries[], + gradientColor?: GradientColor, + className?: string, + height?: number, + compact?: boolean, + timeRange: TimeRange, + customDateRange?: CustomDateRange | null, +}) { + // Merge all series data points by date + const dateSet = new Set(); + for (const s of series) { + for (const d of s.dataPoints) dateSet.add(d.date); + } + + const sortedDates = [...dateSet].sort(); + const filteredDates = filterStackedDatapointsByTimeRange( + sortedDates.map((date) => ({ date })), + timeRange, + customDateRange, + ).map((item) => item.date); + + const merged = filteredDates.map(date => { + const row: Record = { date }; + for (const s of series) { + const pt = s.dataPoints.find(d => d.date === date); + row[s.key] = pt?.activity ?? 0; + } + return row; + }); + + const chartConfig: ChartConfig = Object.fromEntries( + series.map(s => [s.key, { label: s.label, color: s.color }]) + ); + + return ( + +
+
+ {title} +
+ {series.map(s => ( +
+
+ {s.label} +
+ ))} +
+
+
+
+ {merged.length === 0 ? ( +
+ No data available +
+ ) : ( + + + + { + const date = parseChartDate(value); + if (isNaN(date.getTime())) return value; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }} + /> + + } + cursor={{ strokeDasharray: '3 3', stroke: "hsl(var(--border))" }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + {series.map(s => ( + + ))} + + )}
@@ -510,7 +1915,11 @@ export function DonutChartDisplay({ const outerRadius = compact ? 55 : 85; return ( - +
@@ -547,7 +1956,7 @@ export function DonutChartDisplay({ wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} content={ { @@ -623,3 +2032,657 @@ export function DonutChartDisplay({ ); } + +// ── Email stacked bar chart (ok · error · in_progress per day) ─────────────── + +export type EmailStackedDataPoint = { + date: string, + ok: number, + error: number, + in_progress: number, +}; + +const emailStackedChartConfig: ChartConfig = { + ok: { label: "Delivered", color: "hsl(168, 38%, 48%)" }, + error: { label: "Error", color: "hsl(355, 45%, 52%)" }, + in_progress: { label: "Sending", color: "hsl(213, 38%, 52%)" }, +}; + +const EmailStackedTooltip = ({ active, payload }: TooltipProps) => { + if (!active || !payload?.length) return null; + const row = payload[0].payload as EmailStackedDataPoint & { avg7d?: number }; + const date = parseChartDate(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : row.date; + const total = row.ok + row.error + row.in_progress; + const segments: Array<{ key: keyof typeof emailStackedChartConfig, value: number }> = [ + { key: 'ok', value: row.ok }, + { key: 'error', value: row.error }, + { key: 'in_progress', value: row.in_progress }, + ]; + return ( +
+
+
+ {formattedDate} + {total} total +
+ {segments.filter(s => s.value > 0).map((seg) => ( +
+ + + {emailStackedChartConfig[seg.key].label} + + + {seg.value.toLocaleString()} + +
+ ))} + {row.avg7d != null && ( +
+
+ 7-day avg + + {Math.round(row.avg7d).toLocaleString()} + +
+
+ )} +
+
+ ); +}; + +export function EmailStackedBarChartDisplay({ + datapoints, + height, + compact = false, +}: { + datapoints: EmailStackedDataPoint[], + height?: number, + compact?: boolean, +}) { + const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + + const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); + const totals = datapoints.map(p => p.ok + p.error + p.in_progress); + const avgValues = rollingAvg(totals, windowSize); + const sevenDayAvgs = totals.map((_, i) => sevenDayAvg(totals, i)); + const chartData = datapoints.map((p, i) => { + const total = totals[i]; + const rawAvg = avgValues[i]; + const movingAvg = total > 0 && rawAvg != null + ? Math.min(total * 0.9, Math.max(total * 0.35, rawAvg)) + : null; + const isHighlightedAvg = hoveredIndex != null && i <= hoveredIndex && i >= hoveredIndex - 6; + return { + ...p, + movingAvg, + avg7d: sevenDayAvgs[i], + highlightedAvg: isHighlightedAvg ? movingAvg : null, + }; + }); + + const movingAvgConfig: ChartConfig = { + ...emailStackedChartConfig, + movingAvg: { label: "Moving avg", color: "hsl(var(--foreground))" }, + }; + + return ( + + updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > + + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: hoveredIndex == null ? 0.08 : 0.12, radius: 4 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + {/* Stack order: ok (bottom) → in_progress → error (top). Only the topmost segment per bar gets rounded corners. */} + {(["ok", "in_progress", "error"] as const).map((dataKey) => { + const isTopmost = (entry: EmailStackedDataPoint) => { + if (entry.error > 0) return dataKey === "error"; + if (entry.in_progress > 0) return dataKey === "in_progress"; + return dataKey === "ok"; + }; + const colorVar = dataKey === "ok" ? "ok" : dataKey === "in_progress" ? "in_progress" : "error"; + return ( + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + ); + })} + + + {hoveredIndex != null && ( + + )} + { + const d = parseChartDate(value); + return isNaN(d.getTime()) ? value : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }} + /> + + + + ); +} + +// ── Visitors hover chart (page views + avg line) ──────────────────────────── + +const visitorsHoverChartConfig: ChartConfig = { + page_views: { + label: "Page Views", + theme: { light: "hsl(210, 84%, 64%)", dark: "hsl(210, 84%, 72%)" }, + }, + movingAvg: { + label: "Moving avg", + color: "hsl(var(--foreground))", + }, +}; + +function VisitorsHoverTooltip({ active, payload }: TooltipProps) { + if (!active || !payload?.length) return null; + + const row = payload[0]?.payload as VisitorsHoverDataPoint | undefined; + if (!row) return null; + + const date = parseChartDate(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : row.date; + + const topCountries = (row.top_countries ?? []).slice(0, 3); + + return ( +
+
+
+ {formattedDate} + + {row.page_views.toLocaleString()} total + +
+
+
+ + Page Views + + {row.page_views.toLocaleString()} + +
+ {row.avg7d != null && ( +
+ 7-day avg + + {Math.round(row.avg7d).toLocaleString()} + +
+ )} + {topCountries.length > 0 && ( +
+ Top countries + {topCountries.map((country) => ( +
+ {country.country_code} + + {country.count.toLocaleString()} + +
+ ))} +
+ )} +
+
+
+ ); +} + +export function VisitorsHoverChart({ + datapoints, + height, + compact = false, +}: { + datapoints: VisitorsHoverDataPoint[], + height?: number, + compact?: boolean, +}) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); + const totals = datapoints.map((p) => p.page_views); + const avgValues = rollingAvg(totals, windowSize); + const sevenDayAvgs = totals.map((_, i) => sevenDayAvg(totals, i)); + const chartData = datapoints.map((p, i) => { + const total = totals[i]; + const rawAvg = avgValues[i]; + const movingAvg = total > 0 && rawAvg != null + ? Math.min(total * 0.9, Math.max(total * 0.35, rawAvg)) + : null; + const isHighlightedAvg = hoveredIndex != null && i <= hoveredIndex && i >= hoveredIndex - 6; + return { + ...p, + movingAvg, + avg7d: sevenDayAvgs[i], + highlightedAvg: isHighlightedAvg ? movingAvg : null, + }; + }); + + return ( + + updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > + + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: hoveredIndex == null ? 0.08 : 0.12, radius: 4 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + + {hoveredIndex != null && ( + + )} + { + const d = parseChartDate(value); + return isNaN(d.getTime()) ? value : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }} + /> + + + + ); +} + +// ── Revenue hover chart (new_cents + refund_cents stacked bar) ─────────────── + +const revenueHoverChartConfig: ChartConfig = { + new_cents: { + label: "Revenue", + theme: { light: "hsl(268, 82%, 66%)", dark: "hsl(268, 82%, 74%)" }, + }, + refund_cents: { + label: "Refunds", + theme: { light: "hsl(355, 70%, 68%)", dark: "hsl(355, 70%, 76%)" }, + }, + movingAvg: { + label: "Moving avg", + color: "hsl(var(--foreground))", + }, +}; + +function formatUsdCompact(cents: number): string { + const dollars = cents / 100; + if (dollars >= 1000) return `$${(dollars / 1000).toFixed(1)}k`; + return `$${dollars.toFixed(0)}`; +} + +function RevenueHoverTooltip({ active, payload }: TooltipProps) { + if (!active || !payload?.length) return null; + + const row = payload[0]?.payload as RevenueHoverDataPoint | undefined; + if (!row) return null; + + const date = parseChartDate(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : row.date; + + const net = row.new_cents - row.refund_cents; + + return ( +
+
+ {formattedDate} +
+
+ + Revenue + + {formatUsdCompact(row.new_cents)} + +
+
+ + Refunds + + {formatUsdCompact(row.refund_cents)} + +
+
+ Net + + {formatUsdCompact(Math.max(0, net))} + +
+ {row.avg7d != null && ( +
+ 7-day avg + + {formatUsdCompact(Math.round(row.avg7d))} + +
+ )} +
+
+
+ ); +} + +export function RevenueHoverChart({ + datapoints, + height, + compact = false, +}: { + datapoints: RevenueHoverDataPoint[], + height?: number, + compact?: boolean, +}) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const windowSize = Math.max(4, Math.round(datapoints.length / 2.5)); + const totals = datapoints.map((p) => p.new_cents + p.refund_cents); + const avgValues = rollingAvg(totals, windowSize); + const sevenDayAvgs = totals.map((_, i) => sevenDayAvg(totals, i)); + const chartData = datapoints.map((p, i) => { + const total = totals[i]; + const rawAvg = avgValues[i]; + const movingAvg = total > 0 && rawAvg != null + ? Math.min(total * 0.9, Math.max(total * 0.35, rawAvg)) + : null; + const isHighlightedAvg = hoveredIndex != null && i <= hoveredIndex && i >= hoveredIndex - 6; + return { + ...p, + movingAvg, + avg7d: sevenDayAvgs[i], + highlightedAvg: isHighlightedAvg ? movingAvg : null, + }; + }); + + const maxCents = Math.max(...chartData.map(d => d.new_cents + d.refund_cents), 1); + const ticksCents = niceAxisTicks(Math.ceil(maxCents * 1.1), 4); + + return ( + + updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} + > + + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: hoveredIndex == null ? 0.08 : 0.12, radius: 4 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + + {hoveredIndex != null && ( + + )} + { + const d = parseChartDate(value); + return isNaN(d.getTime()) ? value : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }} + /> + formatUsdCompact(v)} + width={36} + /> + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx index 9466fd4442..50f6be81bc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx @@ -1,354 +1,939 @@ 'use client'; import { AppIcon } from "@/components/app-square"; +import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, useInfiniteListWindow } from "@/components/design-components"; import { Link } from "@/components/link"; import { useRouter } from "@/components/router"; -import { cn, Typography } from '@/components/ui'; -import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend"; -import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; -import { CaretUpIcon, CompassIcon, DotsThreeIcon, GlobeIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { cn, Typography } from "@/components/ui"; +import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend"; +import { + type MetricsEmailOverview, + type MetricsRecentEmail, + type MetricsTopReferrer, + useMetricsOrThrow, +} from "@/lib/stack-app-internals"; +import { CompassIcon, EnvelopeIcon, EnvelopeOpenIcon, GlobeIcon, SquaresFourIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; import useResizeObserver from '@react-hook/resize-observer'; -import { useUser } from '@stackframe/stack'; -import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { useUser } from "@stackframe/stack"; +import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; -import { Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ElementType } from "react"; import { PageLayout } from "../page-layout"; -import { useAdminApp, useProjectId } from '../use-admin-app'; -import { GlobeSectionWithData } from './globe-section-with-data'; -import { LineChartDisplayConfig, TabbedMetricsCard, TimeRange, TimeRangeToggle } from './line-chart'; -import { MetricsLoadingFallback } from './metrics-loading'; - -// Widget definitions -type WidgetId = 'apps' | 'daily-active-users' | 'daily-sign-ups' | 'globe' | 'total-users'; - -type WidgetConfig = { - id: WidgetId, - name: string, - description: string, - defaultEnabled: boolean, - area: 'left' | 'right', -} - -const AVAILABLE_WIDGETS: WidgetConfig[] = [ - { id: 'globe', name: 'Globe', description: 'Interactive 3D globe showing user locations', defaultEnabled: true, area: 'left' }, - { id: 'total-users', name: 'Total Users', description: 'Overview of total registered users', defaultEnabled: true, area: 'right' }, - { id: 'apps', name: 'Quick Access', description: 'Quick access to your installed apps', defaultEnabled: true, area: 'right' }, - { id: 'daily-active-users', name: 'Daily Active Users', description: 'Chart and list of active users', defaultEnabled: true, area: 'right' }, - { id: 'daily-sign-ups', name: 'Daily Sign-Ups', description: 'Chart and list of new registrations', defaultEnabled: true, area: 'right' }, -]; - -type DashboardConfig = { - enabledWidgets: WidgetId[], - widgetOrder: WidgetId[], -} +import { useAdminApp, useProjectId } from "../use-admin-app"; +import { GlobeSectionWithData } from "./globe-section-with-data"; +import { + ComposedAnalyticsChart, + ComposedDataPoint, + CustomDateRange, + DataPoint, + DonutChartDisplay, + EmailStackedBarChartDisplay, + EmailStackedDataPoint, + filterStackedDatapointsByTimeRange, + LineChartDisplayConfig, + RevenueHoverChart, + RevenueHoverDataPoint, + StackedBarChartDisplay, + StackedDataPoint, + TabbedMetricsCard, + TimeRange, + TimeRangeToggle, + VisitorsHoverChart, + VisitorsHoverDataPoint, +} from "./line-chart"; +import { MetricsLoadingFallback } from "./metrics-loading"; -const DEFAULT_CONFIG: DashboardConfig = { - enabledWidgets: AVAILABLE_WIDGETS.filter(w => w.defaultEnabled).map(w => w.id), - widgetOrder: AVAILABLE_WIDGETS.map(w => w.id), +const dailySignUpsConfig: LineChartDisplayConfig = { + name: 'Daily Sign-Ups', + chart: { + activity: { + label: "Sign-Ups", + theme: { light: "hsl(221, 83%, 53%)", dark: "hsl(240, 71%, 70%)" }, + }, + }, }; -const STORAGE_KEY = 'stack-dashboard-widget-config'; - -function loadConfig(): DashboardConfig { - if (typeof window === 'undefined') return DEFAULT_CONFIG; - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = JSON.parse(stored); - // Validate and merge with defaults for any new widgets - const validWidgetIds = new Set(AVAILABLE_WIDGETS.map(w => w.id)); - const enabledWidgets = (parsed.enabledWidgets || []).filter((id: string) => validWidgetIds.has(id as WidgetId)); - const widgetOrder = (parsed.widgetOrder || []).filter((id: string) => validWidgetIds.has(id as WidgetId)); - - // Add any new widgets that aren't in the stored config - for (const widget of AVAILABLE_WIDGETS) { - if (!widgetOrder.includes(widget.id)) { - widgetOrder.push(widget.id); - if (widget.defaultEnabled) { - enabledWidgets.push(widget.id); - } - } - } +function formatUsdFromCents(cents: number): string { + return `$${(cents / 100).toLocaleString(undefined, { maximumFractionDigits: 0 })}`; +} - return { enabledWidgets, widgetOrder }; - } - } catch (e) { - console.error('Failed to load dashboard config:', e); - } - return DEFAULT_CONFIG; +function formatSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return "0s"; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return m > 0 ? `${m}m ${s}s` : `${s}s`; } -// TODO: This function will be used when widget configuration UI is implemented -function saveConfig(config: DashboardConfig) { - if (typeof window === 'undefined') return; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); - } catch (e) { - console.error('Failed to save dashboard config:', e); - } +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toLocaleString(); } -const dailySignUpsConfig = { - name: 'Daily Sign-Ups', - chart: { - activity: { - label: "Activity", - theme: { - light: "hsl(221, 83%, 53%)", - dark: "hsl(240, 71%, 70%)", - }, - }, +function calculatePeriodDelta(currentValue: number, previousValue: number): number | undefined { + if (!Number.isFinite(currentValue) || !Number.isFinite(previousValue)) { + return undefined; } -} satisfies LineChartDisplayConfig; - -const dauConfig = { - name: 'Daily Active Users', - chart: { - activity: { - label: "Activity", - theme: { - light: "hsl(180, 95%, 53%)", - dark: "hsl(200, 91%, 70%)", - }, - }, + if (previousValue === 0) { + return currentValue === 0 ? 0 : undefined; } -} satisfies LineChartDisplayConfig; + return Number((((currentValue - previousValue) / previousValue) * 100).toFixed(1)); +} -function TotalUsersDisplay({ includeAnonymous, minimal = false }: { includeAnonymous: boolean, minimal?: boolean }) { - const adminApp = useAdminApp(); - const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous); +function SetupAppPrompt({ + projectId, + appId, + appLabel, + metricLabel, +}: { + projectId: string, + appId: AppId, + appLabel: string, + metricLabel: string, +}) { + return ( +
+
+ + Enable{" "} + + {appLabel} + {" "} + in Explore Apps to track {metricLabel}. + + + Open Explore Apps + +
+
+ ); +} - const totalUsers = data.total_users || 0; +type AnalyticsStatPill = { + label: string, + value: string, + delta?: number, +}; - if (minimal) { - return <>{totalUsers.toLocaleString()}; - } +function StatCard({ + stat, + compact = false, +}: { + stat: AnalyticsStatPill, + compact?: boolean, +}) { + return ( + +
+ + {stat.label} + +
+ + {stat.value} + + {stat.delta != null && ( + 0 ? "text-emerald-600 dark:text-emerald-400" : stat.delta < 0 ? "text-red-500 dark:text-red-400" : "text-muted-foreground" + )}> + {stat.delta > 0 ? "+" : ""}{stat.delta}% + + )} +
+
+
+ ); +} + +type AnalyticsChartMode = 'default' | 'dau' | 'visitors' | 'revenue'; +function AnalyticsInChartPill({ + label, + value, + delta, + color, + isSelected, + controlsId, + tabId, + onActivate, + onHoverPreview, + onHoverEnd, + onArrowNavigate, +}: { + label: string, + value: string, + delta?: number, + color: string, + isSelected: boolean, + controlsId: string, + tabId: string, + onActivate: () => void, + onHoverPreview: () => void, + onHoverEnd: () => void, + onArrowNavigate: (direction: 'next' | 'prev' | 'first' | 'last') => void, +}) { return ( - - {totalUsers.toLocaleString()} users - + ); } +function AnalyticsChartWidget({ + composedData, + dauStackedData, + visitorsData, + revenueData, + outerStats, + dauLabel, + dauTotal, + visitorsLabel, + revenueLabel, + dauDelta, + visitorsTotal, + revenueTotal, + visitorsDelta, + revenueDelta, + analyticsEnabled, + paymentsEnabled, + projectId, + compact = false, +}: { + composedData: ComposedDataPoint[], + dauStackedData: StackedDataPoint[], + visitorsData: VisitorsHoverDataPoint[], + revenueData: RevenueHoverDataPoint[], + outerStats: AnalyticsStatPill[], + dauLabel: string, + dauTotal: string, + visitorsLabel: string, + revenueLabel: string, + dauDelta?: number, + visitorsTotal: string, + revenueTotal: string, + visitorsDelta?: number, + revenueDelta?: number, + analyticsEnabled: boolean, + paymentsEnabled: boolean, + projectId: string, + compact?: boolean, +}) { + const [selectedMode, setSelectedMode] = useState('default'); + const [previewMode, setPreviewMode] = useState(null); + const [displayMode, setDisplayMode] = useState('default'); + const [fadingOut, setFadingOut] = useState(false); + const [fadingIn, setFadingIn] = useState(false); + const fadeTimerRef = useRef | null>(null); + const fadeInRaf1Ref = useRef(null); + const fadeInRaf2Ref = useRef(null); + const FADE_OUT_MS = 140; + + const tablistInstanceId = useId(); + const tabpanelId = `${tablistInstanceId}-panel`; + const dauTabId = `${tablistInstanceId}-tab-dau`; + const visitorsTabId = `${tablistInstanceId}-tab-visitors`; + const revenueTabId = `${tablistInstanceId}-tab-revenue`; + + const activeMode: AnalyticsChartMode = previewMode ?? selectedMode; -// Widget components for better organization -function AppsWidget({ installedApps, projectId }: { installedApps: AppId[], projectId: string }) { - const [ref, setRef] = useState(null); - const [width, setWidth] = useState(0); - const [expanded, setExpanded] = useState(false); + const switchToMode = (mode: AnalyticsChartMode) => { + if (mode === displayMode) return; + if (fadeTimerRef.current != null) { + clearTimeout(fadeTimerRef.current); + } + setFadingOut(true); + fadeTimerRef.current = setTimeout(() => { + setDisplayMode(mode); + setFadingOut(false); + setFadingIn(true); + fadeInRaf1Ref.current = requestAnimationFrame(() => { + fadeInRaf2Ref.current = requestAnimationFrame(() => { + setFadingIn(false); + fadeInRaf2Ref.current = null; + }); + fadeInRaf1Ref.current = null; + }); + fadeTimerRef.current = null; + }, FADE_OUT_MS); + }; - useResizeObserver(ref, (entry) => setWidth(entry.contentRect.width)); + useEffect(() => { + switchToMode(activeMode); + // eslint-disable-next-line react-hooks/exhaustive-deps -- switchToMode closes over displayMode/fade state + }, [activeMode]); - const gap = 8; - const minItemWidth = 90; - const itemsPerRow = Math.max(1, Math.floor((width + gap) / (minItemWidth + gap))); - const maxRows = 2; - const maxItems = itemsPerRow * maxRows; + useEffect(() => { + return () => { + if (fadeTimerRef.current != null) { + clearTimeout(fadeTimerRef.current); + } + if (fadeInRaf1Ref.current != null) { + cancelAnimationFrame(fadeInRaf1Ref.current); + } + if (fadeInRaf2Ref.current != null) { + cancelAnimationFrame(fadeInRaf2Ref.current); + } + }; + }, []); - // Account for Explore button (always shown) and See all button (shown when can expand) - // Explore takes 1 slot, See all takes 1 slot when needed - const slotsForApps = maxItems - 1; // -1 for Explore (always shown) - const canExpand = installedApps.length > slotsForApps && width > 0; - const showSeeAll = !expanded && canExpand; - const showShowLess = expanded && canExpand; - // When See all is shown, we need another slot for it - const displayApps = showSeeAll ? installedApps.slice(0, slotsForApps - 1) : installedApps; + const handleHoverPreview = (mode: 'dau' | 'visitors' | 'revenue') => { + setPreviewMode(mode); + }; + + const handleHoverEnd = () => { + setPreviewMode(null); + }; + + const handleSelect = (mode: AnalyticsChartMode) => { + setSelectedMode(mode); + setPreviewMode(null); + }; + + const PILL_MODE_ORDER = ['dau', 'visitors', 'revenue'] as const; + const handleArrowNavigate = (current: 'dau' | 'visitors' | 'revenue', direction: 'next' | 'prev' | 'first' | 'last') => { + const idx = PILL_MODE_ORDER.indexOf(current); + let nextIdx: number; + switch (direction) { + case 'next': { + nextIdx = (idx + 1) % PILL_MODE_ORDER.length; + break; + } + case 'prev': { + nextIdx = (idx - 1 + PILL_MODE_ORDER.length) % PILL_MODE_ORDER.length; + break; + } + case 'first': { + nextIdx = 0; + break; + } + case 'last': { + nextIdx = PILL_MODE_ORDER.length - 1; + break; + } + } + handleSelect(PILL_MODE_ORDER[nextIdx]); + }; + + const dauColor = "hsl(152, 38%, 52%)"; + const visitorsColor = "hsl(210, 84%, 64%)"; + const revenueColor = "hsl(268, 82%, 66%)"; + const chartViewportHeight = compact ? 260 : 320; return ( -
-
-
- -
- - Quick Access - +
+
3 ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3", + )}> + {outerStats.map((stat) => ( + + ))}
- {installedApps.length === 0 ? ( -
- - No apps installed - -
- ) : ( + +
- {displayApps.map((appId) => { - const appFrontend = ALL_APPS_FRONTEND[appId]; - const app = ALL_APPS[appId]; - const appPath = getAppPath(projectId, appFrontend); - return ( - -
- -
- - {app.displayName} - - - ); - })} - {/* Explore Apps - always shown before See all/Less */} - -
-
- -
-
- - Explore - - - {showSeeAll && ( - - )} - {showShowLess && ( - - )} + {displayMode === 'default' && ( + composedData.length === 0 ? ( +
+ No data available +
+ ) : ( + + ) + )} + {displayMode === 'dau' && ( + dauStackedData.length === 0 ? ( +
+ No daily active user data available +
+ ) : ( + + ) + )} + {displayMode === 'visitors' && ( + !analyticsEnabled ? ( +
+ +
+ ) : visitorsData.length === 0 ? ( +
+ No visitor data available +
+ ) : ( + + ) + )} + {displayMode === 'revenue' && ( + !paymentsEnabled ? ( +
+ +
+ ) : revenueData.length === 0 ? ( +
+ No revenue data available +
+ ) : ( + + ) + )} +
+
- )} +
); } -function DailyActiveUsersWidget({ - data, - projectId, - router, - timeRange +type EmailItem = MetricsRecentEmail; + +const emailStatusConfig = new Map([ + ['sent', { label: 'Sent', icon: EnvelopeIcon, bg: 'bg-blue-500/10 dark:bg-blue-500/15', text: 'text-blue-600 dark:text-blue-400', dot: 'bg-blue-500' }], + ['opened', { label: 'Opened', icon: EnvelopeOpenIcon, bg: 'bg-emerald-500/10 dark:bg-emerald-500/15', text: 'text-emerald-600 dark:text-emerald-400', dot: 'bg-emerald-500' }], + ['delivered', { label: 'Delivered', icon: EnvelopeIcon, bg: 'bg-emerald-500/10 dark:bg-emerald-500/15', text: 'text-emerald-600 dark:text-emerald-400', dot: 'bg-emerald-500' }], + ['bounced', { label: 'Bounced', icon: XCircleIcon, bg: 'bg-red-500/10 dark:bg-red-500/15', text: 'text-red-600 dark:text-red-400', dot: 'bg-red-500' }], + ['error', { label: 'Error', icon: WarningCircleIcon, bg: 'bg-amber-500/10 dark:bg-amber-500/15', text: 'text-amber-600 dark:text-amber-400', dot: 'bg-amber-500' }], + ['in_progress', { label: 'Sending', icon: EnvelopeIcon, bg: 'bg-sky-500/10 dark:bg-sky-500/15', text: 'text-sky-600 dark:text-sky-400', dot: 'bg-sky-400' }], +]); + +const fallbackEmailStatus = { label: 'Unknown', icon: EnvelopeIcon, bg: 'bg-foreground/[0.06]', text: 'text-muted-foreground', dot: 'bg-muted-foreground' }; + +function EmailListRow({ email }: { email: EmailItem }) { + const key = email.status.toLowerCase().replace(/\s+/g, '_'); + const cfg = emailStatusConfig.get(key) ?? fallbackEmailStatus; + const StatusIcon = cfg.icon; + + return ( +
+
+ +
+ +
+
+ {email.subject} +
+
+ +
+ + {cfg.label} +
+
+ ); +} + +const emailLegendItems = [ + { key: 'ok', label: 'Delivered', color: 'hsl(168, 38%, 48%)' }, + { key: 'in_progress', label: 'Sending', color: 'hsl(213, 38%, 52%)' }, + { key: 'error', label: 'Error', color: 'hsl(355, 45%, 52%)' }, +] as const; + +function TabbedEmailsCard({ + stackedChartData, + recentEmails, + timeRange, + customDateRange = null, + compact = false, }: { - data: any, - projectId: string, - router: ReturnType, + stackedChartData: EmailStackedDataPoint[], + recentEmails: MetricsRecentEmail[], timeRange: TimeRange, + customDateRange?: CustomDateRange | null, + compact?: boolean, +}) { + const [view, setView] = useState<'chart' | 'list'>('chart'); + const filteredDatapoints = filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange); + + const listWindow = useInfiniteListWindow(recentEmails.length, view === "list" ? "list" : "chart", view === "list"); + + return ( + +
+ { + if (selectedId === "chart" || selectedId === "list") { + setView(selectedId); + return; + } + throw new Error(`Unsupported emails tab selected: ${selectedId}`); + }} + showBadge={false} + size="sm" + glassmorphic={false} + gradient="blue" + className="flex-1 min-w-0 border-0 [&>button]:rounded-none [&>button]:px-3 [&>button]:py-3.5 [&>button]:text-xs" + /> +
+ {view === 'chart' && ( + + )} +
+ {view === 'chart' ? ( + filteredDatapoints.length === 0 ? ( +
+ No email data for this period +
+ ) : ( + + ) + ) : ( +
+ {recentEmails.length === 0 ? ( +
+ No recent emails +
+ ) : ( +
+ {recentEmails.slice(0, listWindow.visibleCount).map((email) => ( + + ))} + {listWindow.hasMore && ( +
+ + Loading more... + +
+ )} +
+ )} +
+ )} +
+
+ ); +} + +function EmailBreakdownCard({ + deliverabilityStatus, + bounceRate, + clickRate, +}: { + deliverabilityStatus: MetricsEmailOverview['deliverability_status'], + bounceRate: number, + clickRate: number, }) { + const items = [ + { label: 'Delivered', count: deliverabilityStatus.delivered, color: '#10b981' }, + { label: 'Bounced', count: deliverabilityStatus.bounced, color: '#ef4444' }, + { label: 'In Progress', count: deliverabilityStatus.in_progress, color: '#06b6d4' }, + { label: 'Error', count: deliverabilityStatus.error, color: '#f59e0b' }, + ]; + const total = items.reduce((s, i) => s + i.count, 0); + return ( - + +
+ Email Delivery +
+
+ {total === 0 ? ( +
+ No email data +
+ ) : ( + <> +
+ {items.filter(i => i.count > 0).map((item, idx) => ( +
+ ))} +
+
+ {items.filter(i => i.count > 0).map((item, idx) => ( +
+
+
+ {item.label} +
+
+ + {((item.count / total) * 100).toFixed(0)}% + + + {item.count.toLocaleString()} + +
+
+ ))} +
+ + )} + +
+
+
Bounce Rate
+
{bounceRate}%
+
+
+
Click Rate
+
{clickRate}%
+
+
+
+ ); } -function DailySignUpsWidget({ - data, +function ReferrersWithAnalyticsCard({ + topReferrers, + analyticsEnabled, projectId, - router, - timeRange }: { - data: any, + topReferrers: MetricsTopReferrer[], + analyticsEnabled: boolean, projectId: string, - router: ReturnType, - timeRange: TimeRange, }) { + const listWindow = useInfiniteListWindow(topReferrers.length); + return ( - + +
+ Top Referrers +
+
+ {!analyticsEnabled ? ( + + ) : topReferrers.length === 0 ? ( +
+ No referrer data +
+ ) : ( +
+ {topReferrers.slice(0, listWindow.visibleCount).map((item) => { + const max = topReferrers[0].visitors; + return ( +
+
0 ? `${(item.visitors / max) * 100}%` : '0%' }} + /> + {item.referrer} + {item.visitors.toLocaleString()} +
+ ); + })} + {listWindow.hasMore && ( +
+ + Loading more... + +
+ )} +
+ )} +
+ + ); +} + +function QuickAccessApps({ projectId, installedApps }: { projectId: string, installedApps: AppId[] }) { + return ( +
+
+
+
+ +
+ + Quick Access + +
+ + {installedApps.length === 0 ? ( +
+ + No apps installed + +
+ ) : ( +
+ {installedApps.map((appId) => { + const appFrontend = ALL_APPS_FRONTEND[appId]; + const appPath = getAppPath(projectId, appFrontend); + const app = ALL_APPS[appId]; + return ( + +
+ +
+ + {app.displayName} + + + ); + })} + + +
+
+ +
+
+ + Explore + + +
+ )} +
+
); } export default function MetricsPage(props: { toSetup: () => void }) { - const adminApp = useAdminApp(); - const project = adminApp.useProject(); - const config = project.useConfig(); - // Currently always false - can be made configurable in the future const includeAnonymous = false; - const [timeRange, setTimeRange] = useState('30d'); - const [dashboardConfig, setDashboardConfig] = useState(DEFAULT_CONFIG); + const [timeRange, setTimeRange] = useState("30d"); + const [customDateRange, setCustomDateRange] = useState(null); const user = useUser(); - // Load config from localStorage on mount - useEffect(() => { - setDashboardConfig(loadConfig()); - }, []); - - const installedApps = typedEntries(config.apps.installed) - .filter(([_, appConfig]) => appConfig?.enabled) - .map(([appId]) => appId as AppId); - - // Get display name with smart truncation - const displayName = user?.displayName || user?.primaryEmail || 'User'; - const truncatedName = displayName.length > 30 ? displayName.slice(0, 30) + '...' : displayName; + const displayName = user?.displayName || user?.primaryEmail || "User"; + const truncatedName = displayName.length > 30 ? `${displayName.slice(0, 30)}...` : displayName; return ( - +
} fillWidth + fullBleed + wrapHeaderInCard > }> - + ); @@ -356,157 +941,272 @@ export default function MetricsPage(props: { toSetup: () => void }) { function MetricsContent({ includeAnonymous, - installedApps, timeRange, - dashboardConfig, + customDateRange, }: { includeAnonymous: boolean, - installedApps: AppId[], timeRange: TimeRange, - dashboardConfig: DashboardConfig, + customDateRange: CustomDateRange | null, }) { const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); const projectId = useProjectId(); const router = useRouter(); - const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous); + const data = useMetricsOrThrow(adminApp, includeAnonymous); + const installedApps = useMemo( + () => typedEntries(config.apps.installed) + .filter(([_, appConfig]) => appConfig?.enabled === true) + .map(([appId]) => appId as AppId), + [config.apps.installed] + ); + const analyticsEnabled = installedApps.includes("analytics"); + const paymentsEnabled = installedApps.includes("payments"); - const isWidgetEnabled = (id: WidgetId) => dashboardConfig.enabledWidgets.includes(id); + const auth = data.auth_overview; + const payments = data.payments_overview; + const email = data.email_overview; + const analytics = data.analytics_overview; - // Get ordered right-side widgets - const rightWidgets = useMemo(() => { - return dashboardConfig.widgetOrder - .filter(id => { - const widget = AVAILABLE_WIDGETS.find(w => w.id === id); - return widget?.area === 'right' && dashboardConfig.enabledWidgets.includes(id); - }); - }, [dashboardConfig]); + const recentEmails = email.recent_emails; + const topReferrers = analytics.top_referrers; - const showGlobe = isWidgetEnabled('globe'); + const dauSplit = auth.daily_active_users_split; + const dauStackedData = useMemo(() => { + const dateSet = new Set([ + ...dauSplit.new.map(d => d.date), + ...dauSplit.retained.map(d => d.date), + ...dauSplit.reactivated.map(d => d.date), + ]); + const newMap = new Map(dauSplit.new.map(d => [d.date, d.activity])); + const retainedMap = new Map(dauSplit.retained.map(d => [d.date, d.activity])); + const reactivatedMap = new Map(dauSplit.reactivated.map(d => [d.date, d.activity])); + return [...dateSet].sort().map(date => ({ + date, + new: newMap.get(date) ?? 0, + retained: retainedMap.get(date) ?? 0, + reactivated: reactivatedMap.get(date) ?? 0, + })); + }, [dauSplit.new, dauSplit.retained, dauSplit.reactivated]); + const signUpsStackedData = useMemo( + () => data.daily_users.map((point) => ({ + date: point.date, + new: point.activity, + retained: 0, + reactivated: 0, + })), + [data.daily_users], + ); + const filteredDauStackedData = useMemo( + () => filterStackedDatapointsByTimeRange(dauStackedData, timeRange, customDateRange), + [dauStackedData, timeRange, customDateRange], + ); + const dauTotalsByDate = useMemo>( + () => new Map(dauStackedData.map((point) => [point.date, point.new + point.retained + point.reactivated])), + [dauStackedData], + ); - // Track grid container width to calculate globe column width - const gridContainerRef = useRef(null); - const [gridContainerSize, setGridContainerSize] = useState(); + const emailStackedData = useMemo( + () => email.daily_emails_by_status, + [email.daily_emails_by_status], + ); - useLayoutEffect(() => { - setGridContainerSize(gridContainerRef.current?.getBoundingClientRect()); - }, []); + const allComposedData = useMemo(() => { + const dailyRev = analytics.daily_revenue; + const dailyVis = analytics.daily_visitors; - useResizeObserver(gridContainerRef, (entry) => setGridContainerSize(entry.contentRect)); - - // Calculate globe column width (5/12 of grid width, accounting for gaps) - // Grid has 12 columns, globe takes 5, with gap-4 sm:gap-5 between columns - // On lg screens, gap-5 applies = 1.25rem = 20px - const calculateGlobeColumnWidth = () => { - if (!gridContainerSize?.width) return 0; - const gap = 20; // gap-5 = 1.25rem = 20px on lg screens (sm:gap-5 applies) - const totalGaps = gap * 11; // 11 gaps between 12 columns - const availableWidth = gridContainerSize.width - totalGaps; - const columnWidth = availableWidth / 12; - return columnWidth * 5 + gap * 4; // 5 columns + 4 gaps - }; + const visitorMap = new Map(dailyVis.map(d => [d.date, d.activity])); + const revenueMap = new Map(dailyRev.map(d => [d.date, d])); - const globeColumnWidth = calculateGlobeColumnWidth(); + const allDates = new Set([ + ...dailyVis.map(d => d.date), + ...dailyRev.map(d => d.date), + ...dauStackedData.map(d => d.date), + ]); - // Hide globe and total users section when width is less than 352.5px - const GLOBE_MIN_WIDTH = 352.5; - const shouldShowGlobeSection = showGlobe && globeColumnWidth >= GLOBE_MIN_WIDTH; + const points = [...allDates].map(date => ({ + date, + visitors: analyticsEnabled ? (visitorMap.get(date) ?? 0) : 0, + new_cents: paymentsEnabled ? (revenueMap.get(date)?.new_cents ?? 0) : 0, + refund_cents: paymentsEnabled ? (revenueMap.get(date)?.refund_cents ?? 0) : 0, + dau: dauTotalsByDate.get(date) ?? 0, + })).sort((a, b) => stringCompare(a.date, b.date)); - // On lg screens, derive grid height from globe column width - // Formula: height = min(max(viewHeight, globeWidth), globeWidth * 1.75) - const [isLgScreen, setIsLgScreen] = useState(false); - const [viewHeight, setViewHeight] = useState(0); - useEffect(() => { - const mediaQuery = window.matchMedia('(min-width: 1024px)'); - setIsLgScreen(mediaQuery.matches); - const handler = (e: MediaQueryListEvent) => setIsLgScreen(e.matches); - mediaQuery.addEventListener('change', handler); + return points; + }, [analytics.daily_revenue, analytics.daily_visitors, dauStackedData, dauTotalsByDate, analyticsEnabled, paymentsEnabled]); + const composedData = useMemo( + () => filterStackedDatapointsByTimeRange(allComposedData, timeRange, customDateRange), + [allComposedData, timeRange, customDateRange], + ); - // Track viewport height - const updateViewHeight = () => setViewHeight(window.innerHeight - 180); // same as calc(100vh - 180px) - updateViewHeight(); - window.addEventListener('resize', updateViewHeight); + const topCountries = useMemo>(() => { + const countries: Array<{ country_code: string, count: number }> = []; + for (const [countryCode, count] of Object.entries(data.users_by_country)) { + if (countryCode.length === 0) continue; + if (!Number.isFinite(count) || count <= 0) continue; + countries.push({ country_code: countryCode.toUpperCase(), count }); + } + + countries.sort((a, b) => b.count - a.count || stringCompare(a.country_code, b.country_code)); + return countries.slice(0, 3); + }, [data.users_by_country]); + + const visitorsHoverData = useMemo(() => { + if (!analyticsEnabled) { + return []; + } + const dailyPv = analytics.daily_page_views; + + const pvMap = new Map(dailyPv.map(d => [d.date, d.activity])); + const allDates = new Set(dailyPv.map(d => d.date)); + + const points = [...allDates].map(date => ({ + date, + page_views: pvMap.get(date) ?? 0, + top_countries: topCountries, + })).sort((a, b) => stringCompare(a.date, b.date)); + + return filterStackedDatapointsByTimeRange(points, timeRange, customDateRange); + }, [analytics.daily_page_views, timeRange, customDateRange, topCountries, analyticsEnabled]); + + const revenueHoverData = useMemo(() => { + if (!paymentsEnabled) { + return []; + } + + const points = analytics.daily_revenue.map(d => ({ + date: d.date, + new_cents: d.new_cents, + refund_cents: d.refund_cents, + })).sort((a, b) => stringCompare(a.date, b.date)); + + return filterStackedDatapointsByTimeRange(points, timeRange, customDateRange); + }, [analytics.daily_revenue, timeRange, customDateRange, paymentsEnabled]); + + const analyticsOuterStats = useMemo(() => { + const totalUsers = data.total_users; + const mau = Math.min(auth.mau, totalUsers); + const totalEmailsSent = email.emails_sent; + return [ + { + label: "Monthly active users", + value: formatCompact(mau), + }, + { + label: "Total Emails Sent", + value: formatCompact(totalEmailsSent), + }, + { + label: "Avg. Session time", + value: analyticsEnabled ? formatSeconds(analytics.avg_session_seconds) : "—", + }, + ]; + }, [auth.mau, email.emails_sent, analytics.avg_session_seconds, data.total_users, analyticsEnabled]); + + const inChartPillValues = useMemo(() => { + const latestDauPoint = dauStackedData.at(-1); + const latestDau = latestDauPoint == null + ? 0 + : latestDauPoint.new + latestDauPoint.retained + latestDauPoint.reactivated; + const previousDauPoint = dauStackedData.at(-2); + const previousDau = previousDauPoint == null + ? undefined + : previousDauPoint.new + previousDauPoint.retained + previousDauPoint.reactivated; + const visitorsTotalInRange = composedData.reduce((sum, row) => sum + row.visitors, 0); + const totalRevenueCentsInRange = composedData.reduce((sum, row) => sum + row.new_cents, 0); + + const composedIndexByDate = new Map(allComposedData.map((row, index) => [row.date, index])); + const firstComposedPoint = composedData.at(0); + const composedCurrentStartIndex = firstComposedPoint == null ? -1 : (composedIndexByDate.get(firstComposedPoint.date) ?? -1); + const composedCurrentLength = composedData.length; + const composedPreviousStartIndex = composedCurrentStartIndex - composedCurrentLength; + const composedPreviousEndIndex = composedCurrentStartIndex - 1; + const previousComposedWindow = composedPreviousStartIndex < 0 + ? [] + : allComposedData.slice(composedPreviousStartIndex, composedPreviousEndIndex + 1); + const hasFullPreviousComposedWindow = previousComposedWindow.length === composedCurrentLength && composedCurrentLength > 0; + const previousVisitorsTotal = previousComposedWindow.reduce((sum, row) => sum + row.visitors, 0); + const previousRevenueTotalCents = previousComposedWindow.reduce((sum, row) => sum + row.new_cents, 0); + + return { + dauTotal: formatCompact(latestDau), + dauLabel: "Daily Active Users", + dauDelta: previousDau == null ? undefined : calculatePeriodDelta(latestDau, previousDau), + visitorsTotal: analyticsEnabled ? formatCompact(visitorsTotalInRange) : "—", + visitorsLabel: "Unique Visitors", + visitorsDelta: analyticsEnabled && hasFullPreviousComposedWindow ? calculatePeriodDelta(visitorsTotalInRange, previousVisitorsTotal) : undefined, + revenueTotal: paymentsEnabled + ? formatUsdFromCents(totalRevenueCentsInRange) + : "—", + revenueLabel: "Revenue", + revenueDelta: paymentsEnabled && hasFullPreviousComposedWindow ? calculatePeriodDelta(totalRevenueCentsInRange, previousRevenueTotalCents) : undefined, + }; + }, [allComposedData, composedData, dauStackedData, analyticsEnabled, paymentsEnabled]); + + const gridContainerRef = useRef(null); + const [gridContainerWidth, setGridContainerWidth] = useState(0); + const [isLgViewport, setIsLgViewport] = useState(false); + useLayoutEffect(() => { + setGridContainerWidth(gridContainerRef.current?.getBoundingClientRect().width ?? 0); + }, []); + useLayoutEffect(() => { + const mediaQuery = window.matchMedia("(min-width: 1024px)"); + const updateViewportMatch = () => { + setIsLgViewport(mediaQuery.matches); + }; + updateViewportMatch(); + mediaQuery.addEventListener("change", updateViewportMatch); return () => { - mediaQuery.removeEventListener('change', handler); - window.removeEventListener('resize', updateViewHeight); + mediaQuery.removeEventListener("change", updateViewportMatch); }; }, []); + useResizeObserver(gridContainerRef, (entry) => setGridContainerWidth(entry.contentRect.width)); - // Calculate grid height based on globe column width on lg screens - // height = min(max(viewHeight, globeWidth), globeWidth * 1.75) - const gridHeightFromGlobe = isLgScreen && showGlobe && shouldShowGlobeSection && globeColumnWidth > 0 && viewHeight > 0 - ? Math.min(Math.max(viewHeight, globeColumnWidth), globeColumnWidth * 1.75) - : undefined; - - // Track charts grid size to determine layout - const chartsGridRef = useRef(null); - const [chartsGridSize, setChartsGridSize] = useState<{ width: number, height: number }>({ width: 0, height: 0 }); - - useResizeObserver(chartsGridRef, (entry) => { - setChartsGridSize({ width: entry.contentRect.width, height: entry.contentRect.height }); - }); - - // Determine chart layout based on charts grid dimensions: - // - If charts grid is at least 70% as tall as wide (tall/portrait), stack vertically - // - If charts grid is wide and less than 70% as tall, use 2 columns - const shouldUseTwoColumns = chartsGridSize.width > 400 && chartsGridSize.height > 0 && (chartsGridSize.height / chartsGridSize.width) < 0.7; - - // Render a widget by ID - const renderWidget = (widgetId: WidgetId) => { - switch (widgetId) { - case 'apps': { - return ; - } - case 'daily-active-users': { - return ( - - ); - } - case 'daily-sign-ups': { - return ( - - ); - } - default: { - return null; - } + const GLOBE_MIN_WIDTH = 352.5; + const globeColumnWidth = (() => { + if (!gridContainerWidth) return 0; + const gap = 20; + const availableWidth = gridContainerWidth - gap * 11; + return (availableWidth / 12) * 5 + gap * 4; + })(); + const shouldShowGlobe = isLgViewport && globeColumnWidth >= GLOBE_MIN_WIDTH; + const analyticsOuterStatsForLayout = useMemo(() => { + if (shouldShowGlobe) { + return analyticsOuterStats; } - }; - // Group widgets for grid layout - const chartWidgets = rightWidgets.filter(id => id === 'daily-active-users' || id === 'daily-sign-ups'); - const statWidgets = rightWidgets.filter(id => id === 'apps'); + return [ + { + label: "Total Users", + value: formatCompact(data.total_users), + }, + ...analyticsOuterStats, + ]; + }, [shouldShowGlobe, analyticsOuterStats, data.total_users]); return ( -
+
+
- {/* Left Column: Globe - Hidden on mobile */} - {showGlobe && shouldShowGlobeSection && ( -
- {/* Globe takes full space */} + {shouldShowGlobe && ( +
- {/* Total Users overlay */} -
+
@@ -516,80 +1216,89 @@ function MetricsContent({
- - - + {data.total_users.toLocaleString()}
)} - {/* Right Column: Stats Grid */} -
- {/* Stat Widgets Row (Apps) */} - {statWidgets.length > 0 && ( -
- {statWidgets.map(widgetId => ( -
- {renderWidget(widgetId)} -
- ))} -
- )} +
+ +
+
- {/* Charts Grid */} - {chartWidgets.length > 0 && ( -
- {chartWidgets.map(widgetId => ( -
- {renderWidget(widgetId)} -
- ))} -
- )} + - {/* Empty state when no widgets */} - {rightWidgets.length === 0 && !showGlobe && ( -
-
-
- -
- - No widgets enabled - -
-
- )} +
+
+ +
+
+
- {/* Mobile Globe Notice */} - {showGlobe && ( -
- - - Globe visualization is available on larger screens - -
- )} +
+ + + +
); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx new file mode 100644 index 0000000000..70ea162234 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { cn } from "@/lib/utils"; +import { + EyeIcon, + PaperPlaneTiltIcon, + SparkleIcon, + SpinnerGapIcon, + XIcon, +} from "@phosphor-icons/react"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { useCallback, useState, type KeyboardEvent } from "react"; +import type { AiQueryChat } from "./use-ai-query-chat"; + +type AiQueryBarProps = { + chat: AiQueryChat, + /** Whether the AI has committed a query (drives the purple accent). */ + isActive: boolean, + /** Invoked when the user clicks the eye button. */ + onOpenDialog: () => void, + /** Invoked when the user clicks the reset (clear) button. */ + onReset: () => void, +}; + +/** + * AI-powered search bar for the analytics tables page. Drops into the + * DataGridToolbar in place of the built-in quick-search input. Typing + * a message and pressing Enter sends a prompt to the shared AI chat; + * the AI responds by calling the `queryAnalytics` tool, and the + * extracted query drives the grid (via the parent component). + */ +export function AiQueryBar({ + chat, + isActive, + onOpenDialog, + onReset, +}: AiQueryBarProps) { + const [input, setInput] = useState(""); + + const handleSubmit = useCallback(() => { + const text = input.trim(); + if (!text || chat.isResponding) return; + setInput(""); + runAsynchronously(chat.sendMessage({ text })); + }, [input, chat]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit], + ); + + return ( +
+
+ + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + isActive ? "Refine with AI…" : "Ask about your analytics…" + } + className={cn( + "min-w-0 flex-1 bg-transparent text-xs outline-none", + "placeholder:text-muted-foreground/40", + )} + disabled={chat.isResponding} + /> + {chat.isResponding && ( + + )} + {!chat.isResponding && input.trim().length > 0 && ( + + )} + + + +
+ + {isActive && ( + + + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx new file mode 100644 index 0000000000..8359ea35fc --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx @@ -0,0 +1,645 @@ +"use client"; + +import { Button } from "@/components/ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { useUpdateConfig } from "@/lib/config-update"; +import { cn } from "@/lib/utils"; +import { + ArrowCounterClockwiseIcon, + CheckIcon, + CopyIcon, + FloppyDiskIcon, + LayoutIcon, + PaperPlaneTiltIcon, + SparkleIcon, + SpinnerGapIcon, + StopIcon, + TrashIcon, + UserIcon, + XIcon, +} from "@phosphor-icons/react"; +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import { + runAsynchronously, + runAsynchronouslyWithAlert, +} from "@stackframe/stack-shared/dist/utils/promises"; +import type { UIMessage } from "@ai-sdk/react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { CmdKPreviewProps } from "@/components/cmdk-commands"; +import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview"; +import { useAdminApp } from "../../use-admin-app"; +import type { AiQueryChat } from "./use-ai-query-chat"; + +// ─── Chat message rendering ───────────────────────────────────────── + +type OrderedPart = + | { kind: "text", text: string } + | { kind: "tool", id: string, state: string, query: string | null, error: string | null }; + +function getOrderedParts(message: UIMessage): OrderedPart[] { + const parts: OrderedPart[] = []; + for (const [idx, part] of message.parts.entries()) { + if (part.type === "text") { + const text = (part as { type: "text", text: string }).text; + if (text.trim()) { + parts.push({ kind: "text", text }); + } + continue; + } + if (!part.type.startsWith("tool-") || !part.type.endsWith("queryAnalytics")) continue; + const tp = part as { + type: string, + state: string, + input?: Record, + output?: Record, + }; + const query = + typeof tp.input?.query === "string" ? (tp.input.query as string) : null; + const output = tp.output; + let errorMessage: string | null = null; + if (output && typeof output === "object") { + const success = (output as { success?: unknown }).success; + if (success === false) { + const err = (output as { error?: unknown }).error; + errorMessage = typeof err === "string" ? err : "Query failed"; + } + } + parts.push({ + kind: "tool", + id: `${message.id}-${idx}`, + state: tp.state, + query, + error: errorMessage, + }); + } + return parts; +} + +const UserMessageBubble = memo(function UserMessageBubble({ + content, +}: { + content: string, +}) { + return ( +
+
+ +
+
+ {content} +
+
+ ); +}); + +const AssistantMessageBubble = memo(function AssistantMessageBubble({ + parts, + currentQuery, + onRewindToQuery, +}: { + parts: OrderedPart[], + currentQuery: string | null, + onRewindToQuery: (query: string) => void, +}) { + return ( +
+
+ +
+
+ {parts.map((part, idx) => { + if (part.kind === "text") { + return ( +
+ {part.text} +
+ ); + } + const isActiveQuery = part.query != null && part.query === currentQuery; + return ( +
+
+ {part.state !== "output-available" && !part.error && ( + + )} + + {part.error + ? "Query failed" + : part.state === "output-available" + ? "Ran query" + : "Building query"} + + {!isActiveQuery && part.query && part.state === "output-available" && !part.error && ( + <> +
+ + + + + )} +
+ {part.error && ( +

+ {part.error} +

+ )} +
+ ); + })} +
+
+ ); +}); + +// ─── Save query sub-dialog ────────────────────────────────────────── + +function SaveQueryInlineDialog({ + open, + onOpenChange, + sqlQuery, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + sqlQuery: string, +}) { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + + const [displayName, setDisplayName] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setDisplayName(""); + setSaving(false); + } + }, [open]); + + const handleSave = useCallback(async () => { + if (!displayName.trim() || !sqlQuery.trim()) return; + setSaving(true); + try { + // Reuse an existing folder if available, otherwise create an + // "AI Queries" bucket on the fly so the save flow never stalls + // on folder management. + const existingFolders = Object.entries(config.analytics.queryFolders); + let folderId: string; + if (existingFolders.length > 0) { + folderId = existingFolders[0]![0]; + } else { + folderId = generateSecureRandomString(); + await updateConfig({ + adminApp, + configUpdate: { + [`analytics.queryFolders.${folderId}`]: { + displayName: "AI Queries", + sortOrder: 0, + queries: {}, + }, + }, + pushable: false, + }); + } + + const queryId = generateSecureRandomString(); + await updateConfig({ + adminApp, + configUpdate: { + [`analytics.queryFolders.${folderId}.queries.${queryId}`]: { + displayName: displayName.trim(), + sqlQuery, + }, + }, + pushable: false, + }); + onOpenChange(false); + } finally { + setSaving(false); + } + }, [displayName, sqlQuery, config, updateConfig, adminApp, onOpenChange]); + + return ( + + + + Save query + + +
+
+ + setDisplayName(e.target.value)} + placeholder="Recent signups" + onKeyDown={(e) => { + if (e.key === "Enter") { + runAsynchronouslyWithAlert(handleSave); + } + }} + /> +
+
+
+                {sqlQuery}
+              
+
+
+
+ + + + +
+
+ ); +} + +// ─── Build dashboard sub-dialog ───────────────────────────────────── + +/** + * Reuses the existing `CreateDashboardPreview` component (the same + * one the Cmd+K command center uses) so the dashboard-builder + * experience is identical whether you enter it from the command + * palette or from the analytics AI query builder. Most of + * `CmdKPreviewProps` are unused by `CreateDashboardPreview` internally, + * so we pass no-op stubs for them. + */ +function BuildDashboardDialog({ + open, + onOpenChange, + sqlQuery, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + sqlQuery: string, +}) { + // Synthesize a prompt that pre-seeds the SQL query as context so + // the dashboard the AI generates visualizes exactly these results. + const dashboardPrompt = useMemo( + () => + `Build a dashboard that visualizes the results of this ClickHouse query:\n\n\`\`\`sql\n${sqlQuery}\n\`\`\``, + [sqlQuery], + ); + + const stubProps: Omit = { + isSelected: true, + registerOnFocus: () => { + // no-op + }, + unregisterOnFocus: () => { + // no-op + }, + onBlur: () => { + // no-op + }, + registerNestedCommands: () => { + // no-op + }, + navigateToNested: () => { + // no-op + }, + depth: 0, + pathname: "", + }; + + return ( + + + + + + Build dashboard + + +
+ {open && ( + onOpenChange(false)} + {...stubProps} + /> + )} +
+
+
+ ); +} + +// ─── Main dialog ──────────────────────────────────────────────────── + +type AiQueryDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + chat: AiQueryChat, + /** The query currently driving the data grid (may be `null` if none yet). */ + currentQuery: string | null, +}; + +export function AiQueryDialog({ + open, + onOpenChange, + chat, + currentQuery, +}: AiQueryDialogProps) { + const [followUpInput, setFollowUpInput] = useState(""); + const [copied, setCopied] = useState(false); + const [saveOpen, setSaveOpen] = useState(false); + const [buildOpen, setBuildOpen] = useState(false); + const messagesContainerRef = useRef(null); + const inputRef = useRef(null); + + // Auto-scroll to the bottom whenever a new message arrives. + useEffect(() => { + if (!messagesContainerRef.current) return; + messagesContainerRef.current.scrollTop = + messagesContainerRef.current.scrollHeight; + }, [chat.messages, chat.isResponding]); + + // Focus the input when the dialog opens so the user can keep typing. + useEffect(() => { + if (open) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + const handleSend = useCallback(() => { + const text = followUpInput.trim(); + if (!text || chat.isResponding) return; + setFollowUpInput(""); + runAsynchronously(chat.sendMessage({ text })); + }, [followUpInput, chat]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const handleCopy = useCallback(async () => { + if (!currentQuery) return; + await navigator.clipboard.writeText(currentQuery); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [currentQuery]); + + const canActOnQuery = Boolean(currentQuery && currentQuery.trim().length > 0); + + return ( + <> + + + +
+ + + AI query builder + +
+ {chat.messages.length > 0 && ( + + + + )} + +
+
+
+ + {/* Current query */} +
+
+ + {canActOnQuery && ( + + + + )} +
+
+              {currentQuery?.trim() || (
+                
+                  Ask the AI a question to generate a query.
+                
+              )}
+            
+
+ + {/* Chat thread */} +
+ {chat.messages.length === 0 && ( +
+
+ +
+

+ Describe what you want to see. +

+

+ Try: “daily signups over the last 30 days” or + “top 10 users by event count this week”. +

+
+ )} + + {chat.messages.map((message) => { + if (message.role === "user") { + const text = message.parts + .filter((p): p is { type: "text", text: string } => p.type === "text") + .map((p) => p.text) + .join(""); + return ( + + ); + } + const parts = getOrderedParts(message); + if (parts.length === 0) return null; + return ( + + ); + })} + + {chat.isResponding && ( +
+ + Thinking… +
+ )} + + {chat.error && ( +
+ {chat.error.message || "Failed to get a response."} +
+ )} +
+ + {/* Input */} +
+
+ setFollowUpInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Refine the query…" + className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + disabled={chat.isResponding} + /> + {chat.isResponding ? ( + + + + ) : ( + + )} +
+
+ + {/* Actions */} + + + Save the query or turn it into a live dashboard. + +
+ + +
+
+
+
+ + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx index a3dca3d18a..e53123b26e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx @@ -1,654 +1,221 @@ "use client"; import { Link } from "@/components/link"; -import { Alert, Button, Skeleton, Typography } from "@/components/ui"; -import { - Dialog, - DialogBody, - DialogContent, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { SimpleTooltip } from "@/components/ui/simple-tooltip"; -import { Switch } from "@/components/ui/switch"; -import { useFromNow } from "@/hooks/use-from-now"; +import { Button, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { - ArrowClockwiseIcon, - ArrowDownIcon, - ArrowUpIcon, - CalendarIcon, - ClockIcon, - MagnifyingGlassIcon, - SparkleIcon, -} from "@phosphor-icons/react"; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ArrowClockwiseIcon, CodeIcon } from "@phosphor-icons/react"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { useCallback, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; -import { useAdminApp } from "../../use-admin-app"; +import { AiQueryBar } from "./ai-query-bar"; +import { AiQueryDialog } from "./ai-query-dialog"; import { - isDateValue, - isJsonValue, - JsonValue, - parseClickHouseDate, - RowData, -} from "../shared"; + QueryDataGrid, + type QueryDataGridMode, +} from "./query-data-grid"; +import { useAiQueryChat } from "./use-ai-query-chat"; -// Context for date display preference (specific to tables page for toggle feature) -const DateDisplayContext = createContext<{ relative: boolean }>({ relative: true }); +// ─── Available tables ─────────────────────────────────────────────── -// Available tables in the analytics database -const AVAILABLE_TABLES = new Map([ - ["events", { - displayName: "Events", - baseQuery: "SELECT * FROM default.events", - defaultOrderBy: "event_at", - defaultOrderDir: "DESC" as const, - }], - ["users", { - displayName: "Users", - baseQuery: "SELECT * FROM default.users", - defaultOrderBy: "signed_up_at", - defaultOrderDir: "DESC" as const, - }], - ["contact_channels", { - displayName: "Contact Channels", - baseQuery: "SELECT * FROM default.contact_channels", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["teams", { - displayName: "Teams", - baseQuery: "SELECT * FROM default.teams", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["team_member_profiles", { - displayName: "Team Member Profiles", - baseQuery: "SELECT * FROM default.team_member_profiles", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["team_permissions", { - displayName: "Team Permissions", - baseQuery: "SELECT * FROM default.team_permissions", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["team_invitations", { - displayName: "Team Invitations", - baseQuery: "SELECT * FROM default.team_invitations", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["email_outboxes", { - displayName: "Email Outboxes", - baseQuery: "SELECT * FROM default.email_outboxes", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["project_permissions", { - displayName: "Project Permissions", - baseQuery: "SELECT * FROM default.project_permissions", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["notification_preferences", { - displayName: "Notification Preferences", - baseQuery: "SELECT * FROM default.notification_preferences", - defaultOrderBy: "user_id", - defaultOrderDir: "DESC" as const, - }], - ["refresh_tokens", { - displayName: "Refresh Tokens", - baseQuery: "SELECT * FROM default.refresh_tokens", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], - ["connected_accounts", { - displayName: "Connected Accounts", - baseQuery: "SELECT * FROM default.connected_accounts", - defaultOrderBy: "created_at", - defaultOrderDir: "DESC" as const, - }], -]); +type TableConfig = { + displayName: string, + baseQuery: string, + defaultOrderBy: string, + defaultOrderDir: "asc" | "desc", +}; type TableId = string; -type SortDir = "ASC" | "DESC"; - -const PAGE_SIZE = 50; - -// Component for displaying dates with toggle support (specific to tables page) -function DateValue({ value }: { value: string }) { - const { relative } = useContext(DateDisplayContext); - const date = parseClickHouseDate(value); - const fromNow = useFromNow(date); - - if (relative) { - return ( - - {fromNow} - - ); - } - - return {date.toLocaleString()}; -} - -// Format a cell value for display -function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) { - if (value === null || value === undefined) { - return ; - } - - if (isDateValue(value)) { - return ; - } - - if (isJsonValue(value)) { - return ; - } - - const str = String(value); - if (truncate && str.length > 100) { - return ( - - {str.slice(0, 97)}... - - ); - } - - return {str}; -} - -// Row detail dialog -function RowDetailDialog({ - row, - columns, - open, - onOpenChange, -}: { - row: RowData | null, - columns: string[], - open: boolean, - onOpenChange: (open: boolean) => void, -}) { - if (!row) return null; - - return ( - - - - Row Details - - -
- {columns.map((column) => ( -
- -
- {isJsonValue(row[column]) ? ( -
-                      {JSON.stringify(row[column], null, 2)}
-                    
- ) : ( - - )} -
-
- ))} -
-
-
-
- ); -} - -// Column header with sort -function ColumnHeader({ - column, - sortColumn, - sortDir, - onSort, -}: { - column: string, - sortColumn: string | null, - sortDir: SortDir, - onSort: (column: string) => void, -}) { - const isSorted = sortColumn === column; - return ( - - ); -} - -// Virtualized flat table component -function VirtualizedFlatTable({ - columns, - rows, - onRowClick, - onLoadMore, - hasMore, - loadingMore, - sortColumn, - sortDir, - onSort, - refreshing = false, -}: { - columns: string[], - rows: RowData[], - onRowClick: (row: RowData) => void, - onLoadMore: () => void, - hasMore: boolean, - loadingMore: boolean, - sortColumn: string | null, - sortDir: SortDir, - onSort: (column: string) => void, - refreshing?: boolean, -}) { - const parentRef = useRef(null); - - const rowVirtualizer = useVirtualizer({ - count: rows.length + (hasMore ? 1 : 0), // +1 for loading indicator - getScrollElement: () => parentRef.current, - estimateSize: () => 40, - overscan: 10, - }); - - // Trigger load more when scrolling near the end - const virtualItems = rowVirtualizer.getVirtualItems(); - const lastItemIndex = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1]?.index ?? -1 : -1; - - useEffect(() => { - if (lastItemIndex >= rows.length - 10 && hasMore && !loadingMore) { - onLoadMore(); - } - }, [lastItemIndex, rows.length, hasMore, loadingMore, onLoadMore]); - - // Column widths - distribute based on content type - const columnWidths = useMemo(() => { - const widths = new Map(); - columns.forEach((col) => { - if (col.includes("id") && col !== "project_id") { - widths.set(col, "minmax(280px, 1fr)"); - } else if (col.includes("_at") || col.includes("date")) { - widths.set(col, "minmax(120px, 150px)"); - } else if (col === "data" || col.includes("json")) { - widths.set(col, "minmax(200px, 2fr)"); - } else if (col === "event_type" || col === "type") { - widths.set(col, "minmax(120px, 180px)"); - } else { - widths.set(col, "minmax(100px, 1fr)"); - } - }); - return widths; - }, [columns]); - - const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" "); - - // Calculate minimum content width based on columns - const minContentWidth = columns.length * 150; - - return ( -
- {/* Refresh loading overlay */} - {refreshing && ( -
-
-
- Refreshing... -
-
- )} - {/* Single scroll container for both header and body - handles horizontal scroll */} -
- {/* Inner container with min-width for horizontal scrolling */} -
- {/* Sticky header */} -
- {columns.map((column) => ( - - ))} -
- - {/* Virtualized rows container */} -
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const isLoaderRow = virtualRow.index >= rows.length; - const row = rows[virtualRow.index]; - - if (isLoaderRow) { - return ( -
- {loadingMore ? ( -
-
- Loading more... -
- ) : ( - Scroll to load more - )} -
- ); - } +const AVAILABLE_TABLES = new Map([ + [ + "events", + { + displayName: "Events", + baseQuery: "SELECT * FROM default.events", + defaultOrderBy: "event_at", + defaultOrderDir: "desc", + }, + ], + [ + "users", + { + displayName: "Users", + baseQuery: "SELECT * FROM default.users", + defaultOrderBy: "signed_up_at", + defaultOrderDir: "desc", + }, + ], + [ + "contact_channels", + { + displayName: "Contact Channels", + baseQuery: "SELECT * FROM default.contact_channels", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "teams", + { + displayName: "Teams", + baseQuery: "SELECT * FROM default.teams", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "team_member_profiles", + { + displayName: "Team Member Profiles", + baseQuery: "SELECT * FROM default.team_member_profiles", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "team_permissions", + { + displayName: "Team Permissions", + baseQuery: "SELECT * FROM default.team_permissions", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "team_invitations", + { + displayName: "Team Invitations", + baseQuery: "SELECT * FROM default.team_invitations", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "email_outboxes", + { + displayName: "Email Outboxes", + baseQuery: "SELECT * FROM default.email_outboxes", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "project_permissions", + { + displayName: "Project Permissions", + baseQuery: "SELECT * FROM default.project_permissions", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "notification_preferences", + { + displayName: "Notification Preferences", + baseQuery: "SELECT * FROM default.notification_preferences", + defaultOrderBy: "user_id", + defaultOrderDir: "desc", + }, + ], + [ + "refresh_tokens", + { + displayName: "Refresh Tokens", + baseQuery: "SELECT * FROM default.refresh_tokens", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], + [ + "connected_accounts", + { + displayName: "Connected Accounts", + baseQuery: "SELECT * FROM default.connected_accounts", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + }, + ], +]); - return ( -
onRowClick(row)} - > - {columns.map((column) => ( -
- -
- ))} -
- ); - })} -
-
-
-
- ); -} +// ─── Per-table content ────────────────────────────────────────────── function TableContent({ tableId }: { tableId: TableId }) { - const adminApp = useAdminApp(); - const [columns, setColumns] = useState([]); - const [rows, setRows] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); - const [relativeDate, setRelativeDate] = useState(true); - const [selectedRow, setSelectedRow] = useState(null); - const [detailDialogOpen, setDetailDialogOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [sortColumn, setSortColumn] = useState(null); - const [sortDir, setSortDir] = useState("DESC"); - - const tableConfig = AVAILABLE_TABLES.get(tableId); - - const loadData = useCallback(async (offset: number = 0, append: boolean = false, isRefresh: boolean = false) => { - if (!tableConfig) return; - - if (append) { - setLoadingMore(true); - } else if (isRefresh) { - // For refresh, keep existing data visible and just show loading overlay - setLoading(true); - } else { - // Initial load - clear everything - setLoading(true); - setRows([]); - } - setError(null); - - try { - const orderBy = sortColumn ?? tableConfig.defaultOrderBy; - const orderDir = sortColumn ? sortDir : tableConfig.defaultOrderDir; - const query = `${tableConfig.baseQuery} ORDER BY ${orderBy} ${orderDir} LIMIT ${PAGE_SIZE} OFFSET ${offset}`; - - const response = await adminApp.queryAnalytics({ - query, - include_all_branches: false, - timeout_ms: 30000, - }); - - const newRows = response.result as RowData[]; - const newColumns = newRows.length > 0 ? Object.keys(newRows[0]) : []; - - if (append) { - setRows((prev) => [...prev, ...newRows]); - } else { - setColumns(newColumns); - setRows(newRows); - } - - setHasMore(newRows.length === PAGE_SIZE); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : "Failed to load table data"; - setError(message); - } finally { - setLoading(false); - setLoadingMore(false); - } - }, [adminApp, tableConfig, sortColumn, sortDir]); - - // Auto-load data when the component mounts or sort changes - // If we already have rows, treat it as a refresh (keep data visible) - useEffect(() => { - const isRefresh = rows.length > 0; - runAsynchronouslyWithAlert(() => loadData(0, false, isRefresh)); - }, [loadData]); // eslint-disable-line react-hooks/exhaustive-deps -- rows.length is intentionally not a dependency to avoid infinite loop - - const handleLoadMore = useCallback(() => { - if (!loadingMore && hasMore) { - runAsynchronouslyWithAlert(() => loadData(rows.length, true)); - } - }, [loadData, loadingMore, hasMore, rows.length]); - - const handleSort = useCallback((column: string) => { - if (sortColumn === column) { - setSortDir((prev) => (prev === "ASC" ? "DESC" : "ASC")); - } else { - setSortColumn(column); - setSortDir("DESC"); - } - }, [sortColumn]); - - const handleRowClick = (row: RowData) => { - setSelectedRow(row); - setDetailDialogOpen(true); - }; - - const handleRefresh = useCallback(() => { - runAsynchronouslyWithAlert(() => loadData(0, false, true)); - }, [loadData]); - - // Filter rows based on search query - const filteredRows = useMemo(() => { - if (!searchQuery.trim()) return rows; - const query = searchQuery.toLowerCase(); - return rows.filter((row) => - columns.some((col) => { - const value = row[col]; - if (value === null || value === undefined) return false; - const str = typeof value === "object" ? JSON.stringify(value) : String(value); - return str.toLowerCase().includes(query); - }) - ); - }, [rows, columns, searchQuery]); - - if (loading && rows.length === 0) { - // If we have columns from a previous load, show them with skeleton rows - if (columns.length > 0) { - const gridTemplateColumns = columns.map(() => "1fr").join(" "); - return ( -
- {/* Toolbar skeleton */} -
- - -
- {/* Header */} -
- {columns.map((column) => ( - - {column} - - ))} -
- {/* Skeleton rows */} -
-
- {Array.from({ length: 15 }).map((_, i) => ( - - ))} -
-
-
- ); - } - // No columns yet - show simple skeleton - return ( -
-
- {Array.from({ length: 15 }).map((_, i) => ( - - ))} -
-
- ); - } - - if (error) { - return ( -
- {error} - -
- ); - } + const tableConfig = AVAILABLE_TABLES.get(tableId) ?? throwErr(`Unknown analytics table: ${tableId}`); + const [dialogOpen, setDialogOpen] = useState(false); + + // Shared AI chat state — feeds both the search bar and the eye + // dialog, so they operate on a single conversation thread. + const chat = useAiQueryChat(); + + const aiQuery = chat.latestQuery; + const isAiActive = aiQuery != null; + + // When the AI has committed a query, it becomes the source of + // truth; otherwise fall back to the table's own default query. + const effectiveQuery = aiQuery ?? tableConfig.baseQuery; + const effectiveMode: QueryDataGridMode = isAiActive ? "one-shot" : "paginated"; + + // Default sort / search only apply while the AI is inactive — an + // AI-generated aggregate won't have an `event_at` column to sort on. + const defaultOrderBy = isAiActive ? undefined : tableConfig.defaultOrderBy; + const defaultOrderDir = isAiActive ? undefined : tableConfig.defaultOrderDir; + + const handleResetChat = useCallback(() => { + chat.setMessages([]); + }, [chat]); + + const aiSearchBar = ( + setDialogOpen(true)} + onReset={handleResetChat} + /> + ); - if (rows.length === 0) { - return ( -
- No data available - + + {ctx.hasMore + ? `${ctx.rowCount.toLocaleString()}+ rows` + : `${ctx.rowCount.toLocaleString()} rows`} +
- ); - } + ), + [], + ); return ( - -
- {/* Toolbar */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="pl-9 h-8 bg-transparent border-border/50" - /> -
- - {/* Date toggle */} -
- -
- - - -
-
-
- - {/* Refresh */} - - - {/* Row count */} - - {filteredRows.length.toLocaleString()} rows - {hasMore && "+"} - -
- - {/* Table */} - 0} - /> - - -
-
+
+ + + +
); } @@ -658,21 +225,23 @@ export default function PageClient() { return ( -
- {/* Left sidebar - table list (doesn't scroll, border extends full height) */} -
-
- Tables +
+ {/* Left sidebar — hidden on mobile */} +
+
+ + Tables +
{[...AVAILABLE_TABLES.entries()].map(([id, config]) => (
- {/* Right content - table data (scrolls independently, extends to edge) */} -
+ {/* Right content */} +
{selectedTable ? ( ) : (
- Select a table to view its contents + + Select a table to view its contents +
)}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx new file mode 100644 index 0000000000..004f5f6fa7 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx @@ -0,0 +1,676 @@ +"use client"; + +import { Alert, Button, Typography } from "@/components/ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { + createDefaultDataGridState, + DataGrid, + DataGridToolbar, + useDataSource, + type DataGridColumnDef, + type DataGridDataSource, + type DataGridState, + type DataGridToolbarContext, +} from "@stackframe/dashboard-ui-components"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useAdminApp } from "../../use-admin-app"; +import { + isJsonValue, + JsonValue, + parseClickHouseDate, + type RowData, +} from "../shared"; + +// ─── Types ────────────────────────────────────────────────────────── + +export type QueryDataGridMode = "paginated" | "one-shot"; + +/** + * Extended toolbar context exposed by QueryDataGrid on top of the + * underlying DataGrid toolbar context. Adds the async data source's + * reload / loading / pagination hints so a custom toolbar (e.g. an AI + * search bar + refresh button) can wire these up without duplicating + * state management. + */ +export type QueryDataGridToolbarContext = + DataGridToolbarContext & { + reload: () => void, + rowCount: number, + hasMore: boolean, + isLoading: boolean, + isRefetching: boolean, + }; + +export type QueryDataGridProps = { + /** + * The SQL query to execute. + * + * - In `paginated` mode this is treated as a **base** query. The grid + * wraps it with `WHERE` (from quick-search), `ORDER BY` (from sort), + * `LIMIT` and `OFFSET` for infinite scroll, so it must be a simple + * expression like `SELECT * FROM default.events` without its own + * pagination / sort clauses. + * - In `one-shot` mode the query is executed as-is, wrapped only in a + * subquery for sort/filter by the grid. Supply a full, self-contained + * query (aggregates, joins, GROUP BYs, etc.) — the grid will not + * touch its LIMIT. + */ + query: string, + /** Execution mode. Defaults to `paginated`. */ + mode?: QueryDataGridMode, + /** Page size for paginated mode. Defaults to 50. */ + pageSize?: number, + /** Initial sort column. When omitted, no default sort is injected. */ + defaultOrderBy?: string, + /** Initial sort direction. Defaults to `"desc"`. */ + defaultOrderDir?: "asc" | "desc", + /** + * Whether the built-in quick-search input should be wired up as a + * client/server-side ILIKE filter. Defaults to `true` — when the + * caller provides no custom toolbar, typing in the search box filters + * rows across discovered columns. Set `false` (or hook up a custom + * toolbar that doesn't write to `state.quickSearch`) to opt out. + */ + enableQuickSearchFilter?: boolean, + /** + * Custom toolbar renderer. Replaces the default toolbar entirely. + * Receives an extended context with `reload` / `rowCount` etc. on + * top of the built-in DataGrid toolbar context. Use this only when + * you need full control — in most cases prefer `searchBar` + + * `toolbarExtra`, which keep the built-in columns / export actions. + */ + toolbar?: (ctx: QueryDataGridToolbarContext) => ReactNode, + /** + * Replaces the toolbar's built-in quick-search input with a custom + * node (e.g. an AI-powered search bar). The rest of the default + * toolbar — Columns, Export, density — stays intact. Can be a node + * or a function receiving the extended context. + */ + searchBar?: + | ReactNode + | ((ctx: QueryDataGridToolbarContext) => ReactNode), + /** + * Extra content slotted into the default toolbar, to the LEFT of + * the built-in columns / export actions. Can be a node or a function + * receiving the extended context. + */ + toolbarExtra?: + | ReactNode + | ((ctx: QueryDataGridToolbarContext) => ReactNode), + /** Whether the default row-click-to-inspect dialog is enabled. Defaults to `true`. */ + enableRowDetailDialog?: boolean, + /** Custom row click handler. Overrides the default row detail dialog. */ + onRowClick?: (row: RowData) => void, + /** Called whenever the error state changes (null when cleared). */ + onError?: (error: string | null) => void, + /** Called when the discovered schema changes. */ + onSchemaChange?: (columns: string[]) => void, + /** Filename stem for CSV export (without extension). */ + exportFilename?: string, + /** Custom empty state. */ + emptyState?: ReactNode, + /** Show the default footer. Defaults to `false`. */ + footer?: boolean, +}; + +export type QueryDataGridHandle = { + reload: () => void, + getDiscoveredColumns: () => string[], +}; + +const INTERNAL_ROW_ID_KEY = "__stack_row_id"; + +// ─── Utility helpers ──────────────────────────────────────────────── + +/** Detect whether a column name refers to a date/time value. */ +function isDateColumnName(name: string): boolean { + return name.endsWith("_at") || name === "date" || /(^|_)date($|_)/.test(name); +} + +/** Pick a sensible initial width for a column based on its name. */ +function guessColumnWidth(colName: string): number { + if (colName.includes("id") && colName !== "project_id") return 280; + if (colName.includes("_at") || colName.includes("date")) return 170; + if (colName === "data" || colName.includes("json")) return 320; + if (colName === "event_type" || colName === "type") return 160; + return 150; +} + +/** + * ClickHouse emits `"YYYY-MM-DD HH:MM:SS.mmm"` strings. The grid's + * default `new Date()` parser would interpret those as local time; + * this wrapper returns `null` for anything invalid so the grid falls + * back to `—`. + */ +function parseClickHouseDateOrNull(value: unknown): Date | null { + if (typeof value !== "string") return null; + const d = parseClickHouseDate(value); + return isNaN(d.getTime()) ? null : d; +} + +function CellValue({ + value, + truncate = true, +}: { + value: unknown, + truncate?: boolean, +}) { + if (value === null || value === undefined) { + return ; + } + + if (isJsonValue(value)) { + return ; + } + + const str = String(value); + if (truncate && str.length > 100) { + return ( + + {str.slice(0, 97)}... + + ); + } + + return {str}; +} + +function RowDetailDialog({ + row, + columns, + open, + onOpenChange, +}: { + row: RowData | null, + columns: string[], + open: boolean, + onOpenChange: (open: boolean) => void, +}) { + if (!row) return null; + + return ( + + + + Row Details + + +
+ {columns.map((column) => ( +
+ +
+ {isJsonValue(row[column]) ? ( +
+                      {JSON.stringify(row[column], null, 2)}
+                    
+ ) : ( + + )} +
+
+ ))} +
+
+
+
+ ); +} + +// ─── Query building ───────────────────────────────────────────────── + +type BuildQueryArgs = { + baseQuery: string, + mode: QueryDataGridMode, + orderBy: string | null, + orderDir: "ASC" | "DESC", + search: string, + searchableColumns: readonly string[], + pageSize: number, + offset: number, +}; + +function escapeLiteral(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +function buildWhereClause(search: string, columns: readonly string[]): string { + if (!search || columns.length === 0) return ""; + const escaped = escapeLiteral(search); + const clauses = columns + .map((c) => `toString(\`${c}\`) ILIKE '%${escaped}%'`) + .join(" OR "); + return ` WHERE ${clauses}`; +} + +function buildFinalQuery(args: BuildQueryArgs): string { + const { baseQuery, mode, orderBy, orderDir, search, searchableColumns, pageSize, offset } = args; + const where = buildWhereClause(search, searchableColumns); + const orderClause = orderBy ? ` ORDER BY \`${orderBy}\` ${orderDir}` : ""; + if (mode === "paginated") { + return `${baseQuery}${where}${orderClause} LIMIT ${pageSize} OFFSET ${offset}`; + } + // one-shot: wrap the (potentially complex) user query as a subquery + // so we can still layer sort/filter on top without touching the + // inner LIMIT. The outer LIMIT/OFFSET lets the grid paginate through + // the AI query's result set via infinite scroll. + return `SELECT * FROM (${baseQuery})${where}${orderClause} LIMIT ${pageSize} OFFSET ${offset}`; +} + +// ─── Component ────────────────────────────────────────────────────── + +/** + * Reusable, modular DataGrid wrapper that runs a ClickHouse query and + * streams results into a DataGrid. Handles schema discovery, paginated + * vs one-shot execution, client/server-side sort/search, and row + * inspection — callers only need to supply a query and (optionally) + * override the toolbar or row click behaviour. + * + * @example + * ```tsx + * + * ``` + * + * @example with a custom toolbar (e.g. an AI search bar) + * ```tsx + * } + * /> + * ``` + */ +export const QueryDataGrid = forwardRef( + function QueryDataGrid( + { + query, + mode = "paginated", + pageSize = 50, + defaultOrderBy, + defaultOrderDir = "desc", + enableQuickSearchFilter = true, + toolbar, + searchBar, + toolbarExtra, + enableRowDetailDialog = true, + onRowClick, + onError, + onSchemaChange, + exportFilename, + emptyState, + footer = false, + }, + ref, + ) { + const adminApp = useAdminApp(); + + const [discoveredColumns, setDiscoveredColumns] = useState([]); + const [error, setError] = useState(null); + const [selectedRow, setSelectedRow] = useState(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + + // Ref mirror so the async generator (memoised against adminApp) can + // read the latest column list without being re-created every time + // the schema updates. + const discoveredColumnsRef = useRef([]); + const queryRef = useRef(query); + queryRef.current = query; + const modeRef = useRef(mode); + modeRef.current = mode; + const enableSearchFilterRef = useRef(enableQuickSearchFilter); + enableSearchFilterRef.current = enableQuickSearchFilter; + const defaultOrderByRef = useRef(defaultOrderBy); + defaultOrderByRef.current = defaultOrderBy; + const defaultOrderDirRef = useRef(defaultOrderDir); + defaultOrderDirRef.current = defaultOrderDir; + + const [gridState, setGridState] = useState(() => { + const base = createDefaultDataGridState([]); + return { + ...base, + sorting: defaultOrderBy + ? [ + { + columnId: defaultOrderBy, + direction: defaultOrderDir, + }, + ] + : [], + pagination: { pageIndex: 0, pageSize }, + }; + }); + + // Whenever the query changes we want fresh columns + a reset sort + // so a leftover sort from a previous schema doesn't crash the + // next query (nonexistent column in ORDER BY). + useEffect(() => { + setDiscoveredColumns([]); + discoveredColumnsRef.current = []; + setError(null); + setGridState((prev) => ({ + ...prev, + sorting: defaultOrderByRef.current + ? [ + { + columnId: defaultOrderByRef.current, + direction: defaultOrderDirRef.current, + }, + ] + : [], + pagination: { ...prev.pagination, pageIndex: 0 }, + quickSearch: "", + })); + }, [query]); + + useEffect(() => { + onError?.(error); + }, [error, onError]); + + useEffect(() => { + onSchemaChange?.(discoveredColumns); + }, [discoveredColumns, onSchemaChange]); + + const columns = useMemo[]>( + () => + discoveredColumns.map((col): DataGridColumnDef => { + const isDate = isDateColumnName(col); + if (isDate) { + return { + id: col, + header: col, + accessor: (row) => row[col], + width: guessColumnWidth(col), + minWidth: 80, + sortable: true, + type: "dateTime", + parseValue: parseClickHouseDateOrNull, + }; + } + return { + id: col, + header: col, + accessor: (row) => row[col], + width: guessColumnWidth(col), + minWidth: 80, + sortable: true, + type: "string", + renderCell: ({ value }) => , + }; + }), + [discoveredColumns], + ); + + const dataSource = useMemo>(() => { + return async function* (params) { + setError(null); + try { + let orderBy: string | null = null; + let orderDir: "ASC" | "DESC" = "DESC"; + if (params.sorting.length > 0) { + const first = params.sorting[0]!; + orderBy = first.columnId; + orderDir = first.direction === "asc" ? "ASC" : "DESC"; + } else if (defaultOrderByRef.current) { + orderBy = defaultOrderByRef.current; + orderDir = defaultOrderDirRef.current === "asc" ? "ASC" : "DESC"; + } + + const gridPageSize = params.pagination.pageSize; + const offset = params.pagination.pageIndex * gridPageSize; + + const search = params.quickSearch.trim(); + const applyFilter = enableSearchFilterRef.current; + + const finalQuery = buildFinalQuery({ + baseQuery: queryRef.current, + mode: modeRef.current, + orderBy, + orderDir, + search: applyFilter ? search : "", + searchableColumns: discoveredColumnsRef.current, + pageSize: gridPageSize, + offset, + }); + + const response = await adminApp.queryAnalytics({ + query: finalQuery, + include_all_branches: false, + timeout_ms: 30000, + }); + + const newRows = (response.result as RowData[]).map((row, index) => ({ + ...row, + [INTERNAL_ROW_ID_KEY]: `${offset + index}`, + })); + + if (newRows.length > 0) { + const cols = Object.keys(newRows[0]!).filter((col) => col !== INTERNAL_ROW_ID_KEY); + discoveredColumnsRef.current = cols; + setDiscoveredColumns((prev) => { + if (prev.length === cols.length && prev.every((c, i) => c === cols[i])) { + return prev; + } + return cols; + }); + } + + yield { + rows: newRows, + hasMore: newRows.length === gridPageSize, + }; + } catch (e: unknown) { + const message = + e instanceof Error ? e.message : "Failed to load query results"; + setError(message); + yield { rows: [], hasMore: false }; + } + }; + }, [adminApp]); + + const getRowId = useCallback((row: RowData): string => { + if (typeof row[INTERNAL_ROW_ID_KEY] === "string") return row[INTERNAL_ROW_ID_KEY]; + if (row.id != null) return String(row.id); + if (row.event_id != null) return String(row.event_id); + throw new Error("QueryDataGrid row is missing an internal row id"); + }, []); + + const gridData = useDataSource({ + dataSource, + columns, + getRowId, + sorting: gridState.sorting, + quickSearch: gridState.quickSearch, + pagination: gridState.pagination, + paginationMode: "infinite", + }); + + useImperativeHandle( + ref, + () => ({ + reload: () => gridData.reload(), + getDiscoveredColumns: () => discoveredColumnsRef.current, + }), + [gridData], + ); + + const handleRowClick = useCallback( + (row: RowData) => { + if (onRowClick) { + onRowClick(row); + return; + } + if (enableRowDetailDialog) { + setSelectedRow(row); + setDetailDialogOpen(true); + } + }, + [onRowClick, enableRowDetailDialog], + ); + + const showEmptyError = + error != null && !gridData.isLoading && gridData.rows.length === 0; + + /** + * Extend the built-in toolbar context with the async data source + * state (reload, counts, loading flags) so callers can build rich + * toolbars without re-deriving any of this. + */ + const extendCtx = useCallback( + (ctx: DataGridToolbarContext): QueryDataGridToolbarContext => ({ + ...ctx, + reload: gridData.reload, + rowCount: gridData.rows.length, + hasMore: gridData.hasMore, + isLoading: gridData.isLoading, + isRefetching: gridData.isRefetching, + }), + [gridData.reload, gridData.rows.length, gridData.hasMore, gridData.isLoading, gridData.isRefetching], + ); + + /** + * Resolves the toolbar prop passed to the underlying DataGrid. + * + * Priority: + * 1. `toolbar` (full override) — caller owns the whole row + * 2. `searchBar` provided — render our own DataGridToolbar + * wrapper that hides the built-in quick search and slots the + * caller's node where it used to live; keeps Columns/Export + * intact. `toolbarExtra` (if provided) is passed through as + * the built-in extras slot. + * 3. neither — undefined, so the DataGrid + * falls back to its default toolbar behaviour (built-in + * quick search, extras, columns, export). + */ + const renderCustomToolbar = useCallback( + function renderCustomToolbar(ctx: DataGridToolbarContext) { + const extended = extendCtx(ctx); + const leading = + typeof searchBar === "function" ? searchBar(extended) : searchBar; + const extras = + toolbarExtra === undefined + ? undefined + : typeof toolbarExtra === "function" + ? toolbarExtra(extended) + : toolbarExtra; + return ( + + ); + }, + [searchBar, toolbarExtra, extendCtx], + ); + + const renderForwardedToolbar = useCallback( + function renderForwardedToolbar(ctx: DataGridToolbarContext) { + if (!toolbar) return null; + return toolbar(extendCtx(ctx)); + }, + [toolbar, extendCtx], + ); + + const resolvedToolbar = toolbar + ? renderForwardedToolbar + : searchBar !== undefined + ? renderCustomToolbar + : undefined; + + const resolvedToolbarExtra = useMemo(() => { + // When we've already built a custom toolbar above for the + // `searchBar` case, the `toolbarExtra` prop is consumed inside + // that custom toolbar — don't also pass it to DataGrid. + if (toolbar || searchBar !== undefined) return undefined; + if (toolbarExtra === undefined) return undefined; + if (typeof toolbarExtra !== "function") return toolbarExtra; + return (ctx: DataGridToolbarContext) => toolbarExtra(extendCtx(ctx)); + }, [toolbar, searchBar, toolbarExtra, extendCtx]); + + return ( +
+ {error != null && !showEmptyError && ( +
+ {error} +
+ )} + + {showEmptyError && ( +
+ {error} + +
+ )} + + {!showEmptyError && ( +
+ + columns={columns} + rows={gridData.rows} + getRowId={getRowId} + totalRowCount={gridData.totalRowCount} + isLoading={gridData.isLoading} + isRefetching={gridData.isRefetching} + isLoadingMore={gridData.isLoadingMore} + hasMore={gridData.hasMore} + onLoadMore={gridData.loadMore} + state={gridState} + onChange={setGridState} + paginationMode="infinite" + selectionMode="none" + toolbar={resolvedToolbar} + toolbarExtra={resolvedToolbarExtra} + footer={footer ? undefined : false} + exportFilename={exportFilename} + onRowClick={handleRowClick} + emptyState={ + emptyState ?? ( +
+ No data available +
+ ) + } + /> +
+ )} + + {enableRowDetailDialog && ( + + )} +
+ ); + }, +); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts new file mode 100644 index 0000000000..7f2d22df9b --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts @@ -0,0 +1,44 @@ +import type { UIMessage } from "@ai-sdk/react"; +import { describe, expect, it } from "vitest"; +import { extractLatestQuery } from "./use-ai-query-chat"; + +describe("extractLatestQuery", () => { + it("ignores failed queryAnalytics tool calls and keeps the last successful query", () => { + const messages = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-queryAnalytics", + toolCallId: "call-1", + state: "output-available", + input: { query: "SELECT 1" }, + output: { success: true }, + }, + ], + }, + { + id: "assistant-2", + role: "assistant", + parts: [ + { + type: "tool-queryAnalytics", + toolCallId: "call-2", + state: "output-error", + input: { query: "SELECT broken" }, + errorText: "boom", + }, + ], + }, + ] satisfies UIMessage[]; + + const result = extractLatestQuery(messages); + + expect(result).toEqual({ + query: "SELECT 1", + state: "output-available", + toolCallIndex: 2, + }); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts new file mode 100644 index 0000000000..72cdfaf1fa --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts @@ -0,0 +1,193 @@ +"use client"; + +import { buildStackAuthHeaders } from "@/lib/api-headers"; +import { getPublicEnvVar } from "@/lib/env"; +import { useChat, type UIMessage } from "@ai-sdk/react"; +import { useUser } from "@stackframe/stack"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { convertToModelMessages, DefaultChatTransport } from "ai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useProjectId } from "../../use-admin-app"; + +type ToolPart = { + type: string, + state: string, + input?: Record, + output?: Record, +}; + +function isToolPart(part: unknown): part is ToolPart { + if (typeof part !== "object" || part === null) return false; + const typed = part as { type?: unknown }; + return typeof typed.type === "string" && typed.type.startsWith("tool-"); +} + +function isSuccessfulQueryToolPart(part: ToolPart): boolean { + if (part.state === "output-error") return false; + const success = part.output?.success; + if (success === false) return false; + return part.state === "output-available"; +} + +/** + * Walk backwards through messages / parts and return the most recent + * `queryAnalytics` tool call. The frontend uses the `query` argument + * of that call to drive the data grid — the AI is instructed to call + * the tool any time it wants to commit a new query. + */ +export function extractLatestQuery(messages: UIMessage[]): { + query: string, + state: string, + toolCallIndex: number, +} | null { + let toolCallIndex = 0; + // Count total tool calls across the conversation so we can use the + // index as a stable "generation" key — the dialog uses this to know + // when the query has been regenerated (for highlight/animation). + for (const msg of messages) { + if (msg.role !== "assistant") continue; + for (const part of msg.parts) { + if (isToolPart(part) && part.type.endsWith("queryAnalytics")) { + toolCallIndex += 1; + } + } + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]!; + if (msg.role !== "assistant") continue; + for (let j = msg.parts.length - 1; j >= 0; j--) { + const part = msg.parts[j]!; + if (!isToolPart(part)) continue; + if (!part.type.endsWith("queryAnalytics")) continue; + // Wait for a successful tool result before surfacing the query + // to the grid — otherwise we'd re-run partially streamed or + // failed SQL on every chunk / failed turn. + if (!isSuccessfulQueryToolPart(part)) continue; + const query = + typeof part.input?.query === "string" ? (part.input.query as string) : null; + if (query && query.trim().length > 0) { + return { query, state: part.state, toolCallIndex }; + } + } + } + return null; +} + +export type AiQueryChat = ReturnType & { + /** + * The SQL query the frontend should render in the data grid. Only + * updates when an assistant turn COMPLETES, so the grid never + * flashes through intermediate inspection queries — the previously + * committed query stays visible while the AI is still responding. + */ + latestQuery: string | null, + /** Monotonic counter — increments each time a new query is committed. */ + queryGeneration: number, + /** `true` while the AI is thinking or streaming a response. */ + isResponding: boolean, + /** Rewind the active query to a specific SQL string from a previous tool call. */ + rewindToQuery: (query: string) => void, +}; + +/** + * Shared chat hook for the analytics AI query builder. Sends user + * messages to the unified AI endpoint with the `build-analytics-query` + * system prompt and the `sql-query` tool, then extracts the latest + * committed SQL query so the grid can render its results. + * + * Call this ONCE at the top of the tables page and pass the result + * down to both the search bar and the eye dialog, so they share a + * single conversation thread. + */ +export function useAiQueryChat(): AiQueryChat { + const currentUser = useUser(); + const projectId = useProjectId(); + const backendBaseUrl = + getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? + getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? + throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set"); + + const transport = useMemo( + () => + new DefaultChatTransport({ + api: `${backendBaseUrl}/api/latest/ai/query/stream`, + headers: () => buildStackAuthHeaders(currentUser), + prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { + const modelMessages = await convertToModelMessages(uiMessages); + return { + body: { + systemPrompt: "build-analytics-query", + tools: ["sql-query"], + quality: "smart", + speed: "fast", + projectId, + messages: modelMessages.map((m) => ({ + role: m.role, + content: m.content, + })), + }, + headers, + }; + }, + }), + // The transport only needs to be rebuilt if the backend URL or + // project changes; current user is read via closure on each + // request, so it's not in the dep list on purpose. + // eslint-disable-next-line react-hooks/exhaustive-deps + [backendBaseUrl, projectId], + ); + + const chat = useChat({ transport }); + + const isResponding = chat.status === "submitted" || chat.status === "streaming"; + + // Keep the committed query stable while the AI is still working — + // the grid should show the previous committed query (if any) until + // the current turn finishes, so we don't flash intermediate + // inspection queries into the grid. On turn completion we pick the + // LAST successful `queryAnalytics` tool call, which the prompt + // guarantees will be the user-facing final query. + const [committed, setCommitted] = useState<{ + query: string, + generation: number, + } | null>(null); + const wasRespondingRef = useRef(false); + const lastCommittedGenRef = useRef(0); + + useEffect(() => { + const justFinished = wasRespondingRef.current && !isResponding; + wasRespondingRef.current = isResponding; + if (!justFinished) return; + + const latest = extractLatestQuery(chat.messages); + if (latest == null) return; + if (latest.toolCallIndex <= lastCommittedGenRef.current) return; + lastCommittedGenRef.current = latest.toolCallIndex; + setCommitted({ query: latest.query, generation: latest.toolCallIndex }); + }, [isResponding, chat.messages]); + + // When the chat is reset (e.g. setMessages([])) we also clear the + // committed query so the grid falls back to its default. + useEffect(() => { + if (chat.messages.length === 0 && committed != null) { + lastCommittedGenRef.current = 0; + setCommitted(null); + } + }, [chat.messages.length, committed]); + + const rewindToQuery = useCallback((query: string) => { + setCommitted((prev) => ({ + query, + generation: (prev?.generation ?? 0) + 1, + })); + }, []); + + return { + ...chat, + latestQuery: committed?.query ?? null, + queryGeneration: committed?.generation ?? 0, + isResponding, + rewindToQuery, + }; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx index 76f620ee50..17ad8f25bb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx @@ -1,20 +1,23 @@ "use client"; -import { DashboardSandboxHost } from "@/components/commands/create-dashboard/dashboard-sandbox-host"; +import { DashboardSandboxHost, type DashboardRuntimeError, type WidgetSelection } from "@/components/commands/create-dashboard/dashboard-sandbox-host"; import { useRouter, useRouterConfirm } from "@/components/router"; -import { ActionDialog, Button, Typography } from "@/components/ui"; +import { StreamingCodeViewer } from "@/components/streaming-code-viewer"; +import { ActionDialog, Button, Typography, useToast } from "@/components/ui"; import { Input } from "@/components/ui/input"; import { AssistantChat, createDashboardChatAdapter, createHistoryAdapter, DashboardToolUI, + type AssistantComposerApi, } from "@/components/vibe-coding"; import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; import { + ChatCircleIcon, FloppyDiskIcon, PencilSimpleIcon, TrashIcon, @@ -82,7 +85,8 @@ export default function PageClient() { 0; const [isChatOpen, setIsChatOpen] = useState(!hasSource); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [currentTsxSource, setCurrentTsxSource] = useState(tsxSource); const [savedTsxSource, setSavedTsxSource] = useState(tsxSource); + const [isGenerating, setIsGenerating] = useState(false); + const [pendingCode, setPendingCode] = useState(null); + const [iframeReady, setIframeReady] = useState(hasSource); + const [codePhase, setCodePhase] = useState<"typing" | "loading" | "done">("done"); + const codePhaseTimerRef = useRef>(); const hasUnsavedChanges = currentTsxSource !== savedTsxSource; const { setNeedConfirm } = useRouterConfirm(); + const { toast } = useToast(); + + // Handle on the assistant-ui composer, set by AssistantChat once its runtime mounts. + // Used to prefill the composer when the sandbox dashboard throws an error. + const composerApiRef = useRef(null); + const handleComposerReady = useCallback((api: AssistantComposerApi) => { + composerApiRef.current = api; + }, []); + + // Coalesce duplicate error reports — React re-renders a crashed component several times, + // and uncaught-error listeners can fire twice for the same exception. We only surface the + // first unique error per 2-second window so the composer isn't stomped on repeatedly. + const lastErrorRef = useRef<{ signature: string, at: number } | null>(null); + const handleDashboardRuntimeError = useCallback( + (err: DashboardRuntimeError) => { + const signature = `${err.message}::${(err.stack ?? "").slice(0, 200)}`; + const now = Date.now(); + if (lastErrorRef.current && lastErrorRef.current.signature === signature && now - lastErrorRef.current.at < 2000) { + return; + } + lastErrorRef.current = { signature, at: now }; + + // Build a compact fix-request prompt. We keep the stack to ~1200 chars so the + // agent gets enough context to localize the bug without drowning in frame noise. + const stackSlice = (err.stack ?? "").trim().slice(0, 1200); + const componentStackSlice = (err.componentStack ?? "").trim().slice(0, 400); + const prefill = [ + "The dashboard just crashed at runtime. Please diagnose and fix it.", + "", + "Error:", + err.message, + stackSlice ? `\nStack:\n${stackSlice}` : "", + componentStackSlice ? `\nComponent stack:${componentStackSlice}` : "", + ] + .filter(Boolean) + .join("\n"); + + // Open the chat panel if it's closed so the user sees the pre-filled composer. + // The iframe panel doesn't unmount when chat toggles, so no reload cost. + setIsChatOpen(true); + composerApiRef.current?.setText(prefill); + + toast({ + variant: "destructive", + title: "Dashboard crashed", + description: "Error added to chat — hit send to fix it.", + }); + }, + [toast], + ); + + const handleWidgetSelected = useCallback( + (selection: WidgetSelection) => { + const api = composerApiRef.current; + if (!api) return; + + setIsChatOpen(true); + + const { heading, tagName, rect, textPreview } = selection.metadata; + const name = heading ?? `${tagName} (${rect.width}×${rect.height})`; + const domContext = [ + `[Widget: ${name}]`, + textPreview ? `Content: ${textPreview.slice(0, 200)}` : "", + ].filter(Boolean).join("\n"); + + const currentText = api.getText(); + api.setText(domContext + "\n" + currentText); + }, + [], + ); + useEffect(() => { if (!hasUnsavedChanges) return; setNeedConfirm(true); @@ -155,16 +230,42 @@ function DashboardDetailContent({ const currentHasSource = currentTsxSource.length > 0; const handleEditToggle = useCallback(() => { - if (!currentHasSource) return; setIsChatOpen(prev => !prev); - }, [currentHasSource]); + }, []); const handleNavigate = useCallback((path: string) => { router.push(`/projects/${projectId}${path}`); }, [router, projectId]); const handleCodeUpdate = useCallback((toolCall: ToolCallContent) => { - setCurrentTsxSource(toolCall.args.content); + if (typeof toolCall.args.content === "string") { + setPendingCode(toolCall.args.content); + setCurrentTsxSource(toolCall.args.content); + clearTimeout(codePhaseTimerRef.current); + setCodePhase("typing"); + codePhaseTimerRef.current = setTimeout(() => { + setCodePhase("loading"); + codePhaseTimerRef.current = setTimeout(() => { + setCodePhase("done"); + }, 1000); + }, 3000); + } + }, []); + + const handleRunStart = useCallback(() => { + setIsGenerating(true); + setPendingCode(null); + setIframeReady(false); + setCodePhase("typing"); + clearTimeout(codePhaseTimerRef.current); + }, []); + + const handleRunEnd = useCallback(() => { + setIsGenerating(false); + }, []); + + const handleIframeReady = useCallback(() => { + setIframeReady(true); }, []); const handleSaveDashboard = useCallback(async () => { @@ -206,28 +307,84 @@ function DashboardDetailContent({ router.replace(`/projects/${projectId}/dashboards`); }; - const dashboardPreview = currentHasSource ? ( - - ) : ( -
-
-
- - - + const isCreating = !currentHasSource; + const overlayActive = isCreating && (isGenerating || (pendingCode !== null && codePhase !== "done")); + const canShowDashboard = !isCreating || (codePhase === "done" && iframeReady); + + const UPDATE_STATUS_MESSAGES = [ + "Reviewing your current dashboard...", + "Understanding your changes...", + "Analyzing existing components...", + "Planning the update...", + "Building on your layout...", + "Applying modifications...", + "Updating data sources...", + "Adjusting the structure...", + "Refining components...", + "Wiring up interactions...", + "Polishing the details...", + "Almost there...", + ]; + + const dashboardPreview = ( + <> + {overlayActive && ( +
+ {codePhase === "loading" ? ( +
+
+
+
+
+
+
+ Loading dashboard... +
+
+ ) : ( + + )}
- No dashboard yet - - Describe what you'd like to see in the chat to generate your dashboard. - -
-
+ )} + + {currentHasSource ? ( +
+ +
+ ) : !isGenerating ? ( +
+
+
+ + + +
+ No dashboard yet + + Describe what you'd like to see in the chat to generate your dashboard. + +
+
+ ) : null} + ); return ( @@ -238,7 +395,7 @@ function DashboardDetailContent({ {/* Dashboard iframe panel */}
{dashboardPreview}
+ + {!isChatOpen && ( + + )}
{/* Chat panel — slides in from the right. min-w on the inner card prevents content @@ -274,17 +442,20 @@ function DashboardDetailContent({ setIsEditingName(false); }} onDelete={() => setDeleteDialogOpen(true)} - onClose={currentHasSource ? () => setIsChatOpen(false) : undefined} + onClose={() => setIsChatOpen(false)} hasUnsavedChanges={hasUnsavedChanges} onSaveDashboard={handleSaveDashboard} />
} useOffWhiteLightMode - composerPlaceholder={currentHasSource ? undefined : composerPlaceholder} + composerPlaceholder={currentHasSource ? undefined : DASHBOARD_COMPOSER_PLACEHOLDER} + runningStatusMessages={!isCreating ? UPDATE_STATUS_MESSAGES : undefined} + composerAttachments + onComposerReady={handleComposerReady} />
@@ -311,78 +482,17 @@ function DashboardDetailContent({ ); } -const DASHBOARD_PLACEHOLDER_SUFFIXES = [ - "user signups and retention", - "team activity across projects", - "API latency and error rates", - "email open rates and clicks", - "authentication trends", - "revenue and subscription growth", -]; - -function useTypingPlaceholder( - prefix: string, - suffixes: readonly string[], - { typeSpeed = 70, deleteSpeed = 40, pauseAfterType = 2000, pauseAfterDelete = 400 } = {}, -): string { - const [suffixText, setSuffixText] = useState(""); - const state = useRef({ - suffixIndex: 0, - charIndex: 0, - phase: "typing" as "typing" | "pausing" | "deleting" | "waiting", - }); - - useEffect(() => { - let timeoutId: ReturnType; - - function tick() { - const s = state.current; - const target = suffixes[s.suffixIndex % suffixes.length]; - - switch (s.phase) { - case "typing": { - if (s.charIndex < target.length) { - s.charIndex++; - setSuffixText(target.slice(0, s.charIndex)); - timeoutId = setTimeout(tick, typeSpeed); - } else { - s.phase = "pausing"; - timeoutId = setTimeout(tick, pauseAfterType); - } - break; - } - case "pausing": { - s.phase = "deleting"; - timeoutId = setTimeout(tick, deleteSpeed); - break; - } - case "deleting": { - if (s.charIndex > 0) { - s.charIndex--; - setSuffixText(target.slice(0, s.charIndex)); - timeoutId = setTimeout(tick, deleteSpeed); - } else { - s.phase = "waiting"; - timeoutId = setTimeout(tick, pauseAfterDelete); - } - break; - } - case "waiting": { - s.suffixIndex = (s.suffixIndex + 1) % suffixes.length; - s.charIndex = 0; - s.phase = "typing"; - timeoutId = setTimeout(tick, typeSpeed); - break; - } - } - } - - timeoutId = setTimeout(tick, 500); - return () => clearTimeout(timeoutId); - }, [suffixes, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete]); - - return prefix + suffixText; -} +const DASHBOARD_COMPOSER_PLACEHOLDER = { + prefix: "Create a dashboard about ", + suffixes: [ + "user signups and retention", + "team activity across projects", + "API latency and error rates", + "email open rates and clicks", + "authentication trends", + "revenue and subscription growth", + ], +} as const; function ChatPanelHeader({ displayName, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx index 11bc249889..dbb0fa39aa 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/page-client.tsx @@ -12,6 +12,7 @@ import { TrashIcon, } from "@phosphor-icons/react"; import { DesignCard } from "@stackframe/dashboard-ui-components"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { useMemo, useState } from "react"; import * as yup from "yup"; @@ -32,10 +33,12 @@ export default function PageClient() { const [deleteDialogId, setDeleteDialogId] = useState(null); const dashboards = useMemo((): DashboardEntry[] => { - return Object.entries(config.customDashboards).map(([id, dashboard]) => ({ - id, - displayName: (dashboard as { displayName: string }).displayName, - })); + return Object.entries(config.customDashboards) + .map(([id, dashboard]) => ({ + id, + displayName: (dashboard as { displayName: string }).displayName, + })) + .sort((a, b) => stringCompare(a.displayName, b.displayName) || stringCompare(a.id, b.id)); }, [config.customDashboards]); const dashboardToDelete = deleteDialogId diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-events-panel.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-events-panel.tsx new file mode 100644 index 0000000000..86507ea75b --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-events-panel.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { + DesignAnalyticsCard, + DesignAnalyticsCardHeader, + DesignButton, +} from "@/components/design-components"; +import { XIcon } from "@phosphor-icons/react"; + +export type AnalyticsChartLabEvent = { + id: number, + ts: number, + name: string, + payload: string, +}; + +export function AnalyticsChartEventsPanel({ + events, + onClear, +}: { + events: AnalyticsChartLabEvent[], + onClear: () => void, +}) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-state-panel.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-state-panel.tsx new file mode 100644 index 0000000000..9ad39e551f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-state-panel.tsx @@ -0,0 +1,733 @@ +"use client"; + +import { + DesignAnalyticsCard, + DesignAnalyticsCardHeader, + DesignButton, + DesignPillToggle, +} from "@/components/design-components"; +import { + ArrowsClockwiseIcon, + ChartBarIcon, + ChartLineIcon, + ChartLineUpIcon, + ChartPieIcon, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; +import { + DEFAULT_FORMAT_KIND, + findLayerById, + isAnalyticsChartDataLayer, + patchLayerById, + setLayerById, + type AnalyticsChartAnnotationsLayer, + type AnalyticsChartDataLayer, + type AnalyticsChartLayer, + type AnalyticsChartLayerType, + type AnalyticsChartState, + type AnalyticsChartStrokeStyle, + type AnalyticsChartTimeseriesState, + type AnalyticsChartView, + type FormatKind, + type FormatKindDatetime, + type FormatKindPercent, + type FormatKindType, +} from "@stackframe/dashboard-ui-components"; + +/** Shared wrapper row used throughout the state panel: rounded border, + * subtle background, label + mono key caption, and a right-side slot. */ +function FieldRow({ + label, + keyName, + right, + children, +}: { + label: string, + keyName: string, + right?: ReactNode, + children?: ReactNode, +}) { + return ( +
+
+
+ {label} +
+ {keyName} +
+
+ {right != null &&
{right}
} +
+ {children} +
+ ); +} + +/** Boolean on/off toggle with the shared FieldRow chrome. */ +function BoolFieldRow({ + label, + keyName, + value, + onChange, +}: { + label: string, + keyName: string, + value: boolean, + onChange: (next: boolean) => void, +}) { + return ( + onChange(id === "on")} + /> + } + /> + ); +} + +/** Data-layer row — type picker, color, stroke, fill, segmentation, in-progress. */ +function DataLayerRow({ + layer, + dataLength, + setLayerType, + setLayerStrokeStyle, + setLayerFillOpacity, + setLayerSegmented, + setLayerInProgress, + setLayerVisible, + setLayerColor, +}: { + layer: AnalyticsChartDataLayer, + dataLength: number, + setLayerType: (id: string, next: AnalyticsChartLayerType) => void, + setLayerStrokeStyle: (id: string, next: AnalyticsChartStrokeStyle) => void, + setLayerFillOpacity: (id: string, next: number) => void, + setLayerSegmented: (id: string, next: boolean) => void, + setLayerInProgress: (id: string, next: number | null) => void, + setLayerVisible: (id: string, next: boolean) => void, + setLayerColor: (id: string, next: string) => void, +}) { + const supportsStroke = layer.type === "line" || layer.type === "area"; + const supportsFill = layer.type === "area" || layer.type === "bar"; + const currentStroke = "strokeStyle" in layer ? layer.strokeStyle : undefined; + const currentFill = "fillOpacity" in layer ? layer.fillOpacity : undefined; + return ( + + setLayerType(layer.id, id as AnalyticsChartLayerType)} + /> + setLayerVisible(layer.id, id === "on")} + /> + + } + > +
+ + {supportsStroke && currentStroke !== undefined && ( + + )} + {supportsFill && currentFill !== undefined && ( + + )} + + +
+
+ ); +} + +/** Annotations-layer row — color + visibility only. */ +function AnnotationsLayerRow({ + layer, + setLayerColor, + setLayerVisible, +}: { + layer: AnalyticsChartAnnotationsLayer, + setLayerColor: (id: string, next: string) => void, + setLayerVisible: (id: string, next: boolean) => void, +}) { + return ( + + + setLayerVisible(layer.id, id === "on")} + /> + + } + /> + ); +} + +function FormatKindOptions({ + kind, + onChange, +}: { + kind: FormatKind, + onChange: (next: FormatKind) => void, +}) { + const optionLabel = (text: string) => ( + + {text} + + ); + switch (kind.type) { + case "numeric": { + const decimals = kind.decimals ?? 0; + return ( +
+ +
+ ); + } + case "short": { + const precision = kind.precision ?? 1; + return ( +
+ +
+ ); + } + case "currency": { + const currency = kind.currency ?? "USD"; + const divisor = kind.divisor ?? 1; + return ( +
+ + +
+ ); + } + case "duration": { + const unit = kind.unit ?? "s"; + return ( +
+ +
+ ); + } + case "datetime": { + const style = kind.style ?? "short"; + return ( +
+ +
+ ); + } + case "percent": { + const source = kind.source ?? "fraction"; + const decimals = kind.decimals ?? 1; + return ( +
+ + +
+ ); + } + } +} + +function rebuildDataLayer( + current: AnalyticsChartDataLayer, + nextType: AnalyticsChartLayerType, +): AnalyticsChartDataLayer { + const prevStroke: AnalyticsChartStrokeStyle = + "strokeStyle" in current ? current.strokeStyle : "solid"; + const prevFill: number = + "fillOpacity" in current ? current.fillOpacity : 0.22; + const base = { + id: current.id, + kind: current.kind, + label: current.label, + visible: current.visible, + color: current.color, + segmented: current.segmented, + segments: current.segments, + segmentSeries: current.segmentSeries, + inProgressFromIndex: current.inProgressFromIndex, + }; + if (nextType === "line") return { ...base, type: "line", strokeStyle: prevStroke }; + if (nextType === "bar") return { ...base, type: "bar", fillOpacity: prevFill }; + return { ...base, type: "area", strokeStyle: prevStroke, fillOpacity: prevFill }; +} + +export function AnalyticsChartStatePanel({ + state, + onChange, + onReset, + dataLength, +}: { + state: AnalyticsChartState, + onChange: React.Dispatch>, + onReset: () => void, + dataLength: number, +}) { + const setTimeseriesField = ( + key: K, + value: AnalyticsChartTimeseriesState[K], + ) => { + onChange((prev) => { + if (prev.view !== "timeseries") return prev; + return { ...prev, [key]: value }; + }); + }; + const setView = (next: AnalyticsChartView) => { + onChange((prev) => { + if (next === prev.view) return prev; + if (next === "pie") { + return { + view: "pie", + layers: prev.layers, + xFormatKind: prev.xFormatKind, + yFormatKind: prev.yFormatKind, + }; + } + return { + view: "timeseries", + layers: prev.layers, + xFormatKind: prev.xFormatKind, + yFormatKind: prev.yFormatKind, + showGrid: true, + showXAxis: true, + showYAxis: true, + zoomRange: null, + pinnedIndex: null, + }; + }); + }; + const setXFormatKind = (next: FormatKind) => { + onChange((prev) => ({ ...prev, xFormatKind: next })); + }; + const setYFormatKind = (next: FormatKind) => { + onChange((prev) => ({ ...prev, yFormatKind: next })); + }; + const patchLayer = (id: string, patch: Record) => { + onChange((prev) => ({ + ...prev, + layers: patchLayerById(prev.layers, id, patch), + })); + }; + const replaceLayer = (id: string, next: AnalyticsChartLayer) => { + onChange((prev) => ({ + ...prev, + layers: setLayerById(prev.layers, id, next), + })); + }; + + const setLayerType = (id: string, nextType: AnalyticsChartLayerType) => { + const current = findLayerById(state.layers, id); + if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; + replaceLayer(id, rebuildDataLayer(current, nextType)); + }; + const setLayerStrokeStyle = (id: string, style: AnalyticsChartStrokeStyle) => { + const current = findLayerById(state.layers, id); + if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; + if (current.type === "bar") return; + replaceLayer(id, { ...current, strokeStyle: style }); + }; + const setLayerFillOpacity = (id: string, fillOpacity: number) => { + const current = findLayerById(state.layers, id); + if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; + if (current.type === "line") return; + replaceLayer(id, { ...current, fillOpacity }); + }; + const setLayerSegmented = (id: string, segmented: boolean) => patchLayer(id, { segmented }); + const setLayerInProgress = (id: string, inProgressFromIndex: number | null) => + patchLayer(id, { inProgressFromIndex }); + const setLayerVisible = (id: string, visible: boolean) => patchLayer(id, { visible }); + const setLayerColor = (id: string, color: string) => patchLayer(id, { color }); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-usage-viewer.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-usage-viewer.tsx new file mode 100644 index 0000000000..1a013c5243 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-usage-viewer.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { + DesignAnalyticsCard, + DesignAnalyticsCardHeader, + DesignButton, +} from "@/components/design-components"; +import { CursorClickIcon } from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useState } from "react"; +import type { + AnalyticsChartLayer, + AnalyticsChartState, + Annotation, + FormatKind, + Point, +} from "@stackframe/dashboard-ui-components"; + +function formatFormatKindLiteral(kind: FormatKind): string { + const fields: string[] = [`type: "${kind.type}"`]; + for (const [key, value] of Object.entries(kind)) { + if (key === "type" || value === undefined) continue; + if (typeof value === "string") fields.push(`${key}: "${value}"`); + else fields.push(`${key}: ${value}`); + } + return `{ ${fields.join(", ")} }`; +} + +function formatLayerLiteral(l: AnalyticsChartLayer): string { + const fields: string[] = [ + `id: "${l.id}"`, + `kind: "${l.kind}"`, + `label: "${l.label}"`, + `visible: ${l.visible}`, + ]; + if (l.kind === "primary" || l.kind === "compare") { + fields.push( + `color: "${l.color}"`, + `segmented: ${l.segmented}`, + `type: "${l.type}"`, + ); + if (l.type === "line" || l.type === "area") { + fields.push(`strokeStyle: "${l.strokeStyle}"`); + } + if (l.type === "area" || l.type === "bar") { + fields.push(`fillOpacity: ${l.fillOpacity}`); + } + if (l.segments && l.segments.length > 0) { + const rows = l.segments.length; + const cols = l.segments[0]?.length ?? 0; + fields.push(`segments: /* ${rows}×${cols} */`); + } + if (l.segmentSeries && l.segmentSeries.length > 0) { + const keys = l.segmentSeries.map((s) => `"${s.key}"`).join(", "); + fields.push(`segmentSeries: [${keys}]`); + } + if (l.inProgressFromIndex != null) { + fields.push(`inProgressFromIndex: ${l.inProgressFromIndex}`); + } + } else { + fields.push(`color: "${l.color}"`); + } + return `{ ${fields.join(", ")} }`; +} + +function formatStateLiteral(state: AnalyticsChartState, indent: string): string { + const inner = `${indent} `; + const layersBlock = state.layers + .map((l) => `${inner} ${formatLayerLiteral(l)},`) + .join("\n"); + const lines = [ + `${indent}{`, + `${inner}view: "${state.view}",`, + `${inner}layers: [`, + layersBlock, + `${inner}],`, + `${inner}xFormatKind: ${formatFormatKindLiteral(state.xFormatKind)},`, + `${inner}yFormatKind: ${formatFormatKindLiteral(state.yFormatKind)},`, + ]; + if (state.view === "timeseries") { + lines.push( + `${inner}showGrid: ${state.showGrid},`, + `${inner}showXAxis: ${state.showXAxis},`, + `${inner}showYAxis: ${state.showYAxis},`, + `${inner}zoomRange: ${state.zoomRange ? `[${state.zoomRange[0]}, ${state.zoomRange[1]}]` : "null"},`, + `${inner}pinnedIndex: ${state.pinnedIndex ?? "null"},`, + ); + } + lines.push(`${indent}}`); + return lines.join("\n"); +} + +function formatLiteralValue(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return `"${value}"`; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) { + return `[${value.map(formatLiteralValue).join(", ")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .map(([k, v]) => `${k}: ${formatLiteralValue(v)}`) + .join(", "); + return `{ ${entries} }`; + } + return String(value); +} + +function formatDataProp(propName: string, items: unknown[], showCount = 2): string { + if (items.length === 0) return ` ${propName}={[]}`; + const previewItems = items.slice(0, showCount); + const remaining = items.length - previewItems.length; + const lines = [` ${propName}={[`]; + for (const item of previewItems) { + lines.push(` ${formatLiteralValue(item)},`); + } + if (remaining > 0) { + lines.push(` // …${remaining} more`); + } + lines.push(" ]}"); + return lines.join("\n"); +} + +export type AnalyticsChartUsageData = { + data: Point[], + annotations: Annotation[], +}; + +export function generateAnalyticsChartUsage( + state: AnalyticsChartState, + exampleData: AnalyticsChartUsageData, +): string { + const lines: string[] = [ + "`); + lines.push(` setAnnotations((prev) => [...prev, annotation])`); + lines.push(` }`); + lines.push("/>"); + return lines.join("\n"); +} + +export function AnalyticsChartUsageViewer({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + runAsynchronouslyWithAlert(async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1400); + }); + }; + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/fixtures.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/fixtures.ts new file mode 100644 index 0000000000..808a83af5e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/fixtures.ts @@ -0,0 +1,128 @@ +import { + ANALYTICS_CHART_DEFAULT_LAYERS, + DEFAULT_FORMAT_KIND, + pointValue, + type AnalyticsChartLayer, + type AnalyticsChartSeries, + type AnalyticsChartState, + type Annotation, + type Point, +} from "@stackframe/dashboard-ui-components"; + +const DAY_COUNT = 30; + +export const SERIES: Point[] = Array.from({ length: DAY_COUNT }, (_, i) => { + const base = 420; + const trend = i * 14; + const wave = Math.sin(i * 0.48) * 78 + Math.cos(i * 0.21) * 34; + const prev = base + (i * 9) + Math.sin(i * 0.52 + 1.4) * 62; + return { + ts: Date.UTC(2026, 2, 7 + i), + values: { + signups: Math.max(0, Math.round(base + trend + wave)), + previous: Math.max(0, Math.round(prev)), + }, + }; +}); + +export const DEMO_BREAKDOWN_SERIES: AnalyticsChartSeries[] = [ + { key: "us", label: "United States" }, + { key: "eu", label: "European Union" }, + { key: "asia", label: "Asia-Pacific" }, + { key: "latam", label: "Latin America" }, + { key: "other", label: "Other" }, +]; + +const DEMO_BREAKDOWN_RATIOS = [0.32, 0.26, 0.20, 0.13, 0.09]; + +export function allocateByWeight(total: number, weights: number[]): number[] { + const sumW = weights.reduce((a, b) => a + b, 0); + if (sumW <= 0) return weights.map(() => 0); + const raw = weights.map((w) => (total * w) / sumW); + const floors = raw.map((r) => Math.floor(r)); + const base = floors.reduce((a, b) => a + b, 0); + const remainder = total - base; + const order = raw + .map((r, idx) => ({ idx, frac: r - Math.floor(r) })) + .sort((a, b) => b.frac - a.frac); + const result = [...floors]; + for (let i = 0; i < remainder; i++) { + result[order[i % order.length]!.idx]!++; + } + return result; +} + +export const DEMO_BREAKDOWN: number[][] = SERIES.map((p, i) => { + const weights = DEMO_BREAKDOWN_RATIOS.map((r, k) => { + const wave = Math.sin((i + k * 3) * 0.32) * 0.06; + return Math.max(0.01, r + wave); + }); + return allocateByWeight(pointValue(p, "signups"), weights); +}); + +export const DEMO_BREAKDOWN_PREV: number[][] = SERIES.map((p, i) => { + const weights = DEMO_BREAKDOWN_RATIOS.map((r, k) => { + const wave = Math.sin((i + k * 3) * 0.32 + 1.7) * 0.05; + return Math.max(0.01, r + wave); + }); + return allocateByWeight(pointValue(p, "previous"), weights); +}); + +export const ANNOTATIONS: Annotation[] = [ + { index: 8, label: "v4.2", description: "Release v4.2 — new SSO provider" }, + { index: 17, label: "Fix", description: "Hotfix deployed — rate-limit regression" }, + { index: 24, label: "Exp", description: "A/B test launched — signup copy" }, +]; + +function wireDemoLayer(layer: AnalyticsChartLayer): AnalyticsChartLayer { + if (layer.kind === "primary") { + return { + ...layer, + id: "signups", + label: "Sign-ups", + segments: DEMO_BREAKDOWN, + segmentSeries: DEMO_BREAKDOWN_SERIES, + inProgressFromIndex: DAY_COUNT - 1, + }; + } + if (layer.kind === "compare") { + return { + ...layer, + id: "previous", + segments: DEMO_BREAKDOWN_PREV, + segmentSeries: DEMO_BREAKDOWN_SERIES, + inProgressFromIndex: null, + }; + } + return layer; +} + +export const DEMO_DEFAULT_STATE: AnalyticsChartState = { + view: "timeseries", + layers: ANALYTICS_CHART_DEFAULT_LAYERS.map(wireDemoLayer), + xFormatKind: DEFAULT_FORMAT_KIND.datetime, + yFormatKind: DEFAULT_FORMAT_KIND.short, + showGrid: true, + showXAxis: true, + showYAxis: true, + zoomRange: null, + pinnedIndex: null, +}; + +export type TableRow = { + key: string, + label: string, + light: string, + dark: string, + current: number, + previous: number, + trend: number[], +}; + +export const TABLE_ROWS: TableRow[] = [ + { key: "gh", label: "google.com", light: "#2563eb", dark: "#60a5fa", current: 14_820, previous: 12_310, trend: [8, 10, 12, 14, 13, 15, 18, 21, 23, 27, 30, 34] }, + { key: "tw", label: "twitter.com", light: "#059669", dark: "#34d399", current: 7_430, previous: 9_120, trend: [28, 26, 24, 23, 20, 18, 17, 15, 14, 13, 12, 11] }, + { key: "prod", label: "producthunt.com", light: "#d97706", dark: "#fbbf24", current: 5_290, previous: 2_480, trend: [3, 4, 5, 8, 11, 14, 17, 19, 22, 26, 30, 32] }, + { key: "hn", label: "news.ycombinator.com", light: "#7c3aed", dark: "#a78bfa", current: 4_120, previous: 3_980, trend: [12, 13, 14, 14, 13, 15, 14, 13, 14, 15, 14, 14] }, + { key: "direct", label: "(direct)", light: "#94a3b8", dark: "#64748b", current: 3_610, previous: 3_420, trend: [20, 21, 21, 22, 22, 23, 22, 23, 23, 24, 24, 24] }, +]; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/panels.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/panels.tsx new file mode 100644 index 0000000000..e5661c177c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/panels.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { + DesignAlert, + DesignAnalyticsCard, + DesignAnalyticsCardHeader, + DesignButton, + DesignPillToggle, +} from "@/components/design-components"; +import { cn, Typography } from "@/components/ui"; +import { + ArrowDownIcon, + ArrowUpIcon, + ArrowsClockwiseIcon, + ChartLineIcon, + SpinnerGapIcon, +} from "@phosphor-icons/react"; +import { type CSSProperties, type ReactNode, useId, useMemo, useState } from "react"; +import { + formatDelta, + formatValue, + TrendPill, + type FormatKind, +} from "@stackframe/dashboard-ui-components"; +import { TABLE_ROWS, type TableRow } from "./fixtures"; + +export { TrendPill }; + +export function SectionHeading({ + index, + label, + caption, + right, +}: { + index: string, + label: string, + caption: ReactNode, + right?: ReactNode, +}) { + return ( +
+
+ + {index} + +
+ + {label} + + + {caption} + +
+
+ {right != null &&
{right}
} +
+ ); +} + +export function Sparkline({ + values, + width = 90, + height = 22, + padding = 0, + showArea = false, + stroke, + strokeDark, + strokeWidth = 1.5, + className, + ariaHidden = true, +}: { + values: number[], + width?: number, + height?: number, + padding?: number, + showArea?: boolean, + stroke: string, + strokeDark: string, + strokeWidth?: number, + className?: string, + ariaHidden?: boolean, +}) { + const iw = width - padding * 2; + const ih = height - padding * 2; + const min = Math.min(...values); + const max = Math.max(...values) * (showArea ? 1.1 : 1); + const range = (max - min) || 1; + const n = values.length - 1 || 1; + const points = values + .map((v, i) => { + const x = padding + (i / n) * iw; + const y = padding + ih - ((v - min) / range) * ih; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + const viewBox = `0 0 ${width} ${height}`; + const style = { "--s-l": stroke, "--s-d": strokeDark } as CSSProperties; + const areaPath = showArea + ? `M${points.split(" ").join(" L")} L${(padding + iw).toFixed(1)},${(padding + ih).toFixed(1)} L${padding.toFixed(1)},${(padding + ih).toFixed(1)} Z` + : null; + const gradientId = `spark-${useId().replace(/:/g, "")}`; + + return ( + + {areaPath && ( + <> + + + + + + + + + )} + + + ); +} + +export function KpiBlock({ + label, + current, + previous, + formatKind, + gradient, + periodLabel = "30d", + previousPeriodLabel = "prev 30d", +}: { + label: string, + current: number, + previous: number, + formatKind: FormatKind, + gradient: "blue" | "cyan" | "green" | "orange" | "purple", + periodLabel?: string, + previousPeriodLabel?: string, +}) { + const delta = formatDelta(current, previous); + const currentLabel = formatValue(current, formatKind); + const previousLabel = formatValue(previous, formatKind); + return ( + +
+ + {label} + +
+ + {currentLabel} + + +
+
+ {previousLabel} + previous period +
+
+
+
+ + Current · {periodLabel} + + + {currentLabel} + +
+
+ + {previousPeriodLabel} + + + {previousLabel} + +
+
+
+ + Change + + +
+
+
+
+ ); +} + +export function FormatterPanel() { + const rows: { sample: number, kind: FormatKind, hint: string }[] = [ + { sample: 48_271, kind: { type: "numeric", decimals: 0 }, hint: "Locale grouping (en-US)" }, + { sample: 48_271, kind: { type: "numeric", decimals: 2 }, hint: "Locale grouping · 2 decimals" }, + { sample: 4_827_104, kind: { type: "short", precision: 1 }, hint: "Compact (K / M / B)" }, + { sample: 482_701, kind: { type: "currency", currency: "USD", divisor: 100 }, hint: "USD · cents → dollars" }, + { sample: 482_701, kind: { type: "currency", currency: "EUR", divisor: 100 }, hint: "EUR · cents → euros" }, + { sample: 4_271, kind: { type: "duration", unit: "s" }, hint: "1h 11m 11s (seconds)" }, + { sample: 1_240, kind: { type: "duration", unit: "ms" }, hint: "1s 240ms (milliseconds)" }, + { sample: Date.UTC(2026, 2, 17), kind: { type: "datetime", style: "short" }, hint: "Short date" }, + { sample: Date.UTC(2026, 2, 17), kind: { type: "datetime", style: "long" }, hint: "Long date+time" }, + { sample: Date.UTC(2026, 2, 17), kind: { type: "datetime", style: "iso" }, hint: "ISO-8601" }, + { sample: Date.now() - 7_200_000, kind: { type: "datetime", style: "relative" }, hint: "Relative" }, + { sample: 0.482, kind: { type: "percent", source: "fraction", decimals: 1 }, hint: "Fraction → percent" }, + { sample: 4_827, kind: { type: "percent", source: "basis", decimals: 2 }, hint: "Basis points → percent" }, + ]; + return ( + + +
+
    + {rows.map((r, i) => ( +
  • +
    + + {r.kind.type} + + + {r.hint} + +
    + + {formatValue(r.sample, r.kind)} + +
  • + ))} +
+
+
+ ); +} + +type PanelState = "data" | "loading" | "empty" | "error"; + +export function ThreeStatePanel() { + const [state, setState] = useState("data"); + const data = useMemo( + () => Array.from({ length: 24 }, (_, i) => 30 + Math.sin(i * 0.5) * 18 + i * 3), + [], + ); + + return ( + + setState(id as PanelState)} + /> + } + /> +
+ {state === "data" && ( + + )} + {state === "loading" && ( +
+
+ )} + {state === "empty" && ( +
+
+
+
+ No matching events + + Widen the date range or remove breakdown filters to see more. + +
+
+ )} + {state === "error" && ( +
+ + + HOGQL:42 · aggregation timeout after 12.4s + + setState("loading")} + > + +
+ } + /> +
+ )} +
+ + ); +} + +type SortKey = "current" | "previous" | "delta" | "label"; +type SortDir = "asc" | "desc"; + +export function InsightsTablePanel() { + const [sortKey, setSortKey] = useState("current"); + const [sortDir, setSortDir] = useState("desc"); + + const sorted = useMemo(() => { + const rows = [...TABLE_ROWS]; + rows.sort((a, b) => { + const av = sortKey === "label" ? a.label + : sortKey === "current" ? a.current + : sortKey === "previous" ? a.previous + : formatDelta(a.current, a.previous).pct ?? 0; + const bv = sortKey === "label" ? b.label + : sortKey === "current" ? b.current + : sortKey === "previous" ? b.previous + : formatDelta(b.current, b.previous).pct ?? 0; + if (av < bv) return sortDir === "asc" ? -1 : 1; + if (av > bv) return sortDir === "asc" ? 1 : -1; + return 0; + }); + return rows; + }, [sortKey, sortDir]); + + const toggleSort = (key: SortKey) => { + if (key === sortKey) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { + setSortKey(key); + setSortDir(key === "label" ? "asc" : "desc"); + } + }; + + const headCell = (key: SortKey, label: string, align: "left" | "right" = "right") => { + const sortState = sortKey === key ? (sortDir === "asc" ? "ascending" : "descending") : "none"; + return ( + + + + ); + }; + + return ( + + + Click a header to sort + + } + /> +
+ + + + {headCell("label", "Source", "left")} + + {headCell("current", "Current")} + {headCell("previous", "Previous")} + {headCell("delta", "Δ")} + + + + {sorted.map((r: TableRow) => { + const delta = formatDelta(r.current, r.previous); + return ( + + + + + + + + ); + })} + +
+ + Trend + +
+
+ + {r.label} +
+
+ + + {r.current.toLocaleString("en-US")} + + {r.previous.toLocaleString("en-US")} + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/page-client.tsx new file mode 100644 index 0000000000..1645d5aff3 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/page-client.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { + DesignAnalyticsCard, + DesignBadge, +} from "@/components/design-components"; +import { LightningIcon, PulseIcon } from "@phosphor-icons/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PageLayout } from "../../page-layout"; +import { + AnalyticsChart, + DEFAULT_FORMAT_KIND, + pointValue, + type AnalyticsChartState, + type Annotation, +} from "@stackframe/dashboard-ui-components"; +import { + AnalyticsChartEventsPanel, + type AnalyticsChartLabEvent, +} from "./demo/analytics-chart-events-panel"; +import { AnalyticsChartStatePanel } from "./demo/analytics-chart-state-panel"; +import { + AnalyticsChartUsageViewer, + generateAnalyticsChartUsage, +} from "./demo/analytics-chart-usage-viewer"; +import { + ANNOTATIONS, + DEMO_DEFAULT_STATE, + SERIES, +} from "./demo/fixtures"; +import { + FormatterPanel, + InsightsTablePanel, + KpiBlock, + SectionHeading, + ThreeStatePanel, +} from "./demo/panels"; + +export default function PageClient() { + const [pulse, setPulse] = useState(0); + + // Simulated "live" heartbeat so the LIVE badge visibly breathes. + useEffect(() => { + const id = window.setInterval(() => setPulse((p) => p + 1), 2400); + return () => window.clearInterval(id); + }, []); + + const latest = SERIES[SERIES.length - 1]!; + const firstPrev = pointValue(SERIES[0]!, "previous"); + const sumCurrent = SERIES.reduce((a, p) => a + pointValue(p, "signups"), 0); + const sumPrev = SERIES.reduce((a, p) => a + pointValue(p, "previous"), 0); + + // AnalyticsChart is fully controlled — PageClient owns the entire state + // object. DEMO_DEFAULT_STATE ships with the demo breakdown matrices + // pre-wired into the primary/compare layers so the segmented view works + // immediately. + const [labState, setLabState] = useState(DEMO_DEFAULT_STATE); + const resetLabState = () => setLabState(DEMO_DEFAULT_STATE); + + // Annotations are a prop — PageClient owns the array and appends to it + // whenever the chart fires onAnnotationCreate. + const [labAnnotations, setLabAnnotations] = useState(ANNOTATIONS); + + const usageCode = useMemo( + () => + generateAnalyticsChartUsage(labState, { + data: SERIES, + annotations: labAnnotations, + }), + [labState, labAnnotations], + ); + + // Lab playground: live event log subscribed to onChange diffs and the + // discrete onAnnotationCreate callback. Capped at the most recent 16 + // entries so the panel stays compact. + const [labEvents, setLabEvents] = useState([]); + const labEventIdRef = useRef(0); + const logLabEvent = useCallback((name: string, payload: unknown) => { + setLabEvents((prev) => { + labEventIdRef.current += 1; + const next: AnalyticsChartLabEvent = { + id: labEventIdRef.current, + ts: Date.now(), + name, + payload: + payload === null + ? "null" + : typeof payload === "object" + ? JSON.stringify(payload) + : String(payload), + }; + return [next, ...prev].slice(0, 16); + }); + }, []); + const clearLabEvents = useCallback(() => setLabEvents([]), []); + + // Wrap setLabState so every changed field becomes a discrete event in + // the log. Drops rows for the controlled-state props that no longer + // exist (hoverIndex, brush, annotationDraft) — only persistent state + // slices and annotation creation are surfaced. + const handleLabStateChange = useCallback>>( + (action) => { + setLabState((prev) => { + const next = + typeof action === "function" + ? (action as (p: AnalyticsChartState) => AnalyticsChartState)(prev) + : action; + for (const key of Object.keys(next) as (keyof AnalyticsChartState)[]) { + if (!Object.is(next[key], prev[key])) { + logLabEvent(`onChange:${key}`, next[key]); + } + } + return next; + }); + }, + [logLabEvent], + ); + const handleLabAnnotationCreate = useCallback( + (annotation: Annotation) => { + setLabAnnotations((prev) => [...prev, annotation]); + logLabEvent("onAnnotationCreate", annotation); + }, + [logLabEvent], + ); + + return ( + + Ported patterns from PostHog's insight surface — pinnable tooltips, + crosshair, period compare, annotations, formatter pluggability, series + visibility, three-state shims and instant display-type switching. + All shells use DesignAnalyticsCard. + + } + actions={ +
+ + +
+ } + > +
+
+ +
+ + +
+ + Sign-ups + + + 30-day window + +
+
+ +
+
+
+ + +
+
+
+ +
+ +
+ + pointValue(p, "signups")))} + previous={Math.max(...SERIES.map((p) => pointValue(p, "previous")))} + formatKind={DEFAULT_FORMAT_KIND.numeric} + gradient="cyan" + /> + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/page.tsx new file mode 100644 index 0000000000..107377a440 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Chart Interaction Lab", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/demo/fixtures.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/demo/fixtures.tsx new file mode 100644 index 0000000000..566309e202 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/demo/fixtures.tsx @@ -0,0 +1,212 @@ +import type { DataGridColumnDef } from "@stackframe/dashboard-ui-components"; + +// ─── Sample data type ──────────────────────────────────────────────── + +export type User = { + id: string; + name: string; + email: string; + role: "admin" | "editor" | "viewer"; + status: "active" | "inactive" | "pending"; + signUps: number; + lastLogin: Date; + verified: boolean; + country: string; + revenue: number; +}; + +// ─── Column definitions ────────────────────────────────────────────── + +export const DEMO_COLUMNS: DataGridColumnDef[] = [ + { + id: "name", + header: "Name", + accessor: "name", + width: 180, + type: "string", + renderCell: ({ value, row }) => ( + // Custom cell with avatar-like initial +
+
+ {String(value).charAt(0).toUpperCase()} +
+ {String(value)} +
+ ), + }, + { + id: "email", + header: "Email", + accessor: "email", + width: 220, + type: "string", + renderCell: ({ value }) => ( + {String(value)} + ), + }, + { + id: "role", + header: "Role", + accessor: "role", + width: 120, + type: "singleSelect", + valueOptions: [ + { value: "admin", label: "Admin" }, + { value: "editor", label: "Editor" }, + { value: "viewer", label: "Viewer" }, + ], + renderCell: ({ value }) => { + const colors: Record = { + admin: "bg-purple-500/10 text-purple-600 dark:text-purple-400 ring-1 ring-purple-500/20", + editor: "bg-blue-500/10 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/20", + viewer: "bg-foreground/[0.04] text-muted-foreground ring-1 ring-foreground/[0.06]", + }; + return ( + + {String(value)} + + ); + }, + }, + { + id: "status", + header: "Status", + accessor: "status", + width: 110, + type: "singleSelect", + valueOptions: [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "pending", label: "Pending" }, + ], + renderCell: ({ value }) => { + const dot: Record = { + active: "bg-emerald-500", + inactive: "bg-foreground/20", + pending: "bg-amber-500", + }; + return ( +
+
+ {String(value)} +
+ ); + }, + }, + { + id: "signUps", + header: "Sign-ups", + accessor: "signUps", + width: 110, + type: "number", + align: "right", + renderCell: ({ value }) => ( + {Number(value).toLocaleString()} + ), + }, + { + id: "revenue", + header: "Revenue", + accessor: "revenue", + width: 120, + type: "number", + align: "right", + renderCell: ({ value }) => ( + + ${Number(value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + ), + formatValue: (v) => `$${Number(v).toFixed(2)}`, + }, + { + id: "lastLogin", + header: "Last login", + accessor: "lastLogin", + width: 150, + type: "date", + renderCell: ({ value }) => ( + + {value instanceof Date ? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "-"} + + ), + }, + { + id: "verified", + header: "Verified", + accessor: "verified", + width: 90, + type: "boolean", + align: "center", + }, + { + id: "country", + header: "Country", + accessor: "country", + width: 130, + type: "string", + }, +]; + +// ─── Sample data ───────────────────────────────────────────────────── + +const firstNames = [ + "Alice", "Bob", "Carol", "David", "Eve", "Frank", "Grace", "Hank", + "Ivy", "Jack", "Karen", "Leo", "Mia", "Noah", "Olivia", "Paul", + "Quinn", "Rose", "Sam", "Tina", "Uma", "Vince", "Wendy", "Xander", + "Yara", "Zack", "Aria", "Blake", "Clara", "Dean", "Ella", "Finn", + "Gina", "Hugo", "Iris", "Jake", "Kira", "Liam", "Maya", "Nate", + "Opal", "Petra", "Quinn", "Riley", "Sage", "Troy", "Ursa", "Vale", + "Wren", "Zoe", +]; +const lastNames = [ + "Anderson", "Brown", "Chen", "Davis", "Evans", "Fisher", "Garcia", + "Harris", "Ivanov", "Jones", "Kim", "Lee", "Martinez", "Nguyen", + "O'Brien", "Patel", "Quinn", "Robinson", "Smith", "Taylor", "Ueda", + "Vasquez", "Wilson", "Xu", "Yang", "Zhang", "Moore", "Clark", + "Lewis", "Walker", +]; +const countries = [ + "United States", "United Kingdom", "Germany", "France", "Canada", + "Japan", "Australia", "Brazil", "India", "South Korea", "Netherlands", + "Sweden", "Norway", "Mexico", "Spain", +]; +const roles: User["role"][] = ["admin", "editor", "viewer"]; +const statuses: User["status"][] = ["active", "inactive", "pending"]; + +function seededRandom(seed: number) { + let s = seed; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +export function generateUsers(count: number): User[] { + const rng = seededRandom(42); + const users: User[] = []; + + for (let i = 0; i < count; i++) { + const firstName = firstNames[Math.floor(rng() * firstNames.length)]!; + const lastName = lastNames[Math.floor(rng() * lastNames.length)]!; + const domain = ["gmail.com", "company.io", "outlook.com", "hey.com"][Math.floor(rng() * 4)]!; + + users.push({ + id: `user-${i + 1}`, + name: `${firstName} ${lastName}`, + email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${domain}`, + role: roles[Math.floor(rng() * roles.length)]!, + status: statuses[Math.floor(rng() * (i < count * 0.7 ? 2 : 3))]!, + signUps: Math.floor(rng() * 5000), + lastLogin: new Date(Date.now() - Math.floor(rng() * 90 * 24 * 60 * 60 * 1000)), + verified: rng() > 0.25, + country: countries[Math.floor(rng() * countries.length)]!, + revenue: Math.floor(rng() * 100000) / 100, + }); + } + + return users; +} + +// Pre-generate datasets +export const DEMO_USERS_200 = generateUsers(200); +export const DEMO_USERS_10K = generateUsers(10_000); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page-client.tsx new file mode 100644 index 0000000000..dd590059b4 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page-client.tsx @@ -0,0 +1,543 @@ +"use client"; + +import { DesignBadge, DesignCard } from "@/components/design-components"; +import { cn } from "@/components/ui"; +import { + CaretRightIcon, + ClipboardTextIcon, + CursorClickIcon, + LightningIcon, + XIcon, +} from "@phosphor-icons/react"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { PageLayout } from "../../page-layout"; +import { + createDefaultDataGridState, + DataGrid, + useDataSource, + type DataGridCellContext, + type DataGridDataSource, + type DataGridState, +} from "@stackframe/dashboard-ui-components"; +import { + DEMO_COLUMNS, + DEMO_USERS_200, + DEMO_USERS_10K, + type User, +} from "./demo/fixtures"; + +// ─── Layout helpers ────────────────────────────────────────────────── + +function SectionHeading({ index, label, caption }: { index: string; label: string; caption: string }) { + return ( +
+
+ {index} +
+

{label}

+

{caption}

+
+
+
+ ); +} + +function Accordion({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + + {label} + +
{children}
+
+ ); +} + +// ─── Event log ─────────────────────────────────────────────────────── + +type LabEvent = { id: number; ts: number; name: string; payload: string }; + +function useEventLog() { + const [events, setEvents] = useState([]); + const idRef = useRef(0); + const log = useCallback((name: string, payload: string) => { + setEvents((prev) => { + idRef.current += 1; + return [{ id: idRef.current, ts: Date.now(), name, payload }, ...prev].slice(0, 30); + }); + }, []); + const clear = useCallback(() => setEvents([]), []); + return { events, log, clear }; +} + +function EventsPanel({ events, onClear }: { events: LabEvent[]; onClear: () => void }) { + return ( + + Clear + + } + > + {events.length === 0 ? ( +

+ Interact with the grid and callbacks fire here. +

+ ) : ( +
    + {events.map((e) => ( +
  1. + + {new Date(e.ts).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })} + + {e.name} + {e.payload} +
  2. + ))} +
+ )} +
+ ); +} + +// ─── Usage code panel ──────────────────────────────────────────────── + +function UsagePanel({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + return ( + { + runAsynchronouslyWithAlert(async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1400); + }); + }} + > + + {copied ? "Copied" : "Copy"} + + } + > +
+        {code}
+      
+
+ ); +} + +// ─── Usage code generation ─────────────────────────────────────────── + +function genUsage(label: string, state: DataGridState, opts: { paginationMode: string; selectionMode: string; dataSource?: boolean }) { + const lines = [ + `const [state, setState] = useState(() =>`, + ` createDefaultDataGridState(columns),`, + `);`, + ``, + ` row.id}`, + ` state={state}`, + ` onChange={setState}`, + ` paginationMode="${opts.paginationMode}"`, + ` selectionMode="${opts.selectionMode}"`, + ]; + if (state.sorting.length > 0) lines.push(` // sort: ${state.sorting.map((s) => `${s.columnId} ${s.direction}`).join(", ")}`); + if (state.selection.selectedIds.size > 0) lines.push(` // selected: ${state.selection.selectedIds.size} row(s)`); + lines.push( + ` onRowClick={(row, id) => { /* ... */ }}`, + ` onSelectionChange={(ids, rows) => { /* ... */ }}`, + `/>`, + ); + return lines.join("\n"); +} + +// ─── Wired onChange that logs diffs ────────────────────────────────── + +function useTrackedState( + init: () => DataGridState, + log: (name: string, payload: string) => void, +): [DataGridState, React.Dispatch>] { + const [state, setState] = useState(init); + const tracked = useCallback>>( + (action) => { + setState((prev) => { + const next = typeof action === "function" ? action(prev) : action; + if (next.sorting !== prev.sorting) log("onSortChange", JSON.stringify(next.sorting)); + if (next.quickSearch !== prev.quickSearch) log("onQuickSearch", `"${next.quickSearch}"`); + if (next.selection !== prev.selection) + log("onSelectionChange", `${next.selection.selectedIds.size} row(s)`); + if (next.columnWidths !== prev.columnWidths) { + const k = Object.keys(next.columnWidths).find((k) => next.columnWidths[k] !== prev.columnWidths[k]); + if (k) log("onColumnResize", `${k}=${next.columnWidths[k]}px`); + } + if (next.columnVisibility !== prev.columnVisibility) + log("onColumnVisibility", JSON.stringify(next.columnVisibility)); + if (next.pagination !== prev.pagination) + log("onPaginationChange", `page=${next.pagination.pageIndex + 1}, pageSize=${next.pagination.pageSize}`); + return next; + }); + }, + [log], + ); + return [state, tracked]; +} + +// ─── Columns with cell callbacks ───────────────────────────────────── + +function useColumnsWithCallbacks(log: (n: string, p: string) => void) { + return useMemo( + () => + DEMO_COLUMNS.map((col) => ({ + ...col, + onCellClick: (ctx: DataGridCellContext) => log(`cell:click:${col.id}`, `row=${ctx.rowId}`), + onCellDoubleClick: (ctx: DataGridCellContext) => log(`cell:dblclick:${col.id}`, `row=${ctx.rowId}`), + })), + [log], + ); +} + +// ─── Async data source ─────────────────────────────────────────────── + +function createAsyncDataSource(allData: User[]): DataGridDataSource { + return async function* (params) { + await new Promise((r) => setTimeout(r, 600)); + let sorted = [...allData]; + if (params.sorting.length > 0) { + const s = params.sorting[0]!; + sorted.sort((a, b) => { + const va = a[s.columnId as keyof User], vb = b[s.columnId as keyof User]; + let c = 0; + if (typeof va === "number" && typeof vb === "number") c = va - vb; + else if (va instanceof Date && vb instanceof Date) c = va.getTime() - vb.getTime(); + else c = String(va) < String(vb) ? -1 : String(va) > String(vb) ? 1 : 0; + return s.direction === "asc" ? c : -c; + }); + } + const ps = params.pagination.pageSize, st = params.pagination.pageIndex * ps; + const page = sorted.slice(st, st + ps); + yield { rows: page, totalRowCount: sorted.length, hasMore: st + ps < sorted.length, nextCursor: st + ps < sorted.length ? st + ps : undefined }; + }; +} + +// ─── Height edge case helper ───────────────────────────────────────── + +/** + * Renders a single DataGrid inside a parent with a specific height + * constraint, plus a caption describing what's being tested. Used by the + * "Height edge cases" section to exercise every combination of parent + * height × `maxHeight` prop × row count. + */ +function HeightCase({ + index, + title, + expectation, + data, + maxHeight, + parentClassName, + toolbar, + footer, + wrapInFlex, +}: { + index: string; + title: string; + expectation: string; + data: User[]; + maxHeight?: number | string; + parentClassName?: string; + toolbar?: false; + footer?: false; + /** When true, wrap the grid in an extra `flex-1 min-h-0` child so the + * `parentClassName` acts as a flex column with the grid as flex item. */ + wrapInFlex?: boolean; +}) { + const [state, setState] = useState(() => createDefaultDataGridState(DEMO_COLUMNS)); + const ds = useDataSource({ + data, + columns: DEMO_COLUMNS, + getRowId: (r: User) => r.id, + sorting: state.sorting, + quickSearch: state.quickSearch, + pagination: state.pagination, + paginationMode: "client", + }); + + const grid = ( + + columns={DEMO_COLUMNS} + rows={ds.rows} + getRowId={(row) => row.id} + totalRowCount={ds.totalRowCount} + isLoading={ds.isLoading} + state={state} + onChange={setState} + paginationMode="paginated" + selectionMode="none" + maxHeight={maxHeight} + toolbar={toolbar} + footer={footer} + /> + ); + + return ( +
+
+ {index} + {title} +
+

{expectation}

+
+ {wrapInFlex ? ( +
+
+ sibling · shrink-0 +
+
{grid}
+
+ ) : ( + grid + )} +
+
+ ); +} + +// ─── Page ──────────────────────────────────────────────────────────── + +export default function PageClient() { + // ── Section 1: Client-side ───────────────────────────────── + const log1 = useEventLog(); + const cols1 = useColumnsWithCallbacks(log1.log); + const [s1, setS1] = useTrackedState(() => createDefaultDataGridState(DEMO_COLUMNS), log1.log); + const ds1 = useDataSource({ data: DEMO_USERS_200, columns: DEMO_COLUMNS, getRowId: (r: User) => r.id, sorting: s1.sorting, quickSearch: s1.quickSearch, pagination: s1.pagination, paginationMode: "client" }); + const usage1 = useMemo(() => genUsage("Client", s1, { paginationMode: "paginated", selectionMode: "multiple" }), [s1]); + + // ── Section 2: Infinite scroll ───────────────────────────── + const log2 = useEventLog(); + const cols2 = useColumnsWithCallbacks(log2.log); + const [s2, setS2] = useTrackedState(() => ({ ...createDefaultDataGridState(DEMO_COLUMNS), pagination: { pageIndex: 0, pageSize: 50 } }), log2.log); + const ds2Source = useMemo(() => createAsyncDataSource(DEMO_USERS_10K), []); + const ds2 = useDataSource({ dataSource: ds2Source, getRowId: (r: User) => r.id, columns: DEMO_COLUMNS, sorting: s2.sorting, quickSearch: s2.quickSearch, pagination: s2.pagination, paginationMode: "infinite" }); + const usage2 = useMemo(() => genUsage("Infinite", s2, { paginationMode: "infinite", selectionMode: "none", dataSource: true }), [s2]); + + // ── Section 3: Server pagination ─────────────────────────── + const log3 = useEventLog(); + const cols3 = useColumnsWithCallbacks(log3.log); + const [s3, setS3] = useTrackedState(() => ({ ...createDefaultDataGridState(DEMO_COLUMNS), pagination: { pageIndex: 0, pageSize: 25 } }), log3.log); + const ds3Source = useMemo(() => createAsyncDataSource(DEMO_USERS_10K), []); + const ds3 = useDataSource({ dataSource: ds3Source, getRowId: (r: User) => r.id, columns: DEMO_COLUMNS, sorting: s3.sorting, quickSearch: s3.quickSearch, pagination: s3.pagination, paginationMode: "server" }); + const usage3 = useMemo(() => genUsage("Server", s3, { paginationMode: "paginated", selectionMode: "single", dataSource: true }), [s3]); + + // ── Section 4: Selection ─────────────────────────────────── + const log4 = useEventLog(); + const cols4 = useColumnsWithCallbacks(log4.log); + const [s4, setS4] = useTrackedState(() => createDefaultDataGridState(DEMO_COLUMNS), log4.log); + const ds4 = useDataSource({ data: DEMO_USERS_200.slice(0, 50), columns: DEMO_COLUMNS, getRowId: (r: User) => r.id, sorting: s4.sorting, quickSearch: s4.quickSearch, pagination: s4.pagination, paginationMode: "client" }); + const usage4 = useMemo(() => genUsage("Selection", s4, { paginationMode: "paginated", selectionMode: "multiple" }), [s4]); + + return ( + } + > +
+ {/* ── 01 Client-side ───────────────────────────────────── */} +
+ + + columns={cols1} + rows={ds1.rows} + getRowId={(row) => row.id} + totalRowCount={ds1.totalRowCount} + isLoading={ds1.isLoading} + state={s1} + onChange={setS1} + paginationMode="paginated" + selectionMode="multiple" + maxHeight={520} + exportFilename="users-client" + onRowClick={(row, id) => log1.log("onRowClick", `${id} (${row.name})`)} + onRowDoubleClick={(row, id) => log1.log("onRowDoubleClick", `${id} (${row.name})`)} + /> + +
+ + +
+
+
+ + {/* ── 02 Infinite scroll ────────────────────────────────── */} +
+ + + columns={cols2} + rows={ds2.rows} + getRowId={(row) => row.id} + totalRowCount={ds2.totalRowCount} + isLoading={ds2.isLoading} + isLoadingMore={ds2.isLoadingMore} + hasMore={ds2.hasMore} + onLoadMore={ds2.loadMore} + state={s2} + onChange={setS2} + paginationMode="infinite" + selectionMode="none" + maxHeight={480} + exportFilename="users-infinite" + onRowClick={(row, id) => log2.log("onRowClick", `${id} (${row.name})`)} + /> + +
+ + +
+
+
+ + {/* ── 03 Server pagination ──────────────────────────────── */} +
+ + + columns={cols3} + rows={ds3.rows} + getRowId={(row) => row.id} + totalRowCount={ds3.totalRowCount} + isLoading={ds3.isLoading} + isRefetching={ds3.isRefetching} + state={s3} + onChange={setS3} + paginationMode="paginated" + selectionMode="single" + maxHeight={480} + exportFilename="users-server" + onRowClick={(row, id) => log3.log("onRowClick", `${id} (${row.name})`)} + /> + +
+ + +
+
+
+ + {/* ── 04 Selection ──────────────────────────────────────── */} +
+ + + columns={cols4} + rows={ds4.rows} + getRowId={(row) => row.id} + totalRowCount={ds4.totalRowCount} + isLoading={ds4.isLoading} + state={s4} + onChange={setS4} + paginationMode="paginated" + selectionMode="multiple" + maxHeight={400} + footer={false} + onRowClick={(row, id) => log4.log("onRowClick", `${id} (${row.name})`)} + onSelectionChange={(ids, rows) => log4.log("onSelectionChange", `${ids.size} row(s): ${rows.slice(0, 3).map((r) => r.name).join(", ")}${rows.length > 3 ? "\u2026" : ""}`)} + /> + +
+ + +
+
+
+ + {/* ── 05 Height edge cases ─────────────────────────────── */} +
+ +
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page.tsx new file mode 100644 index 0000000000..774c859188 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "DataGrid Interaction Lab", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx index 543e03168b..a1d3f4c097 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx @@ -19,6 +19,16 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { ColumnDef } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; + +const BUILDER_STATUS_MESSAGES = [ + "Reading your current draft...", + "Understanding your changes...", + "Planning the update...", + "Crafting components...", + "Refining the layout...", + "Polishing the details...", + "Almost there...", +]; import { AppEnabledGuard } from "../../app-enabled-guard"; import { useAdminApp } from "../../use-admin-app"; import { SentEmailsView } from "../../email-sent/sent-emails-view"; @@ -94,6 +104,10 @@ export default function PageClient({ draftId }: { draftId: string }) { return () => setNeedConfirm(false); }, [setNeedConfirm, draft, currentCode, selectedThemeId, stage]); + const [isRunning, setIsRunning] = useState(false); + const handleRunStart = useCallback(() => setIsRunning(true), []); + const handleRunEnd = useCallback(() => setIsRunning(false), []); + const handleToolUpdate = (toolCall: ToolCallContent) => { setCurrentCode(toolCall.args.content); }; @@ -233,9 +247,10 @@ export default function PageClient({ draftId }: { draftId: string }) { chatComponent={ currentCode, currentUser)} + chatAdapter={createChatAdapter(backendBaseUrl, "email-draft", handleToolUpdate, () => currentCode, currentUser, handleRunStart, handleRunEnd)} toolComponents={} useOffWhiteLightMode + runningStatusMessages={isRunning ? BUILDER_STATUS_MESSAGES : undefined} /> } /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx index 4aaebe0b26..93c5b1ed09 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/page-client.tsx @@ -1,6 +1,5 @@ "use client"; -import { DesignCard } from "@/components/design-components"; import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; @@ -9,11 +8,26 @@ import { cn } from "@/lib/utils"; import { ArrowLeft, CaretDown, ClockCounterClockwise, Copy, DotsThreeVertical, FileCode, FileText, PaperPlaneTilt, Pencil, Plus, WarningCircle } from "@phosphor-icons/react"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type ElementType } from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +import { DesignAnalyticsCard } from "@/components/design-components"; + +// Section header with icon following design guide +function SectionHeader({ icon: Icon, title }: { icon: ElementType, title: string }) { + return ( +
+
+ +
+ + {title} + +
+ ); +} // Draft item card component function DraftCard({ @@ -251,10 +265,13 @@ export default function PageClient() { // Split drafts into active (not sent) and history (sent) const { activeDrafts, historyDrafts } = useMemo(() => { const active: typeof drafts = []; - const history: typeof drafts = []; + const history: Array<(typeof drafts)[number] & { sentAt: Date }> = []; for (const draft of drafts) { - if (draft.sentAt) { - history.push(draft); + if (draft.sentAt != null) { + history.push({ + ...draft, + sentAt: draft.sentAt, + }); } else { active.push(draft); } @@ -290,16 +307,14 @@ export default function PageClient() { /> } > - {/* Active Drafts Section */} - -
-
-
- + +
+
+
+ + + Compose and manage your email drafts +
Active Drafts @@ -353,40 +368,30 @@ export default function PageClient() { />
)} - + - {/* Draft History Section (only show if there are sent drafts) */} + {/* Draft History */} {historyDrafts.length > 0 && ( - -
-
-
- -
- - Draft History - + +
+
+ + + {historyDrafts.length} sent {historyDrafts.length === 1 ? "draft" : "drafts"} +
-
- {historyDrafts.length} sent +
+ {historyDrafts.map((draft) => ( + handleOpenHistoryDraft(draft.id)} + onDelete={() => runAsynchronouslyWithAlert(() => handleDeleteDraft(draft.id))} + /> + ))}
-
- {historyDrafts.map((draft) => ( - handleOpenHistoryDraft(draft.id)} - onDelete={() => runAsynchronouslyWithAlert(() => handleDeleteDraft(draft.id))} - /> - ))} -
- + )} {/* Shared SMTP Warning Dialog */} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx index 75d6b6f4a7..66f6e3a3c0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx @@ -22,6 +22,16 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { useCallback, useEffect, useRef, useState } from "react"; +const BUILDER_STATUS_MESSAGES = [ + "Reading your current template...", + "Understanding your changes...", + "Planning the update...", + "Crafting components...", + "Refining the layout...", + "Polishing the details...", + "Almost there...", +]; + import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; @@ -117,6 +127,10 @@ export default function PageClient(props: { templateId: string }) { return () => setNeedConfirm(false); }, [setNeedConfirm, template, currentCode, selectedThemeId]); + const [isRunning, setIsRunning] = useState(false); + const handleRunStart = useCallback(() => setIsRunning(true), []); + const handleRunEnd = useCallback(() => setIsRunning(false), []); + const handleCodeUpdate = (toolCall: ToolCallContent) => { setCurrentCode(toolCall.args.content); }; @@ -277,10 +291,11 @@ export default function PageClient(props: { templateId: string }) { } chatComponent={ currentCode, currentUser)} + chatAdapter={createChatAdapter(backendBaseUrl, "email-template", handleCodeUpdate, () => currentCode, currentUser, handleRunStart, handleRunEnd)} historyAdapter={createHistoryAdapter(stackAdminApp, template.id)} toolComponents={} useOffWhiteLightMode + runningStatusMessages={isRunning ? BUILDER_STATUS_MESSAGES : undefined} /> } /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx index 4f0b778e5c..bb76ddbb49 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx @@ -16,6 +16,16 @@ import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/ema import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { useUser } from "@stackframe/stack"; import { useCallback, useEffect, useState } from "react"; + +const BUILDER_STATUS_MESSAGES = [ + "Reading your current theme...", + "Understanding your changes...", + "Planning the update...", + "Crafting components...", + "Refining the layout...", + "Polishing the details...", + "Almost there...", +]; import { AppEnabledGuard } from "../../app-enabled-guard"; import { useAdminApp } from "../../use-admin-app"; @@ -57,6 +67,10 @@ export default function PageClient({ themeId }: { themeId: string }) { return () => setNeedConfirm(false); }, [setNeedConfirm, theme, currentCode]); + const [isRunning, setIsRunning] = useState(false); + const handleRunStart = useCallback(() => setIsRunning(true), []); + const handleRunEnd = useCallback(() => setIsRunning(false), []); + const handleThemeUpdate = (toolCall: ToolCallContent) => { setCurrentCode(toolCall.args.content); }; @@ -131,10 +145,11 @@ export default function PageClient({ themeId }: { themeId: string }) { } chatComponent={ currentCode, currentUser)} + chatAdapter={createChatAdapter(backendBaseUrl, "email-theme", handleThemeUpdate, () => currentCode, currentUser, handleRunStart, handleRunEnd)} historyAdapter={createHistoryAdapter(stackAdminApp, themeId)} toolComponents={} useOffWhiteLightMode + runningStatusMessages={isRunning ? BUILDER_STATUS_MESSAGES : undefined} /> } /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx index 3c146c2e05..5b22ee289f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/page-client.tsx @@ -1,6 +1,5 @@ "use client"; -import { DesignCard } from "@/components/design-components"; import EmailPreview, { DEVICE_VIEWPORTS, DeviceViewport } from "@/components/email-preview"; import { FormDialog } from "@/components/form-dialog"; import { InputField } from "@/components/form-fields"; @@ -12,11 +11,26 @@ import { CheckIcon, DeviceMobile, DeviceTablet, Monitor, Palette, Plus, Trash } import { DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID, previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ElementType } from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +import { DesignAnalyticsCard } from "@/components/design-components"; + +// Section header with icon following design guide +function SectionHeader({ icon: Icon, title }: { icon: ElementType, title: string }) { + return ( +
+
+ +
+ + {title} + +
+ ); +} // Device icon component function DeviceIcon({ type, className }: { type: DeviceViewport['type'], className?: string }) { @@ -149,18 +163,15 @@ export default function PageClient() { >
{/* Active Theme Card */} - -
-
-
- + +
+
+
+ + + Currently using {selectedThemeData.displayName} +
- - Active Theme - - - Currently using {selectedThemeData.displayName} -
- +
{/* Device Preview Card */} - + {/* Header with viewport selector */}
@@ -250,7 +261,7 @@ export default function PageClient() { senderEmail={`noreply@${project.displayName.toLowerCase().replace(/[^a-z0-9]/g, '')}.com`} />
- +
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 7fdd4afaf1..46dfc22f10 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -6,8 +6,7 @@ import { InputField, SelectField, TextAreaField } from "@/components/form-fields import { ActionDialog, Alert, AlertDescription, AlertTitle, Button, DataTable, DataTableColumnHeader, DataTableViewOptions, SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Typography, useToast } from "@/components/ui"; import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; -import { cn } from "@/lib/utils"; -import { CheckCircle, Envelope, HardDrive, Sliders, WarningCircleIcon, XCircle, XIcon } from "@phosphor-icons/react"; +import { ArrowSquareOut, CheckCircle, Envelope, HardDrive, Sliders, WarningCircleIcon, XCircle, XIcon } from "@phosphor-icons/react"; import { AdminEmailConfig, AdminProject, AdminSentEmail, ServerUser, UserAvatar } from "@stackframe/stack"; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { strictEmailSchema } from "@stackframe/stack-shared/dist/schema-fields"; @@ -15,54 +14,15 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { ColumnDef, Table as TableType } from "@tanstack/react-table"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ElementType } from "react"; import * as yup from "yup"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; - -// Glassmorphic card component following design guide -function GlassCard({ - children, - className, - gradientColor = "blue" -}: { - children: React.ReactNode, - className?: string, - gradientColor?: "blue" | "purple" | "green" | "orange" | "slate" | "cyan", -}) { - const hoverTints: Record = { - blue: "group-hover:bg-blue-500/[0.03]", - purple: "group-hover:bg-purple-500/[0.03]", - green: "group-hover:bg-emerald-500/[0.03]", - orange: "group-hover:bg-orange-500/[0.03]", - slate: "group-hover:bg-slate-500/[0.02]", - cyan: "group-hover:bg-cyan-500/[0.03]", - }; - - return ( -
- {/* Subtle glassmorphic background */} -
- {/* Accent hover tint */} -
-
- {children} -
-
- ); -} +import { DesignAnalyticsCard } from "@/components/design-components"; // Section header with icon following design guide -function SectionHeader({ icon: Icon, title }: { icon: React.ElementType, title: string }) { +function SectionHeader({ icon: Icon, title }: { icon: ElementType, title: string }) { return (
@@ -99,6 +59,7 @@ export default function PageClient() { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); const emailConfig = project.useConfig().emails.server; + const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; return ( @@ -118,6 +79,8 @@ export default function PageClient() { } >
+ {isLocalEmulator && } + {/* Email Server Card */} @@ -129,6 +92,44 @@ export default function PageClient() { ); } +function EmulatorModeCard() { + const inbucketWebUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_INBUCKET_WEB_URL"); + const inbucketMonitorUrl = inbucketWebUrl == null + ? null + : `${inbucketWebUrl.replace(/\/$/, "")}/monitor`; + + return ( + +
+
+
+ + + View all emails sent through the emulator in Inbucket + +
+ +
+
+
+ ); +} + function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails']['server'] }) { const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; const serverType = emailConfig.isShared @@ -144,7 +145,7 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' : emailConfig.senderEmail; return ( - +
@@ -238,7 +239,7 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' )}
- + ); } @@ -478,7 +479,7 @@ function EmailLogCard() { if (loading) { return ( - +
@@ -495,13 +496,13 @@ function EmailLogCard() {
-
+ ); } if (error) { return ( - +
@@ -521,13 +522,13 @@ function EmailLogCard() {
- + ); } if (emailLogs.length === 0) { return ( - +
@@ -547,12 +548,12 @@ function EmailLogCard() {
- + ); } return ( - +
@@ -585,7 +586,7 @@ function EmailLogCard() { }} />
- + ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx index 6628e79f63..e2b064a75e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx @@ -9,13 +9,18 @@ export function PageLayout(props: { fillWidth?: boolean, noPadding?: boolean, allowContentOverflow?: boolean, + fullBleed?: boolean, + wrapHeaderInCard?: boolean, } & ({ fillWidth: true, } | { width?: number, })) { return ( -
+
{(props.title || props.description || props.actions) && ( -
-
- {props.title && ( - - {props.title} - - )} - {props.description && ( - - {props.description} - +
+
+
+ {props.title && ( + + {props.title} + + )} + {props.description && ( + + {props.description} + + )} +
+ {props.actions && ( +
+ {props.actions} +
)}
- {props.actions && ( -
- {props.actions} -
- )}
)}
* { + overflow: visible !important; + } + + *::-webkit-scrollbar { + width: 8px; + height: 8px; } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background-color: hsl(var(--foreground) / 0.18); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; + } + + *::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--foreground) / 0.28); + } + body { @apply bg-background text-foreground; background-image: var(--page-ambient); diff --git a/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts new file mode 100644 index 0000000000..7062edca96 --- /dev/null +++ b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts @@ -0,0 +1,47 @@ +import { validateComposerImageByteLength } from "@/components/assistant-ui/image-attachment-validation"; +import { + type AttachmentAdapter, + type CompleteAttachment, + type PendingAttachment, +} from "@assistant-ui/react"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; + +/** Chat composer attachments: UUID ids, shared max file size (see `image-limits`). */ +export class ImageAttachmentAdapter implements AttachmentAdapter { + public readonly accept = "image/*"; + + public async add(state: { file: File }): Promise { + const sizeValidation = validateComposerImageByteLength(state.file.size); + if (!sizeValidation.ok) { + throw new Error(`"${state.file.name}": ${sizeValidation.reason}`); + } + return { + id: generateUuid(), + type: "image", + name: state.file.name, + contentType: state.file.type, + file: state.file, + status: { type: "requires-action", reason: "composer-send" }, + }; + } + + public async send(attachment: PendingAttachment): Promise { + const image = await readFileAsDataUrl(attachment.file); + return { + ...attachment, + status: { type: "complete" }, + content: [{ type: "image", image }], + }; + } + + public async remove(): Promise {} +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} diff --git a/apps/dashboard/src/components/assistant-ui/image-attachment-validation.ts b/apps/dashboard/src/components/assistant-ui/image-attachment-validation.ts new file mode 100644 index 0000000000..d032147bf8 --- /dev/null +++ b/apps/dashboard/src/components/assistant-ui/image-attachment-validation.ts @@ -0,0 +1,27 @@ +import { + MAX_IMAGE_BYTES_PER_FILE, + MAX_IMAGE_MB_PER_FILE, + MAX_IMAGES_PER_MESSAGE, +} from "@stackframe/stack-shared/dist/ai/image-limits"; + +type ValidationResult = { ok: true } | { ok: false, reason: string }; + +export function validateComposerImageCount(imageCount: number): ValidationResult { + if (imageCount > MAX_IMAGES_PER_MESSAGE) { + return { + ok: false, + reason: `Maximum ${MAX_IMAGES_PER_MESSAGE} images per message.`, + }; + } + return { ok: true }; +} + +export function validateComposerImageByteLength(bytes: number): ValidationResult { + if (bytes > MAX_IMAGE_BYTES_PER_FILE) { + return { + ok: false, + reason: `Image exceeds ${MAX_IMAGE_MB_PER_FILE}MB limit (${(bytes / 1024 / 1024).toFixed(2)}MB).`, + }; + } + return { ok: true }; +} diff --git a/apps/dashboard/src/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index 73392e502d..af27c79e4b 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -1,63 +1,122 @@ +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Button, useToast } from "@/components/ui"; import { cn } from "@/lib/utils"; import { + validateComposerImageByteLength, + validateComposerImageCount, +} from "./image-attachment-validation"; +import { + type Attachment, + type AttachmentAdapter, + type CompleteAttachment, ActionBarPrimitive, BranchPickerPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, + useComposer, + useComposerRuntime, + useMessage, + useThreadRuntime, + type PendingAttachment, } from "@assistant-ui/react"; -import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle } from "@phosphor-icons/react"; -import { createContext, useContext, type FC } from "react"; +import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle, XIcon } from "@phosphor-icons/react"; +import { + MAX_IMAGES_PER_MESSAGE, + MAX_IMAGE_MB_PER_FILE, +} from "@stackframe/stack-shared/dist/ai/image-limits"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { createContext, useContext, useEffect, useMemo, useRef, useState, type FC } from "react"; const HideMessageActionsContext = createContext(false); +const HasRunningStatusContext = createContext(false); -import { MarkdownText } from "@/components/assistant-ui/markdown-text"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; -import { Button } from "@/components/ui"; +const ComposerAttachmentsEnabledContext = createContext(false); +const ComposerAttachmentAdapterContext = createContext(null); + +function useComposerAttachmentsEnabled() { + return useContext(ComposerAttachmentsEnabledContext); +} + +function useComposerAttachmentAdapter() { + return useContext(ComposerAttachmentAdapterContext); +} -export const Thread: FC<{ useOffWhiteLightMode?: boolean, composerPlaceholder?: string, hideMessageActions?: boolean }> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false }) => { +/** Static placeholder string, or config for the typing-animation input. */ +export type ComposerPlaceholder = + | string + | { + prefix: string, + suffixes: readonly string[], + typeSpeed?: number, + deleteSpeed?: number, + pauseAfterType?: number, + pauseAfterDelete?: number, + }; + +export const Thread: FC<{ + useOffWhiteLightMode?: boolean, + composerPlaceholder?: ComposerPlaceholder, + hideMessageActions?: boolean, + runningStatusMessages?: string[], + composerAttachments?: boolean, + attachmentAdapter?: AttachmentAdapter, +}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter }) => { return ( - - + + + - - - - - -
- - -
- - -
- - + style={{ + ["--thread-max-width" as string]: "100%", + }} + > + + + + + + {runningStatusMessages && ( + + + + )} + + +
+ + +
+ + +
+ + + + + ); }; @@ -123,16 +182,382 @@ const ThreadWelcomeSuggestions: FC = () => { ); }; -const Composer: FC<{ placeholder?: string }> = ({ placeholder }) => { +type DisplayableAttachment = { + id: string, + name: string, + file?: File, + content?: readonly unknown[], +}; + +function extractImageUrlFromContent(content: readonly unknown[] | undefined): string | null { + if (!Array.isArray(content)) return null; + for (const part of content) { + if (part && typeof part === "object" && (part as { type?: unknown }).type === "image") { + const image = (part as { image?: unknown }).image; + if (typeof image === "string") return image; + } + } + return null; +} + +function isCompleteAttachment(attachment: Attachment): attachment is CompleteAttachment { + return attachment.status.type === "complete"; +} + +function isPendingAttachment(attachment: Attachment): attachment is PendingAttachment { + return attachment.status.type !== "complete"; +} + +function getAttachmentIdentityKey(attachment: Attachment): string { + return attachment.id; +} + +function haveAttachmentListsChanged( + currentAttachments: readonly Attachment[], + originalAttachments: readonly Attachment[], +): boolean { + if (currentAttachments.length !== originalAttachments.length) { + return true; + } + return currentAttachments.some((attachment, index) => ( + getAttachmentIdentityKey(attachment) !== getAttachmentIdentityKey(originalAttachments[index]!) + )); +} + +function getTextContent(parts: readonly { type: string, text?: string }[]): string { + return parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text ?? "") + .join(""); +} + +async function resolveComposerAttachments( + attachments: readonly Attachment[], + attachmentAdapter: AttachmentAdapter | null, +): Promise { + if (attachments.length === 0) { + return []; + } + if (attachmentAdapter == null) { + if (attachments.some(isPendingAttachment)) { + throw new Error("Image attachments are not available in this composer."); + } + return attachments.filter(isCompleteAttachment); + } + + const resolved: CompleteAttachment[] = []; + for (const attachment of attachments) { + if (isCompleteAttachment(attachment)) { + resolved.push(attachment); + continue; + } + resolved.push(await attachmentAdapter.send(attachment)); + } + return resolved; +} + +const AttachmentThumb: FC<{ + attachment: DisplayableAttachment, + onRemove?: () => void, +}> = ({ attachment, onRemove }) => { + const { file, name, content } = attachment; + + const liveFile = file instanceof Blob ? file : null; + + const [objectUrl, setObjectUrl] = useState(null); + useEffect(() => { + if (!liveFile) { + setObjectUrl(null); + return; + } + const url = URL.createObjectURL(liveFile); + setObjectUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [liveFile]); + + const contentUrl = useMemo(() => extractImageUrlFromContent(content), [content]); + const displayUrl = objectUrl ?? contentUrl; + + return ( +
+ {displayUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {name} + ) : ( +
+ +
+ )} + {onRemove && ( + + )} +
+ ); +}; + +const ComposerAttachmentsRow: FC = () => { + const composerRuntime = useComposerRuntime(); + const attachments = useComposer((s) => s.attachments); + if (attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment, index) => ( + + runAsynchronously(composerRuntime.getAttachmentByIndex(index).remove()) + } + /> + ))} +
+ ); +}; + +const UserMessageAttachmentsRow: FC = () => { + const attachments = useMessage((m) => + m.role === "user" ? m.attachments : undefined, + ); + if (!attachments || attachments.length === 0) return null; + + return ( +
+ {attachments.map((attachment) => ( + + ))} +
+ ); +}; + +const ComposerAttachmentsAddButton: FC = () => { + const composerRuntime = useComposerRuntime(); + const count = useComposer((s) => s.attachments.length); + const { toast } = useToast(); + const atLimit = count >= MAX_IMAGES_PER_MESSAGE; + + const handleClick = () => { + const countValidation = validateComposerImageCount(composerRuntime.getState().attachments.length + 1); + if (!countValidation.ok) { + toast({ + variant: "destructive", + description: countValidation.reason, + }); + return; + } + + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.hidden = true; + document.body.appendChild(input); + + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files && files.length > 0) { + const liveCount = composerRuntime.getState().attachments.length; + const remaining = Math.max(0, MAX_IMAGES_PER_MESSAGE - liveCount); + const picked = Array.from(files); + const selected = picked.slice(0, remaining); + const valid: File[] = []; + const oversized: File[] = []; + for (const file of selected) { + const sizeValidation = validateComposerImageByteLength(file.size); + if (sizeValidation.ok) { + valid.push(file); + } else { + oversized.push(file); + } + } + + const countValidation = validateComposerImageCount(liveCount + picked.length); + if (!countValidation.ok) { + toast({ + variant: "destructive", + description: countValidation.reason, + }); + } + + if (oversized.length > 0) { + const firstOversizedValidation = validateComposerImageByteLength(oversized[0]!.size); + toast({ + variant: "destructive", + description: + oversized.length === 1 + ? `"${oversized[0]!.name}": ${firstOversizedValidation.ok ? `Image exceeds ${MAX_IMAGE_MB_PER_FILE}MB limit.` : firstOversizedValidation.reason}` + : `${oversized.length} images exceeded the ${MAX_IMAGE_MB_PER_FILE}MB limit and were skipped.`, + }); + } + + runAsynchronously( + (async () => { + for (const file of valid) { + if (composerRuntime.getState().attachments.length >= MAX_IMAGES_PER_MESSAGE) { + break; + } + try { + await composerRuntime.addAttachment(file); + } catch (err) { + toast({ + variant: "destructive", + description: + err instanceof Error ? err.message : `Failed to attach "${file.name}".`, + }); + } + } + })(), + ); + } + document.body.removeChild(input); + }; + + input.oncancel = () => { + if (!input.files || input.files.length === 0) { + if (input.parentNode) document.body.removeChild(input); + } + }; + input.click(); + }; + + const tooltipText = atLimit + ? `Limit reached (${MAX_IMAGES_PER_MESSAGE}/${MAX_IMAGES_PER_MESSAGE})` + : `Attach image (${count}/${MAX_IMAGES_PER_MESSAGE}, max ${MAX_IMAGE_MB_PER_FILE}MB)`; + + return ( + + + + ); +}; + +const COMPOSER_INPUT_CLASS = + "placeholder:text-muted-foreground/60 max-h-32 w-full resize-none border-none bg-transparent px-4 py-3 text-sm outline-none focus:ring-0 disabled:cursor-not-allowed leading-relaxed"; + +const ComposerAnimatedInput: FC<{ + prefix: string, + suffixes: readonly string[], + typeSpeed: number, + deleteSpeed: number, + pauseAfterType: number, + pauseAfterDelete: number, +}> = ({ prefix, suffixes, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete }) => { + const [suffixText, setSuffixText] = useState(""); + const stateRef = useRef({ + suffixIndex: 0, + charIndex: 0, + phase: "typing" as "typing" | "pausing" | "deleting" | "waiting", + }); + + useEffect(() => { + let timeoutId: ReturnType; + + function tick() { + const s = stateRef.current; + const target = suffixes[s.suffixIndex % suffixes.length]; + switch (s.phase) { + case "typing": { + if (s.charIndex < target.length) { + s.charIndex++; + setSuffixText(target.slice(0, s.charIndex)); + timeoutId = setTimeout(tick, typeSpeed); + } else { + s.phase = "pausing"; + timeoutId = setTimeout(tick, pauseAfterType); + } + break; + } + case "pausing": { + s.phase = "deleting"; + timeoutId = setTimeout(tick, deleteSpeed); + break; + } + case "deleting": { + if (s.charIndex > 0) { + s.charIndex--; + setSuffixText(target.slice(0, s.charIndex)); + timeoutId = setTimeout(tick, deleteSpeed); + } else { + s.phase = "waiting"; + timeoutId = setTimeout(tick, pauseAfterDelete); + } + break; + } + case "waiting": { + s.suffixIndex = (s.suffixIndex + 1) % suffixes.length; + s.charIndex = 0; + s.phase = "typing"; + timeoutId = setTimeout(tick, typeSpeed); + break; + } + } + } + + timeoutId = setTimeout(tick, 500); + return () => clearTimeout(timeoutId); + }, [suffixes, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete]); + + return ( + + ); +}; + +const ComposerStaticInput: FC<{ placeholder?: string }> = ({ placeholder }) => { + return ( + + ); +}; + +const Composer: FC<{ placeholder?: ComposerPlaceholder }> = ({ placeholder }) => { + const attachmentsEnabled = useComposerAttachmentsEnabled(); return ( - -
+ {attachmentsEnabled && } + {typeof placeholder === "object" ? ( + + ) : ( + + )} +
+
+ {attachmentsEnabled && } +
@@ -172,11 +597,14 @@ const ComposerAction: FC = () => { const UserMessage: FC = () => { return ( +
-
- -
+ +
+ +
+
@@ -202,33 +630,96 @@ const UserActionBar: FC = () => { }; const EditComposer: FC = () => { + const attachmentsEnabled = useComposerAttachmentsEnabled(); + const attachmentAdapter = useComposerAttachmentAdapter(); + const composerRuntime = useComposerRuntime(); + const threadRuntime = useThreadRuntime(); + const { toast } = useToast(); + const messageId = useMessage((m) => m.id); + const parentId = useMessage((m) => m.parentId ?? null); + const role = useMessage((m) => m.role); + const originalContent = useMessage((m) => m.content); + const originalAttachments = useMessage((m) => m.attachments ?? []); + const composerText = useComposer((s) => s.text); + const composerAttachments = useComposer((s) => s.attachments); + const originalText = useMemo(() => getTextContent(originalContent), [originalContent]); + const hasChanges = composerText !== originalText + || haveAttachmentListsChanged(composerAttachments, originalAttachments); + + const handleSend = async () => { + if (!hasChanges) { + return; + } + + try { + const composerState = composerRuntime.getState(); + const resolvedAttachments = await resolveComposerAttachments( + composerState.attachments, + attachmentAdapter, + ); + threadRuntime.append({ + parentId, + sourceId: messageId, + role, + content: composerState.text ? [{ type: "text", text: composerState.text }] : [], + attachments: resolvedAttachments, + metadata: { custom: {} }, + runConfig: composerState.runConfig, + }); + composerRuntime.cancel(); + } catch (error) { + toast({ + variant: "destructive", + description: error instanceof Error ? error.message : "Failed to send edited message.", + }); + } + }; + return ( + {attachmentsEnabled && } -
- - - - - - +
+
+ {attachmentsEnabled && } +
+
+ + + + +
); }; const AssistantMessage: FC = () => { + const hasRunningStatus = useContext(HasRunningStatusContext); return (
- -
- - + {hasRunningStatus ? (
- + ) : ( + <> + +
+ + +
+ + + )}
@@ -332,6 +823,40 @@ const BranchPicker: FC = ({ ); }; +const ThreadRunningStatus: FC<{ messages: string[] }> = ({ messages }) => { + const [index, setIndex] = useState(0); + const prevMessagesRef = useRef(messages); + + useEffect(() => { + if (prevMessagesRef.current !== messages) { + prevMessagesRef.current = messages; + setIndex(0); + } + }, [messages]); + + useEffect(() => { + const interval = setInterval(() => { + setIndex((prev) => (prev + 1) % messages.length); + }, 1200); + return () => clearInterval(interval); + }, [messages.length]); + + return ( +
+
+
+
+
+
+
+ + {messages[index]} + +
+
+ ); +}; + const CircleStopIcon = ({ className }: { className?: string }) => { return ( { +Object.entries({ tsx, bash, typescript, python, sql }).forEach(([key, value]) => { SyntaxHighlighter.registerLanguage(key, value); }); diff --git a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx index a74139887f..6691a3bf4e 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx @@ -3,9 +3,10 @@ import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { useRouter } from "@/components/router"; import { Button } from "@/components/ui"; -import { generateDashboardCode } from "@/components/vibe-coding/chat-adapters"; import { useDebouncedAction } from "@/hooks/use-debounced-action"; +import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt"; import type { AppId } from "@/lib/apps-frontend"; +import { buildStackAuthHeaders } from "@/lib/api-headers"; import { useUpdateConfig } from "@/lib/config-update"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; @@ -16,11 +17,12 @@ import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/erro import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { useChat, type UIMessage } from "@ai-sdk/react"; +import { convertToModelMessages, DefaultChatTransport } from "ai"; import { memo, useCallback, useMemo, useRef, useState } from "react"; import { CmdKPreviewProps } from "../../cmdk-commands"; import { DashboardSandboxHost } from "./dashboard-sandbox-host"; - -type GenerationState = "idle" | "generating" | "ready" | "error"; +import { StreamingCodeViewer } from "../../streaming-code-viewer"; type DashboardArtifact = { prompt: string, @@ -32,6 +34,48 @@ type DashboardArtifact = { }, }; +function sanitizeGeneratedCode(code: string): string { + let result = code.trim(); + + if (result.startsWith("```")) { + const lines = result.split("\n"); + lines.shift(); + if (lines[lines.length - 1]?.trim() === "```") { + lines.pop(); + } + result = lines.join("\n").trim(); + } + + result = result + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + + result = result.replace(/;(\s*\n\s*[A-Za-z_$][\w$]*\s*:)/g, ",$1"); + + return result; +} + +function extractToolPart(messages: UIMessage[]): { + state: string, + code: string, +} | null { + const lastAssistant = messages.findLast((m) => m.role === "assistant"); + if (!lastAssistant) return null; + + for (const part of lastAssistant.parts) { + if (!part.type.startsWith("tool-")) continue; + const toolPart = part as { type: string, state: string, input?: Record }; + const code = typeof toolPart.input?.content === "string" ? toolPart.input.content : ""; + if (code) { + return { state: toolPart.state, code }; + } + } + return null; +} + export function CreateDashboardPreview({ query, ...rest }: CmdKPreviewProps) { return ; } @@ -45,7 +89,8 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ const project = adminApp.useProject(); const config = project.useConfig(); const currentUser = useUser({ or: "redirect" }); - const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set");; + const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); + const browserBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? backendBaseUrl; const updateConfig = useUpdateConfig(); const router = useRouter(); const prompt = query.trim(); @@ -57,54 +102,132 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ [config.apps.installed] ); - const [state, setState] = useState("idle"); - const [errorText, setErrorText] = useState(null); const [artifact, setArtifact] = useState(null); + const [iframeReady, setIframeReady] = useState(false); const [isSaving, setIsSaving] = useState(false); - const abortControllerRef = useRef(null); + const [errorText, setErrorText] = useState(null); - const generateDashboard = useCallback(async () => { - if (!projectId || !prompt) { - return; - } + const enabledAppIdsRef = useRef(enabledAppIds); + enabledAppIdsRef.current = enabledAppIds; + const currentUserRef = useRef(currentUser); + currentUserRef.current = currentUser; + const backendBaseUrlRef = useRef(backendBaseUrl); + backendBaseUrlRef.current = backendBaseUrl; + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; + const finalizedRef = useRef(false); - setState("generating"); - setErrorText(null); - setArtifact(null); + const transport = useMemo(() => new DefaultChatTransport({ + api: `${browserBaseUrl}/api/latest/ai/query/stream`, + headers: () => buildStackAuthHeaders(currentUserRef.current), + prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => { + const modelMessages = await convertToModelMessages(uiMessages); + const userMessages = modelMessages.map(m => ({ + role: m.role as string, + content: m.content as unknown, + })); + const contextMessages = await buildDashboardMessages( + backendBaseUrlRef.current, + currentUserRef.current, + userMessages, + undefined, + enabledAppIdsRef.current, + ); + return { + body: { + systemPrompt: "create-dashboard", + tools: ["update-dashboard"], + quality: "smart", + speed: "slow", + projectId: projectIdRef.current, + messages: [...contextMessages, ...userMessages], + }, + headers, + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [browserBaseUrl]); + + const { + messages, + status, + sendMessage, + stop, + setMessages, + error: aiError, + } = useChat({ transport }); + + const toolPart = extractToolPart(messages); + const chatActive = status === "submitted" || status === "streaming"; + + let phase: "idle" | "waiting" | "streaming" | "booting" | "ready" | "error"; + if (artifact && iframeReady) { + phase = "ready"; + } else if (artifact && !iframeReady) { + phase = "booting"; + } else if (toolPart?.state === "input-streaming") { + phase = "streaming"; + } else if (chatActive) { + phase = "waiting"; + } else if (aiError || errorText) { + phase = "error"; + } else { + phase = "idle"; + } + + const displayCode = toolPart?.code ?? ""; + + if (toolPart?.state === "input-available" && !artifact && !finalizedRef.current) { + finalizedRef.current = true; + const sanitized = sanitizeGeneratedCode(toolPart.code); + setArtifact({ + prompt, + projectId, + runtimeCodegen: { + title: prompt.slice(0, 120), + description: "", + uiRuntimeSourceCode: sanitized, + }, + }); + setIframeReady(false); + } + + if (status === "ready" && !chatActive && !toolPart && !artifact && messages.length > 0 && !errorText) { + setErrorText("AI did not return dashboard code."); + } + + if (aiError && !errorText && !artifact) { + captureError("create-dashboard-preview", aiError); + setErrorText("Failed to generate dashboard. Please try again."); + } + + const handleIframeReady = useCallback(() => { + setIframeReady(true); + }, []); + + const generateDashboard = useCallback(async () => { + if (!projectId || !prompt) return; try { - const userMessages: Array<{ role: string, content: string }> = [{ role: "user", content: prompt }]; - const { toolCall } = await generateDashboardCode(backendBaseUrl, currentUser, userMessages, { enabledAppIds, abortSignal: controller.signal }); - - if (controller.signal.aborted) return; - - if (!toolCall?.args?.content) { - setState("error"); - setErrorText("AI did not return dashboard code"); - return; - } - - setArtifact({ - prompt, - projectId, - runtimeCodegen: { - title: prompt.slice(0, 120), - description: "", - uiRuntimeSourceCode: toolCall.args.content, - }, - }); - setState("ready"); - } catch (error) { - if (controller.signal.aborted) return; - captureError("create-dashboard-preview", error); - setState("error"); - setErrorText("Failed to generate dashboard. Please try again."); + await stop(); + } catch { + // nothing to stop } - }, [projectId, prompt, currentUser, backendBaseUrl, enabledAppIds]); + setMessages([]); + setArtifact(null); + setErrorText(null); + setIframeReady(false); + finalizedRef.current = false; + + await sendMessage({ text: prompt }); + }, [projectId, prompt, sendMessage, stop, setMessages]); + + useDebouncedAction({ + action: generateDashboard, + delayMs: 500, + skip: !projectId || !prompt, + }); const handleSave = useCallback(async () => { if (!artifact) return; @@ -128,12 +251,6 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ } }, [artifact, adminApp, updateConfig, router, projectId, onClose]); - useDebouncedAction({ - action: generateDashboard, - delayMs: 500, - skip: !projectId || !prompt, - }); - if (!prompt) { return (
@@ -143,16 +260,18 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ ); } + const isGenerating = phase === "streaming" || phase === "booting" || phase === "waiting"; + return (
-
+
Create Dashboard
{prompt}
-
- {state === "ready" && artifact && ( +
+ {phase === "ready" && artifact && (
- {state === "error" && errorText && ( + {phase === "error" && (errorText || aiError?.message) && (
- {errorText} + {errorText || aiError?.message}
)}
-
- {state === "generating" && ( -
Generating dashboard...
+
+ {(phase === "waiting" || phase === "streaming" || phase === "booting" || (phase === "ready" && artifact)) && ( +
+ +
)} - {state !== "generating" && artifact && ( - + + {(phase === "booting" || phase === "ready") && artifact && ( +
+ +
)} - {state !== "generating" && !artifact && state !== "error" && ( + + {phase === "idle" && (
Waiting for generation...
)} + + {phase === "error" && !artifact && ( +
Generation failed
+ )}
); diff --git a/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx b/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx index 36e3fa6c12..30f5b5f90a 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx @@ -227,6 +227,39 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo .dark { color-scheme: dark; } html, body, #root { scrollbar-width: none; } html::-webkit-scrollbar, body::-webkit-scrollbar, #root::-webkit-scrollbar { display: none; } + + /* Widget selection overlay — active only when chat panel is open */ + .widget-overlay { + position: fixed; + pointer-events: none; + border: 2px dashed hsl(var(--primary) / 0.35); + border-radius: 10px; + z-index: 9999; + transition: top 0.12s ease, left 0.12s ease, width 0.12s ease, height 0.12s ease; + display: none; + background: hsl(var(--primary) / 0.03); + } + .widget-overlay-btn { + position: absolute; + top: 6px; + right: 6px; + pointer-events: auto; + width: 28px; + height: 28px; + border-radius: 8px; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s ease; + box-shadow: 0 2px 8px rgba(0,0,0,0.18); + } + .widget-overlay-btn:hover { transform: scale(1.08); } + .widget-overlay.active .widget-overlay-btn { opacity: 1; } @@ -329,17 +362,37 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo return stackServerApp; } + // Forward uncaught runtime errors (async throws, unhandled rejections) that never + // reach the React boundary. React ErrorBoundary alone misses these, so without this + // the parent has no way to observe e.g. a fetch() that rejected inside useEffect. + window.addEventListener('error', (event) => { + const err = event?.error; + window.parent.postMessage({ + type: 'dashboard-error-boundary', + message: err?.message || event?.message || 'Unknown runtime error', + stack: err?.stack, + }, '*'); + }); + window.addEventListener('unhandledrejection', (event) => { + const reason = event?.reason; + window.parent.postMessage({ + type: 'dashboard-error-boundary', + message: (reason && (reason.message || String(reason))) || 'Unhandled promise rejection', + stack: reason?.stack, + }, '*'); + }); + // Error Boundary Component class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } - + static getDerivedStateFromError(error) { return { hasError: true, error }; } - + componentDidCatch(error, errorInfo) { window.parent.postMessage({ type: 'dashboard-error-boundary', @@ -424,21 +477,157 @@ function getSandboxDocument(artifact: DashboardArtifact, baseUrl: string, dashbo ); }); + + + `; } +/** + * Shape of a runtime error surfaced from the sandbox iframe. Covers three sources: + * 1. React ErrorBoundary catches (componentStack is present) + * 2. Uncaught window errors (sync throws outside render) + * 3. Unhandled promise rejections (async failures inside effects/handlers) + */ +export type DashboardRuntimeError = { + message: string, + stack?: string, + componentStack?: string, +}; + +/** + * Payload sent when the user clicks "Add to chat" on a widget in the iframe. + * `metadata` carries DOM info so the AI knows which part of the dashboard is targeted. + */ +export type WidgetSelection = { + metadata: { + heading: string | null, + tagName: string, + classes: string, + textPreview: string, + rect: { width: number, height: number }, + }, +}; + export const DashboardSandboxHost = memo(function DashboardSandboxHost({ artifact, onBack, onEditToggle, onNavigate, + onReady, + onRuntimeError, + onWidgetSelected, isChatOpen, }: { artifact: DashboardArtifact, onBack?: () => void, onEditToggle?: () => void, onNavigate?: (path: string) => void, + onReady?: () => void, + /** Fires whenever the sandbox reports a runtime error. Parent uses this to auto-insert + the crash into the assistant composer so the user can one-click fix it. */ + onRuntimeError?: (err: DashboardRuntimeError) => void, + /** Fires when the user clicks "Add to chat" on a widget overlay in the iframe. */ + onWidgetSelected?: (selection: WidgetSelection) => void, isChatOpen?: boolean, }) { const iframeRef = useRef(null); @@ -448,6 +637,12 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onEditToggleRef.current = onEditToggle; const onNavigateRef = useRef(onNavigate); onNavigateRef.current = onNavigate; + const onReadyRef = useRef(onReady); + onReadyRef.current = onReady; + const onRuntimeErrorRef = useRef(onRuntimeError); + onRuntimeErrorRef.current = onRuntimeError; + const onWidgetSelectedRef = useRef(onWidgetSelected); + onWidgetSelectedRef.current = onWidgetSelected; const user = useUser({ or: "redirect" }); const { resolvedTheme } = useTheme(); @@ -544,10 +739,42 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ const err = new Error(event.data.message ?? 'Unknown dashboard error'); if (event.data.stack) err.stack = event.data.stack; captureError('dashboard-sandbox-error-boundary', err); + onRuntimeErrorRef.current?.({ + message: typeof event.data.message === "string" ? event.data.message : "Unknown dashboard error", + stack: typeof event.data.stack === "string" ? event.data.stack : undefined, + componentStack: typeof event.data.componentStack === "string" ? event.data.componentStack : undefined, + }); + return; + } + + if (type === "stack-ai-dashboard-error") { + // Thrown during sandbox initialization (deps failed to load, Dashboard export missing, etc.) + // Surface it via the same channel so the UX is consistent with runtime errors. + onRuntimeErrorRef.current?.({ + message: typeof event.data.message === "string" ? event.data.message : "Failed to initialize dashboard", + stack: typeof event.data.stack === "string" ? event.data.stack : undefined, + }); + return; + } + + if (type === "dashboard-widget-selected") { + onWidgetSelectedRef.current?.({ + metadata: { + heading: typeof event.data.metadata?.heading === "string" ? event.data.metadata.heading : null, + tagName: typeof event.data.metadata?.tagName === "string" ? event.data.metadata.tagName : "div", + classes: typeof event.data.metadata?.classes === "string" ? event.data.metadata.classes : "", + textPreview: typeof event.data.metadata?.textPreview === "string" ? event.data.metadata.textPreview : "", + rect: { + width: typeof event.data.metadata?.rect?.width === "number" ? event.data.metadata.rect.width : 0, + height: typeof event.data.metadata?.rect?.height === "number" ? event.data.metadata.rect.height : 0, + }, + }, + }); return; } - if (type === "stack-ai-dashboard-ready" || type === "stack-ai-dashboard-error") { + if (type === "stack-ai-dashboard-ready") { + onReadyRef.current?.(); return; } }; diff --git a/apps/dashboard/src/components/design-components/analytics-card.tsx b/apps/dashboard/src/components/design-components/analytics-card.tsx new file mode 100644 index 0000000000..b6eb7bb003 --- /dev/null +++ b/apps/dashboard/src/components/design-components/analytics-card.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import React, { useEffect, useRef, useState } from "react"; +import { Typography } from "@/components/ui"; + +// ─── Gradient types ─────────────────────────────────────────────────────────── + +export type AnalyticsCardGradient = "blue" | "cyan" | "purple" | "green" | "orange" | "slate"; + +export type AnalyticsChartType = "none" | "line" | "bar" | "stacked-bar" | "composed" | "donut"; +export type AnalyticsTooltipType = "none" | "default" | "stacked" | "composed" | "visitors" | "revenue" | "donut"; +export type AnalyticsHighlightMode = "none" | "bar-segment" | "series-hover" | "dot-hover" | "mixed"; + +export type AnalyticsAverageLineConfig = { + movingAverage?: boolean, + sevenDayAverage?: boolean, + movingAverageDataKey?: string, + sevenDayAverageDataKey?: string, +}; + +export type DesignAnalyticsChartConfig = { + type: AnalyticsChartType, + tooltipType?: AnalyticsTooltipType, + highlightMode?: AnalyticsHighlightMode, + averages?: AnalyticsAverageLineConfig, +}; + +// ─── Internal style maps ────────────────────────────────────────────────────── + +const hoverTintClasses = new Map([ + ["blue", "group-hover:bg-blue-500/[0.03]"], + ["purple", "group-hover:bg-purple-500/[0.03]"], + ["green", "group-hover:bg-emerald-500/[0.03]"], + ["orange", "group-hover:bg-orange-500/[0.03]"], + ["slate", "group-hover:bg-slate-500/[0.02]"], + ["cyan", "group-hover:bg-cyan-500/[0.03]"], +]); + +// ─── DesignAnalyticsCard ────────────────────────────────────────────────────── +// +// A glass-surface card designed as the standard shell for chart widgets on the +// overview page. Key differences from DesignCard: +// +// - Lighter light-mode background (bg-white/90) vs dark (bg-background/60) +// so charts and data pop against the page without competing with other cards. +// - A "chart-card-tooltip-escape" CSS escape layer so Recharts tooltip +// wrappers (which are position:absolute) are not clipped by the card's +// overflow:hidden. +// - Does NOT clip overflow by default, which allows chart axis labels and +// floating tooltips to extend past the card bounds. +// - `hover:z-10` so the hovered card's tooltip sits above adjacent cards. +// +// Props: +// gradient — accent tint shown on hover; defaults to "blue" +// className — forwarded to the outer wrapper div + +export type DesignAnalyticsCardProps = { + gradient?: AnalyticsCardGradient, + className?: string, + chart?: DesignAnalyticsChartConfig, + children: React.ReactNode, +}; + +export function DesignAnalyticsCard({ + gradient = "blue", + className, + chart, + children, +}: DesignAnalyticsCardProps) { + const hoverTint = hoverTintClasses.get(gradient) ?? "group-hover:bg-slate-500/[0.02]"; + const chartType = chart?.type ?? "none"; + const tooltipType = chart?.tooltipType ?? "none"; + const highlightMode = chart?.highlightMode ?? "none"; + const hasMovingAverage = chart?.averages?.movingAverage === true; + const hasSevenDayAverage = chart?.averages?.sevenDayAverage === true; + const hasAverageLines = hasMovingAverage || hasSevenDayAverage; + + return ( + <> +
+ {/* Subtle gradient gloss */} +
+ {/* Gradient hover tint */} +
+ {/* Content layer — must be relative so children stack above pseudo-layers */} +
+ {children} +
+
+ + ); +} + +// ─── DesignAnalyticsCardHeader ──────────────────────────────────────────────── +// +// Compact single-line header bar (label + optional right-side slot) separated +// from body by a thin divider. Used for ranked list cards, stat cards, and any +// card that wants a simple heading above the content area. + +export type DesignAnalyticsCardHeaderProps = { + label: React.ReactNode, + right?: React.ReactNode, + compact?: boolean, + className?: string, +}; + +export function DesignAnalyticsCardHeader({ + label, + right, + compact = false, + className, +}: DesignAnalyticsCardHeaderProps) { + return ( +
+ + {label} + + {right != null && ( +
{right}
+ )} +
+ ); +} + +// ─── DesignChartLegend ──────────────────────────────────────────────────────── +// +// A row of dot + label legend items that appears above or below a stacked chart. +// Used by sign-ups, emails sent, and any stacked-bar chart that needs a legend. + +export type DesignChartLegendItem = { + key: string, + label: string, + color: string, +}; + +export type DesignChartLegendProps = { + items: readonly DesignChartLegendItem[], + compact?: boolean, + className?: string, +}; + +export function DesignChartLegend({ + items, + compact = false, + className, +}: DesignChartLegendProps) { + return ( +
+ {items.map((item) => ( +
+ + + {item.label} + +
+ ))} +
+ ); +} + +// ─── useInfiniteListWindow ──────────────────────────────────────────────────── +// +// Shared hook for incremental list rendering driven by an IntersectionObserver. +// Renders BATCH_SIZE items initially, then reveals the next batch whenever the +// sentinel element at the bottom of the list scrolls into view of its scrollable +// container. +// +// Usage: +// const { visibleCount, scrollRef, sentinelRef, hasMore } = useInfiniteListWindow(items.length); +//
+// {items.slice(0, visibleCount).map(…)} +// {hasMore &&
} +//
+ +const INFINITE_LIST_BATCH_SIZE = 12; + +export type InfiniteListWindow = { + visibleCount: number, + scrollRef: React.RefObject, + sentinelRef: React.RefObject, + hasMore: boolean, +}; + +export function useInfiniteListWindow( + totalCount: number, + /** Reset visible count when this flag changes (e.g. tab switch). */ + resetKey?: unknown, + /** Enable observation only when list UI is mounted. */ + enabled: boolean = true, +): InfiniteListWindow { + const [visibleCount, setVisibleCount] = useState(() => Math.min(INFINITE_LIST_BATCH_SIZE, totalCount)); + // React 19 useRef(null) returns RefObject; cast to RefObject + // for compatibility with JSX ref props that expect RefObject. + const scrollRef = useRef(null) as React.RefObject; + const sentinelRef = useRef(null) as React.RefObject; + const hasMore = visibleCount < totalCount; + + // Reset whenever the list source or reset key changes. + useEffect(() => { + setVisibleCount(Math.min(INFINITE_LIST_BATCH_SIZE, totalCount)); + }, [totalCount, resetKey]); + + // Attach IntersectionObserver only when there is more to reveal. + useEffect(() => { + if (!enabled || !hasMore) return; + // scrollRef.current and sentinelRef.current are typed non-null after the + // cast above, but at mount they start as null at runtime. Guard explicitly. + const root = scrollRef.current as HTMLDivElement | null; + const target = sentinelRef.current as HTMLDivElement | null; + if (root == null || target == null) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry.isIntersecting) return; + setVisibleCount((c) => Math.min(c + INFINITE_LIST_BATCH_SIZE, totalCount)); + }, + { root, rootMargin: "120px 0px", threshold: 0.01 }, + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [enabled, hasMore, totalCount]); + + return { visibleCount, scrollRef, sentinelRef, hasMore }; +} + +// ─── DesignInfiniteScrollList ───────────────────────────────────────────────── +// +// Scrollable list container that automatically appends a "Loading more…" sentinel +// and drives visibility via useInfiniteListWindow. +// Renders children as-is; the consumer slices by `window.visibleCount`. + +export type DesignInfiniteScrollListProps = { + totalCount: number, + resetKey?: unknown, + emptyMessage?: string, + loadingLabel?: string, + children: (window: InfiniteListWindow) => React.ReactNode, + className?: string, +}; + +export function DesignInfiniteScrollList({ + totalCount, + resetKey, + emptyMessage = "No items", + loadingLabel = "Loading more…", + children, + className, +}: DesignInfiniteScrollListProps) { + const window = useInfiniteListWindow(totalCount, resetKey); + + if (totalCount === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {children(window)} + {window.hasMore && ( +
+ {loadingLabel} +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/design-components/index.ts b/apps/dashboard/src/components/design-components/index.ts index ad10260bb1..e92fd172b1 100644 --- a/apps/dashboard/src/components/design-components/index.ts +++ b/apps/dashboard/src/components/design-components/index.ts @@ -1,4 +1,5 @@ export * from "@stackframe/dashboard-ui-components"; +export * from "./analytics-card"; export * from "./editable-grid"; export * from "./list"; export * from "./menu"; diff --git a/apps/dashboard/src/components/streaming-code-viewer.tsx b/apps/dashboard/src/components/streaming-code-viewer.tsx new file mode 100644 index 0000000000..f2d6317c44 --- /dev/null +++ b/apps/dashboard/src/components/streaming-code-viewer.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { memo, useEffect, useRef, useState } from "react"; + +const STATUS_MESSAGES = [ + "Understanding your query...", + "Analyzing project structure...", + "Writing dashboard code...", + "Generating components...", + "Assembling layout...", +]; + +const CHARS_PER_FRAME = 8; +const FRAME_INTERVAL_MS = 16; + +function highlightCode(code: string): React.ReactNode[] { + const lines = code.split("\n"); + return lines.map((line, i) => ( +
+ {highlightLine(line)} +
+ )); +} + +function highlightLine(line: string): React.ReactNode[] { + const tokens: React.ReactNode[] = []; + const regex = /(\/\/.*$|\/\*[\s\S]*?\*\/|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|\b(?:import|export|from|const|let|var|function|return|if|else|for|while|async|await|new|class|extends|typeof|interface|type)\b|\b\d+(?:\.\d+)?\b|<\/?[A-Z][A-Za-z0-9.]*|[{}()[\];,.])/gm; + + let lastIndex = 0; + let match; + while ((match = regex.exec(line)) !== null) { + if (match.index > lastIndex) { + tokens.push(line.slice(lastIndex, match.index)); + } + const token = match[0]; + if (token.startsWith("//") || token.startsWith("/*")) { + tokens.push({token}); + } else if (token.startsWith('"') || token.startsWith("'") || token.startsWith("`")) { + tokens.push({token}); + } else if (/^<\/?[A-Z]/.test(token)) { + tokens.push({token}); + } else if (/^\b(?:import|export|from|const|let|var|function|return|if|else|for|while|async|await|new|class|extends|typeof|interface|type)\b$/.test(token)) { + tokens.push({token}); + } else if (/^\d/.test(token)) { + tokens.push({token}); + } else { + tokens.push(token); + } + lastIndex = match.index + token.length; + } + if (lastIndex < line.length) { + tokens.push(line.slice(lastIndex)); + } + return tokens; +} + +export const StreamingCodeViewer = memo(function StreamingCodeViewer({ + code, + isStreaming, + onComplete, +}: { + code: string, + isStreaming: boolean, + onComplete?: () => void, +}) { + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + const containerRef = useRef(null); + + const [visibleLen, setVisibleLen] = useState(0); + const targetRef = useRef(0); + + useEffect(() => { + targetRef.current = code.length; + }, [code]); + + useEffect(() => { + if (code.length === 0) { + setVisibleLen(0); + targetRef.current = 0; + } + }, [code.length]); + + const isTyping = visibleLen < code.length; + useEffect(() => { + if (!isTyping) return; + const interval = setInterval(() => { + setVisibleLen((prev) => { + const target = targetRef.current; + if (prev >= target) return prev; + return Math.min(prev + CHARS_PER_FRAME, target); + }); + }, FRAME_INTERVAL_MS); + return () => clearInterval(interval); + }, [isTyping]); + + const firedCompleteRef = useRef(false); + useEffect(() => { + if (!isTyping && !isStreaming && code.length > 0 && !firedCompleteRef.current) { + firedCompleteRef.current = true; + onCompleteRef.current?.(); + } + }, [isTyping, isStreaming, code.length]); + + useEffect(() => { + if (code.length === 0) { + firedCompleteRef.current = false; + } + }, [code.length]); + + const visibleCode = code.slice(0, visibleLen); + const hasVisible = visibleLen > 0; + const hasCode = code.length > 0; + const showCursor = isStreaming || isTyping; + + useEffect(() => { + const el = containerRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [visibleCode]); + + const [statusIndex, setStatusIndex] = useState(0); + useEffect(() => { + if (hasCode) return; + const interval = setInterval(() => { + setStatusIndex((prev) => (prev + 1) % STATUS_MESSAGES.length); + }, 2000); + return () => clearInterval(interval); + }, [hasCode]); + + useEffect(() => { + if (!hasCode) setStatusIndex(0); + }, [hasCode]); + + return ( +
+
+
+
+
+
+
+ Dashboard.tsx +
+ +
+ {hasVisible ? ( +
+ {highlightCode(visibleCode)} + {showCursor && ( + + )} +
+ ) : ( +
+
+
+
+
+
+
+ + {STATUS_MESSAGES[statusIndex]} + +
+
+ )} +
+ + {showCursor && hasVisible && ( +
+ )} +
+ ); +}); diff --git a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx index 5966c70280..aa9ad1e339 100644 --- a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx +++ b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx @@ -1,19 +1,58 @@ -import { Thread } from "@/components/assistant-ui/thread"; +import { ImageAttachmentAdapter } from "@/components/assistant-ui/image-attachment-adapter"; +import { Thread, type ComposerPlaceholder } from "@/components/assistant-ui/thread"; import { AssistantRuntimeProvider, + useComposerRuntime, useLocalRuntime, type ChatModelAdapter, type ThreadHistoryAdapter, } from "@assistant-ui/react"; +import { useEffect, useMemo } from "react"; import { TooltipProvider } from "@/components/ui"; +/** + * Imperative handle the parent can use to drive the thread composer from outside the + * assistant-ui runtime — e.g. pre-filling it with a crash report captured from the + * dashboard sandbox iframe. We keep it small on purpose: anything beyond "set the + * current draft" is a smell. + */ +export type AssistantComposerApi = { + setText: (text: string) => void, + getText: () => string, +}; + type AssistantChatProps = { chatAdapter: ChatModelAdapter, historyAdapter: ThreadHistoryAdapter, toolComponents: React.ReactNode, useOffWhiteLightMode?: boolean, - composerPlaceholder?: string, + /** Static string, or `{ prefix, suffixes }` for a typing animation isolated to a leaf input. */ + composerPlaceholder?: ComposerPlaceholder, hideMessageActions?: boolean, + runningStatusMessages?: string[], + /** Enable image attachment UI (subject to shared MAX_IMAGES_PER_MESSAGE/MAX_IMAGE_BYTES_PER_FILE). */ + composerAttachments?: boolean, + /** + * Called once the composer runtime is mounted. Parent stores the handle in a ref + * and can then call `setText(...)` imperatively. Fires inside the runtime provider + * so the hook contract is honored. + */ + onComposerReady?: (api: AssistantComposerApi) => void, +} + +/** + * Lives INSIDE `AssistantRuntimeProvider` so `useComposerRuntime()` resolves. All it + * does is forward a tiny imperative handle upward on mount — no rendered output. + */ +function ComposerBridge({ onReady }: { onReady: (api: AssistantComposerApi) => void }) { + const composerRuntime = useComposerRuntime(); + useEffect(() => { + onReady({ + setText: (text: string) => composerRuntime.setText(text), + getText: () => composerRuntime.getState().text, + }); + }, [composerRuntime, onReady]); + return null; } export default function AssistantChat({ @@ -23,19 +62,40 @@ export default function AssistantChat({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, + runningStatusMessages, + composerAttachments = false, + onComposerReady, }: AssistantChatProps) { + const attachmentAdapter = useMemo( + () => (composerAttachments ? new ImageAttachmentAdapter() : undefined), + [composerAttachments], + ); + const runtime = useLocalRuntime( chatAdapter, - { adapters: { history: historyAdapter } } + { + adapters: { + history: historyAdapter, + ...(attachmentAdapter ? { attachments: attachmentAdapter } : {}), + }, + } ); return (
- + {toolComponents} + {onComposerReady ? : null}
); diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index 769f041b78..d05baf31e7 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -4,12 +4,20 @@ import type { AppId } from "@/lib/apps-frontend"; import { type ChatModelAdapter, type ChatModelRunOptions, + type ChatModelRunResult, type ExportedMessageRepository, type ThreadHistoryAdapter, } from "@assistant-ui/react"; import { StackAdminApp } from "@stackframe/stack"; import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import type { EditableMetadata } from "@stackframe/stack-shared/dist/utils/jsx-editable-transpiler"; +import { + parseJsonEventStream, + readUIMessageStream, + uiMessageChunkSchema, + type UIMessage, + type UIMessageChunk, +} from "ai"; export type ToolCallContent = Extract; @@ -17,17 +25,30 @@ const isToolCall = (content: { type: string }): content is ToolCallContent => { return content.type === "tool-call"; }; -/** - * Sanitizes AI-generated JSX/TSX code before it is applied to the renderer. - * - * Handles four common model output issues: - * 1. Markdown code fences (```tsx ... ```) wrapping the output despite instructions - * 2. HTML-encoded angle brackets (<Component> instead of ) - * 3. Bare & in JSX text content (invalid JSX; must be & or {"&"}) - * 4. Semicolons used as property separators in JS object literals instead of commas - * (the AI confuses TypeScript interface syntax with JS object syntax). - * TypeScript also accepts commas in interfaces/types, so replacing ; → , is always safe. - */ +/** Maps thread messages to the backend wire format; merges `attachments` into `content`. */ +function formatThreadMessagesForBackend( + messages: readonly { role: string, content: readonly { type: string }[], attachments?: readonly { content?: readonly unknown[] }[] }[], +): Array<{ role: string, content: unknown }> { + const formatted: Array<{ role: string, content: unknown }> = []; + for (const msg of messages) { + const textContent = msg.content.filter((c) => !isToolCall(c)); + const attachmentContent: unknown[] = []; + if (msg.attachments) { + for (const attachment of msg.attachments) { + if (Array.isArray(attachment.content)) { + attachmentContent.push(...attachment.content); + } + } + } + const combined = [...textContent, ...attachmentContent]; + if (combined.length > 0) { + formatted.push({ role: msg.role, content: combined }); + } + } + return formatted; +} + +/** Normalizes model JSX: strip fences, decode basic entities, fix `;` vs `,` between object props. */ function sanitizeGeneratedCode(code: string): string { let result = code.trim(); @@ -72,6 +93,7 @@ async function sendAiRequest( systemPrompt: string, tools: string[], messages: Array<{ role: string, content: unknown }>, + projectId?: string, }, abortSignal?: AbortSignal, ): Promise { @@ -104,6 +126,153 @@ function sanitizeAiContent(content: ChatContent): ChatContent { }); } +/** + * Sends a request to the AI streaming endpoint and returns a stream of UIMessageChunks + * (as produced by the Vercel AI SDK's `streamText().toUIMessageStreamResponse()`). + */ +async function sendAiStreamRequest( + backendBaseUrl: string, + currentUser: CurrentUser | undefined, + body: { + quality: string, + speed: string, + systemPrompt: string, + tools: string[], + messages: Array<{ role: string, content: unknown }>, + projectId?: string, + }, + abortSignal?: AbortSignal, +): Promise> { + const authHeaders = await buildStackAuthHeaders(currentUser); + + const response = await fetch(`${backendBaseUrl}/api/latest/ai/query/stream`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "text/event-stream", + ...authHeaders, + }, + ...(abortSignal ? { signal: abortSignal } : {}), + body: JSON.stringify(body), + }); + + if (!response.ok || !response.body) { + throw new Error(`AI stream request failed: ${response.status} ${response.statusText}`); + } + + return parseJsonEventStream({ + stream: response.body, + schema: uiMessageChunkSchema, + }).pipeThrough( + new TransformStream< + { success: true, value: UIMessageChunk, rawValue: unknown } | { success: false, error: unknown, rawValue: unknown }, + UIMessageChunk + >({ + transform(parseResult, controller) { + if (parseResult.success) { + controller.enqueue(parseResult.value); + } + }, + }), + ); +} + +/** + * Converts a UIMessage's parts (as emitted by `readUIMessageStream`) into our + * ChatContent shape so the existing tool UI / sanitizer pipeline keeps working. + */ +function uiPartsToChatContent(parts: UIMessage["parts"]): ChatContent { + const result: ChatContent = []; + for (const part of parts) { + if (part.type === "text") { + if (part.text) { + result.push({ type: "text", text: part.text }); + } + continue; + } + + if (part.type === "dynamic-tool") { + const toolPart = part as { toolCallId: string, toolName: string, input?: unknown, output?: unknown }; + const input = toolPart.input ?? {}; + result.push({ + type: "tool-call", + toolCallId: toolPart.toolCallId, + toolName: toolPart.toolName, + args: input, + argsText: typeof input === "string" ? input : JSON.stringify(input), + result: toolPart.output ?? null, + }); + continue; + } + + if (typeof part.type === "string" && part.type.startsWith("tool-")) { + const toolName = part.type.slice("tool-".length); + const toolPart = part as { toolCallId: string, input?: unknown, output?: unknown }; + const input = toolPart.input ?? {}; + result.push({ + type: "tool-call", + toolCallId: toolPart.toolCallId, + toolName, + args: input, + argsText: typeof input === "string" ? input : JSON.stringify(input), + result: toolPart.output ?? null, + }); + continue; + } + } + return result; +} + +/** + * Streaming dashboard generation: yields progressively updated ChatContent as the AI + * streams text and tool-call input. Each yield represents the full current state of + * the assistant message (not an incremental delta). + */ +export async function* streamDashboardCode( + backendBaseUrl: string, + currentUser: CurrentUser | undefined, + messages: Array<{ role: string, content: unknown }>, + options?: { + currentTsxSource?: string, + abortSignal?: AbortSignal, + enabledAppIds?: AppId[], + projectId?: string, + }, +): AsyncGenerator { + const contextMessages = await buildDashboardMessages( + backendBaseUrl, + currentUser, + messages, + options?.currentTsxSource, + options?.enabledAppIds, + ); + + // Only give the agent the sql-query tool when we know which project to scope it to. + // Without projectId, the tool would fall back to the internal project — wrong target. + const tools = options?.projectId + ? ["update-dashboard", "sql-query"] + : ["update-dashboard"]; + + const chunkStream = await sendAiStreamRequest( + backendBaseUrl, + currentUser, + { + quality: "smart", + speed: "slow", + systemPrompt: "create-dashboard", + tools, + messages: [...contextMessages, ...messages], + projectId: options?.projectId, + }, + options?.abortSignal, + ); + + for await (const message of readUIMessageStream({ stream: chunkStream })) { + if (options?.abortSignal?.aborted) return; + yield sanitizeAiContent(uiPartsToChatContent(message.parts)); + } +} + /** * One-shot dashboard generation: builds context, calls AI, returns the tool call content. * Used by both the cmd+K preview and the dashboard chat adapter. @@ -116,6 +285,7 @@ export async function generateDashboardCode( currentTsxSource?: string, abortSignal?: AbortSignal, enabledAppIds?: AppId[], + projectId?: string, }, ): Promise<{ content: ChatContent, toolCall: ToolCallContent | undefined }> { const contextMessages = await buildDashboardMessages( @@ -125,6 +295,9 @@ export async function generateDashboardCode( options?.currentTsxSource, options?.enabledAppIds, ); + const tools = options?.projectId + ? ["update-dashboard", "sql-query"] + : ["update-dashboard"]; const rawContent = await sendAiRequest( backendBaseUrl, currentUser, @@ -132,8 +305,9 @@ export async function generateDashboardCode( quality: "smart", speed: "slow", systemPrompt: "create-dashboard", - tools: ["update-dashboard"], + tools, messages: [...contextMessages, ...messages], + projectId: options?.projectId, }, options?.abortSignal, ); @@ -155,17 +329,14 @@ export function createChatAdapter( onToolCall: (toolCall: ToolCallContent) => void, getCurrentSource?: () => string, currentUser?: CurrentUser, + onRunStart?: () => void, + onRunEnd?: () => void, ): ChatModelAdapter { return { async run({ messages, abortSignal }: ChatModelRunOptions) { + onRunStart?.(); try { - const formattedMessages: Array<{ role: string, content: unknown }> = []; - for (const msg of messages) { - const textContent = msg.content.filter(c => !isToolCall(c)); - if (textContent.length > 0) { - formattedMessages.push({ role: msg.role, content: textContent }); - } - } + const formattedMessages = formatThreadMessagesForBackend(messages); const { systemPrompt, tools } = CONTEXT_MAP[contextType]; @@ -204,6 +375,8 @@ export function createChatAdapter( return {}; } throw new Error("Failed to get AI response. Please try again."); + } finally { + onRunEnd?.(); } }, }; @@ -215,19 +388,18 @@ export function createDashboardChatAdapter( onToolCall: (toolCall: ToolCallContent) => void, currentUser?: CurrentUser, enabledAppIds?: AppId[], + projectId?: string, + onRunStart?: () => void, + onRunEnd?: () => void, ): ChatModelAdapter { return { - async run({ messages, abortSignal }: ChatModelRunOptions) { + async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { + onRunStart?.(); try { - const formattedMessages: Array<{ role: string, content: unknown }> = []; - for (const msg of messages) { - const textContent = msg.content.filter(c => !isToolCall(c)); - if (textContent.length > 0) { - formattedMessages.push({ role: msg.role, content: textContent }); - } - } + const formattedMessages = formatThreadMessagesForBackend(messages); - const { content, toolCall } = await generateDashboardCode( + let latestContent: ChatContent = []; + for await (const content of streamDashboardCode( backendBaseUrl, currentUser, formattedMessages, @@ -235,19 +407,26 @@ export function createDashboardChatAdapter( currentTsxSource, abortSignal, enabledAppIds, + projectId, }, - ); - - if (toolCall) { - onToolCall(toolCall); + )) { + latestContent = content; + yield { content }; } - return { content }; + const finalToolCall = latestContent.find( + (item): item is ToolCallContent => isToolCall(item) && item.toolName === "updateDashboard", + ); + if (finalToolCall) { + onToolCall(finalToolCall); + } } catch (error) { if (abortSignal.aborted) { - return {}; + return; } throw new Error("Failed to get AI response. Please try again."); + } finally { + onRunEnd?.(); } }, }; diff --git a/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx b/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx index c75b3d61ed..488359cf22 100644 --- a/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx +++ b/apps/dashboard/src/components/vibe-coding/dashboard-tool-components.tsx @@ -1,7 +1,25 @@ -import { Button, Card } from "@/components/ui"; +import { CodeBlock } from "@/components/code-block"; +import { + Button, + Card, + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + SimpleTooltip, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { ArrowCounterClockwiseIcon, CheckCircleIcon } from "@phosphor-icons/react"; -import { useEffect, useSyncExternalStore } from "react"; +import { + ArrowCounterClockwiseIcon, + CheckCircleIcon, + EyeIcon, + MagnifyingGlassIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; let setCurrentCodeRef: ((code: string) => void) | null = null; let currentCodeRef: string = ""; @@ -18,22 +36,73 @@ function useCurrentCode() { return useSyncExternalStore(subscribe, () => currentCodeRef); } -function ToolRender({ args }: { args: { content: string } }) { +function ToolRender({ args, isRunning }: { args: { content: string }, isRunning: boolean }) { const currentCode = useCurrentCode(); const isActive = args.content === currentCode; + const [isCodeOpen, setIsCodeOpen] = useState(false); return ( - - - {isActive && } - Updated dashboard - - {!isActive && ( - - )} - + <> + + + {isActive && } + {isRunning ? "Updating dashboard..." : "Updated dashboard"} + +
+ + + + {!isActive && ( + + + + )} +
+
+ + + + Generated source + + {isRunning + ? "Streaming from the model — updates live as tokens arrive." + : "The TSX the model returned for this turn."} + + + + + {args.content || "// Waiting for the model to produce code..."} + + ) : undefined} + /> + + + + ); } @@ -42,7 +111,233 @@ const ToolUI = makeAssistantToolUI< "success" >({ toolName: "updateDashboard", - render: (props) => , + render: (props) => , +}); + +/* ──────────────────────────────────────────────────────────────────────────── + * queryAnalytics tool UI — inspection steps the agent takes before/between + * writes. Visual weight is DELIBERATELY lighter than the updateDashboard card: + * updateDashboard is a state transition ("the dashboard changed"); an inspection + * step is a breadcrumb ("the agent looked something up"). If both looked alike + * the chat would feel noisy and the user couldn't scan at a glance. + * ────────────────────────────────────────────────────────────────────────── */ + +type QueryAnalyticsSuccess = { + success: true, + rowCount: number, + totalRows?: number, + truncated?: boolean, + truncationNote?: string, + result: Array>, +} + +type QueryAnalyticsError = { + success: false, + error: string, +} + +type QueryAnalyticsResult = QueryAnalyticsSuccess | QueryAnalyticsError; + +function isQueryAnalyticsResult(v: unknown): v is QueryAnalyticsResult { + return typeof v === "object" && v !== null && "success" in v && typeof (v as { success: unknown }).success === "boolean"; +} + +function firstNonEmptyLine(s: string): string { + for (const line of s.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length > 0) return trimmed; + } + return s.trim(); +} + +/** Renders cell values inside the result preview table. Objects are JSON-encoded + * so the agent's `data` JSON column is still inspectable without a custom viewer. */ +function formatCell(v: unknown): string { + if (v === null || v === undefined) return "—"; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v); + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function QueryAnalyticsToolRender({ + args, + result, + isRunning, +}: { + args: { query?: string }, + result: unknown, + isRunning: boolean, +}) { + const [open, setOpen] = useState(false); + const query = (args.query ?? "").trim(); + const parsedResult = isQueryAnalyticsResult(result) ? result : undefined; + + const previewLine = useMemo(() => { + const line = firstNonEmptyLine(query); + return line.length > 0 ? line : "(pending)"; + }, [query]); + + const label = isRunning + ? "Inspecting analytics" + : parsedResult?.success === false + ? "Query failed" + : "Inspected analytics"; + + const isError = parsedResult?.success === false; + + return ( + <> + + + + + + Analytics query + + {isRunning + ? "Running against the project's ClickHouse database…" + : parsedResult?.success + ? parsedResult.truncationNote + ?? `${parsedResult.rowCount} ${parsedResult.rowCount === 1 ? "row" : "rows"} returned` + : isError + ? "The query did not complete successfully." + : "Query details"} + + + + + + {parsedResult?.success && parsedResult.result.length > 0 && ( +
+
+ + + + {Object.keys(parsedResult.result[0]).map((key) => ( + + ))} + + + + {parsedResult.result.map((row, i) => ( + + {Object.keys(parsedResult.result[0]).map((key) => ( + + ))} + + ))} + +
+ {key} +
+ {formatCell(row[key])} +
+
+
+ )} + + {parsedResult?.success && parsedResult.result.length === 0 && ( +
+ Query returned no rows. +
+ )} + + {isError && ( +
+ {parsedResult.error} +
+ )} +
+
+
+ + ); +} + +const QueryAnalyticsToolUI = makeAssistantToolUI< + { query?: string }, + QueryAnalyticsResult +>({ + toolName: "queryAnalytics", + render: (props) => ( + + ), }); type DashboardToolUIProps = { @@ -65,5 +360,10 @@ export const DashboardToolUI = ({ setCurrentCode, currentCode }: DashboardToolUI } }, [currentCode]); - return ; + return ( + <> + + + + ); }; diff --git a/apps/dashboard/src/components/vibe-coding/index.ts b/apps/dashboard/src/components/vibe-coding/index.ts index 64093b619d..445a3b856b 100644 --- a/apps/dashboard/src/components/vibe-coding/index.ts +++ b/apps/dashboard/src/components/vibe-coding/index.ts @@ -1,4 +1,4 @@ -export { default as AssistantChat } from './assistant-chat'; +export { default as AssistantChat, type AssistantComposerApi } from './assistant-chat'; export { createChatAdapter, createDashboardChatAdapter, createHistoryAdapter } from './chat-adapters'; export { default as CodeEditor } from './code-editor'; export { default as VibeCodeLayout, type ViewportMode, type WysiwygDebugInfo } from './vibe-code-layout'; diff --git a/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts b/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts index 67ca812d1f..d008d628d4 100644 --- a/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts +++ b/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts @@ -161,23 +161,34 @@ export async function buildDashboardMessages( const contextMessages: Array<{ role: string, content: string }> = []; + const dashboardUiDocsHeader = [ + "DashboardUI component documentation (READ THIS BEFORE USING ANY COMPONENT):", + "", + "The block below is not just TypeScript types — each component carries a JSDoc block", + "with its mental model, canonical pattern, prop rules, runnable examples, and common", + "mistakes. Before you write code against DataGrid, AnalyticsChart, DesignMetricCard,", + "DesignCard, DesignBadge, DesignButton, or any other DashboardUI.* component, read the", + "JSDoc on that specific component in the block below. The JSDoc is load-bearing — the", + "bare type signatures are NOT enough to use the components correctly.", + ].join("\n"); + if (currentSource != null && currentSource.length > 0) { contextMessages.push({ role: "user", - content: `Here is the current dashboard source code:\n\`\`\`tsx\n${currentSource}\n\`\`\`\n\nHere are the type definitions:\n${typeDefinitions}\n\nHere are the dashboard UI component types:\n${BUNDLED_DASHBOARD_UI_TYPES}${availableRoutes}`, + content: `Here is the current dashboard source code:\n\`\`\`tsx\n${currentSource}\n\`\`\`\n\nHere are the type definitions:\n${typeDefinitions}\n\n${dashboardUiDocsHeader}\n${BUNDLED_DASHBOARD_UI_TYPES}${availableRoutes}`, }); contextMessages.push({ role: "assistant", - content: "I understand the current dashboard code, type definitions, available UI components, and available routes. What changes would you like to make?", + content: "I understand the current dashboard code, type definitions, and available routes. I've also read the JSDoc on every DashboardUI component I plan to use and will follow each component's canonical pattern. What changes would you like to make?", }); } else { contextMessages.push({ role: "user", - content: `Here are the type definitions for the Stack SDK:\n${typeDefinitions}\n\nHere are the dashboard UI component types:\n${BUNDLED_DASHBOARD_UI_TYPES}${availableRoutes}`, + content: `Here are the type definitions for the Stack SDK:\n${typeDefinitions}\n\n${dashboardUiDocsHeader}\n${BUNDLED_DASHBOARD_UI_TYPES}${availableRoutes}`, }); contextMessages.push({ role: "assistant", - content: "I have the type definitions, available UI components, and available routes. What dashboard would you like me to create?", + content: "I have the type definitions and available routes. I've also read the JSDoc on every DashboardUI component I plan to use and will follow each component's canonical pattern. What dashboard would you like me to create?", }); } diff --git a/apps/dashboard/src/lib/stack-app-internals.ts b/apps/dashboard/src/lib/stack-app-internals.ts index 1afd120686..75e314e463 100644 --- a/apps/dashboard/src/lib/stack-app-internals.ts +++ b/apps/dashboard/src/lib/stack-app-internals.ts @@ -1 +1,46 @@ +import { + type MetricsResponse, +} from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + export const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals"); + +// Re-export the metrics response type tree from the shared package so dashboard +// code can read these types without having to know where the schemas live. +export type { + MetricsActivitySplit, + MetricsAnalyticsOverview, + MetricsAuthOverview, + MetricsDailyEmailStatusBreakdown, + MetricsDailyRevenuePoint, + MetricsDataPoint, + MetricsEmailOverview, + MetricsLoginMethodEntry, + MetricsPaymentsOverview, + MetricsRecentEmail, + MetricsResponse, + MetricsTopReferrer, + MetricsTopRegion, +} from "@stackframe/stack-shared/dist/interface/admin-metrics"; + +/** + * Pulls the typed `useMetrics` hook out of the admin app via the internals + * symbol. Throws as a programming error if the symbol is missing or malformed + * — this should never happen at runtime in a correctly-built admin app. + * + * Returns the typed `MetricsResponse` shape derived from the same yup schemas + * the backend route uses, so dashboard call sites do not need `as ...` casts. + */ +export function useMetricsOrThrow(adminApp: object, includeAnonymous: boolean): MetricsResponse { + const internals = Reflect.get(adminApp, stackAppInternalsSymbol); + if (typeof internals !== "object" || internals == null || !("useMetrics" in internals)) { + throw new StackAssertionError("Admin app internals are unavailable: missing useMetrics"); + } + + const useMetrics = internals.useMetrics; + if (typeof useMetrics !== "function") { + throw new StackAssertionError("Admin app internals are unavailable: useMetrics is not callable"); + } + + return useMetrics(includeAnonymous) as MetricsResponse; +} diff --git a/apps/dashboard/tailwind.config.ts b/apps/dashboard/tailwind.config.ts index 4641bdf47e..748cd6be4d 100644 --- a/apps/dashboard/tailwind.config.ts +++ b/apps/dashboard/tailwind.config.ts @@ -6,6 +6,7 @@ const config = { './src/**/*.{ts,tsx}', "./node_modules/@stackframe/stack-ui/src/**/*.{ts,tsx}", "./node_modules/@stackframe/stack-shared/src/**/*.{ts,tsx}", + "./node_modules/@stackframe/dashboard-ui-components/src/**/*.{ts,tsx}", ], prefix: "", theme: { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap b/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap index d15eeba3fc..ea78adab31 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap +++ b/apps/e2e/tests/backend/endpoints/api/v1/__snapshots__/internal-metrics.test.ts.snap @@ -4,6 +4,1572 @@ exports[`should return metrics data > metrics_result_no_users 1`] = ` NiceResponse { "status": 200, "body": { + "analytics_overview": { + "avg_session_seconds": 0, + "daily_clicks": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "daily_page_views": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "daily_revenue": [ + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + { + "date": , + "new_cents": 0, + "refund_cents": 0, + }, + ], + "daily_visitors": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "online_live": 0, + "recent_replays": 0, + "revenue_per_visitor": 0, + "top_referrers": [], + "top_region": null, + "total_replays": 0, + "total_revenue_cents": 0, + "visitors": 0, + }, + "auth_overview": { + "anonymous_users": 0, + "daily_active_teams_split": { + "new": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "reactivated": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "retained": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "total": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + }, + "daily_active_users_split": { + "new": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "reactivated": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "retained": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "total": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + }, + "mau": 0, + "total_teams": 0, + "total_users_filtered": 0, + "unverified_users": 0, + "verified_users": 0, + }, "daily_active_users": [ { "activity": 0, @@ -251,25 +1817,2059 @@ NiceResponse { "activity": 0, "date": , }, - { - "activity": 0, - "date": , + { + "activity": 0, + "date": , + }, + ], + "email_overview": { + "bounce_rate": 0, + "click_rate": 0, + "daily_emails": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "daily_emails_by_status": [ + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + { + "date": , + "error": 0, + "in_progress": 0, + "ok": 0, + }, + ], + "deliverability_rate": 0, + "deliverability_status": { + "bounced": 0, + "delivered": 0, + "error": 0, + "in_progress": 0, + }, + "emails_by_status": {}, + "emails_sent": 0, + "recent_emails": [], + "total_emails": 0, + }, + "login_methods": [], + "payments_overview": { + "active_subscription_count": 0, + "checkout_conversion_rate": 0, + "daily_subscriptions": [ + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "mrr_cents": 0, + "revenue_cents": 0, + "subscriptions_by_status": {}, + "total_one_time_purchases": 0, + "total_orders": 0, + }, + "recently_active": [], + "recently_registered": [], + "total_users": 0, + "users_by_country": {}, + }, + "headers": Headers {