From 97157a4323b1f00a320bc1d1686e2e7d95e84679 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Mon, 9 Mar 2026 17:33:57 -0700 Subject: [PATCH 01/30] Good with dummy data, very buggy, not finalized --- apps/backend/prisma/seed.ts | 559 ++++++++- .../app/api/latest/internal/metrics/route.tsx | 783 ++++++++++++- apps/dashboard/DESIGN-GUIDE.md | 46 + .../[projectId]/(overview)/line-chart.tsx | 551 ++++++++- .../[projectId]/(overview)/metrics-page.tsx | 1012 ++++++++++------- .../endpoints/api/v1/internal-metrics.test.ts | 85 ++ claude/CLAUDE-KNOWLEDGE.md | 6 + 7 files changed, 2580 insertions(+), 462 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 2663aa188c..6987268fbe 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -530,6 +530,7 @@ type UserSeed = { primaryEmailVerified: boolean, isAnonymous: boolean, oauthProviders: UserSeedOauthProvider[], + createdAt?: Date, }; type SeedDummyTeamsOptions = { @@ -592,6 +593,8 @@ async function seedDummyTeams(options: SeedDummyTeamsOptions): Promise> { const { prisma, tenancy, teamNameToId } = options; + const daysAgo = (d: number, h: number = 12) => new Date(Date.now() - d * 24 * 60 * 60 * 1000 + h * 60 * 60 * 1000); + const userSeeds = [ { displayName: 'Amelia Chen', @@ -602,6 +605,7 @@ 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 createdAt = 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 }, + }); + + 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, + }, + }); + await prisma.projectUser.updateMany({ + where: { tenancyId: tenancy.id, projectUserId: created.id }, + data: { createdAt }, + }); + userEmailToId.set(email, created.id); + } + + bulkIndex++; + } + } + return userEmailToId; } @@ -1200,6 +1312,13 @@ const DUMMY_SEED_IDS = { growthLoopRegression: '813c4380-475b-4cb8-ac1a-5204d9839b36', mateoGrowthAnnual: 'c4acea49-302a-43b9-82a7-446b19e0e662', legacyEnterprise: '11664974-38ff-4356-8e39-2fa9105ed84f', + // Additional subs for dashboard breadth + harperStarterActive: '22b3c9d1-a123-4abc-8def-000000000001', + jonasPastDue: '22b3c9d1-a123-4abc-8def-000000000002', + kaiTrialing: '22b3c9d1-a123-4abc-8def-000000000003', + eveGrowthCanceled: '22b3c9d1-a123-4abc-8def-000000000004', + lucaStarterActive: '22b3c9d1-a123-4abc-8def-000000000005', + islaGrowthIncomplete: '22b3c9d1-a123-4abc-8def-000000000006', }, itemQuantityChanges: { designSeatsGrant: '44ca1801-0732-4273-ae14-4fd1c3999e24', @@ -1212,6 +1331,9 @@ const DUMMY_SEED_IDS = { ameliaSeatPack: '0b696a83-c54e-4a74-ae47-3ac5a4db49e6', launchCouncilUpfront: '10766081-37fd-410c-8b2e-1c3351e2d364', designAuditPass: '5939f45e-1cf0-4f76-98f9-d999ed45405b', + // Additional for breadth + niaSnapshot: '33c4d0e2-b234-5bcd-9ef0-000000000001', + theoRegressionAddon: '33c4d0e2-b234-5bcd-9ef0-000000000002', }, emails: { welcomeAmelia: 'af8cfd90-8912-4bf7-93a7-20ff2be54767', @@ -1219,6 +1341,17 @@ const DUMMY_SEED_IDS = { invitePriya: 'b7e31f58-cfd7-46cd-920f-d7616ad66bed', statusDigest: '2423e8d8-72cf-4355-a475-c2028e3ea958', templateFailure: 'faa33233-ba8d-4819-a89a-d442003cd589', + // Additional for breadth + welcomeJonas: '55e5f1a3-c345-6cde-aef1-000000000001', + welcomeHarper: '55e5f1a3-c345-6cde-aef1-000000000002', + bounceKai: '55e5f1a3-c345-6cde-aef1-000000000003', + spamMateo: '55e5f1a3-c345-6cde-aef1-000000000004', + openedEvelyn: '55e5f1a3-c345-6cde-aef1-000000000005', + clickedNia: '55e5f1a3-c345-6cde-aef1-000000000006', + weeklyDigest1: '55e5f1a3-c345-6cde-aef1-000000000007', + weeklyDigest2: '55e5f1a3-c345-6cde-aef1-000000000008', + weeklyDigest3: '55e5f1a3-c345-6cde-aef1-000000000009', + queuedLuca: '55e5f1a3-c345-6cde-aef1-000000000010', }, } as const; @@ -1537,6 +1670,210 @@ async function seedDummyTransactions(options: TransactionsSeedOptions) { }, }); } + + // Additional subscriptions for dashboard breadth + const extraSubscriptionSeeds: SubscriptionSeed[] = [ + { + id: DUMMY_SEED_IDS.subscriptions.harperStarterActive, + customerType: CustomerType.USER, + customerId: resolveUserId('harper.lin@dummy.dev'), + productId: 'starter', + priceId: 'monthly', + product: resolveProduct('starter'), + quantity: 1, + status: SubscriptionStatus.active, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + currentPeriodStart: new Date('2025-11-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-12-01T00:00:00.000Z'), + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_starter_harper', + createdAt: new Date('2025-11-01T00:00:00.000Z'), + }, + { + id: DUMMY_SEED_IDS.subscriptions.jonasPastDue, + customerType: CustomerType.USER, + customerId: resolveUserId('jonas.richter@dummy.dev'), + productId: 'growth', + priceId: 'monthly', + product: resolveProduct('growth'), + quantity: 1, + status: SubscriptionStatus.past_due, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + currentPeriodStart: new Date('2025-12-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2026-01-01T00:00:00.000Z'), + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_growth_jonas', + createdAt: new Date('2025-10-15T00:00:00.000Z'), + }, + { + id: DUMMY_SEED_IDS.subscriptions.kaiTrialing, + customerType: CustomerType.USER, + customerId: resolveUserId('kai.romero@dummy.dev'), + productId: 'starter', + priceId: 'monthly', + product: resolveProduct('starter'), + quantity: 1, + status: SubscriptionStatus.trialing, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + currentPeriodStart: new Date('2026-02-15T00:00:00.000Z'), + currentPeriodEnd: new Date('2026-03-01T00:00:00.000Z'), + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_starter_kai', + createdAt: new Date('2026-02-15T00:00:00.000Z'), + }, + { + id: DUMMY_SEED_IDS.subscriptions.eveGrowthCanceled, + customerType: CustomerType.USER, + customerId: resolveUserId('evelyn.brooks@dummy.dev'), + productId: 'growth', + priceId: 'monthly', + product: resolveProduct('growth'), + quantity: 1, + status: SubscriptionStatus.canceled, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + currentPeriodStart: new Date('2025-08-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-09-01T00:00:00.000Z'), + cancelAtPeriodEnd: true, + stripeSubscriptionId: 'sub_growth_evelyn', + createdAt: new Date('2025-07-10T00:00:00.000Z'), + }, + { + id: DUMMY_SEED_IDS.subscriptions.lucaStarterActive, + customerType: CustomerType.USER, + customerId: resolveUserId('luca.bennett@dummy.dev'), + productId: 'starter', + priceId: 'monthly', + product: resolveProduct('starter'), + quantity: 1, + status: SubscriptionStatus.active, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + currentPeriodStart: new Date('2026-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2026-03-01T00:00:00.000Z'), + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_starter_luca', + createdAt: new Date('2026-02-01T00:00:00.000Z'), + }, + { + id: DUMMY_SEED_IDS.subscriptions.islaGrowthIncomplete, + customerType: CustomerType.USER, + customerId: resolveUserId('isla.rodriguez@dummy.dev'), + productId: 'growth', + priceId: 'annual', + product: resolveProduct('growth'), + quantity: 1, + status: SubscriptionStatus.incomplete, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + currentPeriodStart: new Date('2026-03-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2027-03-01T00:00:00.000Z'), + cancelAtPeriodEnd: false, + stripeSubscriptionId: 'sub_growth_annual_isla', + createdAt: new Date('2026-03-01T00:00:00.000Z'), + }, + ]; + + for (const subscription of extraSubscriptionSeeds) { + await prisma.subscription.upsert({ + where: { + tenancyId_id: { + tenancyId, + id: subscription.id, + }, + }, + update: { + customerId: subscription.customerId, + customerType: subscription.customerType, + productId: subscription.productId ?? null, + priceId: subscription.priceId ?? null, + product: subscription.product, + quantity: subscription.quantity, + status: subscription.status, + currentPeriodEnd: subscription.currentPeriodEnd, + currentPeriodStart: subscription.currentPeriodStart, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + creationSource: subscription.creationSource, + stripeSubscriptionId: subscription.stripeSubscriptionId ?? null, + }, + create: { + tenancyId, + id: subscription.id, + customerId: subscription.customerId, + customerType: subscription.customerType, + productId: subscription.productId ?? null, + priceId: subscription.priceId ?? null, + product: subscription.product, + quantity: subscription.quantity, + status: subscription.status, + currentPeriodEnd: subscription.currentPeriodEnd, + currentPeriodStart: subscription.currentPeriodStart, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + creationSource: subscription.creationSource, + stripeSubscriptionId: subscription.stripeSubscriptionId ?? null, + createdAt: subscription.createdAt, + }, + }); + } + + // Additional one-time purchases for breadth + const extraOneTimePurchaseSeeds: OneTimePurchaseSeed[] = [ + { + id: DUMMY_SEED_IDS.oneTimePurchases.niaSnapshot, + customerType: CustomerType.USER, + customerId: resolveUserId('nia.holloway@dummy.dev'), + productId: 'regression-addon', + priceId: 'monthly', + product: resolveProduct('regression-addon'), + quantity: 1, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + stripePaymentIntentId: 'pi_regression_nia', + createdAt: new Date('2026-01-18T00:00:00.000Z'), + }, + { + id: DUMMY_SEED_IDS.oneTimePurchases.theoRegressionAddon, + customerType: CustomerType.USER, + customerId: resolveUserId('theo.fischer@dummy.dev'), + productId: 'regression-addon', + priceId: 'monthly', + product: resolveProduct('regression-addon'), + quantity: 2, + creationSource: PurchaseCreationSource.PURCHASE_PAGE, + stripePaymentIntentId: 'pi_regression_theo', + createdAt: new Date('2026-02-05T00:00:00.000Z'), + }, + ]; + + for (const purchase of extraOneTimePurchaseSeeds) { + await prisma.oneTimePurchase.upsert({ + where: { + tenancyId_id: { + tenancyId, + id: purchase.id, + }, + }, + update: { + customerId: purchase.customerId, + customerType: purchase.customerType, + productId: purchase.productId ?? null, + priceId: purchase.priceId ?? null, + product: purchase.product, + quantity: purchase.quantity, + creationSource: purchase.creationSource, + stripePaymentIntentId: purchase.stripePaymentIntentId ?? null, + }, + create: { + tenancyId, + id: purchase.id, + customerId: purchase.customerId, + customerType: purchase.customerType, + productId: purchase.productId ?? null, + priceId: purchase.priceId ?? null, + product: purchase.product, + quantity: purchase.quantity, + creationSource: purchase.creationSource, + stripePaymentIntentId: purchase.stripePaymentIntentId ?? null, + createdAt: purchase.createdAt, + }, + }); + } } async function seedDummyEmails(options: EmailSeedOptions) { @@ -1550,13 +1887,23 @@ async function seedDummyEmails(options: EmailSeedOptions) { return userId; }; - const emailSeeds: EmailOutboxSeed[] = [ + type EmailOutboxSeedExtended = EmailOutboxSeed & { + hasBounce?: boolean, + hasMarkedAsSpam?: boolean, + hasOpened?: boolean, + hasClicked?: boolean, + isQueued?: boolean, + }; + + const emailDaysAgo = (d: number, h: number = 12) => new Date(Date.now() - d * 24 * 60 * 60 * 1000 + h * 60 * 60 * 1000); + + const emailSeeds: EmailOutboxSeedExtended[] = [ { id: DUMMY_SEED_IDS.emails.welcomeAmelia, subject: 'Welcome to Dummy Project', html: '

Hi Amelia,
Welcome to Dummy Project.

', text: 'Hi Amelia,\nWelcome to Dummy Project.', - createdAt: new Date('2024-05-01T13:00:00.000Z'), + createdAt: emailDaysAgo(27, 13), userEmail: 'amelia.chen@dummy.dev', }, { @@ -1564,7 +1911,7 @@ async function seedDummyEmails(options: EmailSeedOptions) { subject: 'Your passkey sign-in link', html: '

Complete your sign-in within 10 minutes.

', text: 'Complete your sign-in within 10 minutes.', - createdAt: new Date('2024-05-02T10:00:00.000Z'), + createdAt: emailDaysAgo(25, 10), userEmail: 'milo.adeyemi@dummy.dev', }, { @@ -1572,21 +1919,95 @@ async function seedDummyEmails(options: EmailSeedOptions) { subject: 'Dashboard invite for Ops', html: '

Welcome to the dashboard!

', hasError: true, - createdAt: new Date('2024-05-04T18:30:00.000Z'), + createdAt: emailDaysAgo(23, 18), userEmail: 'priya.narang@dummy.dev', }, { id: DUMMY_SEED_IDS.emails.statusDigest, subject: 'Nightly status digest', text: 'All services operational. 3 warnings acknowledged.', - createdAt: new Date('2024-05-06T07:45:00.000Z'), + createdAt: emailDaysAgo(21, 7), }, { id: DUMMY_SEED_IDS.emails.templateFailure, subject: 'Template rendering failed - Review', html: '

Rendering failed due to undefined data from billing.

', hasError: true, - createdAt: new Date('2024-05-08T12:05:00.000Z'), + createdAt: emailDaysAgo(19, 12), + }, + { + id: DUMMY_SEED_IDS.emails.welcomeJonas, + subject: 'Welcome to Dummy Project, Jonas!', + html: '

Hi Jonas,
Welcome aboard!

', + text: 'Hi Jonas,\nWelcome aboard!', + createdAt: emailDaysAgo(17, 9), + userEmail: 'jonas.richter@dummy.dev', + }, + { + id: DUMMY_SEED_IDS.emails.welcomeHarper, + subject: 'Welcome to Dummy Project, Harper!', + html: '

Hi Harper,
Welcome aboard!

', + text: 'Hi Harper,\nWelcome aboard!', + createdAt: emailDaysAgo(15, 11), + userEmail: 'harper.lin@dummy.dev', + }, + { + id: DUMMY_SEED_IDS.emails.bounceKai, + subject: 'Your monthly report', + html: '

Here is your monthly report.

', + createdAt: emailDaysAgo(13, 14), + userEmail: 'kai.romero@dummy.dev', + hasBounce: true, + }, + { + id: DUMMY_SEED_IDS.emails.spamMateo, + subject: 'Promotional update', + html: '

Check out our latest features!

', + createdAt: emailDaysAgo(11, 10), + userEmail: 'mateo.silva@dummy.dev', + hasMarkedAsSpam: true, + }, + { + id: DUMMY_SEED_IDS.emails.openedEvelyn, + subject: 'Your subscription is active', + html: '

Your subscription is now active. Enjoy!

', + createdAt: emailDaysAgo(9, 8), + userEmail: 'evelyn.brooks@dummy.dev', + hasOpened: true, + }, + { + id: DUMMY_SEED_IDS.emails.clickedNia, + subject: 'Complete your profile', + html: '

Click here to complete your profile!

', + createdAt: emailDaysAgo(7, 9), + userEmail: 'nia.holloway@dummy.dev', + hasClicked: true, + }, + { + id: DUMMY_SEED_IDS.emails.weeklyDigest1, + subject: 'Weekly digest - Week 1', + text: 'Your weekly platform digest.', + createdAt: emailDaysAgo(5, 7), + }, + { + id: DUMMY_SEED_IDS.emails.weeklyDigest2, + subject: 'Weekly digest - Week 2', + text: 'Your weekly platform digest.', + createdAt: emailDaysAgo(3, 7), + }, + { + id: DUMMY_SEED_IDS.emails.weeklyDigest3, + subject: 'Weekly digest - Week 3', + text: 'Your weekly platform digest.', + createdAt: emailDaysAgo(1, 7), + }, + { + id: DUMMY_SEED_IDS.emails.queuedLuca, + subject: 'Scheduled notification for Luca', + html: '

This email is scheduled to be sent.

', + createdAt: emailDaysAgo(0, 8), + userEmail: 'luca.bennett@dummy.dev', + isQueued: true, }, ]; @@ -1596,6 +2017,13 @@ async function seedDummyEmails(options: EmailSeedOptions) { ? { type: 'user-primary-email', userId } : { type: 'custom-emails', emails: ['unknown@dummy.dev'] }; + const isQueued = email.isQueued === true; + const hasBounce = email.hasBounce === true; + const hasMarkedAsSpam = email.hasMarkedAsSpam === true; + const hasOpened = email.hasOpened === true; + const hasClicked = email.hasClicked === true; + const canHaveDelivery = hasOpened || hasClicked || hasBounce || hasMarkedAsSpam; + await globalPrismaClient.emailOutbox.upsert({ where: { tenancyId_id: { @@ -1614,25 +2042,124 @@ async function seedDummyEmails(options: EmailSeedOptions) { shouldSkipDeliverabilityCheck: false, createdWith: EmailOutboxCreatedWith.PROGRAMMATIC_CALL, scheduledAt: email.createdAt, - // Rendering fields - renderedByWorkerId and startedRenderingAt must both be set or both be null - renderedByWorkerId: email.id, // use the email id as a dummy worker id + renderedByWorkerId: email.id, startedRenderingAt: email.createdAt, finishedRenderingAt: email.createdAt, renderedSubject: email.subject, renderedHtml: email.html ?? null, renderedText: email.text ?? null, - // Sending fields - startedSendingAt: email.createdAt, - finishedSendingAt: email.createdAt, - canHaveDeliveryInfo: false, - sendServerErrorExternalMessage: email.hasError ? 'Delivery failed' : null, - sendServerErrorExternalDetails: email.hasError ? {} : Prisma.DbNull, - sendServerErrorInternalMessage: email.hasError ? "Delivery failed. This is the internal error message." : null, - sendServerErrorInternalDetails: email.hasError ? { internalError: "No internal error details." } : Prisma.DbNull, + ...(isQueued ? { + isQueued: true, + } : { + startedSendingAt: email.createdAt, + finishedSendingAt: email.createdAt, + canHaveDeliveryInfo: canHaveDelivery, + deliveredAt: (hasOpened || hasClicked || hasMarkedAsSpam) ? email.createdAt : null, + sendServerErrorExternalMessage: email.hasError ? 'Delivery failed' : null, + sendServerErrorExternalDetails: email.hasError ? {} : Prisma.DbNull, + sendServerErrorInternalMessage: email.hasError ? 'Delivery failed. This is the internal error message.' : null, + sendServerErrorInternalDetails: email.hasError ? { internalError: 'No internal error details.' } : Prisma.DbNull, + bouncedAt: hasBounce ? new Date(email.createdAt.getTime() + 60_000) : null, + markedAsSpamAt: hasMarkedAsSpam ? new Date(email.createdAt.getTime() + 3600_000) : null, + openedAt: (hasOpened || hasClicked) ? new Date(email.createdAt.getTime() + 1800_000) : null, + clickedAt: hasClicked ? new Date(email.createdAt.getTime() + 2400_000) : null, + }), createdAt: email.createdAt, }, }); } + + // Generate additional bulk emails for realistic chart data + const bulkEmailSubjects = [ + 'Your account has been updated', + 'New sign-in from a new device', + 'Weekly activity summary', + 'Your invoice is ready', + 'Password reset requested', + 'Welcome to the platform', + 'Action required: verify your email', + 'Your trial is ending soon', + 'Team invitation accepted', + 'New comment on your project', + 'Security alert: unusual activity', + 'Monthly usage report', + ]; + + let emailBulkSeed = 7777; + const emailBulkRand = () => { + emailBulkSeed = (emailBulkSeed * 1664525 + 1013904223) & 0x7fffffff; + return emailBulkSeed / 0x7fffffff; + }; + + // Per-day email counts — varying with a natural pattern + const dailyEmailCounts = [ + 1, 2, 0, 3, 1, 2, 0, // week 1 + 2, 3, 1, 4, 2, 1, 0, // week 2 + 3, 2, 4, 1, 3, 5, 2, // week 3 + 4, 3, 5, 2, 6, 3, 2, 5, // week 4+ + ]; + + let emailBulkIndex = 0; + for (let dayOffset = 0; dayOffset < dailyEmailCounts.length; dayOffset++) { + const count = dailyEmailCounts[dayOffset]; + const dayBack = dailyEmailCounts.length - dayOffset; + + for (let j = 0; j < count; j++) { + const bulkId = generateUuid(); + const hour = 7 + Math.floor(emailBulkRand() * 14); + const createdAt = emailDaysAgo(dayBack, hour); + const subject = bulkEmailSubjects[Math.floor(emailBulkRand() * bulkEmailSubjects.length)]; + const hasBounce = emailBulkRand() < 0.08; + const hasOpened = !hasBounce && emailBulkRand() < 0.45; + const hasClicked = hasOpened && emailBulkRand() < 0.3; + const hasError = !hasBounce && !hasOpened && emailBulkRand() < 0.05; + + const existing = await globalPrismaClient.emailOutbox.findUnique({ + where: { tenancyId_id: { tenancyId, id: bulkId } }, + select: { id: true }, + }); + if (existing) continue; + + const canHaveDelivery = hasOpened || hasClicked || hasBounce; + + await globalPrismaClient.emailOutbox.upsert({ + where: { tenancyId_id: { tenancyId, id: bulkId } }, + update: {}, + create: { + tenancyId, + id: bulkId, + tsxSource: '', + isHighPriority: false, + to: { type: 'custom-emails', emails: [`bulk-${emailBulkIndex}@dummy.dev`] }, + extraRenderVariables: {}, + shouldSkipDeliverabilityCheck: false, + createdWith: EmailOutboxCreatedWith.PROGRAMMATIC_CALL, + scheduledAt: createdAt, + renderedByWorkerId: bulkId, + startedRenderingAt: createdAt, + finishedRenderingAt: createdAt, + renderedSubject: subject, + renderedHtml: `

${subject}

`, + renderedText: subject, + startedSendingAt: createdAt, + finishedSendingAt: createdAt, + canHaveDeliveryInfo: canHaveDelivery, + deliveredAt: (hasOpened || hasClicked) ? createdAt : null, + sendServerErrorExternalMessage: hasError ? 'Delivery failed' : null, + sendServerErrorExternalDetails: hasError ? {} : Prisma.DbNull, + sendServerErrorInternalMessage: hasError ? 'Internal delivery error' : null, + sendServerErrorInternalDetails: hasError ? { internalError: 'details' } : Prisma.DbNull, + bouncedAt: hasBounce ? new Date(createdAt.getTime() + 60_000) : null, + markedAsSpamAt: null, + openedAt: (hasOpened || hasClicked) ? new Date(createdAt.getTime() + 1800_000) : null, + clickedAt: hasClicked ? new Date(createdAt.getTime() + 2400_000) : null, + createdAt, + }, + }); + + emailBulkIndex++; + } + } } type SessionActivityEventSeedOptions = { 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 85fb905140..1de5cb9adf 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -4,6 +4,7 @@ import { getPrismaClientForTenancy, PrismaClientTransaction, getPrismaSchemaForT 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 { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import yup from 'yup'; import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud"; @@ -156,6 +157,202 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou return out; } +type ActivitySplit = { + total: DataPoints, + new: DataPoints, + retained: DataPoints, + reactivated: DataPoints, +}; + +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 })), + }; +} + +function buildSplitFromDailyEntitySets(options: { + orderedDays: string[], + entityIdsByDay: Map>, + createdDayByEntityId?: Map, +}): ActivitySplit { + const { orderedDays, entityIdsByDay, createdDayByEntityId } = options; + const split = createEmptySplitSeries(orderedDays); + 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 { + newCount += 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; +} + +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() - 30 * 24 * 60 * 60 * 1000); + const untilExclusive = new Date(todayUtc.getTime() + 24 * 60 * 60 * 1000); + const clickhouseClient = getClickhouseAdminClient(); + const prisma = await getPrismaClientForTenancy(tenancy); + + const [userRows, users] = await Promise.all([ + 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) + `, + 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 }[]>), + prisma.projectUser.findMany({ + where: { + tenancyId: tenancy.id, + ...(includeAnonymous ? {} : { isAnonymous: false }), + }, + select: { + projectUserId: true, + createdAt: true, + }, + }), + ]); + + const orderedDays: string[] = []; + const idsByDay = new Map>(); + for (let i = 0; i <= 30; i += 1) { + const date = new Date(since.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + orderedDays.push(date); + idsByDay.set(date, new Set()); + } + for (const row of userRows) { + const day = new Date(row.day + 'Z').toISOString().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.createdAt.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() - 30 * 24 * 60 * 60 * 1000); + const untilExclusive = new Date(todayUtc.getTime() + 24 * 60 * 60 * 1000); + const clickhouseClient = getClickhouseAdminClient(); + const prisma = await getPrismaClientForTenancy(tenancy); + + const [teamRows, teams] = await Promise.all([ + 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} + `, + 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 }[]>), + prisma.team.findMany({ + where: { tenancyId: tenancy.id }, + select: { + teamId: true, + createdAt: true, + }, + }), + ]); + + const orderedDays: string[] = []; + const idsByDay = new Map>(); + for (let i = 0; i <= 30; i += 1) { + const date = new Date(since.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + orderedDays.push(date); + idsByDay.set(date, new Set()); + } + for (const row of teamRows) { + const day = new Date(row.day + 'Z').toISOString().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 +396,557 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boole return dbUsers.map((user) => userPrismaToCrud(user, tenancy.config)); } +// ── Payments Aggregates ────────────────────────────────────────────────────── + +async function loadPaymentsOverview(tenancy: Tenancy) { + const prisma = await getPrismaClientForTenancy(tenancy); + + const [ + subscriptionsByStatus, + activeSubscriptionCount, + totalOneTimePurchases, + recentSubscriptions, + invoiceRevenue, + totalSubscriptionInvoices, + successfulSubscriptionInvoices, + ] = await Promise.all([ + prisma.subscription.groupBy({ + by: ['status'], + where: { tenancyId: tenancy.id }, + _count: { _all: true }, + }), + prisma.subscription.count({ + where: { tenancyId: tenancy.id, status: 'active' }, + }), + prisma.oneTimePurchase.count({ + where: { tenancyId: tenancy.id }, + }), + prisma.subscription.findMany({ + where: { tenancyId: tenancy.id }, + orderBy: { createdAt: 'desc' }, + take: 30, + select: { createdAt: true, status: true, customerType: true, productId: true }, + }), + prisma.subscriptionInvoice.aggregate({ + where: { tenancyId: tenancy.id, amountTotal: { not: null } }, + _sum: { amountTotal: true }, + }), + prisma.subscriptionInvoice.count({ + where: { tenancyId: tenancy.id }, + }), + prisma.subscriptionInvoice.count({ + where: { tenancyId: tenancy.id, status: { in: ['paid', 'succeeded'] } }, + }), + ]); + + // Build subscriptions-by-status map + const subsByStatus: Record = {}; + for (const group of subscriptionsByStatus) { + subsByStatus[group.status.toLowerCase()] = group._count._all; + } + + // Daily subscription signups for the last 30 days + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const recentByDay = new Map(); + for (const sub of recentSubscriptions) { + if (sub.createdAt >= thirtyDaysAgo) { + const key = sub.createdAt.toISOString().split('T')[0]; + recentByDay.set(key, (recentByDay.get(key) ?? 0) + 1); + } + } + const dailySubscriptions: DataPoints = []; + for (let i = 0; i <= 30; i++) { + const day = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000); + const key = day.toISOString().split('T')[0]; + dailySubscriptions.push({ date: key, activity: recentByDay.get(key) ?? 0 }); + } + + const estimatedMrrCents = activeSubscriptionCount * 10_000; + 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: invoiceRevenue._sum.amountTotal ?? 0, + mrr_cents: estimatedMrrCents, + total_orders: totalOrders, + checkout_conversion_rate: checkoutConversionRate, + }; +} + +// ── Email Aggregates ───────────────────────────────────────────────────────── + +async function loadEmailOverview(tenancy: Tenancy) { + const [ + statusGroups, + recentEmails, + deliveredCount, + bouncedCount, + clickedCount, + finishedSendingCount, + ] = await Promise.all([ + // group by simpleStatus + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.groupBy({ + by: ['simpleStatus'], + where: { tenancyId: tenancy.id }, + _count: { _all: true }, + }); + })(), + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.findMany({ + where: { tenancyId: tenancy.id }, + orderBy: { createdAt: 'desc' }, + take: 30, + select: { id: true, createdAt: true, simpleStatus: true, status: true, renderedSubject: true }, + }); + })(), + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, deliveredAt: { not: null } } }); + })(), + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, bouncedAt: { not: null } } }); + })(), + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, clickedAt: { not: null } } }); + })(), + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, finishedSendingAt: { not: null } } }); + })(), + ]); + + const emailsByStatus: Record = {}; + for (const group of statusGroups) { + emailsByStatus[group.simpleStatus.toLowerCase().replace('_', '-')] = group._count._all; + } + + // Daily email sends for last 30 days + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const emailByDay = new Map(); + for (const email of recentEmails) { + if (email.createdAt >= thirtyDaysAgo) { + const key = email.createdAt.toISOString().split('T')[0]; + emailByDay.set(key, (emailByDay.get(key) ?? 0) + 1); + } + } + const dailyEmails: DataPoints = []; + for (let i = 0; i <= 30; i++) { + const day = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000); + 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(((deliveredCount / denom) * 100).toFixed(2)); + const bounceRate = Number(((bouncedCount / denom) * 100).toFixed(2)); + const clickRate = Number(((clickedCount / denom) * 100).toFixed(2)); + + return { + emails_by_status: emailsByStatus, + total_emails: totalEmails, + daily_emails: dailyEmails, + emails_sent: finishedSendingCount, + recent_emails: recentEmails.slice(0, 6).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 ───────────────────────────────────────────────── + +async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { + 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 clickhouseClient = getClickhouseAdminClient(); + + try { + const [pageViewResult, clickResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ + clickhouseClient.query({ + query: ` + SELECT + toDate(event_at) AS day, + count() AS cnt + FROM analytics_internal.events + WHERE event_type = '$page-view' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + GROUP BY day + ORDER BY day ASC + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + toDate(event_at) AS day, + count() AS cnt + FROM analytics_internal.events + WHERE event_type = '$click' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + GROUP BY day + ORDER BY day ASC + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + nullIf(CAST(data.referrer, 'String'), '') AS referrer, + count() AS cnt + FROM analytics_internal.events + WHERE event_type = '$page-view' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + GROUP BY referrer + ORDER BY cnt DESC + LIMIT 5 + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + }, + 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, + count() AS cnt + FROM analytics_internal.events + WHERE event_type = '$token-refresh' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {untilExclusive:DateTime} + GROUP BY country_code, region_code + ORDER BY cnt DESC + LIMIT 1 + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + }, + 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} + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + }, + format: "JSONEachRow", + }), + // session replay count from Postgres + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + const [total, recent, replayRows, visitorCount, revenue] = await Promise.all([ + prisma.sessionReplay.count({ where: { tenancyId: tenancy.id } }), + prisma.sessionReplay.count({ + where: { + tenancyId: tenancy.id, + startedAt: { gte: since }, + }, + }), + prisma.sessionReplay.findMany({ + where: { + tenancyId: tenancy.id, + startedAt: { gte: since }, + }, + select: { + startedAt: true, + lastEventAt: true, + }, + }), + prisma.projectUser.count({ where: { tenancyId: tenancy.id } }), + prisma.subscriptionInvoice.aggregate({ + where: { tenancyId: tenancy.id, amountTotal: { not: null } }, + _sum: { amountTotal: true }, + }), + ]); + + const avgSessionSeconds = replayRows.length > 0 + ? replayRows.reduce((sum, row) => sum + Math.max(0, row.lastEventAt.getTime() - row.startedAt.getTime()), 0) / replayRows.length / 1000 + : 0; + const revenuePerVisitor = visitorCount > 0 + ? Number((((revenue._sum.amountTotal ?? 0) / 100) / visitorCount).toFixed(2)) + : 0; + + return { + total, + recent, + avgSessionSeconds: Number(avgSessionSeconds.toFixed(1)), + visitorCount, + revenuePerVisitor, + }; + })(), + ]); + + const pvRows: { day: string, cnt: number }[] = await pageViewResult.json(); + const clRows: { day: string, cnt: number }[] = await clickResult.json(); + + const pvByDay = new Map(); + for (const row of pvRows) { + const key = new Date(row.day + 'Z').toISOString().split('T')[0]; + pvByDay.set(key, Number(row.cnt)); + } + const clByDay = new Map(); + for (const row of clRows) { + const key = new Date(row.day + 'Z').toISOString().split('T')[0]; + clByDay.set(key, Number(row.cnt)); + } + + const dailyPageViews: DataPoints = []; + const dailyClicks: DataPoints = []; + for (let i = 0; i <= 30; i++) { + const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + 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 }); + } + + const referrers: { referrer: string | null, cnt: number }[] = await referrerResult.json(); + const topRegionRows: { country_code: string | null, region_code: string | null, cnt: number }[] = await topRegionResult.json(); + const onlineRows: { online: number }[] = await onlineResult.json(); + + return { + daily_page_views: dailyPageViews, + daily_clicks: dailyClicks, + total_replays: replayResult.total, + recent_replays: replayResult.recent, + visitors: replayResult.visitorCount, + avg_session_seconds: replayResult.avgSessionSeconds, + online_live: Number(onlineRows[0]?.online ?? 0), + revenue_per_visitor: replayResult.revenuePerVisitor, + top_referrers: referrers.map((row) => ({ + referrer: row.referrer ?? '(direct)', + visitors: Number(row.cnt), + })), + top_region: topRegionRows[0] ? { + country_code: topRegionRows[0].country_code, + region_code: topRegionRows[0].region_code, + count: Number(topRegionRows[0].cnt), + } : null, + }; + } catch { + // Analytics may not be enabled for all projects + return { + daily_page_views: [] as DataPoints, + daily_clicks: [] as DataPoints, + total_replays: 0, + recent_replays: 0, + visitors: 0, + avg_session_seconds: 0, + online_live: 0, + revenue_per_visitor: 0, + top_referrers: [], + top_region: null, + }; + } +} + +// ── Development-mode fallback data for ClickHouse-dependent metrics ────────── +// In development, ClickHouse often has no event data (no $token-refresh, +// $page-view, etc.), so charts appear empty. These functions generate +// realistic-looking synthetic data so the dashboard is usable in dev. + +function seededPrng(seed: number) { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) & 0xffffffff; + return (s >>> 0) / 0xffffffff; + }; +} + +function generateDevDauSplit(now: Date, totalUsers: number): ActivitySplit { + if (getNodeEnvironment() === 'production') { + return createEmptySplitSeries([]); + } + + const rand = seededPrng(12345); + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); + const days: string[] = []; + for (let i = 0; i <= 30; i++) { + days.push(new Date(since.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); + } + + const base = Math.max(2, Math.floor(totalUsers * 0.15)); + const split = createEmptySplitSeries(days); + + for (let i = 0; i < days.length; i++) { + const dayOfWeek = new Date(days[i]).getDay(); + const weekendFactor = (dayOfWeek === 0 || dayOfWeek === 6) ? 0.6 : 1.0; + const trendFactor = 0.7 + (i / days.length) * 0.6; + const noise = 0.7 + rand() * 0.6; + + const total = Math.max(1, Math.round(base * weekendFactor * trendFactor * noise)); + const newPct = 0.15 + rand() * 0.2; + const reactivatedPct = 0.05 + rand() * 0.15; + const retainedPct = 1 - newPct - reactivatedPct; + + split.new[i].activity = Math.max(0, Math.round(total * newPct)); + split.reactivated[i].activity = Math.max(0, Math.round(total * reactivatedPct)); + split.retained[i].activity = Math.max(0, Math.round(total * retainedPct)); + split.total[i].activity = split.new[i].activity + split.reactivated[i].activity + split.retained[i].activity; + } + + return split; +} + +function generateDevAnalyticsOverview(now: Date, totalUsers: number) { + if (getNodeEnvironment() === 'production') return null; + + const rand = seededPrng(67890); + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); + + const dailyPageViews: DataPoints = []; + const dailyClicks: DataPoints = []; + for (let i = 0; i <= 30; i++) { + const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + const key = day.toISOString().split('T')[0]; + const dayOfWeek = day.getDay(); + const weekendFactor = (dayOfWeek === 0 || dayOfWeek === 6) ? 0.55 : 1.0; + const trendFactor = 0.8 + (i / 30) * 0.4; + const pvNoise = 0.6 + rand() * 0.8; + const clNoise = 0.5 + rand() * 1.0; + + const pv = Math.max(3, Math.round(totalUsers * 2.5 * weekendFactor * trendFactor * pvNoise)); + const cl = Math.max(1, Math.round(pv * 0.3 * clNoise)); + dailyPageViews.push({ date: key, activity: pv }); + dailyClicks.push({ date: key, activity: cl }); + } + + const visitors = Math.max(totalUsers, Math.round(totalUsers * 1.8 + rand() * totalUsers * 0.5)); + const avgSessionSeconds = 180 + Math.round(rand() * 440); + const onlineLive = Math.max(1, Math.round(totalUsers * 0.05 + rand() * 3)); + const revenuePerVisitor = Number((rand() * 4.5 + 0.5).toFixed(2)); + + return { + daily_page_views: dailyPageViews, + daily_clicks: dailyClicks, + total_replays: Math.round(visitors * 0.3), + recent_replays: Math.round(visitors * 0.1), + visitors, + avg_session_seconds: avgSessionSeconds, + online_live: onlineLive, + revenue_per_visitor: revenuePerVisitor, + top_referrers: [ + { referrer: 'google.com', visitors: Math.round(visitors * 0.32 + rand() * 10) }, + { referrer: 'github.com', visitors: Math.round(visitors * 0.18 + rand() * 8) }, + { referrer: 'twitter.com', visitors: Math.round(visitors * 0.12 + rand() * 5) }, + { referrer: 'producthunt.com', visitors: Math.round(visitors * 0.08 + rand() * 4) }, + { referrer: '(direct)', visitors: Math.round(visitors * 0.06 + rand() * 3) }, + ], + top_region: { + country_code: 'US', + region_code: 'CA', + count: Math.round(visitors * 0.25), + }, + }; +} + +// ── Auth Extra Aggregates ──────────────────────────────────────────────────── + +async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) { + const prisma = await getPrismaClientForTenancy(tenancy); + + const [totalUsers, verifiedUsers, anonymousUsers, totalTeams] = await Promise.all([ + prisma.projectUser.count({ where: { tenancyId: tenancy.id } }), + prisma.projectUser.count({ + where: { + tenancyId: tenancy.id, + contactChannels: { some: { type: 'EMAIL', isVerified: true } }, + }, + }), + prisma.projectUser.count({ where: { tenancyId: tenancy.id, isAnonymous: true } }), + prisma.team.count({ where: { tenancyId: tenancy.id } }), + ]); + + const filteredTotal = includeAnonymous ? totalUsers : totalUsers - anonymousUsers; + + const [dailyActiveUsersSplit, dailyActiveTeamsSplit] = await Promise.all([ + loadDailyActiveUsersSplit(tenancy, now, includeAnonymous), + loadDailyActiveTeamsSplit(tenancy, now), + ]); + + return { + verified_users: verifiedUsers, + unverified_users: filteredTotal - verifiedUsers, + anonymous_users: anonymousUsers, + total_teams: totalTeams, + daily_active_users_split: dailyActiveUsersSplit, + daily_active_teams_split: dailyActiveTeamsSplit, + }; +} + export const GET = createSmartRouteHandler({ metadata: { hidden: true, @@ -224,6 +972,11 @@ export const GET = createSmartRouteHandler({ recently_registered: yupMixed().defined(), recently_active: yupMixed().defined(), login_methods: yupMixed().defined(), + // Extended cross-product aggregates + auth_overview: yupMixed().defined(), + payments_overview: yupMixed().defined(), + email_overview: yupMixed().defined(), + analytics_overview: yupMixed().defined(), }).defined(), }), handler: async (req) => { @@ -239,7 +992,11 @@ export const GET = createSmartRouteHandler({ usersByCountry, recentlyRegistered, recentlyActive, - loginMethods + loginMethods, + authOverview, + paymentsOverview, + emailOverview, + analyticsOverview, ] = await Promise.all([ prisma.projectUser.count({ where: { tenancyId: req.auth.tenancy.id, ...(includeAnonymous ? {} : { isAnonymous: false }) }, @@ -261,8 +1018,28 @@ 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), + loadEmailOverview(req.auth.tenancy), + loadAnalyticsOverview(req.auth.tenancy, now), ] as const); + // In dev, ClickHouse may have no events — fill in realistic fallback data + const dauSplitIsEmpty = authOverview.daily_active_users_split.total.every( + (d: { activity: number }) => d.activity === 0 + ); + const finalAuthOverview = dauSplitIsEmpty && getNodeEnvironment() !== 'production' + ? { + ...authOverview, + daily_active_users_split: generateDevDauSplit(now, totalUsers), + } + : authOverview; + + const referrersEmpty = (analyticsOverview.top_referrers as { referrer: string, visitors: number }[]).length === 0; + const finalAnalyticsOverview = referrersEmpty && getNodeEnvironment() !== 'production' + ? (generateDevAnalyticsOverview(now, totalUsers) ?? analyticsOverview) + : analyticsOverview; + return { statusCode: 200, bodyType: "json", @@ -274,6 +1051,10 @@ export const GET = createSmartRouteHandler({ recently_registered: recentlyRegistered, recently_active: recentlyActive, login_methods: loginMethods, + auth_overview: finalAuthOverview, + payments_overview: paymentsOverview, + email_overview: emailOverview, + analytics_overview: finalAnalyticsOverview, } }; }, diff --git a/apps/dashboard/DESIGN-GUIDE.md b/apps/dashboard/DESIGN-GUIDE.md index 43f05c8fd9..b52c8875a1 100644 --- a/apps/dashboard/DESIGN-GUIDE.md +++ b/apps/dashboard/DESIGN-GUIDE.md @@ -806,3 +806,49 @@ 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 +--- + +## 13) Frontend Design Reference + +Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. + +This guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + +The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. + +### Design Thinking + +Before coding, understand the context and commit to a bold aesthetic direction: + +- **Purpose**: What problem does this interface solve? Who uses it? +- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. Use these for inspiration but design one that is true to the aesthetic direction. +- **Constraints**: Technical requirements (framework, performance, accessibility). +- **Differentiation**: What makes this unforgettable? What's the one thing someone will remember? + +**Critical**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. + +Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: + +- Production-grade and functional +- Visually striking and memorable +- Cohesive with a clear aesthetic point-of-view +- Meticulously refined in every detail + +### Frontend Aesthetics Guidelines + +Focus on: + +- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. +- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. +- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (`animation-delay`) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. +- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. +- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. + +Never use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. Never converge on common choices (Space Grotesk, for example) across generations. + +**Important**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. + +Remember: You are capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 14d4039a6d..a4cf5969a3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -6,8 +6,8 @@ import { import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { UserAvatar } from '@stackframe/stack'; import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates'; -import { useState } from "react"; -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; +import { useId, useState } from "react"; +import { Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; export type TimeRange = '7d' | '30d' | 'all'; @@ -66,7 +66,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => { }; // Helper function to filter datapoints by time range -function filterDatapointsByTimeRange(datapoints: DataPoint[], timeRange: TimeRange): DataPoint[] { +export function filterDatapointsByTimeRange(datapoints: DataPoint[], timeRange: TimeRange): DataPoint[] { if (timeRange === '7d') { return datapoints.slice(-7); } @@ -77,7 +77,7 @@ function filterDatapointsByTimeRange(datapoints: DataPoint[], timeRange: TimeRan } // Shared BarChart component to reduce duplication -function ActivityBarChart({ +export function ActivityBarChart({ datapoints, config, height, @@ -88,6 +88,7 @@ function ActivityBarChart({ height?: number, compact?: boolean, }) { + const id = useId(); return ( ) => { + if (!active || !payload?.length) return null; + + const row = payload[0].payload as StackedDataPoint; + const date = new Date(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 }, + ]; + + return ( +
+
+ + {formattedDate} + + {segments.map((seg) => ( +
+ + + {stackedChartConfig[seg.key].label} + + + {seg.value.toLocaleString()} + +
+ ))} +
+
+ ); +}; + +export function StackedBarChartDisplay({ + datapoints, + height, + compact = false, +}: { + datapoints: StackedDataPoint[], + height?: number, + compact?: boolean, +}) { + const id = useId(); + return ( + + + + } + cursor={{ fill: "hsl(var(--muted-foreground))", opacity: 0.08, radius: 4 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + {datapoints.map((entry, index) => ( + + ))} + + + {datapoints.map((entry, index) => ( + + ))} + + + {datapoints.map((entry, index) => ( + + ))} + + + { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; + } + return value; + }} + /> + + + ); +} + export type GradientColor = "blue" | "purple" | "green" | "orange" | "slate" | "cyan"; export function ChartCard({ @@ -490,6 +624,415 @@ 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, +}: { + title: string, + series: CorrelationSeries[], + gradientColor?: GradientColor, + className?: string, + height?: number, + compact?: boolean, + timeRange: TimeRange, +}) { + // 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 = (() => { + if (timeRange === '7d') return sortedDates.slice(-7); + if (timeRange === '30d') return sortedDates.slice(-30); + return sortedDates; + })(); + + 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 = new Date(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 => ( + + ))} + + + )} +
+ + ); +} + export type AuthMethodDatapoint = { method: string, count: number, 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 e4918d8e68..d1e08a8103 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 @@ -3,199 +3,508 @@ import { AppIcon } from "@/components/app-square"; 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 { cn, Typography } from "@/components/ui"; +import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; -import { CaretUpIcon, CompassIcon, DotsThreeIcon, GlobeIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { DesignListItemRow } from "@/components/design-components/list"; +import { CompassIcon, GlobeIcon, SquaresFourIcon } 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 { UserAvatar, useUser } from "@stackframe/stack"; +import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; 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, useLayoutEffect, useMemo, useRef, useState } 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', +import { useAdminApp, useProjectId } from "../use-admin-app"; +import { GlobeSectionWithData } from "./globe-section-with-data"; +import { + ActivityBarChart, + ChartCard, + DataPoint, + DonutChartDisplay, + filterDatapointsByTimeRange, + GradientColor, + LineChartDisplayConfig, + StackedBarChartDisplay, + StackedDataPoint, + TabbedMetricsCard, + TimeRange, + TimeRangeToggle, +} from "./line-chart"; +import { MetricsLoadingFallback } from "./metrics-loading"; + +// ── Chart configs ──────────────────────────────────────────────────────────── + +const dailySignUpsConfig: LineChartDisplayConfig = { + name: 'Daily Sign-Ups', + chart: { + activity: { + label: "Sign-Ups", + theme: { light: "hsl(221, 83%, 53%)", dark: "hsl(240, 71%, 70%)" }, + }, + }, +}; + +const dailyEmailsConfig: LineChartDisplayConfig = { + name: 'Emails Sent', + chart: { + activity: { + label: "Emails", + theme: { light: "hsl(38, 92%, 50%)", dark: "hsl(38, 92%, 65%)" }, + }, + }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +type SplitSeries = { + new?: DataPoint[], + reactivated?: DataPoint[], + retained?: DataPoint[], +}; + +function formatUsdFromCents(cents: number): string { + return `$${(cents / 100).toLocaleString(undefined, { maximumFractionDigits: 0 })}`; } -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[], +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`; } -const DEFAULT_CONFIG: DashboardConfig = { - enabledWidgets: AVAILABLE_WIDGETS.filter(w => w.defaultEnabled).map(w => w.id), - widgetOrder: AVAILABLE_WIDGETS.map(w => w.id), -}; +function sumRange(points: DataPoint[], range: TimeRange): number { + return filterDatapointsByTimeRange(points, range).reduce((s, p) => s + p.activity, 0); +} -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 mergeSplitToStacked(split: SplitSeries, range: TimeRange): StackedDataPoint[] { + const newPts = filterDatapointsByTimeRange(split.new ?? [], range); + const reactivatedPts = filterDatapointsByTimeRange(split.reactivated ?? [], range); + const retainedPts = filterDatapointsByTimeRange(split.retained ?? [], range); - return { enabledWidgets, widgetOrder }; + const dateMap = new Map(); + for (const arr of [newPts, reactivatedPts, retainedPts]) { + for (const p of arr) { + if (!dateMap.has(p.date)) { + dateMap.set(p.date, { date: p.date, new: 0, reactivated: 0, retained: 0 }); + } } - } catch (e) { - console.error('Failed to load dashboard config:', e); } - return DEFAULT_CONFIG; + for (const p of newPts) { + const entry = dateMap.get(p.date); + if (entry) entry.new = p.activity; + } + for (const p of reactivatedPts) { + const entry = dateMap.get(p.date); + if (entry) entry.reactivated = p.activity; + } + for (const p of retainedPts) { + const entry = dateMap.get(p.date); + if (entry) entry.retained = p.activity; + } + + return [...dateMap.values()].sort((a, b) => stringCompare(a.date, b.date)); } -// 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); - } +// ── Compact dual-value stat card ───────────────────────────────────────────── + +function DualStatCard({ + label, + value, + subLabel, + subValue, + gradientColor = "blue", +}: { + label: string, + value: string | number, + subLabel: string, + subValue: string | number, + gradientColor?: GradientColor, +}) { + return ( + +
+ + {label} + +
+
+ {typeof value === 'number' ? value.toLocaleString() : value} +
+
+ {subLabel} + + {typeof subValue === 'number' ? subValue.toLocaleString() : subValue} + +
+
+
+
+ ); } -const dailySignUpsConfig = { - name: 'Daily Sign-Ups', - chart: { - activity: { - label: "Activity", - theme: { - light: "hsl(221, 83%, 53%)", - dark: "hsl(240, 71%, 70%)", - }, - }, - } -} satisfies LineChartDisplayConfig; +// ── Tabbed DAU stacked chart + recently active list ────────────────────────── -const dauConfig = { - name: 'Daily Active Users', - chart: { - activity: { - label: "Activity", - theme: { - light: "hsl(180, 95%, 53%)", - dark: "hsl(200, 91%, 70%)", - }, - }, - } -} satisfies LineChartDisplayConfig; +type UserListItem = { + id: string, + profile_image_url?: string | null, + display_name?: string | null, + primary_email?: string | null, + last_active_at_millis?: number | null, + signed_up_at_millis?: number | null, +}; -function TotalUsersDisplay({ includeAnonymous, minimal = false }: { includeAnonymous: boolean, minimal?: boolean }) { - const adminApp = useAdminApp(); - const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous); +function TabbedDauCard({ + dauSplit, + recentlyActive, + timeRange, + projectId, + router, + compact = false, +}: { + dauSplit: SplitSeries, + recentlyActive: UserListItem[], + timeRange: TimeRange, + projectId: string, + router: ReturnType, + compact?: boolean, +}) { + const [view, setView] = useState<'chart' | 'list'>('chart'); - const totalUsers = data.total_users || 0; + const dauStacked = useMemo(() => mergeSplitToStacked(dauSplit, timeRange), [dauSplit, timeRange]); - if (minimal) { - return <>{totalUsers.toLocaleString()}; - } + const activeTabColor = "bg-cyan-500 dark:bg-[hsl(200,91%,70%)]"; + + const tabs: Array<{ id: 'chart' | 'list', label: string }> = [ + { id: 'chart', label: 'Daily Active Users' }, + { id: 'list', label: 'Recently Active' }, + ]; return ( - - {totalUsers.toLocaleString()} users - + +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ +
+ {view === 'chart' && ( + dauStacked.length === 0 ? ( +
+ No activity data +
+ ) : ( +
+ +
+ ) + )} + {view === 'list' && ( +
+ {recentlyActive.length === 0 ? ( +
+ No recently active users +
+ ) : ( +
+ {recentlyActive.map((user) => ( + + ))} +
+ )} +
+ )} +
+
); } +// ── Tabbed Emails card (bar chart + recent list) ───────────────────────────── + +function TabbedEmailsCard({ + chartData, + recentEmails, + timeRange, + compact = false, +}: { + chartData: DataPoint[], + recentEmails: Array<{ id: string, subject: string, status: string }>, + timeRange: TimeRange, + compact?: boolean, +}) { + const [view, setView] = useState<'chart' | 'list'>('chart'); + const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange); -// 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 activeTabColor = "bg-orange-500 dark:bg-[hsl(240,71%,70%)]"; - useResizeObserver(ref, (entry) => setWidth(entry.contentRect.width)); + return ( + +
+
+ {(['chart', 'list'] as const).map((tab) => ( + + ))} +
+
+
+ {view === 'chart' ? ( + filteredDatapoints.length === 0 ? ( +
+ No email data for this period +
+ ) : ( + + ) + ) : ( +
+ {recentEmails.length === 0 ? ( +
+ No recent emails +
+ ) : ( +
+ {recentEmails.map((email) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +} - const gap = 8; - const minItemWidth = 90; - const itemsPerRow = Math.max(1, Math.floor((width + gap) / (minItemWidth + gap))); - const maxRows = 2; - const maxItems = itemsPerRow * maxRows; +// ── Email breakdown with rate footer ───────────────────────────────────────── - // 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; +function EmailBreakdownCard({ + deliverabilityStatus, + bounceRate, + clickRate, +}: { + deliverabilityStatus: Record, + 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}%
+
+
+
+ + ); +} + +// ── Top Referrers with analytics footer ────────────────────────────────────── + +function ReferrersWithAnalyticsCard({ + topReferrers, + avgSession, + revenuePerVisitor, +}: { + topReferrers: Array<{ referrer: string, visitors: number }>, + avgSession: number, + revenuePerVisitor: number, +}) { + return ( + +
+ Top Referrers +
+
+ {topReferrers.length === 0 ? ( +
+ No referrer data +
+ ) : ( +
+ {topReferrers.map((item) => { + const max = topReferrers[0].visitors; + return ( +
+
0 ? `${(item.visitors / max) * 100}%` : '0%' }} + /> + {item.referrer} + {item.visitors.toLocaleString()} +
+ ); + })} +
+ )} + +
+
+
Avg. Session
+
{formatSeconds(avgSession)}
+
+
+
Revenue / User
+
${revenuePerVisitor}
+
+
+
+ + ); +} + +// ── Quick Access Apps ────────────────────────────────────────────────────────── + +function QuickAccessApps({ projectId, installedApps }: { projectId: string, installedApps: AppId[] }) { return (
-
+
- +
- + Quick Access
+ {installedApps.length === 0 ? ( -
+
No apps installed
) : ( -
- {displayApps.map((appId) => { +
+ {installedApps.map((appId) => { const appFrontend = ALL_APPS_FRONTEND[appId]; - const app = ALL_APPS[appId]; const appPath = getAppPath(projectId, appFrontend); + const app = ALL_APPS[appId]; return ( -
+
{app.displayName} @@ -203,134 +512,36 @@ function AppsWidget({ installedApps, projectId }: { installedApps: AppId[], proj ); })} - {/* Explore Apps - always shown before See all/Less */} + -
+
- +
- + Explore - {showSeeAll && ( - - )} - {showShowLess && ( - - )}
)}
); } -function DailyActiveUsersWidget({ - data, - projectId, - router, - timeRange -}: { - data: any, - projectId: string, - router: ReturnType, - timeRange: TimeRange, -}) { - return ( - - ); -} - -function DailySignUpsWidget({ - data, - projectId, - router, - timeRange -}: { - data: any, - projectId: string, - router: ReturnType, - timeRange: TimeRange, -}) { - return ( - - ); -} +// ── Main page ───────────────────────────────────────────────────────────────── 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 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 ( void }) { fillWidth > }> - + ); } -function MetricsContent({ - includeAnonymous, - installedApps, - timeRange, - dashboardConfig, -}: { - includeAnonymous: boolean, - installedApps: AppId[], - timeRange: TimeRange, - dashboardConfig: DashboardConfig, -}) { +// ── Metrics content ────────────────────────────────────────────────────────── + +function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boolean, timeRange: TimeRange }) { 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 installedApps = useMemo( + () => typedEntries(config.apps.installed) + .filter(([_, appConfig]) => appConfig?.enabled === true) + .map(([appId]) => appId as AppId), + [config.apps.installed] + ); - 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 ?? []) as Array<{ id: string, subject: string, status: string }>; + const topReferrers = (analytics.top_referrers ?? []) as Array<{ referrer: string, visitors: number }>; - const showGlobe = isWidgetEnabled('globe'); + const signUpsInRange = sumRange(data.daily_users ?? [], timeRange); + const emailsInRange = sumRange(email.daily_emails ?? [], timeRange); - // Track grid container width to calculate globe column width + // ── Globe visibility ────────────────────────────────────────────────────── const gridContainerRef = useRef(null); - const [gridContainerSize, setGridContainerSize] = useState(); - + const [gridContainerWidth, setGridContainerWidth] = useState(0); useLayoutEffect(() => { - setGridContainerSize(gridContainerRef.current?.getBoundingClientRect()); + setGridContainerWidth(gridContainerRef.current?.getBoundingClientRect().width ?? 0); }, []); + useResizeObserver(gridContainerRef, (entry) => setGridContainerWidth(entry.contentRect.width)); - 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 globeColumnWidth = calculateGlobeColumnWidth(); - - // Hide globe and total users section when width is less than 352.5px + // Show the globe when the 5-column slot is wide enough to look good const GLOBE_MIN_WIDTH = 352.5; - const shouldShowGlobeSection = showGlobe && globeColumnWidth >= GLOBE_MIN_WIDTH; - - // 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); - - // Track viewport height - const updateViewHeight = () => setViewHeight(window.innerHeight - 180); // same as calc(100vh - 180px) - updateViewHeight(); - window.addEventListener('resize', updateViewHeight); - - return () => { - mediaQuery.removeEventListener('change', handler); - window.removeEventListener('resize', updateViewHeight); - }; - }, []); - - // 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; - } - } - }; - - // 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'); + const globeColumnWidth = (() => { + if (!gridContainerWidth) return 0; + const gap = 20; + const availableWidth = gridContainerWidth - gap * 11; + return (availableWidth / 12) * 5 + gap * 4; + })(); + const shouldShowGlobe = globeColumnWidth >= GLOBE_MIN_WIDTH; return ( -
+
+ + {/* ────────────────────────────────────────────────────────────────────── + HERO — Globe + KPIs + Daily Active Users (stacked bar) + ────────────────────────────────────────────────────────────────────── */}
- {/* Left Column: Globe - Hidden on mobile */} - {showGlobe && shouldShowGlobeSection && ( -
- {/* Globe takes full space */} + {shouldShowGlobe && ( +
- {/* Total Users overlay */}
@@ -516,79 +633,92 @@ function MetricsContent({
- - - + {(data.total_users ?? 0).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 - + {/* Mobile total users */} + {!shouldShowGlobe && ( +
+
)} + + {/* ────────────────────────────────────────────────────────────────────── + QUICK ACCESS — App shortcuts + ────────────────────────────────────────────────────────────────────── */} + + + {/* ────────────────────────────────────────────────────────────────────── + ROW 2 — Daily Sign-ups + Emails trend + ────────────────────────────────────────────────────────────────────── */} +
+ + +
+ + {/* ────────────────────────────────────────────────────────────────────── + ROW 3 — Breakdown + ────────────────────────────────────────────────────────────────────── */} +
+ + + +
); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 33255ece04..a4ff380fb5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -248,3 +248,88 @@ it("should handle mixed auth methods excluding anonymous users", async ({ expect await ensureAnonymousUsersAreStillExcluded(response); }); + +it("should return cross-product aggregates in the metrics response", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + } + }); + + await wait(2000); + + const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); + expect(response.status).toBe(200); + + // Core auth fields must always be present + expect(response.body).toHaveProperty('total_users'); + expect(response.body).toHaveProperty('daily_users'); + expect(response.body).toHaveProperty('daily_active_users'); + expect(response.body).toHaveProperty('login_methods'); + + // Extended aggregate groups must always be present (even for sparse projects) + expect(response.body).toHaveProperty('auth_overview'); + expect(response.body).toHaveProperty('payments_overview'); + expect(response.body).toHaveProperty('email_overview'); + expect(response.body).toHaveProperty('analytics_overview'); + + // Auth overview shape + const authOverview = response.body.auth_overview; + expect(typeof authOverview.verified_users).toBe('number'); + expect(typeof authOverview.unverified_users).toBe('number'); + expect(typeof authOverview.anonymous_users).toBe('number'); + expect(typeof authOverview.total_teams).toBe('number'); + + // Payments overview shape + const paymentsOverview = response.body.payments_overview; + expect(typeof paymentsOverview.subscriptions_by_status).toBe('object'); + expect(typeof paymentsOverview.active_subscription_count).toBe('number'); + expect(typeof paymentsOverview.total_one_time_purchases).toBe('number'); + expect(Array.isArray(paymentsOverview.daily_subscriptions)).toBe(true); + + // Email overview shape + const emailOverview = response.body.email_overview; + expect(typeof emailOverview.emails_by_status).toBe('object'); + expect(typeof emailOverview.total_emails).toBe('number'); + expect(Array.isArray(emailOverview.daily_emails)).toBe(true); + + // Analytics overview shape (may have empty arrays for sparse projects) + const analyticsOverview = response.body.analytics_overview; + expect(Array.isArray(analyticsOverview.daily_page_views)).toBe(true); + expect(Array.isArray(analyticsOverview.daily_clicks)).toBe(true); + expect(typeof analyticsOverview.total_replays).toBe('number'); + expect(typeof analyticsOverview.recent_replays).toBe('number'); +}); + +it("should return correct auth_overview breakdown including teams", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + } + }); + + await InternalApiKey.createAndSetProjectKeys(); + + // Create a verified user + const verifiedMailbox = createMailbox(); + backendContext.set({ mailbox: verifiedMailbox }); + await Auth.Otp.signIn(); + + // Create an anonymous user + await Auth.Anonymous.signUp(); + + await wait(2000); + + const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); + expect(response.status).toBe(200); + + const authOverview = response.body.auth_overview; + + // Total = 1 regular (verified by OTP/magic-link) + 1 anonymous + // anonymous_users count should be 1 + expect(authOverview.anonymous_users).toBeGreaterThanOrEqual(1); + + // verified + unverified should match non-anonymous total + const nonAnonFromOverview = authOverview.verified_users + authOverview.unverified_users; + expect(nonAnonFromOverview).toBeGreaterThanOrEqual(1); +}); diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 75adc92716..e721e93196 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -3,6 +3,12 @@ Q: How are the development ports derived now that NEXT_PUBLIC_STACK_PORT_PREFIX exists? A: Host ports use `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}` plus the two-digit suffix (e.g., Postgres is `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28`, Inbucket SMTP `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}29`, POP3 `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}30`, and OTLP `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}31` by default). +Q: How do you expand the internal metrics endpoint to include cross-product aggregates? +A: Extend the existing `/api/v1/internal/metrics` route (in `apps/backend/src/app/api/latest/internal/metrics/route.tsx`) by adding new parallel async queries for each product domain. Add `auth_overview`, `payments_overview`, `email_overview`, and `analytics_overview` to the response schema and the handler. These are loaded via dedicated helper functions that use Prisma (for payments/emails/teams/users) and ClickHouse (for page views, clicks). The client-side `useMetrics` hook already returns `any`, so new fields flow through automatically. + +Q: How can duplicate Recharts keys like `rectangle-25-10-0` appear on overview pages with multiple charts? +A: Recharts can generate colliding internal SVG IDs/keys across chart instances when they share default ID generation paths. Set explicit unique IDs (or instance-unique IDs) on chart roots and avoid duplicated chart-def namespaces to prevent repeated internal keys and console errors. + Q: How can I show helper text beneath metadata text areas in the dashboard? A: Use the shared `TextAreaField` component's `helperText` prop in `apps/dashboard/src/components/form-fields.tsx`; it now renders the helper content in a secondary Typography line under the textarea. From 388796c95a736a91e544ce830295353af0cdd21d Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 10 Mar 2026 09:32:13 -0700 Subject: [PATCH 02/30] Better design, still a lot of bugs, not final --- .../app/api/latest/internal/metrics/route.tsx | 28 +- .../[projectId]/(overview)/line-chart.tsx | 310 ++++++++++++--- .../[projectId]/(overview)/metrics-page.tsx | 367 +++++++++--------- 3 files changed, 477 insertions(+), 228 deletions(-) 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 1de5cb9adf..8009256b05 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -869,6 +869,8 @@ function generateDevAnalyticsOverview(now: Date, totalUsers: number) { const dailyPageViews: DataPoints = []; const dailyClicks: DataPoints = []; + const dailyRevenue: Array<{ date: string, new_cents: number, refund_cents: number }> = []; + const dailyVisitors: DataPoints = []; for (let i = 0; i <= 30; i++) { const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); const key = day.toISOString().split('T')[0]; @@ -882,22 +884,46 @@ function generateDevAnalyticsOverview(now: Date, totalUsers: number) { const cl = Math.max(1, Math.round(pv * 0.3 * clNoise)); dailyPageViews.push({ date: key, activity: pv }); dailyClicks.push({ date: key, activity: cl }); + + const baseRevenue = Math.round(300 + totalUsers * 8 * weekendFactor * trendFactor * (0.7 + rand() * 0.6)); + const refundRate = 0.15 + rand() * 0.25; + const refundCents = Math.round(baseRevenue * refundRate); + dailyRevenue.push({ date: key, new_cents: baseRevenue, refund_cents: refundCents }); + + const vis = Math.max(5, Math.round(pv * (0.4 + rand() * 0.2))); + dailyVisitors.push({ date: key, activity: vis }); } const visitors = Math.max(totalUsers, Math.round(totalUsers * 1.8 + rand() * totalUsers * 0.5)); const avgSessionSeconds = 180 + Math.round(rand() * 440); const onlineLive = Math.max(1, Math.round(totalUsers * 0.05 + rand() * 3)); - const revenuePerVisitor = Number((rand() * 4.5 + 0.5).toFixed(2)); + const totalRevenueCents = dailyRevenue.reduce((sum, d) => sum + d.new_cents, 0); + const revenuePerVisitor = visitors > 0 ? Number(((totalRevenueCents / 100) / visitors).toFixed(2)) : 0; + const bounceRate = Number((55 + rand() * 30).toFixed(1)); + const conversionRate = Number((0.2 + rand() * 0.8).toFixed(2)); return { daily_page_views: dailyPageViews, daily_clicks: dailyClicks, + daily_revenue: dailyRevenue, + daily_visitors: dailyVisitors, total_replays: Math.round(visitors * 0.3), recent_replays: Math.round(visitors * 0.1), visitors, + total_revenue_cents: totalRevenueCents, avg_session_seconds: avgSessionSeconds, online_live: onlineLive, revenue_per_visitor: revenuePerVisitor, + bounce_rate: bounceRate, + conversion_rate: conversionRate, + deltas: { + visitors: Number((-15 + rand() * 30).toFixed(1)), + revenue: Number((-25 + rand() * 50).toFixed(1)), + conversion_rate: Number((-20 + rand() * 40).toFixed(1)), + revenue_per_visitor: Number((-20 + rand() * 40).toFixed(1)), + bounce_rate: Number((-10 + rand() * 20).toFixed(1)), + session_time: Number((-15 + rand() * 30).toFixed(1)), + }, top_referrers: [ { referrer: 'google.com', visitors: Math.round(visitors * 0.32 + rand() * 10) }, { referrer: 'github.com', visitors: Math.round(visitors * 0.18 + rand() * 8) }, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index a4cf5969a3..372d62609f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -7,7 +7,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from " import { UserAvatar } from '@stackframe/stack'; import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates'; import { useId, useState } from "react"; -import { Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; +import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; export type TimeRange = '7d' | '30d' | 'all'; @@ -150,7 +150,7 @@ export 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, @@ -289,7 +289,7 @@ export function StackedBarChartDisplay({ 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); @@ -304,6 +304,208 @@ export function StackedBarChartDisplay({ ); } +// ── Combined bar+line analytics chart ───────────────────────────────────────── + +export type ComposedDataPoint = { + date: string, + new_cents: number, + refund_cents: number, + visitors: number, +}; + +const composedChartConfig: ChartConfig = { + visitors: { + label: "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 = new Date(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }) + : row.date; + + const revenueDollars = (row.new_cents / 100); + const revenuePerVisitor = row.visitors > 0 ? (revenueDollars / row.visitors) : 0; + + return ( +
+
+ + {formattedDate} + + +
+
+ + Visitors +
+ + {row.visitors.toLocaleString()} + +
+ +
+
+ + Revenue +
+ + ${revenueDollars.toLocaleString(undefined, { maximumFractionDigits: 0 })} + +
+ +
+
+ Revenue/visitor + + ${revenuePerVisitor.toFixed(2)} + +
+
+
+
+ ); +} + +export function ComposedAnalyticsChart({ + datapoints, + height, + compact = false, +}: { + datapoints: ComposedDataPoint[], + height?: number, + compact?: boolean, +}) { + const id = useId(); + const maxVisitors = Math.max(...datapoints.map(d => d.visitors), 1); + const maxRevenueCents = Math.max(...datapoints.map(d => d.new_cents), 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 ( + + + + + + + + + + + } + cursor={{ stroke: "hsl(var(--muted-foreground))", strokeOpacity: 0.3, strokeWidth: 1 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} + /> + + + + + { + const date = new Date(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"; export function ChartCard({ @@ -338,7 +540,7 @@ export function ChartCard({ `}
-
+
{children}
@@ -451,7 +653,7 @@ export function TabbedMetricsCard({ const hoverAccentClass = hoverAccentColors[gradientColor]; return ( - +
- ))} + {timeLabel && ( +
+ {timeLabel} +
+ )} + + ); + })}
)}
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 d1e08a8103..fae97f456a 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 @@ -7,11 +7,10 @@ import { cn, Typography } from "@/components/ui"; import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { DesignListItemRow } from "@/components/design-components/list"; -import { CompassIcon, GlobeIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { CompassIcon, EnvelopeIcon, EnvelopeOpenIcon, GlobeIcon, SquaresFourIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; import useResizeObserver from '@react-hook/resize-observer'; -import { UserAvatar, useUser } from "@stackframe/stack"; +import { useUser } from "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; -import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { Suspense, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -21,13 +20,13 @@ import { GlobeSectionWithData } from "./globe-section-with-data"; import { ActivityBarChart, ChartCard, + ComposedAnalyticsChart, + ComposedDataPoint, DataPoint, DonutChartDisplay, filterDatapointsByTimeRange, GradientColor, LineChartDisplayConfig, - StackedBarChartDisplay, - StackedDataPoint, TabbedMetricsCard, TimeRange, TimeRangeToggle, @@ -58,12 +57,6 @@ const dailyEmailsConfig: LineChartDisplayConfig = { // ── Helpers ─────────────────────────────────────────────────────────────────── -type SplitSeries = { - new?: DataPoint[], - reactivated?: DataPoint[], - retained?: DataPoint[], -}; - function formatUsdFromCents(cents: number): string { return `$${(cents / 100).toLocaleString(undefined, { maximumFractionDigits: 0 })}`; } @@ -79,33 +72,10 @@ function sumRange(points: DataPoint[], range: TimeRange): number { return filterDatapointsByTimeRange(points, range).reduce((s, p) => s + p.activity, 0); } -function mergeSplitToStacked(split: SplitSeries, range: TimeRange): StackedDataPoint[] { - const newPts = filterDatapointsByTimeRange(split.new ?? [], range); - const reactivatedPts = filterDatapointsByTimeRange(split.reactivated ?? [], range); - const retainedPts = filterDatapointsByTimeRange(split.retained ?? [], range); - - const dateMap = new Map(); - for (const arr of [newPts, reactivatedPts, retainedPts]) { - for (const p of arr) { - if (!dateMap.has(p.date)) { - dateMap.set(p.date, { date: p.date, new: 0, reactivated: 0, retained: 0 }); - } - } - } - for (const p of newPts) { - const entry = dateMap.get(p.date); - if (entry) entry.new = p.activity; - } - for (const p of reactivatedPts) { - const entry = dateMap.get(p.date); - if (entry) entry.reactivated = p.activity; - } - for (const p of retainedPts) { - const entry = dateMap.get(p.date); - if (entry) entry.retained = p.activity; - } - - return [...dateMap.values()].sort((a, b) => stringCompare(a.date, b.date)); +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(); } // ── Compact dual-value stat card ───────────────────────────────────────────── @@ -145,129 +115,138 @@ function DualStatCard({ ); } -// ── Tabbed DAU stacked chart + recently active list ────────────────────────── +// ── Hero analytics widget (stat pills + composed bar+line chart) ───────────── -type UserListItem = { - id: string, - profile_image_url?: string | null, - display_name?: string | null, - primary_email?: string | null, - last_active_at_millis?: number | null, - signed_up_at_millis?: number | null, +type AnalyticsStatPill = { + label: string, + value: string, + delta?: number, }; -function TabbedDauCard({ - dauSplit, - recentlyActive, - timeRange, - projectId, - router, +function StatPill({ stat }: { stat: AnalyticsStatPill }) { + 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}% + + )} +
+
+ ); +} + +function HeroAnalyticsWidget({ + composedData, + stats, compact = false, }: { - dauSplit: SplitSeries, - recentlyActive: UserListItem[], - timeRange: TimeRange, - projectId: string, - router: ReturnType, + composedData: ComposedDataPoint[], + stats: AnalyticsStatPill[], compact?: boolean, }) { - const [view, setView] = useState<'chart' | 'list'>('chart'); - - const dauStacked = useMemo(() => mergeSplitToStacked(dauSplit, timeRange), [dauSplit, timeRange]); - - const activeTabColor = "bg-cyan-500 dark:bg-[hsl(200,91%,70%)]"; - - const tabs: Array<{ id: 'chart' | 'list', label: string }> = [ - { id: 'chart', label: 'Daily Active Users' }, - { id: 'list', label: 'Recently Active' }, - ]; - return ( - -
-
- {tabs.map((tab) => ( - + +
+ {/* Stat pills row */} +
+ {stats.map((stat) => ( +
+ +
))}
-
-
- {view === 'chart' && ( - dauStacked.length === 0 ? ( -
- No activity data -
- ) : ( -
- -
- ) - )} - {view === 'list' && ( -
- {recentlyActive.length === 0 ? ( + {/* Legend + chart */} +
+
+ {composedData.length === 0 ? (
- No recently active users + No data available
) : ( -
- {recentlyActive.map((user) => ( - - ))} -
+ )}
- )} +
); } +// ── Tabbed DAU stacked chart + recently active list ────────────────────────── + +// ── Email list row ──────────────────────────────────────────────────────────── + +type EmailItem = { id: string, subject: string, status: string }; + +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 ( +
+ {/* Icon badge */} +
+ +
+ + {/* Subject */} +
+
+ {email.subject} +
+
+ + {/* Status pill */} +
+ + {cfg.label} +
+
+ ); +} + // ── Tabbed Emails card (bar chart + recent list) ───────────────────────────── function TabbedEmailsCard({ @@ -287,7 +266,7 @@ function TabbedEmailsCard({ const activeTabColor = "bg-orange-500 dark:bg-[hsl(240,71%,70%)]"; return ( - +
{(['chart', 'list'] as const).map((tab) => ( @@ -308,7 +287,13 @@ function TabbedEmailsCard({ ))}
-
+
{view === 'chart' ? ( filteredDatapoints.length === 0 ? (
@@ -324,9 +309,9 @@ function TabbedEmailsCard({ No recent emails
) : ( -
+
{recentEmails.map((email) => ( - + ))}
)} @@ -413,12 +398,8 @@ function EmailBreakdownCard({ function ReferrersWithAnalyticsCard({ topReferrers, - avgSession, - revenuePerVisitor, }: { topReferrers: Array<{ referrer: string, visitors: number }>, - avgSession: number, - revenuePerVisitor: number, }) { return ( @@ -447,17 +428,6 @@ function ReferrersWithAnalyticsCard({ })}
)} - -
-
-
Avg. Session
-
{formatSeconds(avgSession)}
-
-
-
Revenue / User
-
${revenuePerVisitor}
-
-
); @@ -537,7 +507,7 @@ function QuickAccessApps({ projectId, installedApps }: { projectId: string, inst export default function MetricsPage(props: { toSetup: () => void }) { const includeAnonymous = false; - const [timeRange, setTimeRange] = useState("30d"); + const [timeRange, setTimeRange] = useState("7d"); const user = useUser(); const displayName = user?.displayName || user?.primaryEmail || "User"; @@ -587,6 +557,56 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo const signUpsInRange = sumRange(data.daily_users ?? [], timeRange); const emailsInRange = sumRange(email.daily_emails ?? [], timeRange); + // ── Composed chart data (visitors bars + revenue line) ─────────────────── + const composedData = useMemo(() => { + const analyticsObj = data.analytics_overview ?? {}; + const dailyRev = (analyticsObj.daily_revenue ?? []) as Array<{ date: string, new_cents: number, refund_cents: number }>; + const dailyVis = (analyticsObj.daily_visitors ?? []) as DataPoint[]; + + const visitorMap = new Map(dailyVis.map(d => [d.date, d.activity])); + const revenueMap = new Map(dailyRev.map(d => [d.date, d])); + + const allDates = new Set([ + ...dailyVis.map(d => d.date), + ...dailyRev.map(d => d.date), + ]); + + const points = [...allDates].map(date => ({ + date, + visitors: visitorMap.get(date) ?? 0, + new_cents: revenueMap.get(date)?.new_cents ?? 0, + refund_cents: revenueMap.get(date)?.refund_cents ?? 0, + })).sort((a, b) => stringCompare(a.date, b.date)); + + if (timeRange === '7d') return points.slice(-7); + if (timeRange === '30d') return points.slice(-30); + return points; + }, [data.analytics_overview, timeRange]); + + const heroStats = useMemo(() => { + const analyticsObj = data.analytics_overview ?? {}; + const paymentsObj = data.payments_overview ?? {}; + const deltasObj = (analyticsObj.deltas ?? {}) as Record; + const totalRevenueCents = analyticsObj.total_revenue_cents ?? (paymentsObj.revenue_cents ?? 0); + return [ + { + label: "Visitors", + value: formatCompact(analyticsObj.visitors ?? 0), + delta: deltasObj.visitors, + }, + { + label: "Revenue", + value: formatUsdFromCents(totalRevenueCents), + delta: deltasObj.revenue, + }, + { + label: "Session time", + value: formatSeconds(analyticsObj.avg_session_seconds ?? 0), + delta: deltasObj.session_time, + }, + ]; + }, [data.analytics_overview, data.payments_overview]); + // ── Globe visibility ────────────────────────────────────────────────────── const gridContainerRef = useRef(null); const [gridContainerWidth, setGridContainerWidth] = useState(0); @@ -640,26 +660,13 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo )}
-
- - - - -
- -
- -
+
@@ -678,7 +685,7 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo {/* ────────────────────────────────────────────────────────────────────── ROW 2 — Daily Sign-ups + Emails trend ────────────────────────────────────────────────────────────────────── */} -
+
From c0bf26510bb8f2cb9e362c4f7f43edb2e651c1c2 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 10 Mar 2026 12:31:47 -0700 Subject: [PATCH 03/30] Add email overview metrics and enhance chart interactivity - Implemented loading of per-day email status counts for the last 30 days in the email overview. - Enhanced the ActivityBarChart component to track hovered data points, adjusting opacity for non-hovered bars. - Updated the StackedTooltip to display a 7-day average for email metrics. - Refactored the metrics page to utilize the new stacked data points and improved layout for better user experience. --- .../app/api/latest/internal/metrics/route.tsx | 40 +- .../[projectId]/(overview)/line-chart.tsx | 603 +++++++++++++++++- .../[projectId]/(overview)/metrics-page.tsx | 180 ++++-- claude/CLAUDE-KNOWLEDGE.md | 3 + 4 files changed, 728 insertions(+), 98 deletions(-) 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 8009256b05..1cbaf609db 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -483,6 +483,9 @@ async function loadPaymentsOverview(tenancy: Tenancy) { // ── Email Aggregates ───────────────────────────────────────────────────────── async function loadEmailOverview(tenancy: Tenancy) { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const [ statusGroups, recentEmails, @@ -490,6 +493,7 @@ async function loadEmailOverview(tenancy: Tenancy) { bouncedCount, clickedCount, finishedSendingCount, + emailsByDayAndStatus, ] = await Promise.all([ // group by simpleStatus (async () => { @@ -525,6 +529,17 @@ async function loadEmailOverview(tenancy: Tenancy) { const prisma = await getPrismaClientForTenancy(tenancy); return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, finishedSendingAt: { not: null } } }); })(), + // Per-day per-simpleStatus counts for the last 30 days + (async () => { + const prisma = await getPrismaClientForTenancy(tenancy); + return await prisma.emailOutbox.findMany({ + where: { + tenancyId: tenancy.id, + createdAt: { gte: thirtyDaysAgo }, + }, + select: { createdAt: true, simpleStatus: true }, + }); + })(), ]); const emailsByStatus: Record = {}; @@ -533,8 +548,6 @@ async function loadEmailOverview(tenancy: Tenancy) { } // Daily email sends for last 30 days - const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const emailByDay = new Map(); for (const email of recentEmails) { if (email.createdAt >= thirtyDaysAgo) { @@ -555,10 +568,33 @@ async function loadEmailOverview(tenancy: Tenancy) { const bounceRate = Number(((bouncedCount / denom) * 100).toFixed(2)); const clickRate = Number(((clickedCount / denom) * 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 <= 30; i++) { + const key = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + dayStatusMap.set(key, { ok: 0, error: 0, in_progress: 0 }); + } + for (const email of emailsByDayAndStatus) { + const key = email.createdAt.toISOString().split('T')[0]; + const entry = dayStatusMap.get(key); + if (entry != null) { + const s = email.simpleStatus; + if (s === 'OK') entry.ok += 1; + else if (s === 'ERROR') entry.error += 1; + else if (s === 'IN_PROGRESS') entry.in_progress += 1; + } + } + 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.slice(0, 6).map((email) => ({ id: email.id, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 372d62609f..762fc63c88 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -76,6 +76,71 @@ export function filterDatapointsByTimeRange(datapoints: DataPoint[], timeRange: return datapoints; } +export function filterStackedDatapointsByTimeRange(datapoints: T[], timeRange: TimeRange): T[] { + if (timeRange === '7d') return datapoints.slice(-7); + if (timeRange === '30d') return datapoints.slice(-30); + return datapoints; +} + +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 export function ActivityBarChart({ datapoints, @@ -89,6 +154,8 @@ export function ActivityBarChart({ 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 baseOpacity = isWeekendDay ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; return ( ); })} @@ -182,15 +255,15 @@ export type StackedDataPoint = { }; const stackedChartConfig: ChartConfig = { - retained: { label: "Retained", color: "hsl(221, 83%, 53%)" }, - reactivated: { label: "Reactivated", color: "hsl(38, 92%, 50%)" }, - new: { label: "New", color: "hsl(142, 71%, 45%)" }, + retained: { label: "Retained", color: "hsl(221, 42%, 55%)" }, + reactivated: { label: "Reactivated", color: "hsl(36, 55%, 58%)" }, + new: { label: "New", color: "hsl(152, 38%, 52%)" }, }; const StackedTooltip = ({ active, payload }: TooltipProps) => { if (!active || !payload?.length) return null; - const row = payload[0].payload as StackedDataPoint; + const row = payload[0].payload as StackedDataPoint & { avg7d?: number }; const date = new Date(row.date); const formattedDate = !isNaN(date.getTime()) ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) @@ -201,13 +274,17 @@ const StackedTooltip = ({ active, payload }: TooltipProps) => { { key: 'reactivated', value: row.reactivated }, { key: 'new', value: row.new }, ]; + const total = row.retained + row.reactivated + row.new; return (
- - {formattedDate} - +
+ + {formattedDate} + + {total} total +
{segments.map((seg) => (
) => {
))} + {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; + }); +} + +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, @@ -237,17 +342,45 @@ export function StackedBarChartDisplay({ 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.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 movingAvgConfig: ChartConfig = { + ...stackedChartConfig, + movingAvg: { label: "Moving avg", color: "hsl(var(--foreground))" }, + }; + return ( - updateHoveredIndexFromChartState(state, chartData.length, setHoveredIndex)} + onMouseLeave={() => setHoveredIndex(null)} > } - cursor={{ fill: "hsl(var(--muted-foreground))", opacity: 0.08, radius: 4 }} + 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) => ( - - ))} + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} - {datapoints.map((entry, index) => ( - - ))} + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} - {datapoints.map((entry, index) => ( - - ))} + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {hoveredIndex != null && ( + + )} - + ); } @@ -313,6 +514,12 @@ export type ComposedDataPoint = { visitors: number, }; +type HighlightDotProps = { + cx?: number, + cy?: number, + fill?: string, +}; + const composedChartConfig: ChartConfig = { visitors: { label: "Visitors", @@ -378,6 +585,21 @@ function ComposedTooltip({ active, payload }: TooltipProps) { ); } +function HighlightedLineDot({ cx, cy, fill }: HighlightDotProps) { + if (cx == null || cy == null || fill == null) { + return null; + } + + return ( + + + + + + + ); +} + export function ComposedAnalyticsChart({ datapoints, height, @@ -388,6 +610,8 @@ export function ComposedAnalyticsChart({ compact?: boolean, }) { const id = useId(); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [hoveredX, setHoveredX] = useState(null); const maxVisitors = Math.max(...datapoints.map(d => d.visitors), 1); const maxRevenueCents = Math.max(...datapoints.map(d => d.new_cents), 1); const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5); @@ -404,7 +628,15 @@ export function ComposedAnalyticsChart({ { + updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex); + setHoveredX(getActiveCoordinateX(state)); + }} + onMouseLeave={() => { + setHoveredIndex(null); + setHoveredX(null); + }} > @@ -412,6 +644,16 @@ export function ComposedAnalyticsChart({ + {hoveredX != null && ( + <> + + + + + + + + )} } - cursor={{ stroke: "hsl(var(--muted-foreground))", strokeOpacity: 0.3, strokeWidth: 1 }} + 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' }} @@ -434,21 +676,59 @@ export function ComposedAnalyticsChart({ stroke="var(--color-visitors)" strokeWidth={2} fill={`url(#visitors-fill-${id})`} + fillOpacity={hoveredIndex == null ? 1 : 0.12} + strokeOpacity={hoveredIndex == null ? 1 : 0.22} dot={false} - activeDot={{ r: 4, fill: "var(--color-visitors)" }} + activeDot={} isAnimationActive={false} /> + {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(#revenue-highlight-clip-${id})` }} + legendType="none" + /> + )} ('chart'); const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange); + const filteredStackedDatapoints = stackedChartData ? filterStackedDatapointsByTimeRange(stackedChartData, timeRange) : null; // Calculate total for the selected time range const total = filteredDatapoints.reduce((sum, point) => sum + point.activity, 0); @@ -685,7 +968,7 @@ export function TabbedMetricsCard({
{view === 'chart' && showTotal && ( - + {displayTotal.toLocaleString()} )} @@ -697,6 +980,21 @@ export function TabbedMetricsCard({
)} + {filteredStackedDatapoints != null && view === 'chart' && ( +
+ {[ + { key: 'new', label: 'New', color: 'hsl(152, 38%, 52%)' }, + { key: 'reactivated', label: 'Reactivated', color: 'hsl(36, 55%, 58%)' }, + { key: 'retained', label: 'Retained', color: 'hsl(221, 42%, 55%)' }, + ].map((item) => ( +
+ + {item.label} +
+ ))} +
+ )} +
{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 @@ -1412,3 +1724,238 @@ 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 = new Date(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' }} + /> + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + + {hoveredIndex != null && ( + + )} + { + const d = new Date(value); + return isNaN(d.getTime()) ? value : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }} + /> + + + + ); +} 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 fae97f456a..00b5d3d69c 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 @@ -18,15 +18,18 @@ import { PageLayout } from "../page-layout"; import { useAdminApp, useProjectId } from "../use-admin-app"; import { GlobeSectionWithData } from "./globe-section-with-data"; import { - ActivityBarChart, ChartCard, ComposedAnalyticsChart, ComposedDataPoint, DataPoint, DonutChartDisplay, + EmailStackedBarChartDisplay, + EmailStackedDataPoint, filterDatapointsByTimeRange, + filterStackedDatapointsByTimeRange, GradientColor, LineChartDisplayConfig, + StackedDataPoint, TabbedMetricsCard, TimeRange, TimeRangeToggle, @@ -36,7 +39,7 @@ import { MetricsLoadingFallback } from "./metrics-loading"; // ── Chart configs ──────────────────────────────────────────────────────────── const dailySignUpsConfig: LineChartDisplayConfig = { - name: 'Daily Sign-Ups', + name: 'Daily Active Users', chart: { activity: { label: "Sign-Ups", @@ -45,16 +48,6 @@ const dailySignUpsConfig: LineChartDisplayConfig = { }, }; -const dailyEmailsConfig: LineChartDisplayConfig = { - name: 'Emails Sent', - chart: { - activity: { - label: "Emails", - theme: { light: "hsl(38, 92%, 50%)", dark: "hsl(38, 92%, 65%)" }, - }, - }, -}; - // ── Helpers ─────────────────────────────────────────────────────────────────── function formatUsdFromCents(cents: number): string { @@ -123,26 +116,37 @@ type AnalyticsStatPill = { delta?: number, }; -function StatPill({ stat }: { stat: AnalyticsStatPill }) { +function StatCard({ + stat, + compact = false, +}: { + stat: AnalyticsStatPill, + compact?: boolean, +}) { return ( -
- - {stat.label} - -
- - {stat.value} + +
+ + {stat.label} - {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}% +
+ + {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}% + + )} +
-
+ ); } @@ -156,40 +160,33 @@ function HeroAnalyticsWidget({ compact?: boolean, }) { return ( - -
- {/* Stat pills row */} -
- {stats.map((stat) => ( -
- -
- ))} -
+
+ {/* Stat cards row */} +
+ {stats.map((stat) => ( + + ))} +
- {/* Legend + chart */} -
-
- {composedData.length === 0 ? ( -
- No data available -
- ) : ( - - )} -
+ {/* Chart card */} + +
+ {composedData.length === 0 ? ( +
+ No data available +
+ ) : ( + + )}
-
- + +
); } @@ -247,21 +244,27 @@ function EmailListRow({ email }: { email: EmailItem }) { ); } -// ── Tabbed Emails card (bar chart + recent list) ───────────────────────────── +// ── Tabbed Emails card (stacked bar chart + recent list) ───────────────────── + +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({ - chartData, + stackedChartData, recentEmails, timeRange, compact = false, }: { - chartData: DataPoint[], + stackedChartData: EmailStackedDataPoint[], recentEmails: Array<{ id: string, subject: string, status: string }>, timeRange: TimeRange, compact?: boolean, }) { const [view, setView] = useState<'chart' | 'list'>('chart'); - const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange); + const filteredDatapoints = filterStackedDatapointsByTimeRange(stackedChartData, timeRange); const activeTabColor = "bg-orange-500 dark:bg-[hsl(240,71%,70%)]"; @@ -287,9 +290,19 @@ function TabbedEmailsCard({ ))}
+ {view === 'chart' && ( +
+ {emailLegendItems.map((item) => ( +
+ + {item.label} +
+ ))} +
+ )}
No email data for this period
) : ( - + ) ) : (
@@ -555,7 +568,37 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo const topReferrers = (analytics.top_referrers ?? []) as Array<{ referrer: string, visitors: number }>; const signUpsInRange = sumRange(data.daily_users ?? [], timeRange); - const emailsInRange = sumRange(email.daily_emails ?? [], timeRange); + + // ── DAU split stacked data for sign-ups chart ───────────────────────────── + const dauSplit = (auth.daily_active_users_split ?? {}) as { + new?: DataPoint[], + retained?: DataPoint[], + reactivated?: DataPoint[], + }; + const dauStackedData = useMemo(() => { + const newPoints = dauSplit.new ?? []; + const retainedPoints = dauSplit.retained ?? []; + const reactivatedPoints = dauSplit.reactivated ?? []; + const dateSet = new Set([ + ...newPoints.map(d => d.date), + ...retainedPoints.map(d => d.date), + ...reactivatedPoints.map(d => d.date), + ]); + const newMap = new Map(newPoints.map(d => [d.date, d.activity])); + const retainedMap = new Map(retainedPoints.map(d => [d.date, d.activity])); + const reactivatedMap = new Map(reactivatedPoints.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]); + + // ── Email stacked data (ok/error/in_progress per day) ──────────────────── + const emailStackedData = useMemo(() => { + return (email.daily_emails_by_status ?? []) as EmailStackedDataPoint[]; + }, [email.daily_emails_by_status]); // ── Composed chart data (visitors bars + revenue line) ─────────────────── const composedData = useMemo(() => { @@ -689,6 +732,7 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo Date: Tue, 10 Mar 2026 12:52:44 -0700 Subject: [PATCH 04/30] Add monthly active users metric and enhance dashboard interactivity - Introduced a new function to load monthly active users (MAU) from ClickHouse, aggregating user activity over the last 30 days. - Updated the auth overview to include the MAU metric. - Enhanced the metrics page to support hover interactions for visitors and revenue data, allowing users to switch between different chart views with fade transitions. - Added new data types for visitors and revenue hover charts to improve data representation in the dashboard. --- .../app/api/latest/internal/metrics/route.tsx | 59 +++- .../[projectId]/(overview)/line-chart.tsx | 310 ++++++++++++++++++ .../[projectId]/(overview)/metrics-page.tsx | 300 +++++++++++++++-- .../endpoints/api/v1/internal-metrics.test.ts | 5 + claude/CLAUDE-KNOWLEDGE.md | 6 + 5 files changed, 647 insertions(+), 33 deletions(-) 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 1cbaf609db..dc12670a55 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -396,7 +396,45 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boole return dbUsers.map((user) => userPrismaToCrud(user, tenancy.config)); } -// ── Payments Aggregates ────────────────────────────────────────────────────── +async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolean = false): Promise { + const now = new Date(); + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + // 30-day rolling window for MAU + const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); + const untilExclusive = new Date(todayUtc.getTime() + 24 * 60 * 60 * 1000); + + const clickhouseClient = getClickhouseAdminClient(); + try { + const result = await clickhouseClient.query({ + query: ` + SELECT + uniqExact(assumeNotNull(user_id)) AS mau + 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) + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }); + const rows: { mau: number }[] = await result.json(); + return Number(rows[0]?.mau ?? 0); + } catch { + return 0; + } +} + async function loadPaymentsOverview(tenancy: Tenancy) { const prisma = await getPrismaClientForTenancy(tenancy); @@ -812,6 +850,16 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { return { daily_page_views: dailyPageViews, daily_clicks: dailyClicks, + daily_visitors: dailyPageViews.map((p) => ({ + date: p.date, + activity: Math.round(p.activity * 0.45), + })), + daily_revenue: dailyPageViews.map((p) => ({ + date: p.date, + new_cents: 0, + refund_cents: 0, + })), + total_revenue_cents: 0, total_replays: replayResult.total, recent_replays: replayResult.recent, visitors: replayResult.visitorCount, @@ -833,6 +881,9 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { 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: 0, total_replays: 0, recent_replays: 0, visitors: 0, @@ -994,9 +1045,10 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now const filteredTotal = includeAnonymous ? totalUsers : totalUsers - anonymousUsers; - const [dailyActiveUsersSplit, dailyActiveTeamsSplit] = await Promise.all([ + const [dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([ loadDailyActiveUsersSplit(tenancy, now, includeAnonymous), loadDailyActiveTeamsSplit(tenancy, now), + loadMonthlyActiveUsers(tenancy, includeAnonymous), ]); return { @@ -1004,6 +1056,7 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now unverified_users: filteredTotal - verifiedUsers, anonymous_users: anonymousUsers, total_teams: totalTeams, + mau, daily_active_users_split: dailyActiveUsersSplit, daily_active_teams_split: dailyActiveTeamsSplit, }; @@ -1094,6 +1147,8 @@ export const GET = createSmartRouteHandler({ ? { ...authOverview, daily_active_users_split: generateDevDauSplit(now, totalUsers), + // Fallback MAU is ~30% of total users in dev + mau: authOverview.mau === 0 ? Math.max(1, Math.round(totalUsers * 0.3)) : authOverview.mau, } : authOverview; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 762fc63c88..21ea24b633 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -514,6 +514,18 @@ export type ComposedDataPoint = { visitors: number, }; +export type VisitorsHoverDataPoint = { + date: string, + page_views: number, + clicks: number, +}; + +export type RevenueHoverDataPoint = { + date: string, + new_cents: number, + refund_cents: number, +}; + type HighlightDotProps = { cx?: number, cy?: number, @@ -1959,3 +1971,301 @@ export function EmailStackedBarChartDisplay({ ); } + +// ── Visitors hover chart (page views + clicks stacked bar) ────────────────── + +const visitorsHoverChartConfig: ChartConfig = { + page_views: { + label: "Page Views", + theme: { light: "hsl(210, 84%, 64%)", dark: "hsl(210, 84%, 72%)" }, + }, + clicks: { + label: "Clicks", + theme: { light: "hsl(210, 84%, 82%)", dark: "hsl(210, 84%, 88%)" }, + }, +}; + +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 = new Date(row.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + : row.date; + + const total = row.page_views + row.clicks; + + return ( +
+
+ {formattedDate} +
+
+ + Page Views + + {row.page_views.toLocaleString()} + +
+
+ + Clicks + + {row.clicks.toLocaleString()} + +
+
+ Total + + {total.toLocaleString()} + +
+
+
+
+ ); +} + +export function VisitorsHoverChart({ + datapoints, + compact = false, +}: { + datapoints: VisitorsHoverDataPoint[], + compact?: boolean, +}) { + const [hoveredIndex, setHoveredIndex] = useState(null); + + return ( + + updateHoveredIndexFromChartState(state, datapoints.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(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + { + const d = new Date(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%)" }, + }, +}; + +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 = new Date(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))} + +
+
+
+
+ ); +} + +export function RevenueHoverChart({ + datapoints, + compact = false, +}: { + datapoints: RevenueHoverDataPoint[], + compact?: boolean, +}) { + const [hoveredIndex, setHoveredIndex] = useState(null); + + const maxCents = Math.max(...datapoints.map(d => d.new_cents + d.refund_cents), 1); + const ticksCents = niceAxisTicks(Math.ceil(maxCents * 1.1), 4); + + return ( + + updateHoveredIndexFromChartState(state, datapoints.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(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + + {datapoints.map((entry, index) => { + const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const isActiveBar = hoveredIndex === index; + return ( + + ); + })} + + { + const d = new Date(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 00b5d3d69c..724bbb4b2e 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 @@ -29,10 +29,14 @@ import { filterStackedDatapointsByTimeRange, GradientColor, LineChartDisplayConfig, + RevenueHoverChart, + RevenueHoverDataPoint, StackedDataPoint, TabbedMetricsCard, TimeRange, TimeRangeToggle, + VisitorsHoverChart, + VisitorsHoverDataPoint, } from "./line-chart"; import { MetricsLoadingFallback } from "./metrics-loading"; @@ -150,40 +154,207 @@ function StatCard({ ); } +type HeroChartMode = 'default' | 'visitors' | 'revenue'; + +function HeroInChartPill({ + label, + value, + delta, + color, + isHovered, + onMouseEnter, +}: { + label: string, + value: string, + delta?: number, + color: string, + isHovered: boolean, + onMouseEnter: () => void, +}) { + return ( + + ); +} + function HeroAnalyticsWidget({ composedData, - stats, + visitorsData, + revenueData, + outerStats, + visitorsLabel, + revenueLabel, + visitorsTotal, + revenueTotal, + visitorsDelta, + revenueDelta, compact = false, }: { composedData: ComposedDataPoint[], - stats: AnalyticsStatPill[], + visitorsData: VisitorsHoverDataPoint[], + revenueData: RevenueHoverDataPoint[], + outerStats: AnalyticsStatPill[], + visitorsLabel: string, + revenueLabel: string, + visitorsTotal: string, + revenueTotal: string, + visitorsDelta?: number, + revenueDelta?: number, compact?: boolean, }) { + const [chartMode, setChartMode] = useState('default'); + const [fadingOut, setFadingOut] = useState(false); + const [displayMode, setDisplayMode] = useState('default'); + const fadeTimerRef = useRef | null>(null); + + const switchToMode = (mode: HeroChartMode) => { + if (mode === displayMode) return; + if (fadeTimerRef.current != null) { + clearTimeout(fadeTimerRef.current); + } + setFadingOut(true); + fadeTimerRef.current = setTimeout(() => { + setDisplayMode(mode); + setFadingOut(false); + fadeTimerRef.current = null; + }, 120); + }; + + const handlePillMouseEnter = (mode: 'visitors' | 'revenue') => { + setChartMode(mode); + switchToMode(mode); + }; + + const handlePillMouseLeave = () => { + setChartMode('default'); + switchToMode('default'); + }; + + const visitorsColor = "hsl(210, 84%, 64%)"; + const revenueColor = "hsl(268, 82%, 66%)"; + return (
- {/* Stat cards row */} + {/* Outer stat cards row */}
- {stats.map((stat) => ( + {outerStats.map((stat) => ( ))}
- {/* Chart card */} + {/* Chart card with in-card pills */} -
- {composedData.length === 0 ? ( -
- No data available -
- ) : ( - +
+ {/* In-card pills row */} +
+ handlePillMouseEnter('visitors')} + /> +
+ handlePillMouseEnter('revenue')} + /> +
+ + {/* Chart area with fade transition */} +
+
+ {displayMode === 'default' && ( + composedData.length === 0 ? ( +
+ No data available +
+ ) : ( + + ) + )} + {displayMode === 'visitors' && ( + visitorsData.length === 0 ? ( +
+ No visitor data available +
+ ) : ( + + ) + )} + {displayMode === 'revenue' && ( + revenueData.length === 0 ? ( +
+ No revenue data available +
+ ) : ( + + ) + )} +
+
@@ -626,28 +797,84 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo return points; }, [data.analytics_overview, timeRange]); - const heroStats = useMemo(() => { + // ── Visitors hover chart data (page views + clicks) ─────────────────────── + const visitorsHoverData = useMemo(() => { + const analyticsObj = data.analytics_overview ?? {}; + const dailyPv = (analyticsObj.daily_page_views ?? []) as DataPoint[]; + const dailyCl = (analyticsObj.daily_clicks ?? []) as DataPoint[]; + + const pvMap = new Map(dailyPv.map(d => [d.date, d.activity])); + const clMap = new Map(dailyCl.map(d => [d.date, d.activity])); + + const allDates = new Set([ + ...dailyPv.map(d => d.date), + ...dailyCl.map(d => d.date), + ]); + + const points = [...allDates].map(date => ({ + date, + page_views: pvMap.get(date) ?? 0, + clicks: clMap.get(date) ?? 0, + })).sort((a, b) => stringCompare(a.date, b.date)); + + if (timeRange === '7d') return points.slice(-7); + if (timeRange === '30d') return points.slice(-30); + return points; + }, [data.analytics_overview, timeRange]); + + // ── Revenue hover chart data (new_cents + refund_cents) ─────────────────── + const revenueHoverData = useMemo(() => { + const analyticsObj = data.analytics_overview ?? {}; + const dailyRev = (analyticsObj.daily_revenue ?? []) as Array<{ date: string, new_cents: number, refund_cents: number }>; + + const points = dailyRev.map(d => ({ + date: d.date, + new_cents: d.new_cents, + refund_cents: d.refund_cents, + })).sort((a, b) => stringCompare(a.date, b.date)); + + if (timeRange === '7d') return points.slice(-7); + if (timeRange === '30d') return points.slice(-30); + return points; + }, [data.analytics_overview, timeRange]); + + // ── Hero outer stats: MAUs, Total Emails sent, Session time ─────────────── + const heroOuterStats = useMemo(() => { const analyticsObj = data.analytics_overview ?? {}; - const paymentsObj = data.payments_overview ?? {}; const deltasObj = (analyticsObj.deltas ?? {}) as Record; - const totalRevenueCents = analyticsObj.total_revenue_cents ?? (paymentsObj.revenue_cents ?? 0); + const mau = (auth.mau ?? 0) as number; + const totalEmailsSent = (email.emails_sent ?? 0) as number; return [ { - label: "Visitors", - value: formatCompact(analyticsObj.visitors ?? 0), - delta: deltasObj.visitors, + label: "MAUs", + value: formatCompact(mau), }, { - label: "Revenue", - value: formatUsdFromCents(totalRevenueCents), - delta: deltasObj.revenue, + label: "Total Emails Sent", + value: formatCompact(totalEmailsSent), }, { - label: "Session time", + label: "Session Time", value: formatSeconds(analyticsObj.avg_session_seconds ?? 0), delta: deltasObj.session_time, }, ]; + }, [auth.mau, email.emails_sent, data.analytics_overview]); + + // ── In-chart pill values: Visitors and Revenue ──────────────────────────── + const inChartPillValues = useMemo(() => { + const analyticsObj = data.analytics_overview ?? {}; + const paymentsObj = data.payments_overview ?? {}; + const deltasObj = (analyticsObj.deltas ?? {}) as Record; + const totalRevenueCents = (analyticsObj.total_revenue_cents ?? paymentsObj.revenue_cents ?? 0) as number; + return { + visitorsTotal: formatCompact(analyticsObj.visitors ?? 0), + visitorsLabel: "Visitors", + visitorsDelta: deltasObj.visitors as number | undefined, + revenueTotal: formatUsdFromCents(totalRevenueCents), + revenueLabel: "Revenue", + revenueDelta: deltasObj.revenue as number | undefined, + }; }, [data.analytics_overview, data.payments_overview]); // ── Globe visibility ────────────────────────────────────────────────────── @@ -679,7 +906,9 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo className={cn( "grid gap-4 sm:gap-5 grid-cols-1 lg:grid-cols-12", )} - style={shouldShowGlobe ? { height: Math.max(400, Math.round(globeColumnWidth)) } : undefined} + style={shouldShowGlobe + ? { height: Math.max(400, Math.round(globeColumnWidth)) } + : { minHeight: 400 }} > {shouldShowGlobe && (
@@ -703,11 +932,20 @@ function MetricsContent({ includeAnonymous, timeRange }: { includeAnonymous: boo )}
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index a4ff380fb5..0123a565e2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -279,6 +279,8 @@ it("should return cross-product aggregates in the metrics response", async ({ ex expect(typeof authOverview.unverified_users).toBe('number'); expect(typeof authOverview.anonymous_users).toBe('number'); expect(typeof authOverview.total_teams).toBe('number'); + // MAU field introduced for hero widget + expect(typeof authOverview.mau).toBe('number'); // Payments overview shape const paymentsOverview = response.body.payments_overview; @@ -299,6 +301,9 @@ it("should return cross-product aggregates in the metrics response", async ({ ex expect(Array.isArray(analyticsOverview.daily_clicks)).toBe(true); expect(typeof analyticsOverview.total_replays).toBe('number'); expect(typeof analyticsOverview.recent_replays).toBe('number'); + // Fields used by visitors/revenue hover charts + expect(Array.isArray(analyticsOverview.daily_visitors)).toBe(true); + expect(Array.isArray(analyticsOverview.daily_revenue)).toBe(true); }); it("should return correct auth_overview breakdown including teams", async ({ expect }) => { diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index c1e018308f..151c41138f 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -85,3 +85,9 @@ A: In `apps/e2e/tests/backend/endpoints/api/v1/unsubscribe-link.test.ts`, avoid Q: How can overview Recharts on the dashboard dim non-hovered data while keeping the active day emphasized? A: In `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx`, track `hoveredIndex` from Recharts' `activeTooltipIndex` via chart `onMouseMove`/`onMouseLeave`, then use that index to lower non-hovered `Cell` opacity for bar charts and reduce line/area `strokeOpacity`/`fillOpacity` while relying on `activeDot` plus a stronger tooltip cursor to keep the hovered point visually focused. + +Q: How do you add a hover-to-swap chart interaction to the hero analytics widget with fade transitions? +A: In `metrics-page.tsx`, maintain `chartMode` (the hover intent) and `displayMode` (the chart currently rendered) as separate states. On pill mouse-enter, set chartMode immediately, then use a 120ms timer to set displayMode and clear a `fadingOut` flag. Fade is achieved via CSS opacity transitions on the chart container. The pill component uses `onMouseEnter`/`onMouseLeave` rather than `onClick` so hovering is enough to swap. Clear the timer ref when a new mode is requested to avoid flicker during rapid transitions. + +Q: How do you add a MAU (monthly active users) metric sourced from ClickHouse to the backend metrics endpoint? +A: Add a `loadMonthlyActiveUsers` function in `route.tsx` that runs `uniqExact(user_id)` over `$token-refresh` events in the last 30 days on `analytics_internal.events`. Wrap the ClickHouse call in try/catch and return 0 on error. Add the result to `loadAuthOverview`'s return as `mau`, and in the dev fallback block set `mau: totalUsers * 0.3` when `mau === 0` to ensure the dashboard is usable in development. From b71f9a17525c1ce95c4870b71e85cef4ffd79fa7 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 10 Mar 2026 17:06:19 -0700 Subject: [PATCH 05/30] Enhance dashboard interactivity with custom date range support - Added a `CustomDateRange` type and updated the `TimeRange` to include a 'custom' option for date filtering. - Implemented shared helper functions for parsing and normalizing date inputs to ensure accurate date handling in charts. - Updated various components, including `MetricsPage`, `line-chart`, and `metrics-page`, to support custom date ranges and maintain synchronization across the dashboard. - Improved scrollbar styling in `globals.css` for better user experience. --- .../[projectId]/(overview)/line-chart.tsx | 351 +++++++++++++++--- .../[projectId]/(overview)/metrics-page.tsx | 147 +++++--- apps/dashboard/src/app/globals.css | 26 ++ claude/CLAUDE-KNOWLEDGE.md | 18 + 4 files changed, 436 insertions(+), 106 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 4102c2fa4c..c9a56c58e6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -3,14 +3,21 @@ import { cn, Typography } from "@/components/ui"; +import { Calendar } from "@/components/ui/calendar"; import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { DesignCardTint, DesignCategoryTabs, DesignPillToggle } from "@/components/design-components"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import { UserAvatar } from '@stackframe/stack'; import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates'; import { useId, useState } from "react"; import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; -export type TimeRange = '7d' | '30d' | 'all'; +export type CustomDateRange = { + from: Date, + to: Date, +}; + +export type TimeRange = '7d' | '30d' | 'all' | 'custom'; export type LineChartDisplayConfig = { name: string, @@ -36,7 +43,7 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => { 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; @@ -67,19 +74,80 @@ const CustomTooltip = ({ active, payload }: TooltipProps) => { }; // Helper function to filter datapoints by time range -export 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); + } + + return new Date(dateValue); +} + +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}`; +} + +export function filterDatapointsByTimeRange( + datapoints: DataPoint[], + timeRange: TimeRange, + customDateRange: CustomDateRange | null = null, +): DataPoint[] { 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 filterStackedDatapointsByTimeRange(datapoints: T[], timeRange: TimeRange): T[] { +export function filterStackedDatapointsByTimeRange( + 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; } @@ -196,7 +264,7 @@ export function ActivityBarChart({ isAnimationActive={false} > {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 ( @@ -230,7 +298,7 @@ export function ActivityBarChart({ 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", @@ -265,7 +333,7 @@ const StackedTooltip = ({ active, payload }: TooltipProps) => { if (!active || !payload?.length) return null; const row = payload[0].payload as StackedDataPoint & { avg7d?: number }; - const date = new Date(row.date); + const date = parseChartDate(row.date); const formattedDate = !isNaN(date.getTime()) ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : row.date; @@ -399,7 +467,7 @@ export function StackedBarChartDisplay({ /> {datapoints.map((entry, index) => { - const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; return ( {datapoints.map((entry, index) => { - const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; return ( {datapoints.map((entry, index) => { - const baseOpacity = isWeekend(new Date(entry.date)) ? 0.5 : 1; + const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1; const isActiveBar = hoveredIndex === index; return ( { - const date = new Date(value); + const date = parseChartDate(value); if (!isNaN(date.getTime())) { return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; } @@ -550,7 +618,7 @@ function ComposedTooltip({ active, payload }: TooltipProps) { const row = payload[0]?.payload as ComposedDataPoint | undefined; if (!row) return null; - const date = new Date(row.date); + const date = parseChartDate(row.date); const formattedDate = !isNaN(date.getTime()) ? date.toLocaleDateString('en-US', { weekday: 'long', day: 'numeric', month: 'long' }) : row.date; @@ -641,7 +709,7 @@ export function ComposedAnalyticsChart({ { updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex); setHoveredX(getActiveCoordinateX(state)); @@ -762,10 +830,11 @@ export function ComposedAnalyticsChart({ tickLine={false} tickMargin={compact ? 4 : 6} axisLine={false} + padding={{ left: 8, right: 8 }} 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())) { return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; } @@ -819,6 +888,15 @@ export function ChartCard({ cyan: "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} -
-
- + + {children} + ); } @@ -1215,10 +1184,6 @@ export function TabbedMetricsCard({ stackedLegendItems?: Array<{ key: string, label: string, color: string }>, }) { const [view, setView] = useState<'chart' | 'list'>('chart'); - const LIST_BATCH_SIZE = 12; - const [visibleListCount, setVisibleListCount] = useState(() => Math.min(LIST_BATCH_SIZE, listData.length)); - const listScrollContainerRef = useRef(null); - const listLoadMoreSentinelRef = useRef(null); const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange, customDateRange); const filteredStackedDatapoints = stackedChartData ? filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange) : null; @@ -1240,42 +1205,20 @@ export function TabbedMetricsCard({ const hoverAccentClass = hoverAccentColors[gradientColor]; const tabsGradient: "blue" | "cyan" | "purple" | "green" | "orange" | "default" = gradientColor === "slate" ? "default" : gradientColor; - const hasMoreListItems = visibleListCount < listData.length; - useEffect(() => { - if (view !== "list") { - return; - } - setVisibleListCount(Math.min(LIST_BATCH_SIZE, listData.length)); - }, [view, listData.length]); - - useEffect(() => { - if (view !== "list" || !hasMoreListItems) { - return; - } - const root = listScrollContainerRef.current; - const target = listLoadMoreSentinelRef.current; - if (root == null || target == null) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - const firstEntry = entries[0]; - if (!firstEntry.isIntersecting) { - return; - } - setVisibleListCount((current) => Math.min(current + LIST_BATCH_SIZE, listData.length)); - }, - { root, rootMargin: "120px 0px", threshold: 0.01 }, - ); - - observer.observe(target); - return () => observer.disconnect(); - }, [view, hasMoreListItems, listData.length]); + const listWindow = useInfiniteListWindow(listData.length, view === "list" ? "list" : "chart", view === "list"); return ( - +
- {(stackedLegendItems ?? [ + ( -
- - {item.label} -
- ))} -
+ ])} + compact={compact} + /> )}
) ) : ( -
+
{listData.length === 0 ? (
@@ -1371,7 +1310,7 @@ export function TabbedMetricsCard({
) : (
- {listData.slice(0, visibleListCount).map((user) => { + {listData.slice(0, listWindow.visibleCount).map((user) => { const displayName = user.display_name || user.primary_email || 'Anonymous User'; const secondaryEmail = user.display_name && user.primary_email ? user.primary_email : null; const timeLabel = config.name === 'Daily Active Users' @@ -1420,8 +1359,8 @@ export function TabbedMetricsCard({ ); })} - {hasMoreListItems && ( -
+ {listWindow.hasMore && ( +
Loading more... @@ -1458,7 +1397,15 @@ export function LineChartDisplay({ const filteredDatapoints = filterDatapointsByTimeRange(datapoints, timeRange, customDateRange); return ( - +
@@ -1520,7 +1467,11 @@ export function StatCard({ const isNegative = delta !== undefined && delta < 0; return ( - +
@@ -1952,7 +1903,11 @@ export function DonutChartDisplay({ const outerRadius = compact ? 55 : 85; return ( - +
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 9a732b0fb2..172cdb4aaf 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 @@ -7,6 +7,7 @@ import { cn, Typography } from "@/components/ui"; import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { DesignListItemRow } from "@/components/design-components/list"; +import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, useInfiniteListWindow } from "@/components/design-components"; import { CompassIcon, EnvelopeIcon, EnvelopeOpenIcon, GlobeIcon, SquaresFourIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; import useResizeObserver from '@react-hook/resize-observer'; import { useUser } from "@stackframe/stack"; @@ -18,7 +19,6 @@ import { PageLayout } from "../page-layout"; import { useAdminApp, useProjectId } from "../use-admin-app"; import { GlobeSectionWithData } from "./globe-section-with-data"; import { - ChartCard, ComposedAnalyticsChart, ComposedDataPoint, CustomDateRange, @@ -103,7 +103,7 @@ function DualStatCard({ gradientColor?: GradientColor, }) { return ( - +
{label} @@ -120,7 +120,7 @@ function DualStatCard({
- + ); } @@ -140,7 +140,7 @@ function StatCard({ compact?: boolean, }) { return ( - +
-
+ ); } @@ -261,7 +261,11 @@ function HeroAnalyticsWidget({ const [chartMode, setChartMode] = useState('default'); const [fadingOut, setFadingOut] = useState(false); const [displayMode, setDisplayMode] = useState('default'); + const [fadingIn, setFadingIn] = useState(false); const fadeTimerRef = useRef | null>(null); + const fadeInRaf1Ref = useRef(null); + const fadeInRaf2Ref = useRef(null); + const FADE_OUT_MS = 140; const switchToMode = (mode: HeroChartMode) => { if (mode === displayMode) return; @@ -272,10 +276,33 @@ function HeroAnalyticsWidget({ fadeTimerRef.current = setTimeout(() => { setDisplayMode(mode); setFadingOut(false); + setFadingIn(true); + // Let the browser paint the new chart at opacity-0 first, then fade in + fadeInRaf1Ref.current = requestAnimationFrame(() => { + fadeInRaf2Ref.current = requestAnimationFrame(() => { + setFadingIn(false); + fadeInRaf2Ref.current = null; + }); + fadeInRaf1Ref.current = null; + }); fadeTimerRef.current = null; - }, 120); + }, FADE_OUT_MS); }; + useEffect(() => { + return () => { + if (fadeTimerRef.current != null) { + clearTimeout(fadeTimerRef.current); + } + if (fadeInRaf1Ref.current != null) { + cancelAnimationFrame(fadeInRaf1Ref.current); + } + if (fadeInRaf2Ref.current != null) { + cancelAnimationFrame(fadeInRaf2Ref.current); + } + }; + }, []); + const handlePillMouseEnter = (mode: 'dau' | 'visitors' | 'revenue') => { setChartMode(mode); switchToMode(mode); @@ -303,7 +330,22 @@ function HeroAnalyticsWidget({
{/* Chart card with in-card pills */} - +
{displayMode === 'default' && ( @@ -401,13 +446,11 @@ function HeroAnalyticsWidget({
-
+
); } -// ── Tabbed DAU stacked chart + recently active list ────────────────────────── - // ── Email list row ──────────────────────────────────────────────────────────── type EmailItem = { id: string, subject: string, status: string }; @@ -482,78 +525,43 @@ function TabbedEmailsCard({ compact?: boolean, }) { const [view, setView] = useState<'chart' | 'list'>('chart'); - const LIST_BATCH_SIZE = 12; - const [visibleEmailCount, setVisibleEmailCount] = useState(() => Math.min(LIST_BATCH_SIZE, recentEmails.length)); - const listScrollContainerRef = useRef(null); - const listLoadMoreSentinelRef = useRef(null); const filteredDatapoints = filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange); - const activeTabColor = "bg-orange-500 dark:bg-[hsl(240,71%,70%)]"; - const hasMoreEmails = visibleEmailCount < recentEmails.length; - - useEffect(() => { - if (view !== "list") { - return; - } - setVisibleEmailCount(Math.min(LIST_BATCH_SIZE, recentEmails.length)); - }, [view, recentEmails.length]); - - useEffect(() => { - if (view !== "list" || !hasMoreEmails) { - return; - } - const root = listScrollContainerRef.current; - const target = listLoadMoreSentinelRef.current; - if (root == null || target == null) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - const firstEntry = entries[0]; - if (!firstEntry.isIntersecting) { - return; - } - setVisibleEmailCount((current) => Math.min(current + LIST_BATCH_SIZE, recentEmails.length)); - }, - { root, rootMargin: "120px 0px", threshold: 0.01 }, - ); - - observer.observe(target); - return () => observer.disconnect(); - }, [view, hasMoreEmails, recentEmails.length]); + const listWindow = useInfiniteListWindow(recentEmails.length, view === "list" ? "list" : "chart", view === "list"); return ( - +
-
- {(['chart', 'list'] as const).map((tab) => ( - - ))} -
+ { + 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' && ( -
- {emailLegendItems.map((item) => ( -
- - {item.label} -
- ))} -
+ )}
) ) : ( -
+
{recentEmails.length === 0 ? (
No recent emails
) : (
- {recentEmails.slice(0, visibleEmailCount).map((email) => ( + {recentEmails.slice(0, listWindow.visibleCount).map((email) => ( ))} - {hasMoreEmails && ( -
+ {listWindow.hasMore && ( +
Loading more... @@ -593,7 +601,7 @@ function TabbedEmailsCard({
)}
- + ); } @@ -617,7 +625,7 @@ function EmailBreakdownCard({ const total = items.reduce((s, i) => s + i.count, 0); return ( - +
Email Delivery
@@ -665,10 +673,11 @@ function EmailBreakdownCard({
- + ); } +// ── Tabbed DAU stacked chart + recently active list ────────────────────────── // ── Top Referrers with analytics footer ────────────────────────────────────── function ReferrersWithAnalyticsCard({ @@ -676,54 +685,21 @@ function ReferrersWithAnalyticsCard({ }: { topReferrers: Array<{ referrer: string, visitors: number }>, }) { - const LIST_BATCH_SIZE = 12; - const [visibleReferrerCount, setVisibleReferrerCount] = useState(() => Math.min(LIST_BATCH_SIZE, topReferrers.length)); - const listScrollContainerRef = useRef(null); - const listLoadMoreSentinelRef = useRef(null); - const hasMoreReferrers = visibleReferrerCount < topReferrers.length; - - useEffect(() => { - setVisibleReferrerCount(Math.min(LIST_BATCH_SIZE, topReferrers.length)); - }, [topReferrers.length]); - - useEffect(() => { - if (!hasMoreReferrers) { - return; - } - const root = listScrollContainerRef.current; - const target = listLoadMoreSentinelRef.current; - if (root == null || target == null) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - const firstEntry = entries[0]; - if (!firstEntry.isIntersecting) { - return; - } - setVisibleReferrerCount((current) => Math.min(current + LIST_BATCH_SIZE, topReferrers.length)); - }, - { root, rootMargin: "120px 0px", threshold: 0.01 }, - ); - - observer.observe(target); - return () => observer.disconnect(); - }, [hasMoreReferrers, topReferrers.length]); + const listWindow = useInfiniteListWindow(topReferrers.length); return ( - +
Top Referrers
-
+
{topReferrers.length === 0 ? (
No referrer data
) : (
- {topReferrers.slice(0, visibleReferrerCount).map((item) => { + {topReferrers.slice(0, listWindow.visibleCount).map((item) => { const max = topReferrers[0].visitors; return (
@@ -736,8 +712,8 @@ function ReferrersWithAnalyticsCard({
); })} - {hasMoreReferrers && ( -
+ {listWindow.hasMore && ( +
Loading more... @@ -746,7 +722,7 @@ function ReferrersWithAnalyticsCard({
)}
- + ); } 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 923bf99d1e..e34720e158 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 @@ -13,46 +13,7 @@ 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 }) { @@ -225,7 +186,7 @@ export default function PageClient() { } > - +
@@ -292,7 +253,7 @@ export default function PageClient() {
)} - + {/* Shared SMTP Warning Dialog */} - {/* 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 }) { @@ -188,7 +163,7 @@ export default function PageClient() { >
{/* Active Theme Card */} - +
@@ -244,10 +219,10 @@ export default function PageClient() {
- + {/* Device Preview Card */} - + {/* Header with viewport selector */}
@@ -281,7 +256,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 ed79916668..c6c44d6bec 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 @@ -20,46 +20,7 @@ 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 }) { @@ -135,7 +96,7 @@ export default function PageClient() { function EmulatorModeCard() { return ( - +
@@ -157,7 +118,7 @@ function EmulatorModeCard() {
- + ); } @@ -175,7 +136,7 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' : emailConfig.senderEmail; return ( - +
@@ -256,7 +217,7 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' )}
- + ); } @@ -496,7 +457,7 @@ function EmailLogCard() { if (loading) { return ( - +
@@ -513,13 +474,13 @@ function EmailLogCard() {
-
+ ); } if (error) { return ( - +
@@ -539,13 +500,13 @@ function EmailLogCard() {
- + ); } if (emailLogs.length === 0) { return ( - +
@@ -565,12 +526,12 @@ function EmailLogCard() {
-
+ ); } return ( - +
@@ -603,7 +564,7 @@ function EmailLogCard() { }} />
- + ); } 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..113555c499 --- /dev/null +++ b/apps/dashboard/src/components/design-components/analytics-card.tsx @@ -0,0 +1,319 @@ +"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 ( + <> + {/* Inject tooltip-escape styles once per card instance. */} + +
+ {/* 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 06b84cd39f..053fdf4e94 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 "./alert"; +export * from "./analytics-card"; export * from "./badge"; export * from "./button"; export * from "./card"; diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 1c85c45467..1e18764a1b 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -123,3 +123,9 @@ A: Return a larger bounded page from `/api/v1/internal/metrics` (for example 100 Q: How can the Top Referrers card on overview support infinite lazy loading? A: In `metrics-page.tsx`, make the referrers list container scrollable (`min-h-0 overflow-y-auto`) and append rows incrementally via an `IntersectionObserver` sentinel (e.g. 12 rows per batch). In `internal/metrics/route.tsx`, raise the ClickHouse referrer query limit (e.g. `TOP_REFERRERS_PAGE_SIZE = 100`) so the UI has enough rows to lazy-load. + +Q: Where does the shared glassmorphic chart-card shell live after the design-component refactor? +A: In `packages/dashboard/src/components/design-components/analytics-card.tsx`. It exports `DesignAnalyticsCard` (the glass card with Recharts tooltip escape), `DesignAnalyticsCardHeader` (compact header row with divider), `DesignChartLegend` (dot+label legend strip), `useInfiniteListWindow` (IntersectionObserver-based incremental list hook), and `DesignInfiniteScrollList` (a scroll container that drives `useInfiniteListWindow`). The page-local `ChartCard` wrapper in `line-chart.tsx` and all `GlassCard` clones in emails/email-drafts/email-themes pages were replaced with `DesignAnalyticsCard`. + +Q: How do you fix "RefObject is not assignable to LegacyRef" TS errors when using useRef with JSX in React 19? +A: In React 19 with TypeScript 5.x, `useRef(null)` returns `RefObject`, but JSX `ref` props still expect `RefObject`. Cast the result: `const ref = useRef(null) as React.RefObject`. Then inside effects, cast `.current` back to `T | null` when doing null checks to avoid triggering `@typescript-eslint/no-unnecessary-condition`. From f34c4ba32a025020621a2244dbcec3c159aa6c44 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Mar 2026 01:13:40 -0700 Subject: [PATCH 08/30] Enhance analytics overview with additional visitor metrics - Updated the `loadAnalyticsOverview` function to include parameters for anonymous user tracking. - Added queries to retrieve daily visitor counts and total unique visitors from ClickHouse. - Adjusted the metrics returned to include daily visitors and total revenue calculations based on visitor data. - Modified the `MetricsContent` component to handle revenue display logic based on available data, improving overall dashboard accuracy. --- .../app/api/latest/internal/metrics/route.tsx | 84 +++++++++++++++---- .../[projectId]/(overview)/metrics-page.tsx | 4 +- 2 files changed, 69 insertions(+), 19 deletions(-) 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 407575aa75..5766e8cfdf 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -654,7 +654,7 @@ async function loadEmailOverview(tenancy: Tenancy) { // ── Web Analytics Aggregates ───────────────────────────────────────────────── -async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { +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() - 30 * 24 * 60 * 60 * 1000); @@ -663,7 +663,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { const clickhouseClient = getClickhouseAdminClient(); try { - const [pageViewResult, clickResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ + const [pageViewResult, clickResult, dailyVisitorResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ clickhouseClient.query({ query: ` SELECT @@ -686,6 +686,53 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { }, format: "JSONEachRow", }), + clickhouseClient.query({ + query: ` + SELECT + toDate(event_at) AS day, + uniqExact(assumeNotNull(user_id)) AS cnt + FROM analytics_internal.events + WHERE event_type = '$page-view' + 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 + ORDER BY day ASC + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), + clickhouseClient.query({ + query: ` + SELECT + uniqExact(assumeNotNull(user_id)) AS visitors + FROM analytics_internal.events + WHERE event_type = '$page-view' + 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) + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, + }, + format: "JSONEachRow", + }), clickhouseClient.query({ query: ` SELECT @@ -778,7 +825,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { // session replay count from Postgres (async () => { const prisma = await getPrismaClientForTenancy(tenancy); - const [total, recent, replayRows, visitorCount, revenue] = await Promise.all([ + const [total, recent, replayRows, revenue] = await Promise.all([ prisma.sessionReplay.count({ where: { tenancyId: tenancy.id } }), prisma.sessionReplay.count({ where: { @@ -796,7 +843,6 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { lastEventAt: true, }, }), - prisma.projectUser.count({ where: { tenancyId: tenancy.id } }), prisma.subscriptionInvoice.aggregate({ where: { tenancyId: tenancy.id, amountTotal: { not: null } }, _sum: { amountTotal: true }, @@ -806,16 +852,11 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { const avgSessionSeconds = replayRows.length > 0 ? replayRows.reduce((sum, row) => sum + Math.max(0, row.lastEventAt.getTime() - row.startedAt.getTime()), 0) / replayRows.length / 1000 : 0; - const revenuePerVisitor = visitorCount > 0 - ? Number((((revenue._sum.amountTotal ?? 0) / 100) / visitorCount).toFixed(2)) - : 0; - return { total, recent, avgSessionSeconds: Number(avgSessionSeconds.toFixed(1)), - visitorCount, - revenuePerVisitor, + totalRevenueCents: Number(revenue._sum.amountTotal ?? 0), }; })(), ]); @@ -833,14 +874,24 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { const key = new Date(row.day + 'Z').toISOString().split('T')[0]; clByDay.set(key, Number(row.cnt)); } + const visitorRows: { day: string, cnt: number }[] = await dailyVisitorResult.json(); + const visitorByDay = new Map(); + for (const row of visitorRows) { + const key = new Date(row.day + 'Z').toISOString().split('T')[0]; + visitorByDay.set(key, Number(row.cnt)); + } + 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 <= 30; i++) { const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); 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, cnt: number }[] = await referrerResult.json(); @@ -850,10 +901,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { return { daily_page_views: dailyPageViews, daily_clicks: dailyClicks, - daily_visitors: dailyPageViews.map((p) => ({ - date: p.date, - activity: Math.round(p.activity * 0.45), - })), + daily_visitors: dailyVisitors, daily_revenue: dailyPageViews.map((p) => ({ date: p.date, new_cents: 0, @@ -862,10 +910,12 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date) { total_revenue_cents: 0, total_replays: replayResult.total, recent_replays: replayResult.recent, - visitors: replayResult.visitorCount, + visitors, avg_session_seconds: replayResult.avgSessionSeconds, online_live: Number(onlineRows[0]?.online ?? 0), - revenue_per_visitor: replayResult.revenuePerVisitor, + revenue_per_visitor: visitors > 0 + ? Number(((replayResult.totalRevenueCents / 100) / visitors).toFixed(2)) + : 0, top_referrers: referrers.map((row) => ({ referrer: row.referrer ?? '(direct)', visitors: Number(row.cnt), @@ -1139,7 +1189,7 @@ export const GET = createSmartRouteHandler({ loadAuthOverview(req.auth.tenancy, includeAnonymous, now), loadPaymentsOverview(req.auth.tenancy), loadEmailOverview(req.auth.tenancy), - loadAnalyticsOverview(req.auth.tenancy, now), + loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous), ] as const); // In dev, ClickHouse may have no events — fill in realistic fallback data 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 172cdb4aaf..527dd4b906 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 @@ -1046,11 +1046,11 @@ function MetricsContent({ visitorsTotal: formatCompact(visitorsTotalInRange), visitorsLabel: "Unique Visitors", visitorsDelta: hasFullPreviousComposedWindow ? calculatePeriodDelta(visitorsTotalInRange, previousVisitorsTotal) : undefined, - revenueTotal: formatUsdFromCents(totalRevenueCentsInRange), + revenueTotal: formatUsdFromCents(totalRevenueCentsInRange > 0 ? totalRevenueCentsInRange : (payments.revenue_cents ?? 0)), revenueLabel: "Revenue", revenueDelta: hasFullPreviousComposedWindow ? calculatePeriodDelta(totalRevenueCentsInRange, previousRevenueTotalCents) : undefined, }; - }, [allComposedData, composedData, dauStackedData]); + }, [allComposedData, composedData, dauStackedData, payments.revenue_cents]); // ── Globe visibility ────────────────────────────────────────────────────── const gridContainerRef = useRef(null); From 4bd4ed2882ce3fa3110a3211e7f34ba507120ce3 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Mar 2026 09:39:38 -0700 Subject: [PATCH 09/30] Cursor PR review changes #1 --- apps/backend/prisma/seed.ts | 6 - .../app/api/latest/internal/metrics/route.tsx | 39 +- apps/dashboard/DESIGN-GUIDE.md | 45 - .../[projectId]/(overview)/line-chart.tsx | 34 +- .../[projectId]/(overview)/metrics-page.tsx | 17 +- .../internal-metrics.test.ts.snap | 4327 +++++++++++++++++ .../endpoints/api/v1/internal-metrics.test.ts | 48 +- 7 files changed, 4429 insertions(+), 87 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 6987268fbe..107ece313e 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -2114,12 +2114,6 @@ async function seedDummyEmails(options: EmailSeedOptions) { const hasClicked = hasOpened && emailBulkRand() < 0.3; const hasError = !hasBounce && !hasOpened && emailBulkRand() < 0.05; - const existing = await globalPrismaClient.emailOutbox.findUnique({ - where: { tenancyId_id: { tenancyId, id: bulkId } }, - select: { id: true }, - }); - if (existing) continue; - const canHaveDelivery = hasOpened || hasClicked || hasBounce; await globalPrismaClient.emailOutbox.upsert({ 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 5766e8cfdf..ff924404f5 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1,10 +1,11 @@ import { getClickhouseAdminClient } from "@/lib/clickhouse"; 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 { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import yup from 'yup'; import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud"; @@ -23,7 +24,7 @@ function formatClickhouseDateTimeParam(date: Date): string { return date.toISOString().slice(0, 19); } -async function loadUsersByCountry(tenancy: Tenancy, prisma: PrismaClientTransaction, includeAnonymous: boolean = false): Promise> { +async function loadUsersByCountry(tenancy: Tenancy, includeAnonymous: boolean = false): Promise> { const clickhouseClient = getClickhouseAdminClient(); const res = await clickhouseClient.query({ query: ` @@ -240,6 +241,7 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno 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, @@ -309,6 +311,7 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise= {since:DateTime} AND event_at < {untilExclusive:DateTime} + GROUP BY day, team_id `, query_params: { projectId: tenancy.project.id, @@ -430,7 +433,15 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea }); const rows: { mau: number }[] = await result.json(); return Number(rows[0]?.mau ?? 0); - } catch { + } catch (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; } } @@ -587,11 +598,9 @@ async function loadEmailOverview(tenancy: Tenancy) { // Daily email sends for last 30 days const emailByDay = new Map(); - for (const email of recentEmails) { - if (email.createdAt >= thirtyDaysAgo) { - const key = email.createdAt.toISOString().split('T')[0]; - emailByDay.set(key, (emailByDay.get(key) ?? 0) + 1); - } + for (const email of emailsByDayAndStatus) { + const key = email.createdAt.toISOString().split('T')[0]; + emailByDay.set(key, (emailByDay.get(key) ?? 0) + 1); } const dailyEmails: DataPoints = []; for (let i = 0; i <= 30; i++) { @@ -663,7 +672,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const clickhouseClient = getClickhouseAdminClient(); try { - const [pageViewResult, clickResult, dailyVisitorResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ + const [pageViewResult, dailyVisitorResult, totalVisitorResult, clickResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ clickhouseClient.query({ query: ` SELECT @@ -926,7 +935,15 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo count: Number(topRegionRows[0].cnt), } : null, }; - } catch { + } catch (error) { + captureError("internal-metrics-analytics-overview-fallback", new StackAssertionError( + "Falling back to empty analytics overview due to query failure.", + { + cause: error, + projectId: tenancy.project.id, + branchId: tenancy.branchId, + }, + )); // Analytics may not be enabled for all projects return { daily_page_views: [] as DataPoints, @@ -1171,7 +1188,7 @@ export const GET = createSmartRouteHandler({ }), 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: { diff --git a/apps/dashboard/DESIGN-GUIDE.md b/apps/dashboard/DESIGN-GUIDE.md index 831fc86f34..7923f49fdb 100644 --- a/apps/dashboard/DESIGN-GUIDE.md +++ b/apps/dashboard/DESIGN-GUIDE.md @@ -851,49 +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 ---- - -## 13) Frontend Design Reference - -Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. - -This guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. - -The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. - -### Design Thinking - -Before coding, understand the context and commit to a bold aesthetic direction: - -- **Purpose**: What problem does this interface solve? Who uses it? -- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. Use these for inspiration but design one that is true to the aesthetic direction. -- **Constraints**: Technical requirements (framework, performance, accessibility). -- **Differentiation**: What makes this unforgettable? What's the one thing someone will remember? - -**Critical**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. - -Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: - -- Production-grade and functional -- Visually striking and memorable -- Cohesive with a clear aesthetic point-of-view -- Meticulously refined in every detail - -### Frontend Aesthetics Guidelines - -Focus on: - -- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. -- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. -- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (`animation-delay`) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. -- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. -- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. - -Never use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. - -Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. Never converge on common choices (Space Grotesk, for example) across generations. - -**Important**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. - -Remember: You are capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 626c9ef544..e83647693e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -103,7 +103,11 @@ function parseChartDate(dateValue: string): Date { return new Date(year, month - 1, day); } - return new Date(dateValue); + 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 { @@ -117,11 +121,11 @@ function formatDateRangeLabel(range: CustomDateRange | null): string { return `${fromLabel} - ${toLabel}`; } -export function filterDatapointsByTimeRange( - datapoints: DataPoint[], +function filterPointsByTimeRange( + datapoints: T[], timeRange: TimeRange, customDateRange: CustomDateRange | null = null, -): DataPoint[] { +): T[] { if (timeRange === '7d') { return datapoints.slice(-7); } @@ -141,24 +145,20 @@ export function filterDatapointsByTimeRange( 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[] { - 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; + return filterPointsByTimeRange(datapoints, timeRange, customDateRange); } function getHoveredDataIndex(activeTooltipIndex: unknown, dataLength: number): number | null { 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 527dd4b906..763a55d1bb 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 @@ -87,6 +87,20 @@ function calculatePeriodDelta(currentValue: number, previousValue: number): numb return Number((((currentValue - previousValue) / previousValue) * 100).toFixed(1)); } +function useMetricsOrThrow(adminApp: object, includeAnonymous: boolean) { + const internals = Reflect.get(adminApp, stackAppInternalsSymbol); + if (typeof internals !== "object" || internals == null || !("useMetrics" in internals)) { + throw new Error("Admin app internals are unavailable: missing useMetrics"); + } + + const useMetrics = internals.useMetrics; + if (typeof useMetrics !== "function") { + throw new Error("Admin app internals are unavailable: useMetrics is not callable"); + } + + return useMetrics(includeAnonymous); +} + // ── Compact dual-value stat card ───────────────────────────────────────────── function DualStatCard({ @@ -845,7 +859,7 @@ function MetricsContent({ 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) @@ -992,7 +1006,6 @@ function MetricsContent({ // ── Hero outer stats: MAUs, Total Emails sent, Session time ─────────────── const heroOuterStats = useMemo(() => { const analyticsObj = data.analytics_overview ?? {}; - const deltasObj = (analyticsObj.deltas ?? {}) as Record; const mau = (auth.mau ?? 0) as number; const totalEmailsSent = (email.emails_sent ?? 0) as number; return [ 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 db45766580..2665ba7a4d 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,1606 @@ exports[`should return metrics data > metrics_result_no_users 1`] = ` NiceResponse { "status": 200, "body": { + "analytics_overview": { + "avg_session_seconds": 597, + "bounce_rate": 72.1, + "conversion_rate": 0.38, + "daily_clicks": [ + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + ], + "daily_page_views": [ + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + ], + "daily_revenue": [ + { + "date": , + "new_cents": 300, + "refund_cents": 98, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 58, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 104, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 65, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 90, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 73, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 72, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 85, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 65, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 79, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 105, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 112, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 88, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 88, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 55, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 74, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 68, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 99, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 72, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 119, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 73, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 72, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 65, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 94, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 62, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 100, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 112, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 58, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 65, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 50, + }, + { + "date": , + "new_cents": 300, + "refund_cents": 55, + }, + ], + "daily_visitors": [ + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + ], + "deltas": { + "bounce_rate": 3.6, + "conversion_rate": -13.3, + "revenue": 12.3, + "revenue_per_visitor": -5.1, + "session_time": -14.4, + "visitors": 3.9, + }, + "online_live": 1, + "recent_replays": 0, + "revenue_per_visitor": 0, + "top_referrers": [ + { + "referrer": "google.com", + "visitors": 9, + }, + { + "referrer": "github.com", + "visitors": 0, + }, + { + "referrer": "twitter.com", + "visitors": 1, + }, + { + "referrer": "producthunt.com", + "visitors": 3, + }, + { + "referrer": "(direct)", + "visitors": 2, + }, + ], + "top_region": { + "count": 0, + "country_code": "US", + "region_code": "CA", + }, + "total_replays": 0, + "total_revenue_cents": 9300, + "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": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "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": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 0, + "date": , + }, + ], + "retained": [ + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 2, + "date": , + }, + ], + "total": [ + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 0, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 1, + "date": , + }, + { + "activity": 4, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + ], + }, + "mau": 1, + "total_teams": 0, + "unverified_users": 0, + "verified_users": 0, + }, "daily_active_users": [ { "activity": 0, @@ -256,7 +1856,471 @@ NiceResponse { "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, @@ -270,6 +2334,1606 @@ exports[`should return metrics data with users > metrics_result_with_users 1`] = NiceResponse { "status": 200, "body": { + "analytics_overview": { + "avg_session_seconds": 597, + "bounce_rate": 72.1, + "conversion_rate": 0.38, + "daily_clicks": [ + { + "activity": 2, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 10, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 9, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 9, + "date": , + }, + { + "activity": 9, + "date": , + }, + { + "activity": 11, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 2, + "date": , + }, + { + "activity": 4, + "date": , + }, + { + "activity": 4, + "date": , + }, + { + "activity": 10, + "date": , + }, + { + "activity": 7, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 3, + "date": , + }, + { + "activity": 4, + "date": , + }, + { + "activity": 5, + "date": , + }, + ], + "daily_page_views": [ + { + "activity": 10, + "date": , + }, + { + "activity": 21, + "date": , + }, + { + "activity": 15, + "date": , + }, + { + "activity": 22, + "date": , + }, + { + "activity": 18, + "date": , + }, + { + "activity": 24, + "date": , + }, + { + "activity": 13, + "date": , + }, + { + "activity": 7, + "date": , + }, + { + "activity": 24, + "date": , + }, + { + "activity": 23, + "date": , + }, + { + "activity": 25, + "date": , + }, + { + "activity": 18, + "date": , + }, + { + "activity": 14, + "date": , + }, + { + "activity": 14, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 22, + "date": , + }, + { + "activity": 31, + "date": , + }, + { + "activity": 27, + "date": , + }, + { + "activity": 25, + "date": , + }, + { + "activity": 16, + "date": , + }, + { + "activity": 9, + "date": , + }, + { + "activity": 11, + "date": , + }, + { + "activity": 21, + "date": , + }, + { + "activity": 24, + "date": , + }, + { + "activity": 33, + "date": , + }, + { + "activity": 23, + "date": , + }, + { + "activity": 30, + "date": , + }, + { + "activity": 17, + "date": , + }, + { + "activity": 16, + "date": , + }, + { + "activity": 18, + "date": , + }, + { + "activity": 21, + "date": , + }, + ], + "daily_revenue": [ + { + "date": , + "new_cents": 335, + "refund_cents": 110, + }, + { + "date": , + "new_cents": 350, + "refund_cents": 68, + }, + { + "date": , + "new_cents": 367, + "refund_cents": 127, + }, + { + "date": , + "new_cents": 352, + "refund_cents": 76, + }, + { + "date": , + "new_cents": 361, + "refund_cents": 108, + }, + { + "date": , + "new_cents": 357, + "refund_cents": 87, + }, + { + "date": , + "new_cents": 338, + "refund_cents": 81, + }, + { + "date": , + "new_cents": 343, + "refund_cents": 97, + }, + { + "date": , + "new_cents": 377, + "refund_cents": 82, + }, + { + "date": , + "new_cents": 355, + "refund_cents": 94, + }, + { + "date": , + "new_cents": 357, + "refund_cents": 125, + }, + { + "date": , + "new_cents": 380, + "refund_cents": 142, + }, + { + "date": , + "new_cents": 366, + "refund_cents": 107, + }, + { + "date": , + "new_cents": 339, + "refund_cents": 99, + }, + { + "date": , + "new_cents": 333, + "refund_cents": 62, + }, + { + "date": , + "new_cents": 375, + "refund_cents": 93, + }, + { + "date": , + "new_cents": 377, + "refund_cents": 85, + }, + { + "date": , + "new_cents": 355, + "refund_cents": 117, + }, + { + "date": , + "new_cents": 395, + "refund_cents": 94, + }, + { + "date": , + "new_cents": 383, + "refund_cents": 152, + }, + { + "date": , + "new_cents": 332, + "refund_cents": 81, + }, + { + "date": , + "new_cents": 352, + "refund_cents": 85, + }, + { + "date": , + "new_cents": 360, + "refund_cents": 78, + }, + { + "date": , + "new_cents": 387, + "refund_cents": 121, + }, + { + "date": , + "new_cents": 388, + "refund_cents": 80, + }, + { + "date": , + "new_cents": 402, + "refund_cents": 133, + }, + { + "date": , + "new_cents": 378, + "refund_cents": 141, + }, + { + "date": , + "new_cents": 352, + "refund_cents": 68, + }, + { + "date": , + "new_cents": 341, + "refund_cents": 74, + }, + { + "date": , + "new_cents": 360, + "refund_cents": 60, + }, + { + "date": , + "new_cents": 388, + "refund_cents": 71, + }, + ], + "daily_visitors": [ + { + "activity": 6, + "date": , + }, + { + "activity": 12, + "date": , + }, + { + "activity": 6, + "date": , + }, + { + "activity": 10, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 12, + "date": , + }, + { + "activity": 7, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 10, + "date": , + }, + { + "activity": 12, + "date": , + }, + { + "activity": 12, + "date": , + }, + { + "activity": 10, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 12, + "date": , + }, + { + "activity": 18, + "date": , + }, + { + "activity": 12, + "date": , + }, + { + "activity": 11, + "date": , + }, + { + "activity": 8, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 5, + "date": , + }, + { + "activity": 10, + "date": , + }, + { + "activity": 11, + "date": , + }, + { + "activity": 16, + "date": , + }, + { + "activity": 11, + "date": , + }, + { + "activity": 17, + "date": , + }, + { + "activity": 9, + "date": , + }, + { + "activity": 9, + "date": , + }, + { + "activity": 11, + "date": , + }, + { + "activity": 12, + "date": , + }, + ], + "deltas": { + "bounce_rate": 3.6, + "conversion_rate": -13.3, + "revenue": 12.3, + "revenue_per_visitor": -5.1, + "session_time": -14.4, + "visitors": 3.9, + }, + "online_live": 1, + "recent_replays": 2, + "revenue_per_visitor": 6.24, + "top_referrers": [ + { + "referrer": "google.com", + "visitors": 15, + }, + { + "referrer": "github.com", + "visitors": 3, + }, + { + "referrer": "twitter.com", + "visitors": 3, + }, + { + "referrer": "producthunt.com", + "visitors": 4, + }, + { + "referrer": "(direct)", + "visitors": 3, + }, + ], + "top_region": { + "count": 5, + "country_code": "US", + "region_code": "CA", + }, + "total_replays": 5, + "total_revenue_cents": 11235, + "visitors": 18, + }, + "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": 1, + "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": 1, + "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": 10, + "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": 10, + "date": , + }, + ], + }, + "mau": 10, + "total_teams": 0, + "unverified_users": 0, + "verified_users": 9, + }, "daily_active_users": [ { "activity": 0, @@ -522,12 +4186,567 @@ NiceResponse { "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": 15, + "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": 15, + }, + ], + "deliverability_rate": 0, + "deliverability_status": { + "bounced": 0, + "delivered": 0, + "error": 0, + "in_progress": 0, + }, + "emails_by_status": { "ok": 15 }, + "emails_sent": 15, + "recent_emails": [ + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + { + "created_at_millis": , + "id": "", + "status": "SENT", + "subject": "Sign in to New Project: Your code is ", + }, + ], + "total_emails": 15, + }, "login_methods": [ { "count": 9, "method": "otp", }, ], + "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": [ { "auth_with_email": true, @@ -801,6 +5020,114 @@ NiceResponse { "server_metadata": null, "signed_up_at_millis": , }, + { + "auth_with_email": true, + "client_metadata": null, + "client_read_only_metadata": null, + "display_name": null, + "has_password": false, + "id": "", + "is_anonymous": false, + "is_restricted": false, + "last_active_at_millis": , + "oauth_providers": [], + "otp_auth_enabled": true, + "passkey_auth_enabled": false, + "primary_email": "mailbox-4--@stack-generated.example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": null, + "requires_totp_mfa": false, + "restricted_by_admin": false, + "restricted_by_admin_private_details": null, + "restricted_by_admin_reason": null, + "restricted_reason": null, + "selected_team": null, + "selected_team_id": null, + "server_metadata": null, + "signed_up_at_millis": , + }, + { + "auth_with_email": true, + "client_metadata": null, + "client_read_only_metadata": null, + "display_name": null, + "has_password": false, + "id": "", + "is_anonymous": false, + "is_restricted": false, + "last_active_at_millis": , + "oauth_providers": [], + "otp_auth_enabled": true, + "passkey_auth_enabled": false, + "primary_email": "mailbox-3--@stack-generated.example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": null, + "requires_totp_mfa": false, + "restricted_by_admin": false, + "restricted_by_admin_private_details": null, + "restricted_by_admin_reason": null, + "restricted_reason": null, + "selected_team": null, + "selected_team_id": null, + "server_metadata": null, + "signed_up_at_millis": , + }, + { + "auth_with_email": true, + "client_metadata": null, + "client_read_only_metadata": null, + "display_name": null, + "has_password": false, + "id": "", + "is_anonymous": false, + "is_restricted": false, + "last_active_at_millis": , + "oauth_providers": [], + "otp_auth_enabled": true, + "passkey_auth_enabled": false, + "primary_email": "mailbox-2--@stack-generated.example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": null, + "requires_totp_mfa": false, + "restricted_by_admin": false, + "restricted_by_admin_private_details": null, + "restricted_by_admin_reason": null, + "restricted_reason": null, + "selected_team": null, + "selected_team_id": null, + "server_metadata": null, + "signed_up_at_millis": , + }, + { + "auth_with_email": true, + "client_metadata": null, + "client_read_only_metadata": null, + "display_name": null, + "has_password": false, + "id": "", + "is_anonymous": false, + "is_restricted": false, + "last_active_at_millis": , + "oauth_providers": [], + "otp_auth_enabled": true, + "passkey_auth_enabled": false, + "primary_email": "mailbox-1--@stack-generated.example.com", + "primary_email_auth_enabled": true, + "primary_email_verified": true, + "profile_image_url": null, + "requires_totp_mfa": false, + "restricted_by_admin": false, + "restricted_by_admin_private_details": null, + "restricted_by_admin_reason": null, + "restricted_reason": null, + "selected_team": null, + "selected_team_id": null, + "server_metadata": null, + "signed_up_at_millis": , + }, ], "total_users": 9, "users_by_country": { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 0123a565e2..63ad090814 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -3,13 +3,47 @@ import { expect } from "vitest"; import { NiceResponse, it } from "../../../../helpers"; import { Auth, InternalApiKey, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers"; +type MetricsUser = { + is_anonymous: boolean, +}; + +type LoginMethodMetric = { + count: number, +}; + async function ensureAnonymousUsersAreStillExcluded(metricsResponse: NiceResponse) { + const baselineTotalUsers = metricsResponse.body.total_users as number; + const baselineUsersByCountry = metricsResponse.body.users_by_country as Record; + const baselineRecentlyRegisteredIds = (metricsResponse.body.recently_registered as Array<{ id: string }>).map((user) => user.id); + const baselineRecentlyActiveIds = (metricsResponse.body.recently_active as Array<{ id: string }>).map((user) => user.id); + for (let i = 0; i < 2; i++) { await Auth.Anonymous.signUp(); } - await wait(2000); // the event log is async, so let's give it some time to be written to the DB - const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); - expect(response.body).toEqual(metricsResponse.body); + + // ClickHouse ingestion is async; poll until anonymous users are excluded again. + let response!: NiceResponse; + for (let i = 0; i < 10; i++) { + await wait(2_000); + response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); + const noAnonymousInRecentlyRegistered = (response.body.recently_registered as MetricsUser[]).every((user) => !user.is_anonymous); + const noAnonymousInRecentlyActive = (response.body.recently_active as MetricsUser[]).every((user) => !user.is_anonymous); + if ( + response.body.total_users === baselineTotalUsers && + JSON.stringify(response.body.users_by_country) === JSON.stringify(baselineUsersByCountry) && + noAnonymousInRecentlyRegistered && + noAnonymousInRecentlyActive + ) { + return; + } + } + + expect(response.body.total_users).toBe(baselineTotalUsers); + expect(response.body.users_by_country).toEqual(baselineUsersByCountry); + expect((response.body.recently_registered as MetricsUser[]).every((user) => !user.is_anonymous)).toBe(true); + expect((response.body.recently_active as MetricsUser[]).every((user) => !user.is_anonymous)).toBe(true); + expect((response.body.recently_registered as Array<{ id: string }>).map((user) => user.id)).toEqual(baselineRecentlyRegisteredIds); + expect((response.body.recently_active as Array<{ id: string }>).map((user) => user.id)).toEqual(baselineRecentlyActiveIds); } async function waitForMetricsToIncludeUsersByCountry(options: { countryCode: string, expectedCount: number }): Promise { @@ -152,10 +186,10 @@ it("should exclude anonymous users from metrics", async ({ expect }) => { // Verify anonymous users don't appear in recently_registered expect(result.body.recently_registered.length).toBe(1); - expect(result.body.recently_registered.every((user: any) => !user.is_anonymous)).toBe(true); + expect(result.body.recently_registered.every((user: MetricsUser) => !user.is_anonymous)).toBe(true); // Verify anonymous users don't appear in recently_active - expect(result.body.recently_active.every((user: any) => !user.is_anonymous)).toBe(true); + expect(result.body.recently_active.every((user: MetricsUser) => !user.is_anonymous)).toBe(true); // Verify anonymous users aren't counted in daily_users const lastDayUsers = result.body.daily_users[result.body.daily_users.length - 1]; @@ -243,7 +277,7 @@ it("should handle mixed auth methods excluding anonymous users", async ({ expect // Login methods should only count regular users' methods const loginMethods = response.body.login_methods; - const totalMethodCount = loginMethods.reduce((sum: number, method: any) => sum + method.count, 0); + const totalMethodCount = loginMethods.reduce((sum: number, method: LoginMethodMetric) => sum + method.count, 0); expect(totalMethodCount).toBe(2); // 1 OTP + 1 password, no anonymous await ensureAnonymousUsersAreStillExcluded(response); @@ -304,6 +338,8 @@ it("should return cross-product aggregates in the metrics response", async ({ ex // Fields used by visitors/revenue hover charts expect(Array.isArray(analyticsOverview.daily_visitors)).toBe(true); expect(Array.isArray(analyticsOverview.daily_revenue)).toBe(true); + expect(typeof analyticsOverview.visitors).toBe('number'); + expect(Array.isArray(analyticsOverview.top_referrers)).toBe(true); }); it("should return correct auth_overview breakdown including teams", async ({ expect }) => { From 6672e70d10989e76dce79a3b9c424be1dc7254a2 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Mar 2026 13:14:26 -0700 Subject: [PATCH 10/30] Enhance ComposedAnalyticsChart with conditional rendering for visitors and revenue metrics - Added `showVisitors` and `showRevenue` props to the `ComposedAnalyticsChart` for better control over displayed metrics. - Updated logic to conditionally render visitor and revenue lines based on the new props. - Introduced `SetupAppPrompt` component in `metrics-page` to guide users in enabling analytics and payments features. - Adjusted `MetricsContent` to handle data visibility based on the availability of analytics and payments apps. --- .../[projectId]/(overview)/line-chart.tsx | 138 ++++++++++-------- .../[projectId]/(overview)/metrics-page.tsx | 113 +++++++++++--- 2 files changed, 172 insertions(+), 79 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index e83647693e..5c0087346c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -715,18 +715,22 @@ function HighlightedLineDot({ cx, cy, fill }: HighlightDotProps) { 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 maxVisitors = Math.max(...datapoints.map(d => Math.max(d.visitors, d.dau)), 1); - const maxRevenueCents = Math.max(...datapoints.map(d => d.new_cents), 1); + 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; @@ -785,35 +789,39 @@ export function ComposedAnalyticsChart({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> - } - isAnimationActive={false} - /> - {hoveredIndex != null && hoveredX != null && ( - } - isAnimationActive={false} - strokeLinecap="round" - strokeLinejoin="round" - style={{ clipPath: `url(#visitors-highlight-clip-${id})` }} - legendType="none" - /> + {showVisitors && ( + <> + } + isAnimationActive={false} + /> + {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(#revenue-highlight-clip-${id})` }} - legendType="none" - /> + {showRevenue && ( + <> + } + isAnimationActive={false} + /> + {hoveredIndex != null && hoveredX != null && ( + } + isAnimationActive={false} + strokeLinecap="round" + strokeLinejoin="round" + style={{ clipPath: `url(#revenue-highlight-clip-${id})` }} + legendType="none" + /> + )} + )} ) export function VisitorsHoverChart({ datapoints, + height, compact = false, }: { datapoints: VisitorsHoverDataPoint[], + height?: number, compact?: boolean, }) { const [hoveredIndex, setHoveredIndex] = useState(null); @@ -2343,6 +2357,7 @@ export function VisitorsHoverChart({ ) export function RevenueHoverChart({ datapoints, + height, compact = false, }: { datapoints: RevenueHoverDataPoint[], + height?: number, compact?: boolean, }) { const [hoveredIndex, setHoveredIndex] = useState(null); @@ -2553,6 +2570,7 @@ export function RevenueHoverChart({ +
+ + Enable{" "} + + {appLabel} + {" "} + in Explore Apps to track {metricLabel}. + + + Open Explore Apps + +
+
+ ); +} + // ── Compact dual-value stat card ───────────────────────────────────────────── function DualStatCard({ @@ -225,7 +257,7 @@ function HeroInChartPill({ {value} - {delta != null && ( + {delta != null && delta !== 0 && ( 0 ? "text-emerald-500 dark:text-emerald-400" : delta < 0 ? "text-red-500 dark:text-red-400" : "text-muted-foreground" @@ -254,6 +286,9 @@ function HeroAnalyticsWidget({ revenueTotal, visitorsDelta, revenueDelta, + analyticsEnabled, + paymentsEnabled, + projectId, compact = false, }: { composedData: ComposedDataPoint[], @@ -270,6 +305,9 @@ function HeroAnalyticsWidget({ revenueTotal: string, visitorsDelta?: number, revenueDelta?: number, + analyticsEnabled: boolean, + paymentsEnabled: boolean, + projectId: string, compact?: boolean, }) { const [chartMode, setChartMode] = useState('default'); @@ -330,6 +368,7 @@ function HeroAnalyticsWidget({ const dauColor = "hsl(152, 38%, 52%)"; const visitorsColor = "hsl(210, 84%, 64%)"; const revenueColor = "hsl(268, 82%, 66%)"; + const chartViewportHeight = compact ? 260 : 320; return (
@@ -398,7 +437,7 @@ function HeroAnalyticsWidget({
{/* Chart area with fade transition */} -
+
) @@ -429,30 +471,41 @@ function HeroAnalyticsWidget({ ) : ( ) )} {displayMode === 'visitors' && ( - visitorsData.length === 0 ? ( + !analyticsEnabled ? ( +
+ +
+ ) : visitorsData.length === 0 ? (
No visitor data available
) : ( ) )} {displayMode === 'revenue' && ( - revenueData.length === 0 ? ( + !paymentsEnabled ? ( +
+ +
+ ) : revenueData.length === 0 ? (
No revenue data available
) : ( ) @@ -696,8 +749,12 @@ function EmailBreakdownCard({ function ReferrersWithAnalyticsCard({ topReferrers, + analyticsEnabled, + projectId, }: { topReferrers: Array<{ referrer: string, visitors: number }>, + analyticsEnabled: boolean, + projectId: string, }) { const listWindow = useInfiniteListWindow(topReferrers.length); @@ -707,7 +764,9 @@ function ReferrersWithAnalyticsCard({ Top Referrers
- {topReferrers.length === 0 ? ( + {!analyticsEnabled ? ( + + ) : topReferrers.length === 0 ? (
No referrer data
@@ -866,6 +925,8 @@ function MetricsContent({ .map(([appId]) => appId as AppId), [config.apps.installed] ); + const analyticsEnabled = installedApps.includes("analytics"); + const paymentsEnabled = installedApps.includes("payments"); const auth = data.auth_overview ?? {}; const payments = data.payments_overview ?? {}; @@ -942,14 +1003,14 @@ function MetricsContent({ const points = [...allDates].map(date => ({ date, - visitors: visitorMap.get(date) ?? 0, - new_cents: revenueMap.get(date)?.new_cents ?? 0, - refund_cents: revenueMap.get(date)?.refund_cents ?? 0, + 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)); return points; - }, [data.analytics_overview, dauStackedData, dauTotalsByDate]); + }, [data.analytics_overview, dauStackedData, dauTotalsByDate, analyticsEnabled, paymentsEnabled]); const composedData = useMemo( () => filterStackedDatapointsByTimeRange(allComposedData, timeRange, customDateRange), [allComposedData, timeRange, customDateRange], @@ -974,6 +1035,9 @@ function MetricsContent({ // ── Visitors hover chart data (page views with top countries) ───────────── const visitorsHoverData = useMemo(() => { + if (!analyticsEnabled) { + return []; + } const analyticsObj = data.analytics_overview ?? {}; const dailyPv = (analyticsObj.daily_page_views ?? []) as DataPoint[]; @@ -987,10 +1051,13 @@ function MetricsContent({ })).sort((a, b) => stringCompare(a.date, b.date)); return filterStackedDatapointsByTimeRange(points, timeRange, customDateRange); - }, [data.analytics_overview, timeRange, customDateRange, topCountries]); + }, [data.analytics_overview, timeRange, customDateRange, topCountries, analyticsEnabled]); // ── Revenue hover chart data (new_cents + refund_cents) ─────────────────── const revenueHoverData = useMemo(() => { + if (!paymentsEnabled) { + return []; + } const analyticsObj = data.analytics_overview ?? {}; const dailyRev = (analyticsObj.daily_revenue ?? []) as Array<{ date: string, new_cents: number, refund_cents: number }>; @@ -1001,12 +1068,13 @@ function MetricsContent({ })).sort((a, b) => stringCompare(a.date, b.date)); return filterStackedDatapointsByTimeRange(points, timeRange, customDateRange); - }, [data.analytics_overview, timeRange, customDateRange]); + }, [data.analytics_overview, timeRange, customDateRange, paymentsEnabled]); // ── Hero outer stats: MAUs, Total Emails sent, Session time ─────────────── const heroOuterStats = useMemo(() => { const analyticsObj = data.analytics_overview ?? {}; - const mau = (auth.mau ?? 0) as number; + const totalUsers = data.total_users ?? 0; + const mau = Math.min((auth.mau ?? 0) as number, totalUsers); const totalEmailsSent = (email.emails_sent ?? 0) as number; return [ { @@ -1019,10 +1087,10 @@ function MetricsContent({ }, { label: "Avg. Session time", - value: formatSeconds(analyticsObj.avg_session_seconds ?? 0), + value: analyticsEnabled ? formatSeconds(analyticsObj.avg_session_seconds ?? 0) : "—", }, ]; - }, [auth.mau, email.emails_sent, data.analytics_overview]); + }, [auth.mau, email.emails_sent, data.analytics_overview, data.total_users, analyticsEnabled]); // ── In-chart pill values: Visitors and Revenue ──────────────────────────── const inChartPillValues = useMemo(() => { @@ -1056,14 +1124,16 @@ function MetricsContent({ dauLabel: "Daily Active Users", // DAU delta is day-over-day and independent from the selected time range. dauDelta: previousDau == null ? undefined : calculatePeriodDelta(latestDau, previousDau), - visitorsTotal: formatCompact(visitorsTotalInRange), + visitorsTotal: analyticsEnabled ? formatCompact(visitorsTotalInRange) : "—", visitorsLabel: "Unique Visitors", - visitorsDelta: hasFullPreviousComposedWindow ? calculatePeriodDelta(visitorsTotalInRange, previousVisitorsTotal) : undefined, - revenueTotal: formatUsdFromCents(totalRevenueCentsInRange > 0 ? totalRevenueCentsInRange : (payments.revenue_cents ?? 0)), + visitorsDelta: analyticsEnabled && hasFullPreviousComposedWindow ? calculatePeriodDelta(visitorsTotalInRange, previousVisitorsTotal) : undefined, + revenueTotal: paymentsEnabled + ? formatUsdFromCents(totalRevenueCentsInRange > 0 ? totalRevenueCentsInRange : (payments.revenue_cents ?? 0)) + : "—", revenueLabel: "Revenue", - revenueDelta: hasFullPreviousComposedWindow ? calculatePeriodDelta(totalRevenueCentsInRange, previousRevenueTotalCents) : undefined, + revenueDelta: paymentsEnabled && hasFullPreviousComposedWindow ? calculatePeriodDelta(totalRevenueCentsInRange, previousRevenueTotalCents) : undefined, }; - }, [allComposedData, composedData, dauStackedData, payments.revenue_cents]); + }, [allComposedData, composedData, dauStackedData, payments.revenue_cents, analyticsEnabled, paymentsEnabled]); // ── Globe visibility ────────────────────────────────────────────────────── const gridContainerRef = useRef(null); @@ -1154,6 +1224,9 @@ function MetricsContent({ dauStackedData={filteredDauStackedData} visitorsData={visitorsHoverData} revenueData={revenueHoverData} + analyticsEnabled={analyticsEnabled} + paymentsEnabled={paymentsEnabled} + projectId={projectId} outerStats={heroOuterStatsForLayout} dauLabel={inChartPillValues.dauLabel} dauTotal={inChartPillValues.dauTotal} @@ -1224,6 +1297,8 @@ function MetricsContent({ />
From 659b5a6d4fd27f64831037248aa089cd26e81964 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Mar 2026 14:19:10 -0700 Subject: [PATCH 11/30] Enhance PageLayout and ComposedAnalyticsChart with new props and conditional rendering - Added `fullBleed` and `wrapHeaderInCard` props to the `PageLayout` component for improved layout flexibility. - Updated `ComposedAnalyticsChart` to utilize `useMemo` for tagged data points, enhancing performance and conditional rendering of visitor and revenue metrics based on new props. - Improved tooltip display logic in `ComposedAnalyticsChart` to handle visibility based on user preferences for visitors and revenue data. --- .../[projectId]/(overview)/line-chart.tsx | 146 +++++++++--------- .../[projectId]/(overview)/metrics-page.tsx | 132 ++++++++-------- .../projects/[projectId]/page-layout.tsx | 46 ++++-- 3 files changed, 173 insertions(+), 151 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx index 5c0087346c..18f2189630 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/line-chart.tsx @@ -17,7 +17,7 @@ import { import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import { UserAvatar } from '@stackframe/stack'; import { fromNow, isWeekend } from '@stackframe/stack-shared/dist/utils/dates'; -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts"; export type CustomDateRange = { @@ -593,6 +593,8 @@ export type ComposedDataPoint = { refund_cents: number, visitors: number, dau: number, + _showVisitors?: boolean, + _showRevenue?: boolean, }; export type VisitorsHoverDataPoint = { @@ -645,8 +647,10 @@ function ComposedTooltip({ active, payload }: TooltipProps) { ? 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 = row.visitors > 0 ? (revenueDollars / row.visitors) : 0; + const revenuePerVisitor = visitorsEnabled && revenueEnabled && row.visitors > 0 ? (revenueDollars / row.visitors) : null; return (
@@ -671,7 +675,7 @@ function ComposedTooltip({ active, payload }: TooltipProps) { Unique visitors
- {row.visitors.toLocaleString()} + {visitorsEnabled ? row.visitors.toLocaleString() : "—"}
@@ -681,7 +685,7 @@ function ComposedTooltip({ active, payload }: TooltipProps) { Revenue
- ${revenueDollars.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {revenueEnabled ? `$${revenueDollars.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—"}
@@ -689,7 +693,7 @@ function ComposedTooltip({ active, payload }: TooltipProps) {
Revenue/visitor - ${revenuePerVisitor.toFixed(2)} + {revenuePerVisitor != null ? `$${revenuePerVisitor.toFixed(2)}` : "—"}
@@ -729,6 +733,10 @@ export function ComposedAnalyticsChart({ 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); @@ -744,7 +752,7 @@ export function ComposedAnalyticsChart({ > { updateHoveredIndexFromChartState(state, datapoints.length, setHoveredIndex); @@ -789,39 +797,35 @@ export function ComposedAnalyticsChart({ allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }} /> - {showVisitors && ( - <> - } - isAnimationActive={false} - /> - {hoveredIndex != null && hoveredX != null && ( - } - isAnimationActive={false} - strokeLinecap="round" - strokeLinejoin="round" - style={{ clipPath: `url(#visitors-highlight-clip-${id})` }} - legendType="none" - /> - )} - + : false} + isAnimationActive={false} + /> + {showVisitors && hoveredIndex != null && hoveredX != null && ( + } + isAnimationActive={false} + strokeLinecap="round" + strokeLinejoin="round" + style={{ clipPath: `url(#visitors-highlight-clip-${id})` }} + legendType="none" + /> )} )} - {showRevenue && ( - <> - } - isAnimationActive={false} - /> - {hoveredIndex != null && hoveredX != null && ( - } - isAnimationActive={false} - strokeLinecap="round" - strokeLinejoin="round" - style={{ clipPath: `url(#revenue-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" + /> )} { if ( selectedId === '7d' || 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 c7201e5362..532ba84e99 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,13 +1,12 @@ '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, type AppId, getAppPath } from "@/lib/apps-frontend"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; -import { DesignListItemRow } from "@/components/design-components/list"; -import { DesignAnalyticsCard, DesignCategoryTabs, DesignChartLegend, useInfiniteListWindow } from "@/components/design-components"; import { CompassIcon, EnvelopeIcon, EnvelopeOpenIcon, GlobeIcon, SquaresFourIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react"; import useResizeObserver from '@react-hook/resize-observer'; import { useUser } from "@stackframe/stack"; @@ -803,68 +802,73 @@ function ReferrersWithAnalyticsCard({ function QuickAccessApps({ projectId, installedApps }: { projectId: string, installedApps: AppId[] }) { return ( -
-
-
- +
+
+
+
+ +
+ + Quick Access +
- - 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 ( - -
- -
- + + 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} - - - ); - })} - - -
-
- +
+ +
+ + {app.displayName} + + + ); + })} + + +
+
+ +
-
- - Explore - - -
- )} + + Explore + + +
+ )} +
); } @@ -894,6 +898,8 @@ export default function MetricsPage(props: { toSetup: () => void }) {
} fillWidth + fullBleed + wrapHeaderInCard > }> @@ -1195,11 +1201,15 @@ function MetricsContent({ : { minHeight: 400 }} > {shouldShowGlobe && ( -
+
-
+
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} -
- )}
)}
Date: Wed, 11 Mar 2026 14:42:11 -0700 Subject: [PATCH 12/30] fix --- .../(protected)/projects/[projectId]/emails/page-client.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 16aebb5edc..b8f395f071 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"; From 515d695fdad0619a06fea51c2b7991ac9a77087c Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Mar 2026 18:38:30 -0700 Subject: [PATCH 13/30] Refactor seeding functions and enhance metrics loading logic - Updated `daysAgo` function in `seedDummyUsers` and `seedDummyEmails` to set hours correctly and improve date handling. - Refactored metrics loading in `loadDailyActiveUsersSplit` and `loadDailyActiveTeamsSplit` to streamline database queries and enhance performance. - Improved handling of active user and team IDs to ensure accurate data retrieval. - Added tooltip escape styles directly in the `DesignAnalyticsCard` component for better tooltip visibility. - Introduced a draft history section in the email drafts page to display sent drafts, enhancing user experience. --- apps/backend/prisma/seed.ts | 19 ++- .../app/api/latest/internal/metrics/route.tsx | 156 ++++++++++-------- .../[projectId]/(overview)/metrics-page.tsx | 59 ++----- .../[projectId]/email-drafts/page-client.tsx | 34 +++- .../[projectId]/email-themes/page-client.tsx | 11 +- .../[projectId]/emails/page-client.tsx | 7 +- apps/dashboard/src/app/globals.css | 12 +- .../design-components/analytics-card.tsx | 10 -- .../endpoints/api/v1/internal-metrics.test.ts | 6 +- claude/CLAUDE-KNOWLEDGE.md | 2 +- 10 files changed, 168 insertions(+), 148 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index a4c764ffaf..f381c0381f 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -587,7 +587,13 @@ async function seedDummyTeams(options: SeedDummyTeamsOptions): Promise> { const { prisma, tenancy, teamNameToId } = options; - const daysAgo = (d: number, h: number = 12) => new Date(Date.now() - d * 24 * 60 * 60 * 1000 + h * 60 * 60 * 1000); + const daysAgo = (d: number, h: number = 12) => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - d); + date.setHours(h, 0, 0, 0); + return date; + }; const userSeeds = [ { @@ -1889,7 +1895,13 @@ async function seedDummyEmails(options: EmailSeedOptions) { isQueued?: boolean, }; - const emailDaysAgo = (d: number, h: number = 12) => new Date(Date.now() - d * 24 * 60 * 60 * 1000 + h * 60 * 60 * 1000); + const emailDaysAgo = (d: number, h: number = 12) => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(date.getDate() - d); + date.setHours(h, 0, 0, 0); + return date; + }; const emailSeeds: EmailOutboxSeedExtended[] = [ { @@ -2099,7 +2111,8 @@ async function seedDummyEmails(options: EmailSeedOptions) { const dayBack = dailyEmailCounts.length - dayOffset; for (let j = 0; j < count; j++) { - const bulkId = generateUuid(); + // Deterministic ID so seeding is idempotent across runs + const bulkId = `66000000-bulk-seed-${String(emailBulkIndex).padStart(4, '0')}-000000000000`; const hour = 7 + Math.floor(emailBulkRand() * 14); const createdAt = emailDaysAgo(dayBack, hour); const subject = bulkEmailSubjects[Math.floor(emailBulkRand() * bulkEmailSubjects.length)]; 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 ff924404f5..3eac29d7ac 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -227,42 +227,45 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno const clickhouseClient = getClickhouseAdminClient(); const prisma = await getPrismaClientForTenancy(tenancy); - const [userRows, users] = await Promise.all([ - 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 }[]>), - prisma.projectUser.findMany({ + 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 activeUserIds = [...new Set(userRows.map((row) => row.user_id))]; + const users = activeUserIds.length === 0 + ? [] + : await prisma.projectUser.findMany({ where: { tenancyId: tenancy.id, + projectUserId: { in: activeUserIds }, ...(includeAnonymous ? {} : { isAnonymous: false }), }, select: { projectUserId: true, createdAt: true, }, - }), - ]); + }); const orderedDays: string[] = []; const idsByDay = new Map>(); @@ -298,37 +301,42 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise= {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), + 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 activeTeamIds = [...new Set(teamRows.map((row) => row.team_id))]; + const teams = activeTeamIds.length === 0 + ? [] + : await prisma.team.findMany({ + where: { + tenancyId: tenancy.id, + teamId: { in: activeTeamIds }, }, - format: "JSONEachRow", - }).then((result) => result.json() as Promise<{ day: string, team_id: string }[]>), - prisma.team.findMany({ - where: { tenancyId: tenancy.id }, select: { teamId: true, createdAt: true, }, - }), - ]); + }); const orderedDays: string[] = []; const idsByDay = new Map>(); @@ -471,9 +479,11 @@ async function loadPaymentsOverview(tenancy: Tenancy) { where: { tenancyId: tenancy.id }, }), prisma.subscription.findMany({ - where: { tenancyId: tenancy.id }, + where: { + tenancyId: tenancy.id, + createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, + }, orderBy: { createdAt: 'desc' }, - take: 30, select: { createdAt: true, status: true, customerType: true, productId: true }, }), prisma.subscriptionInvoice.aggregate({ @@ -488,11 +498,11 @@ async function loadPaymentsOverview(tenancy: Tenancy) { }), ]); - // Build subscriptions-by-status map - const subsByStatus: Record = {}; + const subsByStatusMap = new Map(); for (const group of subscriptionsByStatus) { - subsByStatus[group.status.toLowerCase()] = group._count._all; + subsByStatusMap.set(group.status.toLowerCase(), group._count._all); } + const subsByStatus = Object.fromEntries(subsByStatusMap); // Daily subscription signups for the last 30 days const now = new Date(); @@ -591,10 +601,11 @@ async function loadEmailOverview(tenancy: Tenancy) { })(), ]); - const emailsByStatus: Record = {}; + const emailsByStatusMap = new Map(); for (const group of statusGroups) { - emailsByStatus[group.simpleStatus.toLowerCase().replace('_', '-')] = group._count._all; + emailsByStatusMap.set(group.simpleStatus.toLowerCase().replace('_', '-'), group._count._all); } + const emailsByStatus = Object.fromEntries(emailsByStatusMap); // Daily email sends for last 30 days const emailByDay = new Map(); @@ -611,9 +622,9 @@ async function loadEmailOverview(tenancy: Tenancy) { const totalEmails = Object.values(emailsByStatus).reduce((a, b) => a + b, 0); const denom = finishedSendingCount > 0 ? finishedSendingCount : 1; - const deliverabilityRate = Number(((deliveredCount / denom) * 100).toFixed(2)); - const bounceRate = Number(((bouncedCount / denom) * 100).toFixed(2)); - const clickRate = Number(((clickedCount / denom) * 100).toFixed(2)); + 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 }; @@ -916,7 +927,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo new_cents: 0, refund_cents: 0, })), - total_revenue_cents: 0, + total_revenue_cents: replayResult.totalRevenueCents, total_replays: replayResult.total, recent_replays: replayResult.recent, visitors, @@ -1098,7 +1109,7 @@ function generateDevAnalyticsOverview(now: Date, totalUsers: number) { async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) { const prisma = await getPrismaClientForTenancy(tenancy); - const [totalUsers, verifiedUsers, anonymousUsers, totalTeams] = await Promise.all([ + const [totalUsers, verifiedUsers, verifiedNonAnonymousUsers, anonymousUsers, totalTeams] = await Promise.all([ prisma.projectUser.count({ where: { tenancyId: tenancy.id } }), prisma.projectUser.count({ where: { @@ -1106,11 +1117,18 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now contactChannels: { some: { type: 'EMAIL', isVerified: true } }, }, }), + prisma.projectUser.count({ + where: { + tenancyId: tenancy.id, + isAnonymous: false, + contactChannels: { some: { type: 'EMAIL', isVerified: true } }, + }, + }), prisma.projectUser.count({ where: { tenancyId: tenancy.id, isAnonymous: true } }), prisma.team.count({ where: { tenancyId: tenancy.id } }), ]); - const filteredTotal = includeAnonymous ? totalUsers : totalUsers - anonymousUsers; + const nonAnonymousTotal = totalUsers - anonymousUsers; const [dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([ loadDailyActiveUsersSplit(tenancy, now, includeAnonymous), @@ -1119,8 +1137,8 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now ]); return { - verified_users: verifiedUsers, - unverified_users: filteredTotal - verifiedUsers, + verified_users: includeAnonymous ? verifiedUsers : verifiedNonAnonymousUsers, + unverified_users: nonAnonymousTotal - verifiedNonAnonymousUsers, anonymous_users: anonymousUsers, total_teams: totalTeams, mau, 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 532ba84e99..577b62ae8e 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 @@ -25,9 +25,7 @@ import { DonutChartDisplay, EmailStackedBarChartDisplay, EmailStackedDataPoint, - filterDatapointsByTimeRange, filterStackedDatapointsByTimeRange, - GradientColor, LineChartDisplayConfig, RevenueHoverChart, RevenueHoverDataPoint, @@ -66,10 +64,6 @@ function formatSeconds(seconds: number): string { return m > 0 ? `${m}m ${s}s` : `${s}s`; } -function sumRange(points: DataPoint[], range: TimeRange, customDateRange: CustomDateRange | null): number { - return filterDatapointsByTimeRange(points, range, customDateRange).reduce((s, p) => s + p.activity, 0); -} - 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`; @@ -132,43 +126,6 @@ function SetupAppPrompt({ ); } -// ── Compact dual-value stat card ───────────────────────────────────────────── - -function DualStatCard({ - label, - value, - subLabel, - subValue, - gradientColor = "blue", -}: { - label: string, - value: string | number, - subLabel: string, - subValue: string | number, - gradientColor?: GradientColor, -}) { - return ( - -
- - {label} - -
-
- {typeof value === 'number' ? value.toLocaleString() : value} -
-
- {subLabel} - - {typeof subValue === 'number' ? subValue.toLocaleString() : subValue} - -
-
-
-
- ); -} - // ── Hero analytics widget (stat pills + composed bar+line chart) ───────────── type AnalyticsStatPill = { @@ -220,6 +177,7 @@ function HeroInChartPill({ color, isHovered, onMouseEnter, + onMouseLeave, }: { label: string, value: string, @@ -227,11 +185,17 @@ function HeroInChartPill({ color: string, isHovered: boolean, onMouseEnter: () => void, + onMouseLeave?: () => void, }) { return ( + ))} +
+
+ + )} + {/* Shared SMTP Warning Dialog */}
@@ -173,12 +172,6 @@ export default function PageClient() { Currently using {selectedThemeData.displayName}
- - Active Theme - - - Currently using {selectedThemeData.displayName} -
@@ -59,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 ( @@ -78,6 +79,8 @@ export default function PageClient() { } >
+ {isLocalEmulator && } + {/* Email Server Card */} diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 84e1661a78..59bd0aced5 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -133,13 +133,19 @@ @layer base { * { @apply border-border; - } - - * { scrollbar-width: thin; scrollbar-color: hsl(var(--foreground) / 0.22) transparent; } + .analytics-card-tooltip-escape .recharts-tooltip-wrapper { + z-index: 9999 !important; + overflow: visible !important; + } + + .analytics-card-tooltip-escape .recharts-tooltip-wrapper > * { + overflow: visible !important; + } + *::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/apps/dashboard/src/components/design-components/analytics-card.tsx b/apps/dashboard/src/components/design-components/analytics-card.tsx index 113555c499..b6eb7bb003 100644 --- a/apps/dashboard/src/components/design-components/analytics-card.tsx +++ b/apps/dashboard/src/components/design-components/analytics-card.tsx @@ -78,16 +78,6 @@ export function DesignAnalyticsCard({ return ( <> - {/* Inject tooltip-escape styles once per card instance. */} -
!user.is_anonymous); const noAnonymousInRecentlyActive = (response.body.recently_active as MetricsUser[]).every((user) => !user.is_anonymous); + const currentRecentlyRegisteredIds = (response.body.recently_registered as Array<{ id: string }>).map((user) => user.id); + const currentRecentlyActiveIds = (response.body.recently_active as Array<{ id: string }>).map((user) => user.id); if ( response.body.total_users === baselineTotalUsers && JSON.stringify(response.body.users_by_country) === JSON.stringify(baselineUsersByCountry) && noAnonymousInRecentlyRegistered && - noAnonymousInRecentlyActive + noAnonymousInRecentlyActive && + JSON.stringify(currentRecentlyRegisteredIds) === JSON.stringify(baselineRecentlyRegisteredIds) && + JSON.stringify(currentRecentlyActiveIds) === JSON.stringify(baselineRecentlyActiveIds) ) { return; } diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 1e18764a1b..3c78f223e0 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -125,7 +125,7 @@ Q: How can the Top Referrers card on overview support infinite lazy loading? A: In `metrics-page.tsx`, make the referrers list container scrollable (`min-h-0 overflow-y-auto`) and append rows incrementally via an `IntersectionObserver` sentinel (e.g. 12 rows per batch). In `internal/metrics/route.tsx`, raise the ClickHouse referrer query limit (e.g. `TOP_REFERRERS_PAGE_SIZE = 100`) so the UI has enough rows to lazy-load. Q: Where does the shared glassmorphic chart-card shell live after the design-component refactor? -A: In `packages/dashboard/src/components/design-components/analytics-card.tsx`. It exports `DesignAnalyticsCard` (the glass card with Recharts tooltip escape), `DesignAnalyticsCardHeader` (compact header row with divider), `DesignChartLegend` (dot+label legend strip), `useInfiniteListWindow` (IntersectionObserver-based incremental list hook), and `DesignInfiniteScrollList` (a scroll container that drives `useInfiniteListWindow`). The page-local `ChartCard` wrapper in `line-chart.tsx` and all `GlassCard` clones in emails/email-drafts/email-themes pages were replaced with `DesignAnalyticsCard`. +A: In `apps/dashboard/src/components/design-components/analytics-card.tsx`. It exports `DesignAnalyticsCard` (the glass card with Recharts tooltip escape), `DesignAnalyticsCardHeader` (compact header row with divider), `DesignChartLegend` (dot+label legend strip), `useInfiniteListWindow` (IntersectionObserver-based incremental list hook), and `DesignInfiniteScrollList` (a scroll container that drives `useInfiniteListWindow`). The page-local `ChartCard` wrapper in `line-chart.tsx` and all `GlassCard` clones in emails/email-drafts/email-themes pages were replaced with `DesignAnalyticsCard`. Q: How do you fix "RefObject is not assignable to LegacyRef" TS errors when using useRef with JSX in React 19? A: In React 19 with TypeScript 5.x, `useRef(null)` returns `RefObject`, but JSX `ref` props still expect `RefObject`. Cast the result: `const ref = useRef(null) as React.RefObject`. Then inside effects, cast `.current` back to `T | null` when doing null checks to avoid triggering `@typescript-eslint/no-unnecessary-condition`. From d15f3f98fe81e80d819ca189533d34561003d436 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Mar 2026 18:59:00 -0700 Subject: [PATCH 14/30] Refactor date handling in metrics loading functions - Updated date parsing in `loadDailyActiveUsers`, `loadDailyActiveUsersSplit`, `loadDailyActiveTeamsSplit`, and `loadAnalyticsOverview` to simplify date extraction by removing unnecessary timezone handling. - Improved code readability and consistency across metrics loading functions. --- .../src/app/api/latest/internal/metrics/route.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 3eac29d7ac..26d2ee57b6 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -142,7 +142,7 @@ 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)); } @@ -275,7 +275,7 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno idsByDay.set(date, new Set()); } for (const row of userRows) { - const day = new Date(row.day + 'Z').toISOString().split('T')[0]; + const day = row.day.split('T')[0]; const daySet = idsByDay.get(day); if (daySet) { daySet.add(row.user_id); @@ -346,7 +346,7 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise()); } for (const row of teamRows) { - const day = new Date(row.day + 'Z').toISOString().split('T')[0]; + const day = row.day.split('T')[0]; const daySet = idsByDay.get(day); if (daySet) { daySet.add(row.team_id); @@ -886,18 +886,18 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const pvByDay = new Map(); for (const row of pvRows) { - const key = new Date(row.day + 'Z').toISOString().split('T')[0]; + const key = row.day.split('T')[0]; pvByDay.set(key, Number(row.cnt)); } const clByDay = new Map(); for (const row of clRows) { - const key = new Date(row.day + 'Z').toISOString().split('T')[0]; + const key = row.day.split('T')[0]; clByDay.set(key, Number(row.cnt)); } const visitorRows: { day: string, cnt: number }[] = await dailyVisitorResult.json(); const visitorByDay = new Map(); for (const row of visitorRows) { - const key = new Date(row.day + 'Z').toISOString().split('T')[0]; + const key = row.day.split('T')[0]; visitorByDay.set(key, Number(row.cnt)); } const totalVisitorRows: { visitors: number }[] = await totalVisitorResult.json(); From d3bd0f2442db63aba4efebf5aa6f180309c3bd03 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Thu, 12 Mar 2026 11:18:17 -0700 Subject: [PATCH 15/30] Refactor UUID handling and improve metrics data sanitization - Updated the `bulkId` generation in `seedDummyEmails` for better idempotency. - Introduced `normalizeUuidFromEvent` function to sanitize user and team IDs in metrics loading functions, ensuring only valid UUIDs are processed. - Enhanced the logic in `loadDailyActiveUsersSplit` and `loadDailyActiveTeamsSplit` to utilize sanitized data, improving data integrity and accuracy in metrics retrieval. - Removed unnecessary mouse leave event handlers in `HeroAnalyticsWidget` for cleaner interaction. --- apps/backend/prisma/seed.ts | 2 +- .../app/api/latest/internal/metrics/route.tsx | 30 ++++++++++++++++--- .../[projectId]/(overview)/metrics-page.tsx | 3 -- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index f381c0381f..543e1a3260 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -2112,7 +2112,7 @@ async function seedDummyEmails(options: EmailSeedOptions) { for (let j = 0; j < count; j++) { // Deterministic ID so seeding is idempotent across runs - const bulkId = `66000000-bulk-seed-${String(emailBulkIndex).padStart(4, '0')}-000000000000`; + const bulkId = `66000000-0000-4000-8000-${emailBulkIndex.toString(16).padStart(12, '0')}`; const hour = 7 + Math.floor(emailBulkRand() * 14); const createdAt = emailDaysAgo(dayBack, hour); const subject = bulkEmailSubjects[Math.floor(emailBulkRand() * bulkEmailSubjects.length)]; 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 26d2ee57b6..ca87a693f8 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -6,6 +6,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; 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, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import yup from 'yup'; import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud"; @@ -24,6 +25,11 @@ function formatClickhouseDateTimeParam(date: Date): string { return date.toISOString().slice(0, 19); } +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({ @@ -252,7 +258,15 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno format: "JSONEachRow", }).then((result) => result.json() as Promise<{ day: string, user_id: string }[]>); - const activeUserIds = [...new Set(userRows.map((row) => row.user_id))]; + 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 = activeUserIds.length === 0 ? [] : await prisma.projectUser.findMany({ @@ -274,7 +288,7 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno orderedDays.push(date); idsByDay.set(date, new Set()); } - for (const row of userRows) { + for (const row of sanitizedUserRows) { const day = row.day.split('T')[0]; const daySet = idsByDay.get(day); if (daySet) { @@ -324,7 +338,15 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise result.json() as Promise<{ day: string, team_id: string }[]>); - const activeTeamIds = [...new Set(teamRows.map((row) => row.team_id))]; + 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 = activeTeamIds.length === 0 ? [] : await prisma.team.findMany({ @@ -345,7 +367,7 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise()); } - for (const row of teamRows) { + for (const row of sanitizedTeamRows) { const day = row.day.split('T')[0]; const daySet = idsByDay.get(day); if (daySet) { 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 577b62ae8e..67b759e091 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 @@ -378,7 +378,6 @@ function HeroAnalyticsWidget({ color={dauColor} isHovered={chartMode === 'dau'} onMouseEnter={() => handlePillMouseEnter('dau')} - onMouseLeave={handlePillMouseLeave} />
handlePillMouseEnter('visitors')} - onMouseLeave={handlePillMouseLeave} />
handlePillMouseEnter('revenue')} - onMouseLeave={handlePillMouseLeave} />
From 5c42f80322fa4b70db5a3a0dbf8e21477bf433cc Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 3 Apr 2026 17:07:32 -0700 Subject: [PATCH 16/30] Fix PR comments --- apps/backend/prisma/seed.ts | 14 +++++++---- .../app/api/latest/internal/metrics/route.tsx | 14 ++++++++--- .../playground/page-client.tsx | 6 ++--- .../[projectId]/(overview)/metrics-page.tsx | 12 +++++----- .../[projectId]/email-drafts/page-client.tsx | 24 +++++++++---------- .../[projectId]/emails/page-client.tsx | 12 +++++++++- apps/dashboard/src/app/globals.css | 1 + .../endpoints/api/v1/internal-metrics.test.ts | 4 +++- 8 files changed, 54 insertions(+), 33 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 7d73b0b133..31c4ce1ff0 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -892,6 +892,7 @@ async function seedDummyUsers(options: SeedDummyUsersOptions): Promise= {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, @@ -461,8 +462,15 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea }, format: "JSONEachRow", }); - const rows: { mau: number }[] = await result.json(); - return Number(rows[0]?.mau ?? 0); + 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) { captureError("internal-metrics-load-monthly-active-users-failed", new StackAssertionError( "Failed to load monthly active users for internal metrics.", 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 856b4c73c0..e01098195a 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 @@ -11,6 +11,7 @@ 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 { @@ -31,15 +32,12 @@ import { import { CursorBlastEffect, DesignAlert, - DesignAnalyticsCard, - DesignAnalyticsCardHeader, DesignBadge, type DesignBadgeColor, type DesignBadgeContentMode, DesignButton, DesignCard, DesignCategoryTabs, - DesignChartLegend, DesignInput, DesignPillToggle, } from "@stackframe/dashboard-ui-components"; @@ -1785,7 +1783,7 @@ export default function PageClient() { ? `\n ` : ""; const legendSnippet = analyticsCardShowLegend - ? `\n ` + ? `\n ` : ""; return ` s + i.count, 0); 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 9a55bdf4a5..531416c9db 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 @@ -265,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); } @@ -379,17 +382,12 @@ export default function PageClient() {
{historyDrafts.map((draft) => ( - + draft={draft} + onOpen={() => handleOpenHistoryDraft(draft.id)} + onDelete={() => runAsynchronouslyWithAlert(() => handleDeleteDraft(draft.id))} + /> ))}
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 4249d8a48b..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 @@ -93,6 +93,11 @@ export default function PageClient() { } function EmulatorModeCard() { + const inbucketWebUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_INBUCKET_WEB_URL"); + const inbucketMonitorUrl = inbucketWebUrl == null + ? null + : `${inbucketWebUrl.replace(/\/$/, "")}/monitor`; + return (
@@ -107,8 +112,13 @@ function EmulatorModeCard() { variant='secondary' size="sm" className="h-8 px-3 text-xs gap-1.5 flex-shrink-0" + disabled={inbucketMonitorUrl == null} + title={inbucketMonitorUrl == null ? "Set NEXT_PUBLIC_STACK_INBUCKET_WEB_URL to open Inbucket" : undefined} onClick={() => { - window.open(getPublicEnvVar('NEXT_PUBLIC_STACK_INBUCKET_WEB_URL') + '/monitor', '_blank'); + if (inbucketMonitorUrl == null) { + throwErr("NEXT_PUBLIC_STACK_INBUCKET_WEB_URL must be configured to open Inbucket monitor."); + } + window.open(inbucketMonitorUrl, "_blank", "noopener,noreferrer"); }} > diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 59bd0aced5..e041e515c6 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -133,6 +133,7 @@ @layer base { * { @apply border-border; + scrollbar-width: thin; scrollbar-color: hsl(var(--foreground) / 0.22) transparent; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 35b3b7ccc8..673e5c7973 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -1,7 +1,7 @@ import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { expect } from "vitest"; import { NiceResponse, it } from "../../../../helpers"; -import { Auth, InternalApiKey, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, InternalApiKey, Project, Team, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers"; type MetricsUser = { is_anonymous: boolean, @@ -352,6 +352,7 @@ it("should return correct auth_overview breakdown including teams", async ({ exp magic_link_enabled: true, } }); + await Team.create(); await InternalApiKey.createAndSetProjectKeys(); @@ -377,4 +378,5 @@ it("should return correct auth_overview breakdown including teams", async ({ exp // verified + unverified should match non-anonymous total const nonAnonFromOverview = authOverview.verified_users + authOverview.unverified_users; expect(nonAnonFromOverview).toBeGreaterThanOrEqual(1); + expect(authOverview.total_teams).toBeGreaterThanOrEqual(1); }); From 396a9b2a6a8947789e25dd01e57981a0f5ac2201 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 6 Apr 2026 11:08:22 -0700 Subject: [PATCH 17/30] Enhance metrics calculations and UI components - Introduced a new function to load daily revenue over the last 30 days, aggregating paid invoices. - Updated the payments overview to include the new daily revenue data and adjusted MRR calculations accordingly. - Improved the globe component's zoom logic for better visual consistency across different screen sizes. - Refined the email breakdown card's deliverability status type to be more flexible. - Replaced JSON.stringify comparisons with deep equality checks in internal metrics tests for improved accuracy. - Cleaned up exports in the dashboard UI components by removing unused exports. --- .../app/api/latest/internal/metrics/route.tsx | 102 ++++++++++++++---- .../projects/[projectId]/(overview)/globe.tsx | 11 +- .../[projectId]/(overview)/metrics-page.tsx | 2 +- .../endpoints/api/v1/internal-metrics.test.ts | 9 +- .../src/components/chart-legend.tsx | 2 - packages/dashboard-ui-components/src/index.ts | 2 +- 6 files changed, 96 insertions(+), 32 deletions(-) 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 4da9463d8b..d4b5da5ee1 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -188,6 +188,7 @@ function buildSplitFromDailyEntitySets(options: { }): ActivitySplit { const { orderedDays, entityIdsByDay, createdDayByEntityId } = options; const split = createEmptySplitSeries(orderedDays); + const windowStart = orderedDays[0]; const previouslySeen = new Set(); let previousDaySet = new Set(); @@ -206,8 +207,16 @@ function buildSplitFromDailyEntitySets(options: { retainedCount += 1; } else if (previouslySeen.has(entityId)) { reactivatedCount += 1; - } else { + } 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; } } @@ -485,9 +494,44 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea } +async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise> { + const prisma = await getPrismaClientForTenancy(tenancy); + const todayUtc = new Date(now); + todayUtc.setUTCHours(0, 0, 0, 0); + const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); + + const paidInvoicesInRange = await prisma.subscriptionInvoice.findMany({ + where: { + tenancyId: tenancy.id, + amountTotal: { not: null }, + status: { in: ['paid', 'succeeded'] }, + createdAt: { gte: since }, + }, + select: { createdAt: true, amountTotal: true }, + }); + + const revenueByDay = new Map(); + for (const invoice of paidInvoicesInRange) { + const key = invoice.createdAt.toISOString().split('T')[0]; + revenueByDay.set(key, (revenueByDay.get(key) ?? 0) + (invoice.amountTotal ?? 0)); + } + + const dailyRevenue: Array<{ date: string, new_cents: number, refund_cents: number }> = []; + for (let i = 0; i <= 30; i++) { + const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + 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) { const prisma = await getPrismaClientForTenancy(tenancy); + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const [ subscriptionsByStatus, activeSubscriptionCount, @@ -496,6 +540,7 @@ async function loadPaymentsOverview(tenancy: Tenancy) { invoiceRevenue, totalSubscriptionInvoices, successfulSubscriptionInvoices, + paidInvoicesLast30Days, ] = await Promise.all([ prisma.subscription.groupBy({ by: ['status'], @@ -511,7 +556,7 @@ async function loadPaymentsOverview(tenancy: Tenancy) { prisma.subscription.findMany({ where: { tenancyId: tenancy.id, - createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }, + createdAt: { gte: thirtyDaysAgo }, }, orderBy: { createdAt: 'desc' }, select: { createdAt: true, status: true, customerType: true, productId: true }, @@ -526,6 +571,16 @@ async function loadPaymentsOverview(tenancy: Tenancy) { prisma.subscriptionInvoice.count({ where: { tenancyId: tenancy.id, status: { in: ['paid', 'succeeded'] } }, }), + // Trailing-30-day revenue used as the MRR proxy. + prisma.subscriptionInvoice.aggregate({ + where: { + tenancyId: tenancy.id, + amountTotal: { not: null }, + status: { in: ['paid', 'succeeded'] }, + createdAt: { gte: thirtyDaysAgo }, + }, + _sum: { amountTotal: true }, + }), ]); const subsByStatusMap = new Map(); @@ -535,8 +590,6 @@ async function loadPaymentsOverview(tenancy: Tenancy) { const subsByStatus = Object.fromEntries(subsByStatusMap); // Daily subscription signups for the last 30 days - const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const recentByDay = new Map(); for (const sub of recentSubscriptions) { if (sub.createdAt >= thirtyDaysAgo) { @@ -551,7 +604,10 @@ async function loadPaymentsOverview(tenancy: Tenancy) { dailySubscriptions.push({ date: key, activity: recentByDay.get(key) ?? 0 }); } - const estimatedMrrCents = activeSubscriptionCount * 10_000; + // 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 mrrCents = paidInvoicesLast30Days._sum.amountTotal ?? 0; const totalOrders = totalOneTimePurchases + totalSubscriptionInvoices; const checkoutConversionRate = totalOrders > 0 ? Number((((successfulSubscriptionInvoices + totalOneTimePurchases) / totalOrders) * 100).toFixed(2)) @@ -563,7 +619,7 @@ async function loadPaymentsOverview(tenancy: Tenancy) { total_one_time_purchases: totalOneTimePurchases, daily_subscriptions: dailySubscriptions, revenue_cents: invoiceRevenue._sum.amountTotal ?? 0, - mrr_cents: estimatedMrrCents, + mrr_cents: mrrCents, total_orders: totalOrders, checkout_conversion_rate: checkoutConversionRate, }; @@ -948,15 +1004,15 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const topRegionRows: { country_code: string | null, region_code: string | null, cnt: number }[] = await topRegionResult.json(); const onlineRows: { online: number }[] = await onlineResult.json(); + // 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. return { daily_page_views: dailyPageViews, daily_clicks: dailyClicks, daily_visitors: dailyVisitors, - daily_revenue: dailyPageViews.map((p) => ({ - date: p.date, - new_cents: 0, - refund_cents: 0, - })), + 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, @@ -1139,14 +1195,8 @@ function generateDevAnalyticsOverview(now: Date, totalUsers: number) { async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) { const prisma = await getPrismaClientForTenancy(tenancy); - const [totalUsers, verifiedUsers, verifiedNonAnonymousUsers, anonymousUsers, totalTeams] = await Promise.all([ + const [totalUsers, verifiedNonAnonymousUsers, anonymousUsers, totalTeams] = await Promise.all([ prisma.projectUser.count({ where: { tenancyId: tenancy.id } }), - prisma.projectUser.count({ - where: { - tenancyId: tenancy.id, - contactChannels: { some: { type: 'EMAIL', isVerified: true } }, - }, - }), prisma.projectUser.count({ where: { tenancyId: tenancy.id, @@ -1166,8 +1216,11 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now loadMonthlyActiveUsers(tenancy, includeAnonymous), ]); + // 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: includeAnonymous ? verifiedUsers : verifiedNonAnonymousUsers, + verified_users: verifiedNonAnonymousUsers, unverified_users: nonAnonymousTotal - verifiedNonAnonymousUsers, anonymous_users: anonymousUsers, total_teams: totalTeams, @@ -1230,6 +1283,7 @@ export const GET = createSmartRouteHandler({ paymentsOverview, emailOverview, analyticsOverview, + dailyRevenue, ] = await Promise.all([ prisma.projectUser.count({ where: { tenancyId: req.auth.tenancy.id, ...(includeAnonymous ? {} : { isAnonymous: false }) }, @@ -1255,6 +1309,7 @@ export const GET = createSmartRouteHandler({ loadPaymentsOverview(req.auth.tenancy), loadEmailOverview(req.auth.tenancy), loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous), + loadDailyRevenue(req.auth.tenancy, now), ] as const); // In dev, ClickHouse may have no events — fill in realistic fallback data @@ -1271,9 +1326,16 @@ export const GET = createSmartRouteHandler({ : authOverview; const referrersEmpty = (analyticsOverview.top_referrers as { referrer: string, visitors: number }[]).length === 0; - const finalAnalyticsOverview = referrersEmpty && getNodeEnvironment() !== 'production' + const baseAnalyticsOverview = referrersEmpty && getNodeEnvironment() !== 'production' ? (generateDevAnalyticsOverview(now, totalUsers) ?? analyticsOverview) : analyticsOverview; + // Stitch real daily revenue (from paid invoices) into analytics_overview so + // the dashboard can keep reading it from a single location. Preserve dev + // fallback synthetic data when no real revenue exists outside production. + const hasRealRevenue = dailyRevenue.some((row) => row.new_cents > 0); + const finalAnalyticsOverview = hasRealRevenue || getNodeEnvironment() === 'production' + ? { ...baseAnalyticsOverview, daily_revenue: dailyRevenue } + : baseAnalyticsOverview; return { statusCode: 200, 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 ( -
+
, + deliverabilityStatus: Partial>, bounceRate: number, clickRate: number, }) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 673e5c7973..09ed264a3a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -1,3 +1,4 @@ +import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { expect } from "vitest"; import { NiceResponse, it } from "../../../../helpers"; @@ -32,11 +33,11 @@ async function ensureAnonymousUsersAreStillExcluded(metricsResponse: NiceRespons const currentRecentlyActiveIds = (response.body.recently_active as Array<{ id: string }>).map((user) => user.id); if ( response.body.total_users === baselineTotalUsers && - JSON.stringify(response.body.users_by_country) === JSON.stringify(baselineUsersByCountry) && + deepPlainEquals(response.body.users_by_country, baselineUsersByCountry) && noAnonymousInRecentlyRegistered && noAnonymousInRecentlyActive && - JSON.stringify(currentRecentlyRegisteredIds) === JSON.stringify(baselineRecentlyRegisteredIds) && - JSON.stringify(currentRecentlyActiveIds) === JSON.stringify(baselineRecentlyActiveIds) + deepPlainEquals(currentRecentlyRegisteredIds, baselineRecentlyRegisteredIds) && + deepPlainEquals(currentRecentlyActiveIds, baselineRecentlyActiveIds) ) { return; } @@ -179,7 +180,7 @@ it("should exclude anonymous users from metrics", async ({ expect }) => { let result!: NiceResponse; for (let i = 0; i < 5; i++) { result = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); - if (JSON.stringify(result.body) === JSON.stringify(beforeMetrics.body)) { + if (deepPlainEquals(result.body, beforeMetrics.body)) { break; } await wait(2_000); diff --git a/packages/dashboard-ui-components/src/components/chart-legend.tsx b/packages/dashboard-ui-components/src/components/chart-legend.tsx index 65bc488e44..48785acd7b 100644 --- a/packages/dashboard-ui-components/src/components/chart-legend.tsx +++ b/packages/dashboard-ui-components/src/components/chart-legend.tsx @@ -5,8 +5,6 @@ import * as RechartsPrimitive from "recharts"; import { cn } from "@stackframe/stack-ui"; import { useDesignChart, getPayloadConfigFromPayload } from "./chart-container"; -export const DesignChartLegend = RechartsPrimitive.Legend; - export const DesignChartLegendContent = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & diff --git a/packages/dashboard-ui-components/src/index.ts b/packages/dashboard-ui-components/src/index.ts index 39cff89c82..5463387a5d 100644 --- a/packages/dashboard-ui-components/src/index.ts +++ b/packages/dashboard-ui-components/src/index.ts @@ -40,7 +40,7 @@ export type { DesignChartConfig } from "./components/chart-container"; export { DesignChartTooltip, DesignChartTooltipContent } from "./components/chart-tooltip"; -export { DesignChartLegend, DesignChartLegendContent } from "./components/chart-legend"; +export { DesignChartLegendContent } from "./components/chart-legend"; export { DesignChartCard } from "./components/chart-card"; export type { DesignChartCardProps } from "./components/chart-card"; From 3f9b656592bcdeec5438009a90e2a4fe60b58b4b Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 6 Apr 2026 11:50:36 -0700 Subject: [PATCH 18/30] Refactor metrics data retrieval to use raw SQL queries - Replaced Prisma's `findMany` method with raw SQL queries for fetching active users and teams, improving performance and flexibility. - Updated the daily revenue calculation to utilize raw SQL for aggregating subscription invoices. - Introduced schema retrieval for tenancy to ensure correct table references in queries. - Enhanced the payments overview to aggregate subscription data using raw SQL, streamlining data processing. --- .../app/api/latest/internal/metrics/route.tsx | 538 +++++++++--------- 1 file changed, 266 insertions(+), 272 deletions(-) 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 d4b5da5ee1..87b1f669a7 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1,3 +1,4 @@ +import { Prisma } from "@/generated/prisma/client"; import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; @@ -240,6 +241,7 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); const untilExclusive = new Date(todayUtc.getTime() + 24 * 60 * 60 * 1000); const clickhouseClient = getClickhouseAdminClient(); + const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); const userRows = await clickhouseClient.query({ @@ -276,19 +278,15 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno }); const activeUserIds = [...new Set(sanitizedUserRows.map((row) => row.user_id))]; - const users = activeUserIds.length === 0 + const users: { projectUserId: string, createdAt: Date }[] = activeUserIds.length === 0 ? [] - : await prisma.projectUser.findMany({ - where: { - tenancyId: tenancy.id, - projectUserId: { in: activeUserIds }, - ...(includeAnonymous ? {} : { isAnonymous: false }), - }, - select: { - projectUserId: true, - createdAt: true, - }, - }); + : await prisma.$replica().$queryRaw<{ projectUserId: string, createdAt: Date }[]>` + SELECT "projectUserId"::text AS "projectUserId", "createdAt" + 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>(); @@ -322,6 +320,7 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise row.team_id))]; - const teams = activeTeamIds.length === 0 + const teams: { teamId: string, createdAt: Date }[] = activeTeamIds.length === 0 ? [] - : await prisma.team.findMany({ - where: { - tenancyId: tenancy.id, - teamId: { in: activeTeamIds }, - }, - select: { - teamId: true, - createdAt: true, - }, - }); + : 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>(); @@ -495,25 +490,28 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise> { + const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); - const paidInvoicesInRange = await prisma.subscriptionInvoice.findMany({ - where: { - tenancyId: tenancy.id, - amountTotal: { not: null }, - status: { in: ['paid', 'succeeded'] }, - createdAt: { gte: since }, - }, - select: { createdAt: true, amountTotal: true }, - }); + 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 ('paid', 'succeeded') + AND "createdAt" >= ${since} + GROUP BY day + ORDER BY day + `; const revenueByDay = new Map(); - for (const invoice of paidInvoicesInRange) { - const key = invoice.createdAt.toISOString().split('T')[0]; - revenueByDay.set(key, (revenueByDay.get(key) ?? 0) + (invoice.amountTotal ?? 0)); + for (const row of rows) { + revenueByDay.set(row.day, Number(row.new_cents)); } const dailyRevenue: Array<{ date: string, new_cents: number, refund_cents: number }> = []; @@ -527,6 +525,7 @@ async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise` + 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) 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 ('paid', 'succeeded')) 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 ('paid', 'succeeded') + 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(), group._count._all); + subsByStatusMap.set(group.status.toLowerCase(), Number(group.cnt)); } const subsByStatus = Object.fromEntries(subsByStatusMap); - // Daily subscription signups for the last 30 days + 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 sub of recentSubscriptions) { - if (sub.createdAt >= thirtyDaysAgo) { - const key = sub.createdAt.toISOString().split('T')[0]; - recentByDay.set(key, (recentByDay.get(key) ?? 0) + 1); - } + for (const row of dailySubscriptionRows) { + recentByDay.set(row.day, Number(row.cnt)); } const dailySubscriptions: DataPoints = []; for (let i = 0; i <= 30; i++) { @@ -607,7 +616,6 @@ async function loadPaymentsOverview(tenancy: Tenancy) { // 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 mrrCents = paidInvoicesLast30Days._sum.amountTotal ?? 0; const totalOrders = totalOneTimePurchases + totalSubscriptionInvoices; const checkoutConversionRate = totalOrders > 0 ? Number((((successfulSubscriptionInvoices + totalOneTimePurchases) / totalOrders) * 100).toFixed(2)) @@ -618,7 +626,7 @@ async function loadPaymentsOverview(tenancy: Tenancy) { active_subscription_count: activeSubscriptionCount, total_one_time_purchases: totalOneTimePurchases, daily_subscriptions: dailySubscriptions, - revenue_cents: invoiceRevenue._sum.amountTotal ?? 0, + revenue_cents: invoiceRevenueCents, mrr_cents: mrrCents, total_orders: totalOrders, checkout_conversion_rate: checkoutConversionRate, @@ -628,76 +636,88 @@ async function loadPaymentsOverview(tenancy: Tenancy) { // ── Email Aggregates ───────────────────────────────────────────────────────── async function loadEmailOverview(tenancy: Tenancy) { + const schema = await getPrismaSchemaForTenancy(tenancy); + const prisma = await getPrismaClientForTenancy(tenancy); const now = new Date(); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const [ - statusGroups, + counts, recentEmails, - deliveredCount, - bouncedCount, - clickedCount, - finishedSendingCount, emailsByDayAndStatus, ] = await Promise.all([ - // group by simpleStatus - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.groupBy({ - by: ['simpleStatus'], - where: { tenancyId: tenancy.id }, - _count: { _all: true }, - }); - })(), - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.findMany({ - where: { tenancyId: tenancy.id }, - orderBy: { createdAt: 'desc' }, - take: RECENT_LIST_PAGE_SIZE, - select: { id: true, createdAt: true, simpleStatus: true, status: true, renderedSubject: true }, - }); - })(), - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, deliveredAt: { not: null } } }); - })(), - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, bouncedAt: { not: null } } }); - })(), - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, clickedAt: { not: null } } }); - })(), - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.count({ where: { tenancyId: tenancy.id, finishedSendingAt: { not: null } } }); - })(), + // 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 - (async () => { - const prisma = await getPrismaClientForTenancy(tenancy); - return await prisma.emailOutbox.findMany({ - where: { - tenancyId: tenancy.id, - createdAt: { gte: thirtyDaysAgo }, - }, - select: { createdAt: true, simpleStatus: true }, - }); - })(), + 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 emailsByStatusMap = new Map(); - for (const group of statusGroups) { - emailsByStatusMap.set(group.simpleStatus.toLowerCase().replace('_', '-'), group._count._all); - } - const emailsByStatus = Object.fromEntries(emailsByStatusMap); + 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 email of emailsByDayAndStatus) { - const key = email.createdAt.toISOString().split('T')[0]; - emailByDay.set(key, (emailByDay.get(key) ?? 0) + 1); + 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 <= 30; i++) { @@ -719,14 +739,13 @@ async function loadEmailOverview(tenancy: Tenancy) { const key = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; dayStatusMap.set(key, { ok: 0, error: 0, in_progress: 0 }); } - for (const email of emailsByDayAndStatus) { - const key = email.createdAt.toISOString().split('T')[0]; - const entry = dayStatusMap.get(key); + for (const row of emailsByDayAndStatus) { + const entry = dayStatusMap.get(row.day); if (entry != null) { - const s = email.simpleStatus; - if (s === 'OK') entry.ok += 1; - else if (s === 'ERROR') entry.error += 1; - else entry.in_progress += 1; + const count = Number(row.cnt); + if (row.status === 'OK') entry.ok += count; + else if (row.status === 'ERROR') entry.error += count; + else entry.in_progress += count; } } const dailyEmailsByStatus: DayStatusCounts[] = [...dayStatusMap.entries()].map(([date, counts]) => ({ @@ -769,45 +788,30 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const clickhouseClient = getClickhouseAdminClient(); try { - const [pageViewResult, dailyVisitorResult, totalVisitorResult, clickResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ + const [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult, replayResult] = 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(event_at) AS day, - count() AS cnt + countIf(event_type = '$page-view') AS pv, + countIf(event_type = '$click') AS cl, + uniqExactIf( + assumeNotNull(user_id), + event_type = '$page-view' + AND 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 = '$page-view' - AND project_id = {projectId:String} + WHERE project_id = {projectId:String} AND branch_id = {branchId:String} + AND event_type IN ('$page-view', '$click') AND event_at >= {since:DateTime} AND event_at < {untilExclusive:DateTime} GROUP BY day ORDER BY day ASC `, - query_params: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, - since: formatClickhouseDateTimeParam(since), - untilExclusive: formatClickhouseDateTimeParam(untilExclusive), - }, - format: "JSONEachRow", - }), - clickhouseClient.query({ - query: ` - SELECT - toDate(event_at) AS day, - uniqExact(assumeNotNull(user_id)) AS cnt - FROM analytics_internal.events - WHERE event_type = '$page-view' - 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 - ORDER BY day ASC - `, query_params: { projectId: tenancy.project.id, branchId: tenancy.branchId, @@ -839,28 +843,6 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo }, format: "JSONEachRow", }), - clickhouseClient.query({ - query: ` - SELECT - toDate(event_at) AS day, - count() AS cnt - FROM analytics_internal.events - WHERE event_type = '$click' - AND project_id = {projectId:String} - AND branch_id = {branchId:String} - AND event_at >= {since:DateTime} - AND event_at < {untilExclusive:DateTime} - GROUP BY day - ORDER BY day ASC - `, - query_params: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, - since: formatClickhouseDateTimeParam(since), - untilExclusive: formatClickhouseDateTimeParam(untilExclusive), - }, - format: "JSONEachRow", - }), clickhouseClient.query({ query: ` SELECT @@ -928,63 +910,54 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo }, format: "JSONEachRow", }), - // session replay count from Postgres + // session replay aggregates from Postgres (async () => { + const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); - const [total, recent, replayRows, revenue] = await Promise.all([ - prisma.sessionReplay.count({ where: { tenancyId: tenancy.id } }), - prisma.sessionReplay.count({ - where: { - tenancyId: tenancy.id, - startedAt: { gte: since }, - }, - }), - prisma.sessionReplay.findMany({ - where: { - tenancyId: tenancy.id, - startedAt: { gte: since }, - }, - select: { - startedAt: true, - lastEventAt: true, - }, - }), - prisma.subscriptionInvoice.aggregate({ - where: { tenancyId: tenancy.id, amountTotal: { not: null } }, - _sum: { amountTotal: true }, - }), - ]); - - const avgSessionSeconds = replayRows.length > 0 - ? replayRows.reduce((sum, row) => sum + Math.max(0, row.lastEventAt.getTime() - row.startedAt.getTime()), 0) / replayRows.length / 1000 - : 0; + 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) AS total_revenue_cents + `; + + const row = result[0]; + const avgSessionSeconds = Number(((Number(row.avg_ms ?? 0)) / 1000).toFixed(1)); return { - total, - recent, - avgSessionSeconds: Number(avgSessionSeconds.toFixed(1)), - totalRevenueCents: Number(revenue._sum.amountTotal ?? 0), + total: Number(row.total), + recent: Number(row.recent), + avgSessionSeconds, + totalRevenueCents: Number(row.total_revenue_cents), }; })(), ]); - const pvRows: { day: string, cnt: number }[] = await pageViewResult.json(); - const clRows: { day: string, cnt: number }[] = await clickResult.json(); - + const dailyEventRows: { day: string, pv: number, cl: number, visitors: number }[] = await dailyEventResult.json(); const pvByDay = new Map(); - for (const row of pvRows) { - const key = row.day.split('T')[0]; - pvByDay.set(key, Number(row.cnt)); - } const clByDay = new Map(); - for (const row of clRows) { - const key = row.day.split('T')[0]; - clByDay.set(key, Number(row.cnt)); - } - const visitorRows: { day: string, cnt: number }[] = await dailyVisitorResult.json(); const visitorByDay = new Map(); - for (const row of visitorRows) { + for (const row of dailyEventRows) { const key = row.day.split('T')[0]; - visitorByDay.set(key, Number(row.cnt)); + 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); @@ -1193,29 +1166,53 @@ function generateDevAnalyticsOverview(now: Date, totalUsers: number) { // ── Auth Extra Aggregates ──────────────────────────────────────────────────── async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now: Date) { + const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); - const [totalUsers, verifiedNonAnonymousUsers, anonymousUsers, totalTeams] = await Promise.all([ - prisma.projectUser.count({ where: { tenancyId: tenancy.id } }), - prisma.projectUser.count({ - where: { - tenancyId: tenancy.id, - isAnonymous: false, - contactChannels: { some: { type: 'EMAIL', isVerified: true } }, - }, - }), - prisma.projectUser.count({ where: { tenancyId: tenancy.id, isAnonymous: true } }), - prisma.team.count({ where: { tenancyId: tenancy.id } }), - ]); - - const nonAnonymousTotal = totalUsers - anonymousUsers; - - const [dailyActiveUsersSplit, dailyActiveTeamsSplit, mau] = await Promise.all([ + 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, 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. @@ -1227,6 +1224,7 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now mau, daily_active_users_split: dailyActiveUsersSplit, daily_active_teams_split: dailyActiveTeamsSplit, + total_users_filtered: totalUsersFiltered, }; } @@ -1269,10 +1267,7 @@ export const GET = createSmartRouteHandler({ const now = new Date(); const includeAnonymous = req.query.include_anonymous === "true"; - const prisma = await getPrismaClientForTenancy(req.auth.tenancy); - const [ - totalUsers, dailyUsers, dailyActiveUsers, usersByCountry, @@ -1285,9 +1280,6 @@ export const GET = createSmartRouteHandler({ 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, includeAnonymous), @@ -1312,6 +1304,8 @@ export const GET = createSmartRouteHandler({ loadDailyRevenue(req.auth.tenancy, now), ] as const); + const totalUsers = authOverview.total_users_filtered; + // In dev, ClickHouse may have no events — fill in realistic fallback data const dauSplitIsEmpty = authOverview.daily_active_users_split.total.every( (d: { activity: number }) => d.activity === 0 From c7f64ca4ff521d2fee7c8378f06db2cc4e376c6e Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 6 Apr 2026 13:52:08 -0700 Subject: [PATCH 19/30] Enhance metrics functionality and introduce deterministic UUIDs - Added functions for generating deterministic UUIDs and a stable PRNG to ensure consistent seeding of session activity events. - Updated the seeding logic to use deterministic values for session IDs, IP addresses, and event IDs, preventing duplicates on re-runs. - Refactored date handling in metrics calculations to anchor on midnight UTC for stable time windows across runs. - Introduced a new metrics activity split utility to classify user activity into new, retained, and reactivated categories based on daily engagement. - Enhanced the metrics API to support new data structures and schemas for improved type safety and validation. --- apps/backend/prisma/seed.ts | 127 ++++-- .../app/api/latest/internal/metrics/route.tsx | 414 +++++++++--------- .../backend/src/lib/metrics-activity-split.ts | 244 +++++++++++ .../[projectId]/(overview)/metrics-page.tsx | 320 +++++++++----- apps/dashboard/src/lib/stack-app-internals.ts | 45 ++ claude/CLAUDE-KNOWLEDGE.md | 2 +- .../src/interface/admin-interface.ts | 5 +- .../src/interface/admin-metrics.ts | 170 +++++++ .../apps/implementations/admin-app-impl.ts | 5 +- 9 files changed, 974 insertions(+), 358 deletions(-) create mode 100644 apps/backend/src/lib/metrics-activity-split.ts create mode 100644 packages/stack-shared/src/interface/admin-metrics.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 79febcf16a..952e0adbc3 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -23,8 +23,44 @@ import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates'; 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 DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063'; + +/** + * Derive a stable v4-shaped UUID from an arbitrary namespaced string. Same + * input always produces the same output, 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'); + // Format as a UUID and force the v4 nibble + variant bits so the value + // satisfies our `isUuid` check elsewhere in the codebase. + 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; +} const EXPLORATORY_TEAM_DISPLAY_NAME = 'Exploratory Research and Insight Partnership With Very Long Collaborative Name For Testing'; export async function seed() { @@ -2213,9 +2249,14 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO { countryCode: 'CH', regionCode: 'ZH', cityName: 'Zurich', latitude: 47.3769, longitude: 8.5417, tzIdentifier: 'Europe/Zurich' }, ]; - const now = new Date(); - const twoMonthsAgo = new Date(now); + // Anchor time on midnight today so the seeded window is also stable across + // runs done within the same day. (Across days the dates legitimately move + // forward by one day each, which is the desired behavior.) + 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()); @@ -2225,26 +2266,26 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO const userId = userEmailToId.get(email); if (!userId) continue; - // Create 15-25 session events per user over the past 2 months - const eventCount = 15 + Math.floor(Math.random() * 11); + // Per-user seeded PRNG so the event count, timestamps, locations and IPs + // are deterministic across re-runs of the seed. This pairs with the + // deterministic ipInfoId / eventId below so upserts hit the same rows. + const userRand = deterministicPrng(seedFromString(`session-events:${tenancyId}:${userId}`)); - for (let i = 0; i < eventCount; i++) { - // Random timestamp within the past 2 months - const randomTime = new Date( - twoMonthsAgo.getTime() + Math.random() * (now.getTime() - twoMonthsAgo.getTime()) - ); + const eventCount = 15 + Math.floor(userRand() * 11); // 15-25 events - // Pick a random location - const location = locations[Math.floor(Math.random() * locations.length)]; + for (let i = 0; i < eventCount; i++) { + const randomTime = new Date(twoMonthsAgo.getTime() + userRand() * windowMs); + const location = locations[Math.floor(userRand() * locations.length)]; - // Generate a session ID (simulating a refresh token ID) - const sessionId = `session-${userId.substring(0, 8)}-${i.toString().padStart(3, '0')}-${randomTime.getTime().toString(36)}`; + // Session id is derived from the user and event index — stable across runs. + 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)}`; - // Generate a unique IP address for this session - const ipAddress = `${10 + Math.floor(Math.random() * 200)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}`; + // Stable IDs derived from tenancy + user + event index so re-running + // the seed upserts into existing rows instead of duplicating them. + const ipInfoId = deterministicUuid(`event-ip-info:${tenancyId}:${userId}:${i}`); + const eventId = deterministicUuid(`event:${tenancyId}:${userId}:${i}`); - // Create EventIpInfo entry with a proper UUID - const ipInfoId = generateUuid(); // TODO: This should be a deterministic UUID so we don't keep recreating the session info await globalPrismaClient.eventIpInfo.upsert({ where: { id: ipInfoId }, update: { @@ -2271,8 +2312,6 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO }, }); - // Create the Event entry with a proper UUID - const eventId = generateUuid(); await globalPrismaClient.event.upsert({ where: { id: eventId }, update: { @@ -2331,49 +2370,53 @@ async function seedDummySessionReplays(options: SessionReplaySeedOptions) { targetSessionReplayCount = 250, } = options; - const existingCount = await prisma.sessionReplay.count({ - where: { - tenancyId, - }, - }); - - if (existingCount >= targetSessionReplayCount) { - console.log(`Dummy project already has ${existingCount} session replays, skipping seeding`); - return; - } - - const toCreate = targetSessionReplayCount - existingCount; const userIds = Array.from(userEmailToId.values()); if (userIds.length === 0) { throw new Error('Cannot seed session replays: no dummy project users exist'); } - const now = new Date(); - const twoWeeksAgo = new Date(now); + // 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 < toCreate; i++) { - const startedAt = new Date( - twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()), - ); - const durationMs = 10_000 + Math.floor(Math.random() * (20 * 60 * 1000)); // 10s..20m + 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(Math.random() * userIds.length)]!; + const projectUserId = userIds[Math.floor(rand() * userIds.length)]!; seeds.push({ tenancyId, - refreshTokenId: generateUuid(), + refreshTokenId: deterministicUuid(`session-replay-refresh-token:${tenancyId}:${i}`), projectUserId, - id: generateUuid(), + id: deterministicUuid(`session-replay:${tenancyId}:${i}`), startedAt, lastEventAt, }); } + // Use createMany with skipDuplicates so re-running the seed updates timestamps + // by upserting per-id. Prisma createMany doesn't support upsert, so we fall + // back to deleting the deterministic id range first then bulk-inserting. + 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 ${toCreate} session replays`); + console.log(`Seeded ${targetSessionReplayCount} session replays`); } 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 87b1f669a7..ab40957fd6 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1,25 +1,35 @@ 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, createEmptySplitSeries } from "@/lib/metrics-activity-split"; import { Tenancy } from "@/lib/tenancies"; 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 { + type MetricsDataPoint, + MetricsAnalyticsOverviewSchema, + MetricsAuthOverviewSchema, + MetricsDataPointsSchema as DataPointsSchema, + MetricsEmailOverviewSchema, + MetricsLoginMethodEntrySchema, + MetricsPaymentsOverviewSchema, + MetricsRecentUserSchema, +} from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; 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, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import yup from 'yup'; +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 DataPointsSchema = yupArray(yupObject({ - date: yupString().defined(), - activity: yupNumber().defined(), -}).defined()).defined(); +const METRICS_WINDOW_DAYS = 30; +const METRICS_WINDOW_MS = METRICS_WINDOW_DAYS * 24 * 60 * 60 * 1000; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; function formatClickhouseDateTimeParam(date: Date): string { // ClickHouse DateTime params are passed as "YYYY-MM-DDTHH:MM:SS" (no timezone); treat them as UTC. @@ -115,8 +125,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({ @@ -154,8 +164,8 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou } 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, @@ -165,81 +175,11 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou return out; } -type ActivitySplit = { - total: DataPoints, - new: DataPoints, - retained: DataPoints, - reactivated: DataPoints, -}; - -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 })), - }; -} - -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; -} - 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() - 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 schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); @@ -290,8 +230,8 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno const orderedDays: string[] = []; const idsByDay = new Map>(); - for (let i = 0; i <= 30; i += 1) { - const date = new Date(since.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + 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()); } @@ -317,8 +257,8 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno 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() - 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 schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); @@ -366,8 +306,8 @@ async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise>(); - for (let i = 0; i <= 30; i += 1) { - const date = new Date(since.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + 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()); } @@ -438,8 +378,8 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); // 30-day rolling window for MAU - 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(); try { @@ -476,6 +416,12 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea } 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.", { @@ -494,7 +440,7 @@ async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise` SELECT @@ -515,8 +461,8 @@ async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise = []; - for (let i = 0; i <= 30; i++) { - const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + 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 }); } @@ -529,7 +475,7 @@ async function loadPaymentsOverview(tenancy: Tenancy) { const prisma = await getPrismaClientForTenancy(tenancy); const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - METRICS_WINDOW_MS); const [ subscriptionsByStatus, @@ -607,8 +553,8 @@ async function loadPaymentsOverview(tenancy: Tenancy) { recentByDay.set(row.day, Number(row.cnt)); } const dailySubscriptions: DataPoints = []; - for (let i = 0; i <= 30; i++) { - const day = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000); + 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 }); } @@ -639,7 +585,7 @@ async function loadEmailOverview(tenancy: Tenancy) { const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(now.getTime() - METRICS_WINDOW_MS); const [ counts, @@ -720,8 +666,8 @@ async function loadEmailOverview(tenancy: Tenancy) { emailByDay.set(row.day, (emailByDay.get(row.day) ?? 0) + Number(row.cnt)); } const dailyEmails: DataPoints = []; - for (let i = 0; i <= 30; i++) { - const day = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000); + 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 }); } @@ -735,17 +681,37 @@ async function loadEmailOverview(tenancy: Tenancy) { // 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 <= 30; i++) { - const key = new Date(thirtyDaysAgo.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + 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) { - const count = Number(row.cnt); - if (row.status === 'OK') entry.ok += count; - else if (row.status === 'ERROR') entry.error += count; - else entry.in_progress += count; + 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]) => ({ @@ -779,16 +745,76 @@ async function loadEmailOverview(tenancy: Tenancy) { // ── 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) 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() - 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(); + // 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 [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult, replayResult] = await Promise.all([ + 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({ @@ -910,43 +936,6 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo }, format: "JSONEachRow", }), - // session replay aggregates from Postgres - (async () => { - 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) 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), - }; - })(), ]); const dailyEventRows: { day: string, pv: number, cl: number, visitors: number }[] = await dailyEventResult.json(); @@ -965,8 +954,8 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const dailyPageViews: DataPoints = []; const dailyClicks: DataPoints = []; const dailyVisitors: DataPoints = []; - for (let i = 0; i <= 30; i++) { - const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + 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 }); @@ -977,60 +966,82 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const topRegionRows: { country_code: string | null, region_code: string | null, cnt: number }[] = await topRegionResult.json(); const onlineRows: { online: number }[] = await onlineResult.json(); - // 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. - return { - daily_page_views: dailyPageViews, - daily_clicks: dailyClicks, - daily_visitors: 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, + clickhouseAggregates = { + dailyPageViews, + dailyClicks, + dailyVisitors, visitors, - avg_session_seconds: replayResult.avgSessionSeconds, - online_live: Number(onlineRows[0]?.online ?? 0), - revenue_per_visitor: visitors > 0 - ? Number(((replayResult.totalRevenueCents / 100) / visitors).toFixed(2)) - : 0, - top_referrers: referrers.map((row) => ({ + onlineLive: Number(onlineRows[0]?.online ?? 0), + topReferrers: referrers.map((row) => ({ referrer: row.referrer ?? '(direct)', visitors: Number(row.cnt), })), - top_region: topRegionRows[0] ? { + topRegion: topRegionRows[0] ? { country_code: topRegionRows[0].country_code, region_code: topRegionRows[0].region_code, count: Number(topRegionRows[0].cnt), } : null, }; } catch (error) { - captureError("internal-metrics-analytics-overview-fallback", new StackAssertionError( - "Falling back to empty analytics overview due to query failure.", + // 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, }, )); - // Analytics may not be enabled for all projects + // 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: 0, - total_replays: 0, - recent_replays: 0, + total_revenue_cents: replayResult.totalRevenueCents, + total_replays: replayResult.total, + recent_replays: replayResult.recent, visitors: 0, - avg_session_seconds: 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, + }; } // ── Development-mode fallback data for ClickHouse-dependent metrics ────────── @@ -1054,10 +1065,10 @@ function generateDevDauSplit(now: Date, totalUsers: number): ActivitySplit { const rand = seededPrng(12345); const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); - const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); + const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); const days: string[] = []; - for (let i = 0; i <= 30; i++) { - days.push(new Date(since.getTime() + i * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); + for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) { + days.push(new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0]); } const base = Math.max(2, Math.floor(totalUsers * 0.15)); @@ -1089,14 +1100,14 @@ function generateDevAnalyticsOverview(now: Date, totalUsers: number) { const rand = seededPrng(67890); const todayUtc = new Date(now); todayUtc.setUTCHours(0, 0, 0, 0); - const since = new Date(todayUtc.getTime() - 30 * 24 * 60 * 60 * 1000); + const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); const dailyPageViews: DataPoints = []; const dailyClicks: DataPoints = []; const dailyRevenue: Array<{ date: string, new_cents: number, refund_cents: number }> = []; const dailyVisitors: DataPoints = []; - for (let i = 0; i <= 30; i++) { - const day = new Date(since.getTime() + i * 24 * 60 * 60 * 1000); + 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]; const dayOfWeek = day.getDay(); const weekendFactor = (dayOfWeek === 0 || dayOfWeek === 6) ? 0.55 : 1.0; @@ -1251,16 +1262,18 @@ 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(), - // Extended cross-product aggregates - auth_overview: yupMixed().defined(), - payments_overview: yupMixed().defined(), - email_overview: yupMixed().defined(), - analytics_overview: 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) => { @@ -1306,11 +1319,16 @@ export const GET = createSmartRouteHandler({ const totalUsers = authOverview.total_users_filtered; - // In dev, ClickHouse may have no events — fill in realistic fallback data + // In dev, ClickHouse may have no events — fill in realistic fallback data. + // This is strictly a developer-experience nicety for `pnpm dev`; tests must + // exercise the real (deterministic) data path, and production always uses + // real data. The synthetic data is day-of-week-dependent, so enabling it in + // tests causes snapshots to drift over time. + const isDevFallbackActive = getNodeEnvironment() === 'development'; const dauSplitIsEmpty = authOverview.daily_active_users_split.total.every( (d: { activity: number }) => d.activity === 0 ); - const finalAuthOverview = dauSplitIsEmpty && getNodeEnvironment() !== 'production' + const finalAuthOverview = dauSplitIsEmpty && isDevFallbackActive ? { ...authOverview, daily_active_users_split: generateDevDauSplit(now, totalUsers), @@ -1319,15 +1337,15 @@ export const GET = createSmartRouteHandler({ } : authOverview; - const referrersEmpty = (analyticsOverview.top_referrers as { referrer: string, visitors: number }[]).length === 0; - const baseAnalyticsOverview = referrersEmpty && getNodeEnvironment() !== 'production' + const referrersEmpty = analyticsOverview.top_referrers.length === 0; + const baseAnalyticsOverview = referrersEmpty && isDevFallbackActive ? (generateDevAnalyticsOverview(now, totalUsers) ?? analyticsOverview) : analyticsOverview; // Stitch real daily revenue (from paid invoices) into analytics_overview so // the dashboard can keep reading it from a single location. Preserve dev - // fallback synthetic data when no real revenue exists outside production. + // fallback synthetic data when no real revenue exists in dev mode. const hasRealRevenue = dailyRevenue.some((row) => row.new_cents > 0); - const finalAnalyticsOverview = hasRealRevenue || getNodeEnvironment() === 'production' + const finalAnalyticsOverview = hasRealRevenue || !isDevFallbackActive ? { ...baseAnalyticsOverview, daily_revenue: dailyRevenue } : baseAnalyticsOverview; 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/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 b74a0dde9e..0978908668 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 @@ -6,7 +6,12 @@ import { Link } from "@/components/link"; import { useRouter } from "@/components/router"; import { cn, Typography } from "@/components/ui"; import { ALL_APPS_FRONTEND, type AppId, getAppPath } from "@/lib/apps-frontend"; -import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +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"; @@ -80,20 +85,6 @@ function calculatePeriodDelta(currentValue: number, previousValue: number): numb return Number((((currentValue - previousValue) / previousValue) * 100).toFixed(1)); } -function useMetricsOrThrow(adminApp: object, includeAnonymous: boolean) { - const internals = Reflect.get(adminApp, stackAppInternalsSymbol); - if (typeof internals !== "object" || internals == null || !("useMetrics" in internals)) { - throw new Error("Admin app internals are unavailable: missing useMetrics"); - } - - const useMetrics = internals.useMetrics; - if (typeof useMetrics !== "function") { - throw new Error("Admin app internals are unavailable: useMetrics is not callable"); - } - - return useMetrics(includeAnonymous); -} - function SetupAppPrompt({ projectId, appId, @@ -175,30 +166,63 @@ function HeroInChartPill({ value, delta, color, - isHovered, - onMouseEnter, - onMouseLeave, + isSelected, + controlsId, + tabId, + onActivate, + onHoverPreview, + onHoverEnd, + onArrowNavigate, }: { label: string, value: string, delta?: number, color: string, - isHovered: boolean, - onMouseEnter: () => void, - onMouseLeave?: () => void, + isSelected: boolean, + controlsId: string, + tabId: string, + onActivate: () => void, + onHoverPreview: () => void, + onHoverEnd: () => void, + onArrowNavigate: (direction: 'next' | 'prev' | 'first' | 'last') => void, }) { return ( +
+ )} + + {committedRange && !brushing && (() => { + const [lo, hi] = committedRange; + const center = (lo + hi) / 2; + // Centered percentage within the visible window — used only to + // pick an edge to snap to when the range is too close to clip. + const centerPct = N <= 1 ? 50 : (center / (N - 1)) * 100; + const snapLeft = centerPct < 22; + const snapRight = centerPct > 78; + // Keep the bar inside the chart box; snap to an edge when the range + // is too close to it to be centered without overflow. + const anchorStyle: React.CSSProperties = snapLeft + ? { left: "8px" } + : snapRight + ? { right: "8px" } + : { left: indexToCss(center), transform: "translateX(-50%)" }; + const days = hi - lo + 1; + const draft = annotationDraft; + return ( +
+
+ {draft == null ? ( + <> + + + {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)} + + · + {strings.daysShort(days)} + + +
+ ); + })()} + + {showAnnotationsLayer && ( +
+ {annotations.map((a) => { + return ( +
+ + + {a.description} + +
+ ); + })} +
+ )} + + {/* Positioning lives on this wrapper; the body is rendered through + the `renderTooltip` slot (defaults to `DefaultHeroChartTooltip`). + Context is built once per render so custom renderers don't have + to recompute segmented totals / delta / etc. themselves. */} + {activeIndex != null && activePoint && (() => { + // Build primary layer view. + const primaryView: HeroChartTooltipLayerView | null = (showPrimary && primaryLayer) + ? { + id: primaryLayer.id, + label: primaryLayer.label || seriesLabel, + color: primaryColor, + total: primarySegmented + ? (primarySegmentTotals[activeIndex] ?? 0) + : pointValue(activePoint, primaryLayer.id), + segmented: primarySegmented, + segments: primarySegmented + ? primarySegmentSeries.map((s, sIdx) => ({ + key: s.key, + label: s.label, + value: primarySegments[activeIndex]?.[sIdx] ?? 0, + color: segmentColors.primary.light[sIdx] ?? primaryColor, + colorDark: segmentColors.primary.dark[sIdx] ?? primaryColor, + })) + : [], + } + : null; + // Build compare layer view. + const compareView: HeroChartTooltipLayerView | null = (showCompare && compareLayer) + ? { + id: compareLayer.id, + label: compareLayer.label, + color: previousColor, + total: compareSegmented + ? (compareSegmentTotals[activeIndex] ?? 0) + : pointValue(activePoint, compareLayer.id), + segmented: compareSegmented, + segments: compareSegmented + ? compareSegmentSeries.map((s, sIdx) => ({ + key: s.key, + label: s.label, + value: compareSegments[activeIndex]?.[sIdx] ?? 0, + color: segmentColors.compare.light[sIdx] ?? previousColor, + colorDark: segmentColors.compare.dark[sIdx] ?? previousColor, + })) + : [], + } + : null; + // Delta is between the final rendered totals — works regardless of + // segmented-vs-flat mode on either side. + const delta: HeroChartDelta | null = primaryView && compareView + ? formatDelta(primaryView.total, compareView.total) + : null; + const ctx: HeroChartTooltipContext = { + activeIndex, + point: activePoint, + isPinned: pinnedIndex != null, + primary: primaryView, + compare: compareView, + delta, + formatValue: (v) => fmtValue(v, yFormatKind), + formatDate: (ts) => fmtValue(ts, xFormatKind), + strings, + }; + return ( +
+ {renderTooltipFn(ctx)} +
+ ); + })()} +
+ ); +} + + +type PieBodyProps = { + wrapperRef: React.RefObject, + /** Primary layer breakdown vocabulary. */ + primarySegmentSeries: readonly HeroBreakdownSeries[], + /** Compare layer breakdown vocabulary — may differ from primary's. */ + compareSegmentSeries: readonly HeroBreakdownSeries[], + /** Per-segment totals for the primary layer over the visible window. + * Indexed to match `primarySegmentSeries`. */ + aggregatedPrimarySegments: number[], + /** Per-segment totals for the compare layer over the visible window. + * Indexed to match `compareSegmentSeries`. */ + aggregatedCompareSegments: number[], + aggregatedPrimaryTotal: number, + aggregatedCompareTotal: number, + segmentColors: { + primary: { light: string[], dark: string[] }, + compare: { light: string[], dark: string[] }, + }, + showPrimary: boolean, + showCompare: boolean, + xFormatKind: FormatKind, + yFormatKind: FormatKind, + hoverKey: string | null, + setHoverKey: (k: string | null) => void, + zoomRange: [number, number] | null, + onResetZoom: () => void, + visibleStart: number, + visibleEnd: number, + fullData: Point[], + strings: HeroChartStrings, + fmtValue: (value: number, kind: FormatKind) => string, + innerRadius: number, + outerRadius: number, + compareInnerRadius: number, + compareOuterRadius: number, + containerClassName: string, +}; + +function PieBody({ + wrapperRef, + primarySegmentSeries, + compareSegmentSeries, + aggregatedPrimarySegments, + aggregatedCompareSegments, + aggregatedPrimaryTotal, + aggregatedCompareTotal, + segmentColors, + showPrimary, + showCompare, + xFormatKind, + yFormatKind, + hoverKey, + setHoverKey, + zoomRange, + onResetZoom, + visibleStart, + visibleEnd, + fullData, + strings, + fmtValue, + innerRadius, + outerRadius, + compareInnerRadius, + compareOuterRadius, + containerClassName, +}: PieBodyProps) { + // The legend uses the primary layer's vocabulary when the primary layer + // is segmented; if not, it falls back to the compare layer's. This + // means the legend always reflects "the layer driving the chart". If + // both primary and compare have the SAME segment keys (the common + // case), the compare value for a given key is pulled by key lookup; + // if they differ, the compare ring still renders independently but + // its slices may not perfectly align with the legend rows. + const canonicalSeries = primarySegmentSeries.length > 0 + ? primarySegmentSeries + : compareSegmentSeries; + const usePrimaryForCanonical = primarySegmentSeries.length > 0; + + // Key → value lookup tables so the legend can show cross-layer values + // even when the two series use the same keys. + const compareValueByKey = useMemo(() => { + const m = new Map(); + compareSegmentSeries.forEach((s, sIdx) => { + m.set(s.key, aggregatedCompareSegments[sIdx] ?? 0); + }); + return m; + }, [compareSegmentSeries, aggregatedCompareSegments]); + const primaryValueByKey = useMemo(() => { + const m = new Map(); + primarySegmentSeries.forEach((s, sIdx) => { + m.set(s.key, aggregatedPrimarySegments[sIdx] ?? 0); + }); + return m; + }, [primarySegmentSeries, aggregatedPrimarySegments]); + + // Canonical total — matches whichever series is driving the legend. + const canonicalTotal = usePrimaryForCanonical + ? aggregatedPrimaryTotal + : aggregatedCompareTotal; + + // Sorted-by-value listing for the legend column. Each row carries its + // own canonical value (for the sort + primary ring) and a cross-layer + // value (for the compare ring + delta pill). + const legendRows = useMemo( + () => + canonicalSeries + .map((s, sIdx) => { + const value = usePrimaryForCanonical + ? (aggregatedPrimarySegments[sIdx] ?? 0) + : (aggregatedCompareSegments[sIdx] ?? 0); + const prevValue = usePrimaryForCanonical + ? (compareValueByKey.get(s.key) ?? 0) + : (primaryValueByKey.get(s.key) ?? 0); + return { + key: s.key, + label: s.label, + sIdx, + value, + prevValue, + pct: canonicalTotal > 0 ? value / canonicalTotal : 0, + fill: segmentColors.primary.light[sIdx] ?? "#888", + fillDark: segmentColors.primary.dark[sIdx] ?? "#888", + fillCompare: segmentColors.compare.light[sIdx] ?? "#888", + fillCompareDark: segmentColors.compare.dark[sIdx] ?? "#888", + }; + }) + .sort((a, b) => b.value - a.value), + [ + canonicalSeries, + usePrimaryForCanonical, + aggregatedPrimarySegments, + aggregatedCompareSegments, + compareValueByKey, + primaryValueByKey, + canonicalTotal, + segmentColors, + ], + ); + + // ChartConfig keyed by segment id so the shadcn ChartContainer injects + // matching --color-${id} CSS variables for tooltips and legend rows. + const chartConfig = useMemo(() => { + const config: ChartConfig = {}; + canonicalSeries.forEach((s, sIdx) => { + config[s.key] = { + label: s.label, + color: segmentColors.primary.light[sIdx], + }; + }); + return config; + }, [canonicalSeries, segmentColors]); + + // The currently isolated row (if any) — drives the center stat + dim state. + const activeRow = hoverKey + ? legendRows.find((r) => r.key === hoverKey) ?? null + : null; + const activeDelta = activeRow + ? formatDelta(activeRow.value, activeRow.prevValue) + : formatDelta(aggregatedPrimaryTotal, aggregatedCompareTotal); + + const windowDays = visibleEnd - visibleStart + 1; + const startLabel = fmtValue(fullData[visibleStart]!.ts, xFormatKind); + const endLabel = fmtValue(fullData[visibleEnd]!.ts, xFormatKind); + + // Pie data uses the same row order as the legend so the slice ↔ legend + // mapping is stable and we can drive `activeIndex` from the hovered key. + const outerData = legendRows.map((r) => ({ name: r.key, value: r.value, fill: r.fill })); + const innerData = legendRows.map((r) => ({ name: r.key, value: r.prevValue, fill: r.fillCompare })); + const activeIdx = hoverKey ? legendRows.findIndex((r) => r.key === hoverKey) : -1; + + return ( +
e.stopPropagation()} + > + {/* Reset-zoom badge — same affordance as the time-series body so the + pie always reflects the visible window the user has chosen. */} + {zoomRange && ( +
+ +
+ )} + +
+ {/* The pie SVG and its absolutely-positioned center stat live + inside `.relative`. The date-range + trend-pill caption sits + OUTSIDE the ring so the center stat only has to fit the + label + big value + (optional) percent — no more clipping + when the compare ring shrinks the available inner area. */} +
+
+ + + {showPrimary && ( + + {outerData.map((d, i) => { + const inactive = activeIdx >= 0 && activeIdx !== i; + return ( + setHoverKey(d.name as string)} + onMouseLeave={() => setHoverKey(null)} + /> + ); + })} + + )} + {showCompare && ( + + {innerData.map((d, i) => { + const inactive = activeIdx >= 0 && activeIdx !== i; + return ( + setHoverKey(d.name as string)} + onMouseLeave={() => setHoverKey(null)} + /> + ); + })} + + )} + + + + {/* Center stat — strictly label + big value. Nothing else. + The compare ring (compareInnerRadius=36) leaves only ~72px + of clean vertical space in the center; any third row bleeds + into the ring. Per-segment % is already in the legend and + the date range + trend pill live in the caption row below. */} +
+ + {activeRow ? activeRow.label : strings.pieTotalCenter} + + + {fmtValue( + activeRow ? activeRow.value : canonicalTotal, + yFormatKind, + )} + +
+
+ + {/* Caption row below the pie — date range + (optional) trend + pill. Lives outside the ring so the center stat never has + to compete for space. */} +
+ + {startLabel} – {endLabel} + + {showCompare && ( + + )} +
+
+ + {/* Column widths use `min-w-*` rather than `w-*` so long values + (large numbers, 4-digit percentages like "+123.4%") can grow + past their baseline allotment instead of clipping. The label + column is flex-grow + truncate so it's the only one that + gives ground when space gets tight. */} +
    + {legendRows.map((r) => { + const isActive = hoverKey === r.key; + const dimmed = hoverKey != null && !isActive; + const rowDelta = formatDelta(r.value, r.prevValue); + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} + +// HeroChart — gives you the DesignAnalyticsCard surface, an optional +// title/subtitle row, and forwards the controlled `state` / `onChange` +// pair through to the chart. Everything else is configured via props. +// HeroCanvas does NOT render any inline configuration UI of its own. +// There used to be a `controls` prop that opted into header pills +// (display type / segments / compare / format), legend dots, axis icon +// toggles, and a help-text row — those are gone. The chart's behaviour +// is fully driven by `state` and the prop surface; consumers who want a +// pill bar, legend, or axis toggles render their own UI and dispatch +// into `onChange` themselves. Keeps the chart focused on the data +// presentation and stops two parallel UIs (the chart's pills + the +// consumer's pills) from drifting out of sync. + +type HeroCanvasProps = { + /** Time-series points — each point carries `values` keyed by layer id. */ + data: Point[], + /** Annotations are fully prop-driven. The consumer owns the array. */ + annotations?: Annotation[], + /** Label used when the primary layer doesn't supply one of its own. */ + seriesLabel?: string, + /** Optional title / subtitle shown above the chart. Pure visual + * chrome — no interactive controls. Pass `null` to omit entirely. */ + title?: ReactNode, + subtitle?: ReactNode, + /** Fully-controlled state. The component owns nothing. */ + state: HeroCanvasState, + onChange: React.Dispatch>, + /** Fired when the user submits the in-chart annotation form. + Consumer is expected to append to its own annotations array. */ + onAnnotationCreate?: (annotation: Annotation) => void, + gradient?: "blue" | "cyan" | "green" | "orange" | "purple", + /** Override any user-visible copy inside the chart body (tooltip hints, + * Reset zoom, Annotate/Save/Cancel, pie center, …). Deep-merges over + * `HERO_CHART_DEFAULT_STRINGS`. */ + strings?: Partial, + /** Override the segment color ramps. Deep-merges over + * `HERO_CHART_DEFAULT_PALETTE`. */ + palette?: Partial, + /** Render slot for the tooltip body. Receives a prepared context with + * the active point, primary / compare views, pre-bound formatters, + * and resolved strings. Defaults to `DefaultHeroChartTooltip`. */ + renderTooltip?: (ctx: HeroChartTooltipContext) => ReactNode, + /** When `true`, runtime validation errors on `state.layers` become + * warnings instead of throwing. Forwarded to the inner HeroChart. */ + ignoreInvalidConfig?: boolean, + hoverIndex?: number | null, + onHoverIndexChange?: (index: number | null) => void, + committedRange?: [number, number] | null, + onCommittedRangeChange?: (range: [number, number] | null) => void, + onBrushChange?: (brush: { start: number, end: number } | null) => void, + annotationDraft?: string | null, + onAnnotationDraftChange?: (draft: string | null) => void, + plotMargin?: { top?: number, right?: number, bottom?: number, left?: number }, + yAxisWidth?: number, + yDomainPadding?: number, + pieInnerRadius?: number, + pieOuterRadius?: number, + pieCompareInnerRadius?: number, + pieCompareOuterRadius?: number, + pieContainerClassName?: string, + valueFormatter?: (value: number, kind: FormatKind) => string, +}; + +function HeroCanvas({ + data, + annotations = [], + seriesLabel = "Sign-ups", + title = "Sign-ups", + subtitle = "30-day window", + state, + onChange, + onAnnotationCreate, + gradient = "blue", + strings, + palette, + renderTooltip, + ignoreInvalidConfig = false, + hoverIndex, + onHoverIndexChange, + committedRange, + onCommittedRangeChange, + onBrushChange, + annotationDraft, + onAnnotationDraftChange, + plotMargin, + yAxisWidth, + yDomainPadding, + pieInnerRadius, + pieOuterRadius, + pieCompareInnerRadius, + pieCompareOuterRadius, + pieContainerClassName, + valueFormatter, +}: HeroCanvasProps) { + const showTitleRow = title != null || subtitle != null; + return ( + + {showTitleRow && ( +
+ {title != null && ( + + {title} + + )} + {subtitle != null && ( + + {subtitle} + + )} +
+ )} +
+ +
+
+ ); +} + +// Generates the JSX a consumer would write for the current state shape. +// Long data props are abbreviated to 1-2 representative items + a count +// comment so the snippet shows the actual data shape without dumping +// thirty rows. + +function formatFormatKindLiteral(kind: FormatKind, indent: string): string { + const fields: string[] = [`type: "${kind.type}"`]; + // Emit the option fields explicitly so the consumer sees the full shape + // they'd write. Using the literal value (not stringified) so booleans / + // numbers stay as-is. + 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 `${indent}{ ${fields.join(", ")} }`; +} + +// Emit the layer literal — dispatched on `kind`, each variant only +// prints the fields it actually owns. Segment matrices are elided to a +// `[…N×M]` summary so the usage snippet stays readable — the full values +// would swamp the panel. +function formatLayerLiteral(l: HeroCanvasLayer): 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 { + // Annotations layer — only kind left after the data-layer branch. + fields.push(`color: "${l.color}"`); + } + return `{ ${fields.join(", ")} }`; +} + +function formatStateLiteral(state: HeroCanvasState, 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, "")},`, + ]; + // Timeseries-only fields are emitted only when the state variant has + // them, so the generated snippet always type-checks against the union. + 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"); +} + +// JSON-ish but human-readable. Strings get double quotes, numbers stay raw, +// nested objects are emitted on one line so the data preview stays compact. +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); +} + +// Render an array prop as a `data={[\n item1,\n item2,\n // …N more\n]}` +// block. The number of inline items is bounded so the snippet stays +// readable for any input length. +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"); +} + +type HeroCanvasUsageData = { + data: Point[], + annotations: Annotation[], +}; + +function generateHeroCanvasUsage( + state: HeroCanvasState, + exampleData: HeroCanvasUsageData, +): string { + const lines: string[] = [ + "`); + lines.push(` setAnnotations((prev) => [...prev, annotation])`); + lines.push(` }`); + + lines.push("/>"); + return lines.join("\n"); +} + +function HeroCanvasUsageViewer({ 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 ( + + + + ); +} + +// panel (for live editing) and the formatter demo (so the same widget renders +// the editing UI for whatever variant is selected). + +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 ( +
+ + +
+ ); + } + } +} + +// every state slice the component reads from props — view, per-layer +// visibility + type, segments, format, axes, zoom, pin. Edits flow through +// the same `setState` setter the component uses, so the live preview, the +// usage code and the events panel all observe the exact same state. + +function HeroCanvasStatePanel({ + state, + onChange, + onReset, + dataLength, +}: { + state: HeroCanvasState, + onChange: React.Dispatch>, + onReset: () => void, + /** Used by the in-progress toggle to default the marker index to the + * last bucket when flipping it on. */ + dataLength: number, +}) { + const setTimeseriesField = ( + key: K, + value: HeroCanvasTimeseriesState[K], + ) => { + onChange((prev) => { + if (prev.view !== "timeseries") return prev; + return { ...prev, [key]: value }; + }); + }; + const setView = (next: HeroCanvasView) => { + onChange((prev) => { + if (next === prev.view) return prev; + if (next === "pie") { + // Moving into pie drops every timeseries-only field. Layer + // segmentation flags are preserved so switching back to + // timeseries restores whatever the user had configured. + return { + view: "pie", + layers: prev.layers, + xFormatKind: prev.xFormatKind, + yFormatKind: prev.yFormatKind, + }; + } + // Moving out of pie rebuilds a fresh timeseries state with sensible + // axis defaults. + 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 setDataLayerSegmented = (id: string, segmented: boolean) => { + onChange((prev) => ({ + ...prev, + layers: patchLayerById(prev.layers, id, { segmented }), + })); + }; + const setDataLayerInProgress = (id: string, inProgressFromIndex: number | null) => { + onChange((prev) => ({ + ...prev, + layers: patchLayerById(prev.layers, id, { inProgressFromIndex }), + })); + }; + + // Data-layer mutations. The helpers below look the layer up by id + // (arbitrary string) and rebuild it into whichever variant the user + // picked — line has no fillOpacity, bar has no strokeStyle, etc. + const replaceLayerById = (id: string, next: HeroCanvasLayer) => { + onChange((prev) => ({ + ...prev, + layers: setLayerById(prev.layers, id, next), + })); + }; + const rebuildDataLayer = ( + current: HeroCanvasDataLayer, + nextType: HeroCanvasLayerType, + ): HeroCanvasDataLayer => { + const prevStroke: HeroCanvasStrokeStyle = + "strokeStyle" in current ? current.strokeStyle : "solid"; + const prevFill: number = + "fillOpacity" in current ? current.fillOpacity : 0.22; + // Everything on Common carries through unchanged — only the + // type-variant fields (strokeStyle / fillOpacity) get rewritten. + // In particular `segments`, `segmentSeries`, `inProgressFromIndex` + // and the `segmented` flag all stay put so flipping bar → line + // doesn't silently drop a configured stack or the in-progress tail. + 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 }; + }; + const setDataLayerType = (id: string, nextType: HeroCanvasLayerType) => { + const current = findLayerById(state.layers, id); + if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; + replaceLayerById(id, rebuildDataLayer(current, nextType)); + }; + const setDataLayerStrokeStyle = (id: string, style: HeroCanvasStrokeStyle) => { + const current = findLayerById(state.layers, id); + if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; + if (current.type === "bar") return; // Bars have no stroke pattern. + replaceLayerById(id, { ...current, strokeStyle: style }); + }; + const setDataLayerFillOpacity = (id: string, fillOpacity: number) => { + const current = findLayerById(state.layers, id); + if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; + if (current.type === "line") return; // Lines have no fill. + replaceLayerById(id, { ...current, fillOpacity }); + }; + const setLayerVisible = (id: string, visible: boolean) => { + onChange((prev) => ({ + ...prev, + layers: patchLayerById(prev.layers, id, { visible }), + })); + }; + const setLayerLabel = (id: string, label: string) => { + onChange((prev) => ({ + ...prev, + layers: patchLayerById(prev.layers, id, { label }), + })); + }; + const setLayerColor = (id: string, color: string) => { + onChange((prev) => ({ + ...prev, + layers: patchLayerById(prev.layers, id, { color }), + })); + }; + + const renderBoolField = ( + label: string, + key: keyof HeroCanvasTimeseriesState, + value: boolean, + ) => ( +
+
+ {label} +
+ {String(key)} +
+
+ setTimeseriesField(key, (id === "on") as never)} + /> +
+ ); + + const renderDataLayerRow = (layer: HeroCanvasDataLayer) => { + 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 ( +
+ {/* Top row: label · type · visibility */} +
+
+ {layer.label} +
+ layers.{layer.id} +
+
+
+ setDataLayerType(layer.id, id as HeroCanvasLayerType)} + /> + setLayerVisible(layer.id, id === "on")} + /> +
+
+ {/* Bottom row: color · stroke style · fill opacity · segmented */} +
+ + {supportsStroke && currentStroke !== undefined && ( + + )} + {supportsFill && currentFill !== undefined && ( + + )} + {/* Per-layer segmentation toggle. Independent of the other + data layer, so you can run a stacked signups chart against + a flat previous line or vice versa. Ignored in pie view. */} + + {/* Per-layer in-progress toggle. When on, the layer's tail + renders dashed to signal "this period isn't done yet". + Toggling on sets the marker to the last index of the + data array (`data.length - 1`); off resets to null. */} + +
+
+ ); + }; + + // Simpler layer rows for the marker layer (no type picker). + const renderSimpleLayerRow = (layer: HeroCanvasAnnotationsLayer) => { + return ( +
+
+ {layer.label} +
+ layers.{layer.id} +
+
+
+ + setLayerVisible(layer.id, id === "on")} + /> +
+
+ ); + }; + return ( + + + + ); +} + +// exposes and surfaces the most recent invocations as a live log. Lets the +// reader literally watch the API fire as they interact with the preview. + +type HeroCanvasLabEvent = { + id: number, + ts: number, + name: string, + payload: string, +}; + +function HeroCanvasEventsPanel({ + events, + onClear, +}: { + events: HeroCanvasLabEvent[], + onClear: () => void, +}) { + return ( + + + + ); +} + + +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); + return ( + + {/* Databuddy pattern: the stat itself is a hover target that reveals a + richer period-comparison card. The trigger is also the card content, + so there's no extra chrome in the default state. */} +
+ + {label} + +
+ + {formatValue(current, formatKind)} + + +
+
+ + {formatValue(previous, formatKind)} + + previous period +
+ {/* Hover-only comparison card (non-interactive tooltip, kept pure-CSS) */} +
+
+
+ + Current · {periodLabel} + + + {formatValue(current, formatKind)} + +
+
+ + {previousPeriodLabel} + + + {formatValue(previous, formatKind)} + +
+
+
+ + Change + + +
+
+
+
+ ); +} + +// variant's default options so the demo stays compact; the state panel +// (above) is where consumers play with sub-options live. + +function FormatterPanel() { + // Each row supplies its own input value because the variants interpret + // numbers differently (currency wants cents, duration wants seconds / + // milliseconds, percent wants a fraction, datetime wants a timestamp). + 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"; + +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")} + > + +
+ } + /> +
+ )} +
+ + ); +} + +function MiniSparkline({ values }: { values: number[] }) { + const W = 520, H = 160, P = 16; + const iw = W - P * 2; + const ih = H - P * 2; + const max = Math.max(...values) * 1.1; + const path = values + .map((v, i) => `${i === 0 ? "M" : "L"}${(P + (i / (values.length - 1)) * iw).toFixed(1)},${(P + ih - (v / max) * ih).toFixed(1)}`) + .join(" "); + const area = `${path} L${(P + iw).toFixed(1)},${(P + ih).toFixed(1)} L${P},${(P + ih).toFixed(1)} Z`; + return ( + + + + + + + + + + + ); +} + + +type TableRow = { + key: string, + label: string, + light: string, + dark: string, + current: number, + previous: number, + trend: number[], +}; + +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] }, +]; + +type SortKey = "current" | "previous" | "delta" | "label"; +type SortDir = "asc" | "desc"; + +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) => { + const delta = formatDelta(r.current, r.previous); + return ( + + + + + + + + ); + })} + +
+ + Trend + +
+
+ + + {r.label} +
+
+ + + {r.current.toLocaleString("en-US")} + + {r.previous.toLocaleString("en-US")} + + +
+
+
+ ); +} + +function RowSparkline({ values, light, dark }: { values: number[], light: string, dark: string }) { + const W = 90, H = 22; + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const stepX = W / (values.length - 1); + const points = values + .map((v, i) => `${(i * stepX).toFixed(1)},${(H - ((v - min) / range) * H).toFixed(1)}`) + .join(" "); + const last = values[values.length - 1]!; + const first = values[0]!; + const up = last >= first; + return ( + + ); +} + + +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); + + // The HeroCanvas component is fully controlled — PageClient owns the + // entire state object. Mix-and-match presets are just initial values for + // this state; changing them at runtime takes effect immediately because + // the component reads everything from props on every render. + const [labState, setLabState] = useState(HERO_CANVAS_DEFAULT_STATE); + const resetLabState = () => setLabState(HERO_CANVAS_DEFAULT_STATE); + + // Annotations are also a prop. The consumer (PageClient) owns the array + // and appends to it whenever HeroCanvas fires onAnnotationCreate. + const [labAnnotations, setLabAnnotations] = useState(ANNOTATIONS); + + const heroCanvasUsage = useMemo( + () => + generateHeroCanvasUsage(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: HeroCanvasLabEvent = { + 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. The events panel renders one row per state slice that + // actually changed, mirroring how a granular per-callback API would + // look without forcing the component to expose 13 separate props. + const handleLabStateChange = useCallback>>( + (action) => { + setLabState((prev) => { + const next = + typeof action === "function" + ? (action as (p: HeroCanvasState) => HeroCanvasState)(prev) + : action; + for (const key of Object.keys(next) as (keyof HeroCanvasState)[]) { + 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={ +
+ + +
+ } + > +
+
+ +
+ {/* Lab playground — state panel / live preview / usage / events */} + + +
+ + +
+
+
+ +
+ +
+ + 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 ; +} From 451bbb074f98426eba7187ac61aa2440340193de Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 8 Apr 2026 13:31:11 -0700 Subject: [PATCH 21/30] Implement new analytics chart demo components - Added `analytics-chart-events-panel.tsx`, `analytics-chart-state-panel.tsx`, `analytics-chart-usage-viewer.tsx`, and `fixtures.ts` to enhance the chart demo with interactive event tracking, state management, and usage visualization. - Introduced `panels.tsx` for structured layout and presentation of various analytics components. - Created `analytics-chart-pie.tsx` and `analytics-chart.tsx` for improved pie chart rendering and overall chart functionality. - These additions aim to provide a comprehensive and engaging user experience for data visualization within the dashboard. --- .../demo/analytics-chart-events-panel.tsx | 80 + .../demo/analytics-chart-state-panel.tsx | 733 +++ .../demo/analytics-chart-usage-viewer.tsx | 182 + .../chart-demo/demo/fixtures.ts | 133 + .../chart-demo/demo/panels.tsx | 507 ++ .../chart-demo/page-client.tsx | 4540 +---------------- .../analytics-chart/analytics-chart-pie.tsx | 330 ++ .../analytics-chart/analytics-chart.tsx | 1096 ++++ .../default-analytics-chart-tooltip.tsx | 265 + .../src/components/analytics-chart/format.ts | 101 + .../src/components/analytics-chart/index.ts | 75 + .../src/components/analytics-chart/palette.ts | 68 + .../analytics-chart/render-data-series.tsx | 169 + .../src/components/analytics-chart/state.ts | 147 + .../src/components/analytics-chart/strings.ts | 72 + .../src/components/analytics-chart/types.ts | 208 + 16 files changed, 4240 insertions(+), 4466 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-events-panel.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-state-panel.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/analytics-chart-usage-viewer.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/fixtures.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/panels.tsx create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/default-analytics-chart-tooltip.tsx create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/format.ts create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/index.ts create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/palette.ts create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/render-data-series.tsx create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/state.ts create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/strings.ts create mode 100644 packages/dashboard-ui-components/src/components/analytics-chart/types.ts 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..8252afaa6d --- /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 "../analytics-chart"; + +/** 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..cf6ba149d8 --- /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 "../analytics-chart"; + +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..90255f0ef0 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/fixtures.ts @@ -0,0 +1,133 @@ +import { + ANALYTICS_CHART_DEFAULT_LAYERS, + DEFAULT_FORMAT_KIND, + pointValue, + type AnalyticsChartLayer, + type AnalyticsChartSeries, + type AnalyticsChartState, + type Annotation, + type Point, +} from "../analytics-chart"; + +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), // Mar 7, 2026 → ~Apr 5 + 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; + // Distribute the remainder one unit at a time to the segments with the + // largest fractional parts — Hamilton's method for apportioning seats. + 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" }, +]; + +/** Demo-specific initial state: the same layer union the reusable chart + * ships with, but pre-wired with the sign-ups breakdown matrix and an + * in-progress marker on "today" (the last day of the demo window). */ +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..bab7a9fd4f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/chart-demo/demo/panels.tsx @@ -0,0 +1,507 @@ +"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 "../analytics-chart"; +import { TABLE_ROWS, type TableRow } from "./fixtures"; + +// Re-export TrendPill from the demo barrel so demo files never reach into +// analytics-chart/ directly for this primitive. +export { TrendPill }; + +export function SectionHeading({ + index, + label, + caption, + right, +}: { + index: string, + label: string, + caption: ReactNode, + right?: ReactNode, +}) { + return ( +
+
+ + {index} + +
+ + {label} + + + {caption} + +
+
+ {right != null &&
{right}
} +
+ ); +} + +/** Unified sparkline. Replaces the former MiniSparkline (wide + area) and + * RowSparkline (tight + line-only) via a single prop-driven SVG. Uses one + * `` with CSS custom properties + Tailwind arbitrary variants so + * light/dark colors resolve without duplicating DOM. */ +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; + // For the area variant we need a closed path. Building it manually from + // the points string keeps the single-polyline pattern for the line and + // adds an explicit `` underneath for the gradient fill. + 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; + // useId gives a stable unique id across SSR/CSR without the collision risk + // of Math.random. The colon React uses gets escaped for CSS selectors. + 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() { + // Each row supplies its own input value because the variants interpret + // numbers differently (currency wants cents, duration wants seconds / ms, + // percent wants a fraction, datetime wants a timestamp). + 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 index 700b06a9f0..197a9b1782 100644 --- 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 @@ -1,4452 +1,45 @@ "use client"; import { - DesignAlert, DesignAnalyticsCard, - DesignAnalyticsCardHeader, DesignBadge, - DesignButton, - DesignPillToggle, } from "@/components/design-components"; -import { cn, Typography } from "@/components/ui"; +import { LightningIcon, PulseIcon } from "@phosphor-icons/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { PageLayout } from "../../page-layout"; import { - type ChartConfig, - ChartContainer, -} from "@/components/ui/chart"; + AnalyticsChart, + DEFAULT_FORMAT_KIND, + pointValue, + type AnalyticsChartState, + type Annotation, +} from "./analytics-chart"; import { - Area, - Bar, - CartesianGrid, - Cell, - ComposedChart, - Line, - Pie, - PieChart, - ReferenceArea, - ReferenceLine, - XAxis, - YAxis, -} from "recharts"; + AnalyticsChartEventsPanel, + type AnalyticsChartLabEvent, +} from "./demo/analytics-chart-events-panel"; +import { AnalyticsChartStatePanel } from "./demo/analytics-chart-state-panel"; import { - ArrowDownIcon, - ArrowUpIcon, - ArrowsClockwiseIcon, - ChartBarIcon, - ChartPieIcon, - ChartLineIcon, - ChartLineUpIcon, - CursorClickIcon, - FlagIcon, - LightningIcon, - MagnifyingGlassMinusIcon, - MagnifyingGlassPlusIcon, - MinusIcon, - PulseIcon, - PushPinSimpleIcon, - SpinnerGapIcon, - XIcon, -} from "@phosphor-icons/react"; + AnalyticsChartUsageViewer, + generateAnalyticsChartUsage, +} from "./demo/analytics-chart-usage-viewer"; import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { z } from "zod"; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { PageLayout } from "../../page-layout"; - - -const DAY_COUNT = 30; - -/** Chart point: `values` keyed by layer id. */ -type Point = { - ts: number, - values: Record, -}; - -/** Missing or non-finite values become 0. */ -function pointValue(p: Point, id: string): number { - const v = p.values[id]; - return typeof v === "number" && Number.isFinite(v) ? v : 0; -} - -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), // Mar 7, 2026 → ~Apr 5 - values: { - signups: Math.max(0, Math.round(base + trend + wave)), - previous: Math.max(0, Math.round(prev)), - }, - }; -}); - -type Annotation = { - index: number, - label: string, - description: string, -}; - - -type HeroBreakdownSeries = { - key: string, - label: string, -}; - -const HERO_BREAKDOWN_SERIES: HeroBreakdownSeries[] = [ - { 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 HERO_BREAKDOWN_RATIOS = [0.32, 0.26, 0.20, 0.13, 0.09]; - -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; - // Distribute the remainder one unit at a time to the segments with the - // largest fractional parts — same algorithm Hamilton's method uses for - // apportioning seats. - 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; -} - -const HERO_BREAKDOWN: number[][] = SERIES.map((p, i) => { - const weights = HERO_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); -}); - -const HERO_BREAKDOWN_PREV: number[][] = SERIES.map((p, i) => { - const weights = HERO_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 type HeroChartSegmentRamp = - | { - kind: "procedural", - /** HSL hue (0-360). */ - hue: number, - /** HSL saturation percent (0-100). */ - sat: number, - /** Lightness range `[start, end]` for the light theme (0-100). */ - shadeRangeLight: [number, number], - /** Lightness range `[start, end]` for the dark theme (0-100). */ - shadeRangeDark: [number, number], - } - | { - kind: "explicit", - /** Concrete light-theme color list. Indexed by segment. */ - light: readonly string[], - /** Concrete dark-theme color list. Indexed by segment. */ - dark: readonly string[], - }; - -export type HeroChartPalette = { - /** Color ramp for the primary data layer (sign-ups / current period). */ - primary: HeroChartSegmentRamp, - /** Color ramp for the compare data layer (previous period). */ - compare: HeroChartSegmentRamp, -}; - -export const HERO_CHART_DEFAULT_PALETTE: HeroChartPalette = { - primary: { - kind: "procedural", - hue: 220, - sat: 78, - shadeRangeLight: [28, 62], - shadeRangeDark: [52, 82], - }, - compare: { - kind: "procedural", - hue: 38, - sat: 92, - shadeRangeLight: [28, 62], - shadeRangeDark: [52, 82], - }, -}; - -function resolveHeroChartPalette( - override: Partial | undefined, -): HeroChartPalette { - if (!override) return HERO_CHART_DEFAULT_PALETTE; - return { - primary: override.primary ?? HERO_CHART_DEFAULT_PALETTE.primary, - compare: override.compare ?? HERO_CHART_DEFAULT_PALETTE.compare, - }; -} - -/** Expand a ramp into N colors for a given theme. */ -function buildRampColors( - ramp: HeroChartSegmentRamp, - count: number, - theme: "light" | "dark", -): string[] { - if (ramp.kind === "explicit") { - const list = theme === "light" ? ramp.light : ramp.dark; - // If the consumer supplied an empty list we fall back to a neutral - // grey for every segment. Otherwise, pad by clamping the index to - // the last entry so the color list extends naturally if the consumer - // supplied fewer colors than there are segments. - if (list.length === 0) return Array.from({ length: count }, () => "#888"); - return Array.from( - { length: count }, - (_, i) => list[i < list.length ? i : list.length - 1]!, - ); - } - const range = theme === "light" ? ramp.shadeRangeLight : ramp.shadeRangeDark; - return Array.from({ length: count }, (_, i) => { - const t = count <= 1 ? 0.5 : i / (count - 1); - const l = range[0] + t * (range[1] - range[0]); - return `hsl(${ramp.hue} ${ramp.sat}% ${l.toFixed(1)}%)`; - }); -} - -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" }, -]; - - -type FormatKindType = - | "numeric" - | "short" - | "currency" - | "duration" - | "datetime" - | "percent"; - -type FormatKindNumeric = { - type: "numeric", - /** Locale used for grouping and digit separators. Defaults to "en-US". */ - locale?: string, - /** Fixed decimal places (0-4). Defaults to 0. */ - decimals?: number, -}; -type FormatKindShort = { - type: "short", - /** Decimal places after the unit suffix (1.2k vs 1.20k). Defaults to 1. */ - precision?: number, -}; -type FormatKindCurrency = { - type: "currency", - /** ISO 4217 code. Defaults to "USD". */ - currency?: string, - /** Divisor applied before formatting — e.g. 100 for cents → dollars. Defaults to 1. */ - divisor?: number, - locale?: string, -}; -type FormatKindDuration = { - type: "duration", - /** Source unit of the input value. Defaults to "s". */ - unit?: "ms" | "s" | "m" | "h", - /** Show the smallest unit even when zero. Defaults to false. */ - showZero?: boolean, -}; -type FormatKindDatetime = { - type: "datetime", - /** Render style. Defaults to "short". */ - style?: "short" | "long" | "iso" | "relative", - locale?: string, -}; -type FormatKindPercent = { - type: "percent", - /** How to interpret the input value: - * - "fraction" → 0..1 → multiply by 100 (default) - * - "basis" → 0..10000 → divide by 100 - * - "whole" → 0..100 → no scaling - */ - source?: "fraction" | "basis" | "whole", - /** Decimal places. Defaults to 1. */ - decimals?: number, -}; - -type FormatKind = - | FormatKindNumeric - | FormatKindShort - | FormatKindCurrency - | FormatKindDuration - | FormatKindDatetime - | FormatKindPercent; - -const FORMAT_KIND_TYPES: FormatKindType[] = [ - "numeric", - "short", - "currency", - "duration", - "datetime", - "percent", -]; - -const DEFAULT_FORMAT_KIND: { [K in FormatKindType]: Extract } = { - numeric: { type: "numeric", locale: "en-US", decimals: 0 }, - short: { type: "short", precision: 1 }, - currency: { type: "currency", currency: "USD", divisor: 100, locale: "en-US" }, - duration: { type: "duration", unit: "s", showZero: false }, - datetime: { type: "datetime", style: "short", locale: "en-US" }, - percent: { type: "percent", source: "fraction", decimals: 1 }, -}; - -function formatValue(value: number, kind: FormatKind): string { - switch (kind.type) { - case "numeric": { - const decimals = kind.decimals ?? 0; - return value.toLocaleString(kind.locale ?? "en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }); - } - case "short": { - const p = kind.precision ?? 1; - const abs = Math.abs(value); - if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(p)}B`; - if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(p)}M`; - if (abs >= 1_000) return `${(value / 1_000).toFixed(p)}k`; - return `${value.toFixed(p === 0 ? 0 : 0)}`; - } - case "currency": { - const divisor = kind.divisor ?? 1; - return new Intl.NumberFormat(kind.locale ?? "en-US", { - style: "currency", - currency: kind.currency ?? "USD", - }).format(value / divisor); - } - case "duration": { - const unit = kind.unit ?? "s"; - // Normalize the input value to seconds before splitting into h/m/s. - const seconds = unit === "ms" ? value / 1000 - : unit === "m" ? value * 60 - : unit === "h" ? value * 3600 - : value; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - const ms = unit === "ms" && seconds < 1 ? Math.round(value) : 0; - if (h > 0) return `${h}h ${m}m ${s}s`; - if (m > 0) return `${m}m ${s}s`; - if (unit === "ms" && seconds < 1) return `${ms}ms`; - if (s > 0 || kind.showZero) return `${s}s`; - return "0s"; - } - case "datetime": { - const d = new Date(value); - const style = kind.style ?? "short"; - const locale = kind.locale ?? "en-US"; - if (style === "iso") return d.toISOString(); - if (style === "relative") { - const diff = Date.now() - value; - const past = diff > 0; - const abs = Math.abs(diff); - const days = Math.floor(abs / 86_400_000); - const hours = Math.floor(abs / 3_600_000); - const mins = Math.floor(abs / 60_000); - const suffix = past ? "ago" : "from now"; - if (days >= 1) return `${days}d ${suffix}`; - if (hours >= 1) return `${hours}h ${suffix}`; - if (mins >= 1) return `${mins}m ${suffix}`; - return "just now"; - } - if (style === "long") return d.toLocaleString(locale, { - dateStyle: "medium", - timeStyle: "short", - }); - return d.toLocaleDateString(locale, { month: "short", day: "numeric" }); - } - case "percent": { - const source = kind.source ?? "fraction"; - const decimals = kind.decimals ?? 1; - const pct = source === "basis" ? value / 100 - : source === "whole" ? value - : value * 100; - return `${pct.toFixed(decimals)}%`; - } - } -} - -function formatDelta(current: number, previous: number): { - pct: number | null, - sign: "up" | "down" | "flat" | "na", -} { - if (!Number.isFinite(current) || !Number.isFinite(previous)) return { pct: null, sign: "na" }; - if (previous === 0) return current === 0 ? { pct: 0, sign: "flat" } : { pct: null, sign: "na" }; - const pct = Number((((current - previous) / previous) * 100).toFixed(1)); - const sign = pct > 0 ? "up" : pct < 0 ? "down" : "flat"; - return { pct, sign }; -} - -function formatDate(ts: number, opts?: { short?: boolean }): string { - const d = new Date(ts); - if (opts?.short) { - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - } - return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); -} - - -function SectionHeading({ - index, - label, - caption, - right, -}: { - index: string, - label: string, - caption: ReactNode, - right?: ReactNode, -}) { - return ( -
-
- - {index} - -
- - {label} - - - {caption} - -
-
- {right != null &&
{right}
} -
- ); -} - - -function TrendPill({ - delta, - label, - size = "sm", -}: { - delta: { pct: number | null, sign: "up" | "down" | "flat" | "na" }, - label?: string, - size?: "sm" | "md", -}) { - const { pct, sign } = delta; - const tone = - sign === "up" ? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10" - : sign === "down" ? "text-rose-600 dark:text-rose-400 bg-rose-500/10" - : sign === "flat" ? "text-muted-foreground bg-foreground/[0.06]" - : "text-muted-foreground bg-foreground/[0.06]"; - const Icon = sign === "up" ? ArrowUpIcon : sign === "down" ? ArrowDownIcon : MinusIcon; - const text = pct == null ? "—" : `${pct > 0 ? "+" : ""}${pct}%`; - return ( - - - ); -} - -type HeroCanvasView = "timeseries" | "pie"; -type HeroCanvasLayerType = "line" | "area" | "bar"; -type HeroCanvasStrokeStyle = "solid" | "dashed" | "dotted"; - -type HeroCanvasLayerKind = "primary" | "compare" | "annotations"; - -type HeroCanvasDataLayerCommon = { - id: string, - kind: "primary" | "compare", - label: string, - visible: boolean, - color: string, - segmented: boolean, - /** Per-day per-category values. Outer index is the day (matches the - * `data` array index); inner index is the category (matches - * `segmentSeries`). Rows should sum to `point.values[layer.id]`. */ - segments?: readonly (readonly number[])[], - /** Breakdown category definitions — `{ key, label }` tuples. Ordered to - * match the inner index of `segments`. */ - segmentSeries?: readonly HeroBreakdownSeries[], - /** Absolute index into `data` at which this layer's values become - * "in progress" (incomplete and still changing). Points from this - * index onward render with a dashed overlay so users don't panic at a - * half-filled bucket. Applies to line and area rendering only. */ - inProgressFromIndex?: number | null, -}; -type HeroCanvasLineLayer = HeroCanvasDataLayerCommon & { - type: "line", - strokeStyle: HeroCanvasStrokeStyle, -}; -type HeroCanvasAreaLayer = HeroCanvasDataLayerCommon & { - type: "area", - strokeStyle: HeroCanvasStrokeStyle, - fillOpacity: number, -}; -type HeroCanvasBarLayer = HeroCanvasDataLayerCommon & { - type: "bar", - fillOpacity: number, -}; - -type HeroCanvasDataLayer = - | HeroCanvasLineLayer - | HeroCanvasAreaLayer - | HeroCanvasBarLayer; - -type HeroCanvasAnnotationsLayer = { - id: string, - kind: "annotations", - label: string, - visible: boolean, - color: string, -}; - -type HeroCanvasLayer = - | HeroCanvasDataLayer - | HeroCanvasAnnotationsLayer; - -type HeroCanvasLayers = readonly HeroCanvasLayer[]; - -const HERO_CANVAS_DEFAULT_LAYERS: HeroCanvasLayers = [ - { - id: "signups", - kind: "primary", - label: "Sign-ups", - visible: true, - color: "#2563eb", - segmented: false, - type: "area", - strokeStyle: "solid", - fillOpacity: 0.22, - segments: HERO_BREAKDOWN, - segmentSeries: HERO_BREAKDOWN_SERIES, - // Last day of the demo window is "today" — render its tail dashed. - inProgressFromIndex: DAY_COUNT - 1, - }, - { - id: "previous", - kind: "compare", - label: "Previous period", - visible: true, - color: "#f59e0b", - segmented: false, - type: "line", - strokeStyle: "dashed", - segments: HERO_BREAKDOWN_PREV, - segmentSeries: HERO_BREAKDOWN_SERIES, - // Previous period is fully committed — no in-progress tail. - inProgressFromIndex: null, - }, - { id: "annotations", kind: "annotations", label: "Annotations", visible: true, color: "#f59e0b" }, -]; - -const STROKE_DASHARRAY: Record = { - solid: undefined, - dashed: "5 4", - dotted: "1 4", -}; - -const EMPTY_SERIES: readonly HeroBreakdownSeries[] = []; -const EMPTY_MATRIX: readonly (readonly number[])[] = []; - -type HeroCanvasTimeseriesState = { - view: "timeseries", - layers: HeroCanvasLayers, - /** Format applied to every x-axis value (ticks, tooltip header, brush - * popup, action bar, pie window range). Defaults to `datetime / short` - * so the x-axis renders as a date. Set to any `FormatKind` to render - * x-values as numbers, durations, etc. */ - xFormatKind: FormatKind, - /** Format applied to every y-axis value (ticks, tooltip rows, per-layer - * totals, pie center, pie legend). Defaults to `short` so 1234 → "1k". */ - yFormatKind: FormatKind, - showGrid: boolean, - showXAxis: boolean, - showYAxis: boolean, - zoomRange: [number, number] | null, - pinnedIndex: number | null, -}; -type HeroCanvasPieState = { - view: "pie", - layers: HeroCanvasLayers, - xFormatKind: FormatKind, - yFormatKind: FormatKind, -}; -type HeroCanvasState = HeroCanvasTimeseriesState | HeroCanvasPieState; - -const HERO_CANVAS_DEFAULT_STATE: HeroCanvasState = { - view: "timeseries", - layers: HERO_CANVAS_DEFAULT_LAYERS, - xFormatKind: DEFAULT_FORMAT_KIND.datetime, - yFormatKind: DEFAULT_FORMAT_KIND.short, - showGrid: true, - showXAxis: true, - showYAxis: true, - zoomRange: null, - pinnedIndex: null, -}; - -function findLayerByKind( - layers: HeroCanvasLayers, - kind: K, -): HeroCanvasDataLayer | undefined; -function findLayerByKind( - layers: HeroCanvasLayers, - kind: K, -): HeroCanvasDataLayer | undefined; -function findLayerByKind( - layers: HeroCanvasLayers, - kind: K, -): HeroCanvasAnnotationsLayer | undefined; -function findLayerByKind( - layers: HeroCanvasLayers, - kind: HeroCanvasLayerKind, -): HeroCanvasLayer | undefined { - return layers.find((l) => l.kind === kind); -} - -function findLayerById( - layers: HeroCanvasLayers, - id: string, -): HeroCanvasLayer | undefined { - return layers.find((l) => l.id === id); -} - -/** Type guard for the data-layer variants (primary / compare). Useful in - * `.map` callbacks where TypeScript's union narrowing can't peel off the - * data-layer branch on its own — because the data layer is itself a - * discriminated union, TS doesn't see `kind === "primary"` as eliminating - * it from the parent union. This predicate makes the split explicit. */ -function isHeroCanvasDataLayer(l: HeroCanvasLayer): l is HeroCanvasDataLayer { - return l.kind === "primary" || l.kind === "compare"; -} - -/** Replace a single layer (looked up by id) with a new layer object. */ -function setLayerById( - layers: HeroCanvasLayers, - id: string, - next: HeroCanvasLayer, -): HeroCanvasLayers { - return layers.map((l) => (l.id === id ? next : l)); -} - -/** Shallow-patch fields on a layer by id. Unlike the old typed variant, - * the patch type is deliberately loose — callers are trusted to supply - * only fields the layer's `kind`/`type` actually owns. Runtime validation - * (see `validateHeroCanvasLayers`) catches bad shapes at the prop boundary - * instead of at every patch site. */ -function patchLayerById( - layers: HeroCanvasLayers, - id: string, - patch: Record, -): HeroCanvasLayers { - return layers.map((l) => (l.id === id ? ({ ...l, ...patch } as HeroCanvasLayer) : l)); -} - -type HeroCanvasResolvedDataLayerStyle = { - color: string, - type: HeroCanvasLayerType, - strokeStyle: HeroCanvasStrokeStyle, - fillOpacity: number, -}; -function resolveDataLayerStyle( - layer: HeroCanvasDataLayer, -): HeroCanvasResolvedDataLayerStyle { - return { - color: layer.color, - type: layer.type, - // Bars have no stroke pattern — defaults to solid for the underline. - strokeStyle: layer.type === "bar" ? "solid" : layer.strokeStyle, - // Lines have no fill — defaults to 0 so gradient overlays sit flat. - fillOpacity: layer.type === "line" ? 0 : layer.fillOpacity, - }; -} - - -const strokeStyleSchema = z.enum(["solid", "dashed", "dotted"]); - -const heroBreakdownSeriesSchema = z.object({ - key: z.string(), - label: z.string(), -}); - -const dataLayerCommonFields = { - id: z.string(), - kind: z.enum(["primary", "compare"]), - label: z.string(), - visible: z.boolean(), - color: z.string(), - segmented: z.boolean(), - segments: z.array(z.array(z.number())).optional(), - segmentSeries: z.array(heroBreakdownSeriesSchema).optional(), - inProgressFromIndex: z.number().int().nullable().optional(), -}; - -const heroCanvasDataLayerSchema = z.discriminatedUnion("type", [ - z.object({ - ...dataLayerCommonFields, - type: z.literal("line"), - strokeStyle: strokeStyleSchema, - }), - z.object({ - ...dataLayerCommonFields, - type: z.literal("area"), - strokeStyle: strokeStyleSchema, - fillOpacity: z.number(), - }), - z.object({ - ...dataLayerCommonFields, - type: z.literal("bar"), - fillOpacity: z.number(), - }), -]); - -const heroCanvasAnnotationsLayerSchema = z.object({ - id: z.string(), - kind: z.literal("annotations"), - label: z.string(), - visible: z.boolean(), - color: z.string(), -}); - -const heroCanvasLayerSchema = z.union([ - heroCanvasDataLayerSchema, - heroCanvasAnnotationsLayerSchema, -]); - -const heroCanvasLayersSchema = z.array(heroCanvasLayerSchema); - -export type HeroCanvasValidationWarning = { - code: "shape" | "count" | "duplicate-id", - message: string, -}; - -/** Validate a layer array against the HeroCanvas shape + semantic - * constraints. Returns the validated array. Behavior on failure is - * governed by `ignoreInvalidConfig`: - * - * - `false` (default): any error throws immediately. - * - `true`: warnings are logged via `onWarning` (default: `console.warn`) - * and the function returns either the (shape-valid) input layers or - * `HERO_CANVAS_DEFAULT_LAYERS` if the shape is broken beyond repair. - */ -function validateHeroCanvasLayers( - input: unknown, - opts?: { - ignoreInvalidConfig?: boolean, - onWarning?: (warning: HeroCanvasValidationWarning) => void, - }, -): HeroCanvasLayers { - const ignore = opts?.ignoreInvalidConfig ?? false; - const warn = opts?.onWarning ?? ((w) => console.warn(`[HeroChart] ${w.code}: ${w.message}`)); - - // Shape validation — a hard failure here means we can't even read the - // data, so fall back to defaults in ignore mode. - const parsed = heroCanvasLayersSchema.safeParse(input); - if (!parsed.success) { - const message = `invalid layer shape — ${parsed.error.issues.map((i) => i.message).join("; ")}`; - if (!ignore) throw new Error(`[HeroChart] ${message}`); - warn({ code: "shape", message }); - return HERO_CANVAS_DEFAULT_LAYERS; - } - const layers = parsed.data; - - // Semantic validation — counts + uniqueness. These are non-fatal in - // ignore mode (the array still renders, just with a warning). - const errors: HeroCanvasValidationWarning[] = []; - const primaries = layers.filter((l) => l.kind === "primary"); - const compares = layers.filter((l) => l.kind === "compare"); - const annotationList = layers.filter((l) => l.kind === "annotations"); - if (primaries.length !== 1) { - errors.push({ - code: "count", - message: `expected exactly 1 layer with kind="primary", got ${primaries.length}`, - }); - } - if (compares.length > 1) { - errors.push({ - code: "count", - message: `expected ≤1 layer with kind="compare", got ${compares.length}`, - }); - } - if (annotationList.length > 1) { - errors.push({ - code: "count", - message: `expected ≤1 layer with kind="annotations", got ${annotationList.length}`, - }); - } - const ids = layers.map((l) => l.id); - const idSet = new Set(ids); - if (idSet.size !== ids.length) { - const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); - errors.push({ - code: "duplicate-id", - message: `duplicate layer ids: ${[...new Set(dupes)].join(", ")}`, - }); - } - - if (errors.length > 0) { - if (!ignore) { - throw new Error(`[HeroChart] ${errors.map((e) => e.message).join("; ")}`); - } - errors.forEach(warn); - } - return layers; -} - -/** Type guard for the `timeseries` state variant. Used at the top of - * HeroChart / HeroCanvas so the rest of the function body can freely - * read `state.showGrid` etc. when true. */ -function isTimeseriesState( - state: HeroCanvasState, -): state is HeroCanvasTimeseriesState { - return state.view === "timeseries"; -} - -function useControllableState(config: { - prop: T | undefined, - defaultProp: T, - onChange?: (value: T) => void, -}): [T, (next: T | ((prev: T) => T)) => void] { - const { prop, defaultProp, onChange } = config; - const [uncontrolled, setUncontrolled] = useState(defaultProp); - const isControlled = prop !== undefined; - const value = isControlled ? prop : uncontrolled; - - // Refs keep the setter identity stable across renders without losing - // access to the freshest `value` / `onChange`. This matters because - // mouse handlers capture `setValue` and we don't want stale closures. - const valueRef = useRef(value); - useEffect(() => { - valueRef.current = value; - }, [value]); - const onChangeRef = useRef(onChange); - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - const setValue = useCallback( - (next: T | ((prev: T) => T)) => { - const resolved = - typeof next === "function" - ? (next as (prev: T) => T)(valueRef.current) - : next; - if (Object.is(resolved, valueRef.current)) return; - if (!isControlled) setUncontrolled(resolved); - onChangeRef.current?.(resolved); - }, - [isControlled], - ); - return [value, setValue]; -} - -export type HeroChartStrings = { - /** Reset-zoom badge in the top-right corner when `state.zoomRange` is set. */ - resetZoom: string, - /** Header label above the timestamps in the live range-brush popup. */ - rangeLabel: string, - /** Formatted day-count suffix used by the brush + action-bar. */ - daysShort: (days: number) => string, - /** "Zoom in" button inside the committed-range action bar. */ - zoomIn: string, - /** "Annotate" button inside the committed-range action bar. */ - annotate: string, - /** Placeholder for the annotation label input. */ - annotationPlaceholder: string, - /** `aria-label` for the annotation label input. */ - annotationLabelAria: string, - /** Save button in the annotation form. */ - save: string, - /** Cancel button in the annotation form. */ - cancel: string, - /** `aria-label` for the X button that clears the committed range. */ - clearSelection: string, - /** Pinned badge shown in the tooltip header when the tooltip is pinned. */ - pinnedBadge: string, - /** "Δ vs prev" row label in the tooltip (both segmented and flat modes). */ - deltaVsPrev: string, - /** Suffix appended to a layer's label in the per-layer totals section - * (e.g. "Sign-ups" → "Sign-ups total"). */ - layerTotalSuffix: string, - /** Hint row shown in the tooltip while it is floating (not pinned). */ - hintClickToPin: string, - /** Hint row shown in the tooltip while it is pinned. */ - hintClickAnywhereUnpin: string, - /** Center-stat heading shown in the pie when no segment is hovered. */ - pieTotalCenter: string, - /** Label on the TrendPill in the pie center. */ - pieVsPrev: string, - /** `aria-label` for the PieChart SVG. */ - pieAriaLabel: (ctx: { segmentCount: number, windowDays: number }) => string, - /** Percentage-of-total caption shown under an active pie slice. */ - piePercentOfTotal: (pct: number) => string, -}; - -export const HERO_CHART_DEFAULT_STRINGS: HeroChartStrings = { - resetZoom: "Reset zoom", - rangeLabel: "Range", - daysShort: (days) => `${days}d`, - zoomIn: "Zoom in", - annotate: "Annotate", - annotationPlaceholder: "Label this range…", - annotationLabelAria: "Annotation label", - save: "Save", - cancel: "Cancel", - clearSelection: "Clear selection", - pinnedBadge: "Pinned", - deltaVsPrev: "Δ vs prev", - layerTotalSuffix: " total", - hintClickToPin: "Click to pin this point", - hintClickAnywhereUnpin: "Click anywhere · Esc\u00A0to unpin", - pieTotalCenter: "Total", - pieVsPrev: "vs prev", - pieAriaLabel: ({ segmentCount, windowDays }) => - `${segmentCount} segment share-of-total over the visible ${windowDays}-day range`, - piePercentOfTotal: (pct) => `${(pct * 100).toFixed(1)}% of total`, -}; - -/** Merge a Partial over the defaults. Deliberately shallow - * because every field is a primitive / flat function — no nested objects. */ -function resolveHeroChartStrings( - override: Partial | undefined, -): HeroChartStrings { - if (!override) return HERO_CHART_DEFAULT_STRINGS; - return { ...HERO_CHART_DEFAULT_STRINGS, ...override }; -} - - -export type HeroChartDelta = { - pct: number | null, - sign: "up" | "down" | "flat" | "na", -}; - -export type HeroChartTooltipSegmentRow = { - key: string, - label: string, - value: number, - /** Light-theme color for the dot / swatch. */ - color: string, - /** Dark-theme color for the dot / swatch. */ - colorDark: string, -}; - -export type HeroChartTooltipLayerView = { - /** Stable layer id (`"signups"`, `"previous"`). */ - id: string, - /** Consumer-provided layer label. */ - label: string, - /** The resolved layer color (light theme). */ - color: string, - /** Flat total for this layer at the hovered index. Always populated - * regardless of segmentation, so consumers can render the same number - * in either mode. */ - total: number, - /** True iff this layer is rendered as a stacked break-down (i.e. the - * tooltip should show per-segment rows instead of a single total). */ - segmented: boolean, - /** Per-segment rows — empty when `segmented === false`. Order matches - * `segmentSeries`. */ - segments: HeroChartTooltipSegmentRow[], -}; - -export type HeroChartTooltipContext = { - /** Index into the visible window. */ - activeIndex: number, - /** Raw point at `activeIndex` — convenient for `.ts` access. */ - point: Point, - /** True when the tooltip is pinned (via click) and stable under hover. */ - isPinned: boolean, - /** Primary layer view or null when the primary layer is hidden. */ - primary: HeroChartTooltipLayerView | null, - /** Compare layer view or null when the compare layer is hidden. */ - compare: HeroChartTooltipLayerView | null, - /** Flat-mode delta between primary and compare totals. Null when either - * side is hidden. Consumers should feed this into their trend pill. */ - delta: HeroChartDelta | null, - /** Pre-bound value formatter for y-axis values (applied with - * `state.yFormatKind`). */ - formatValue: (v: number) => string, - /** Pre-bound formatter for x-axis values (applied with - * `state.xFormatKind`). Use for the tooltip header or any x-value the - * consumer wants to render. */ - formatDate: (ts: number) => string, - /** Resolved strings — already merged with `HERO_CHART_DEFAULT_STRINGS`. */ - strings: HeroChartStrings, -}; - -/** Props for the default tooltip renderer. Exposed so consumers can - * compose on top of it (e.g. wrap with an outer title). */ -export type DefaultHeroChartTooltipProps = { - ctx: HeroChartTooltipContext, -}; - -export function DefaultHeroChartTooltip({ ctx }: DefaultHeroChartTooltipProps) { - const { point, isPinned, primary, compare, delta, formatValue: fv, formatDate: fd, strings } = ctx; - const anySegmented = (primary?.segmented ?? false) || (compare?.segmented ?? false); - return ( -
-
- - {fd(point.ts)} - - {isPinned && ( - - - )} -
-
- {/* Primary — either a single total row or a per-segment breakdown. */} - {primary && !primary.segmented && ( -
- - {primary.label} - - {fv(primary.total)} - -
- )} - {primary && primary.segmented && primary.segments.map((s) => ( -
- - - {s.label} - - {fv(s.value)} - -
- ))} - {/* Compare — independent breakdown / total row. */} - {compare && !compare.segmented && ( -
- - {compare.label} - - {fv(compare.total)} - -
- )} - {compare && compare.segmented && compare.segments.map((s) => ( -
- - - {s.label} - - {fv(s.value)} - -
- ))} -
- {/* Per-layer totals — shown only when that layer is segmented, so the - user can see the sum of the stacked bars at a glance. */} - {anySegmented && ( -
- {primary?.segmented && ( -
- - {primary.label}{strings.layerTotalSuffix} - - - {fv(primary.total)} - -
- )} - {compare?.segmented && ( -
- - {compare.label}{strings.layerTotalSuffix} - - - {fv(compare.total)} - -
- )} - {primary?.segmented && compare?.segmented && delta && ( -
- - {strings.deltaVsPrev} - - -
- )} -
- )} - {!anySegmented && delta && primary && compare && ( -
- - {strings.deltaVsPrev} - - -
- )} - {!isPinned ? ( -
-
- ) : ( -
-
- )} -
- ); -} - -type HeroChartProps = { - // Data — passed in by the consumer; the chart never mutates it. - // - // Segment data lives on the layer itself (`layer.segments` + - // `layer.segmentSeries`), so each data layer carries its own breakdown - // vocabulary. There are no sibling segment props here anymore — if a - // consumer wants to segment the compare layer by device type while the - // primary layer is segmented by region, that's two independent setups - // on two layer objects. - data: Point[], - annotations: Annotation[], - seriesLabel: string, - // Fully-controlled state object + dispatch. The chart reads every config - // and persistent-interaction slice from `state` and mutates it through - // `onChange` (which accepts both values and updater functions, exactly - // like React's `useState` setter). - state: HeroCanvasState, - onChange: React.Dispatch>, - // Fired whenever the user submits the in-chart annotation form. The - // consumer is expected to append to its own annotations array. - onAnnotationCreate?: (annotation: Annotation) => void, - /** Override any user-visible copy. Deep-merges over - * `HERO_CHART_DEFAULT_STRINGS`. */ - strings?: Partial, - /** Override the segment color ramps. Each ramp is either procedural - * (hue + sat + lightness range) or explicit (concrete color lists per - * theme). Unspecified ramps fall back to the blue / amber defaults. */ - palette?: Partial, - /** Render slot for the tooltip body. Receives a prepared context with - * the active point, primary / compare layer views, pre-bound formatters, - * and resolved strings. Defaults to `DefaultHeroChartTooltip` — consumers - * can wrap it instead of reimplementing from scratch. */ - renderTooltip?: (ctx: HeroChartTooltipContext) => ReactNode, - /** When `true`, runtime validation errors on `state.layers` become - * warnings and the chart falls back to defaults / soldiers on instead - * of throwing. Defaults to `false` (throw on invalid config). */ - ignoreInvalidConfig?: boolean, - // Each piece of ephemeral interaction state is exposed as an optional - // controlled prop + change callback. Passing `undefined` leaves the - // chart to manage the state itself; the callback still fires so the - // consumer can observe transitions without owning them. Passing a - // non-`undefined` value switches that piece of state into fully - // controlled mode (consumer owns it, chart only emits changes). - /** Current hovered index (index into the visible data window). */ - hoverIndex?: number | null, - /** Fires whenever the hovered index changes. */ - onHoverIndexChange?: (index: number | null) => void, - /** The user-committed brush range (after a drag completes). */ - committedRange?: [number, number] | null, - /** Fires whenever the committed range changes (including cleared). */ - onCommittedRangeChange?: (range: [number, number] | null) => void, - /** Live brush preview during an active drag. `null` when no drag in - * progress. Observation-only — the chart always owns the drag itself. */ - onBrushChange?: (brush: { start: number, end: number } | null) => void, - /** Current annotation-form draft string, or `null` when the form is - * closed. Controllable so consumers can open/pre-fill the form. */ - annotationDraft?: string | null, - /** Fires whenever the annotation draft changes. */ - onAnnotationDraftChange?: (draft: string | null) => void, - /** Recharts plot margins. Also drives overlay positioning math for - * the crosshair, tooltip anchor, brush popup, and flag markers so - * they line up with the actual plot area. Defaults to - * `{ top: 16, right: 24, bottom: 8, left: 12 }`. */ - plotMargin?: { top?: number, right?: number, bottom?: number, left?: number }, - /** Y-axis reserved width in pixels. Defaults to 48. */ - yAxisWidth?: number, - /** Fractional headroom added to the y-axis top (e.g. 0.1 = 10%). - * Defaults to 0.1. Pass 0 to let the chart top touch the plot edge. */ - yDomainPadding?: number, - /** Primary pie ring inner radius (pixels). Defaults to 60. */ - pieInnerRadius?: number, - /** Primary pie ring outer radius (pixels). Defaults to 84. */ - pieOuterRadius?: number, - /** Compare pie ring inner radius (pixels). Defaults to 36. */ - pieCompareInnerRadius?: number, - /** Compare pie ring outer radius (pixels). Defaults to 52. */ - pieCompareOuterRadius?: number, - /** Tailwind class list applied to the `` that wraps - * the pie chart. Defaults to an aspect-square 220–240px box. */ - pieContainerClassName?: string, - /** Custom number formatter. Receives the raw value and the kind to - * format with — the same function is invoked for both x-axis values - * (where kind = `state.xFormatKind`) and y-axis values (where kind = - * `state.yFormatKind`). Defaults to the built-in `formatValue`, which - * handles every variant of `FormatKind` including `datetime`. */ - valueFormatter?: (value: number, kind: FormatKind) => string, -}; - - -function HeroChart({ - data: fullData, - annotations: fullAnnotations, - seriesLabel, - state, - onChange, - onAnnotationCreate, - strings: stringsOverride, - palette: paletteOverride, - renderTooltip, - ignoreInvalidConfig = false, - hoverIndex: controlledHoverIndex, - onHoverIndexChange, - committedRange: controlledCommittedRange, - onCommittedRangeChange, - onBrushChange, - annotationDraft: controlledAnnotationDraft, - onAnnotationDraftChange, - plotMargin, - yAxisWidth = 48, - yDomainPadding = 0.1, - pieInnerRadius = 60, - pieOuterRadius = 84, - pieCompareInnerRadius = 36, - pieCompareOuterRadius = 52, - pieContainerClassName = "aspect-square h-[220px] w-[220px] sm:h-[240px] sm:w-[240px]", - valueFormatter, -}: HeroChartProps) { - // Resolved plot margins + formatter. - const resolvedPlotMargin = useMemo( - () => ({ - top: plotMargin?.top ?? 16, - right: plotMargin?.right ?? 24, - bottom: plotMargin?.bottom ?? 8, - left: plotMargin?.left ?? 12, - }), - [plotMargin], - ); - const fmtValue = valueFormatter ?? formatValue; - const strings = useMemo( - () => resolveHeroChartStrings(stringsOverride), - [stringsOverride], - ); - const palette = useMemo( - () => resolveHeroChartPalette(paletteOverride), - [paletteOverride], - ); - // Validate the layer array at the prop boundary. In strict mode (the - // default) this throws on bad shape / count violations — the chart - // fails loudly in dev. Consumers who explicitly opt in to - // `ignoreInvalidConfig` get `console.warn` instead plus a graceful - // fallback to the defaults on fatal shape errors. - const validatedLayers = useMemo( - () => validateHeroCanvasLayers(state.layers, { ignoreInvalidConfig }), - [state.layers, ignoreInvalidConfig], - ); - const renderTooltipFn = renderTooltip ?? ((ctx) => ); - // Common fields live on both state variants; pie-incompatible fields - // are sourced from a narrowed helper so the rest of the function body - // can treat them as simple values without chasing discriminators. - const { xFormatKind, yFormatKind } = state; - // `layers` is sourced from `validatedLayers` rather than `state.layers` - // directly, so every downstream lookup sees the sanitized version. In - // strict mode the two are identical; in `ignoreInvalidConfig` mode the - // validator may have swapped a broken shape for the default layers. - const layers = validatedLayers; - const timeseries = isTimeseriesState(state) ? state : null; - const showGrid = timeseries?.showGrid ?? false; - const showXAxis = timeseries?.showXAxis ?? false; - const showYAxis = timeseries?.showYAxis ?? false; - const zoomRange = timeseries?.zoomRange ?? null; - const pinnedIndex = timeseries?.pinnedIndex ?? null; - - // Kind-based layer lookups. Each may be `undefined` — the renderer - // falls back gracefully below, and the zod validation step at the prop - // boundary will have already complained if a required kind is missing. - const primaryLayer = findLayerByKind(layers, "primary"); - const compareLayer = findLayerByKind(layers, "compare"); - const annotationsLayer = findLayerByKind(layers, "annotations"); - const showPrimary = primaryLayer?.visible ?? false; - const showCompare = compareLayer?.visible ?? false; - const showAnnotationsLayer = annotationsLayer?.visible ?? false; - - // Resolved style objects — one uniform shape per data layer regardless - // of variant. Variant-specific fields (strokeStyle on line/area, - // fillOpacity on area/bar) are filled in with sensible defaults for the - // fields the variant doesn't track. When a layer is absent we stub with - // neutral defaults so the renderer doesn't have to `?.` every access. - const primaryStyle: HeroCanvasResolvedDataLayerStyle = primaryLayer - ? resolveDataLayerStyle(primaryLayer) - : { color: "#2563eb", type: "area", strokeStyle: "solid", fillOpacity: 0 }; - const previousStyleResolved: HeroCanvasResolvedDataLayerStyle = compareLayer - ? resolveDataLayerStyle(compareLayer) - : { color: "#f59e0b", type: "line", strokeStyle: "dashed", fillOpacity: 0 }; - const primaryType = primaryStyle.type; - const previousType = previousStyleResolved.type; - const primaryColor = primaryStyle.color; - const previousColor = previousStyleResolved.color; - const annotationColor = annotationsLayer?.color ?? "#f59e0b"; - const primaryStroke = STROKE_DASHARRAY[primaryStyle.strokeStyle]; - const previousStroke = STROKE_DASHARRAY[previousStyleResolved.strokeStyle]; - const primaryFillOpacity = primaryStyle.fillOpacity; - const previousFillOpacity = previousStyleResolved.fillOpacity; - - // Pie and timeseries states have different shapes; we forward the patch - // into whichever variant is active so `state.view === "pie"` can't - // accidentally receive a `showGrid` update. - const setTimeseriesField = useCallback( - ( - key: K, - value: HeroCanvasTimeseriesState[K], - ) => { - onChange((prev) => { - if (prev.view !== "timeseries") return prev; - return { ...prev, [key]: value }; - }); - }, - [onChange], - ); - - // Hover, committed range, and annotation draft are all optionally - // controlled — consumers can leave them alone (chart manages them - // internally + callbacks fire on change) or pass a controlled value to - // drive them from outside. Drag anchor and live brush preview remain - // strictly internal because they only matter while a drag is in - // progress; an `onBrushChange` callback still lets observers mirror - // the live state if they want to. - const wrapperRef = useRef(null); - const [hoverIndex, setHoverIndex] = useControllableState({ - prop: controlledHoverIndex, - defaultProp: null, - onChange: onHoverIndexChange, - }); - const [committedRange, setCommittedRange] = useControllableState<[number, number] | null>({ - prop: controlledCommittedRange, - defaultProp: null, - onChange: onCommittedRangeChange, - }); - const [annotationDraft, setAnnotationDraft] = useControllableState({ - prop: controlledAnnotationDraft, - defaultProp: null, - onChange: onAnnotationDraftChange, - }); - const [dragAnchor, setDragAnchor] = useState(null); - const [brushStart, setBrushStartInternal] = useState(null); - const [brushEnd, setBrushEndInternal] = useState(null); - // Wrap the brush setters so the observer callback fires on every - // transition (including the one that clears the live brush). Keeps - // the call sites unchanged but forwards state into `onBrushChange`. - const onBrushChangeRef = useRef(onBrushChange); - useEffect(() => { - onBrushChangeRef.current = onBrushChange; - }, [onBrushChange]); - const setBrushStart = useCallback((next: number | null) => { - setBrushStartInternal(next); - if (next === null) { - onBrushChangeRef.current?.(null); - } - }, []); - const setBrushEnd = useCallback((next: number | null) => { - setBrushEndInternal((prevEnd) => { - if (next !== null) { - // Read the latest `brushStart` via the state setter closure — - // simplest way to emit `{ start, end }` without a second ref. - setBrushStartInternal((currentStart) => { - if (currentStart !== null) { - onBrushChangeRef.current?.({ start: currentStart, end: next }); - } - return currentStart; - }); - } else if (prevEnd !== null) { - onBrushChangeRef.current?.(null); - } - return next; - }); - }, []); - const activeIndex = pinnedIndex ?? hoverIndex; - - // Segments are now carried on the data layers themselves. Each layer - // has its own `segmentSeries` (vocabulary) and `segments` (day × cat - // values). We extract them here with stable empty-array fallbacks - // (via useMemo) so the downstream hooks keyed off these don't churn - // their caches when the layer has no segment data. - const primarySegmentSeries = useMemo( - () => primaryLayer?.segmentSeries ?? EMPTY_SERIES, - [primaryLayer?.segmentSeries], - ); - const compareSegmentSeries = useMemo( - () => compareLayer?.segmentSeries ?? EMPTY_SERIES, - [compareLayer?.segmentSeries], - ); - const primaryFullSegments = useMemo( - () => primaryLayer?.segments ?? EMPTY_MATRIX, - [primaryLayer?.segments], - ); - const compareFullSegments = useMemo( - () => compareLayer?.segments ?? EMPTY_MATRIX, - [compareLayer?.segments], - ); - - // Each layer's `segmented` flag is the on/off switch, but segment data - // must also be present for the stacked rendering to actually happen. - // `primarySegmented` / `compareSegmented` are the authoritative gates - // used by chartData, chartConfig, the render blocks, and the tooltip. - const primarySegmented = - (primaryLayer?.segmented ?? false) - && showPrimary - && primarySegmentSeries.length > 0 - && primaryFullSegments.length > 0; - const compareSegmented = - (compareLayer?.segmented ?? false) - && showCompare - && compareSegmentSeries.length > 0 - && compareFullSegments.length > 0; - const anySegmented = primarySegmented || compareSegmented; - - const visibleStart = zoomRange ? zoomRange[0] : 0; - const visibleEnd = zoomRange ? zoomRange[1] : fullData.length - 1; - - const data = useMemo( - () => fullData.slice(visibleStart, visibleEnd + 1), - [fullData, visibleStart, visibleEnd], - ); - // Sliced per-layer segment matrices — exactly mirror the `data` window - // so segment row `i` lines up with `data[i]`. - const primarySegments = useMemo( - () => primaryFullSegments.slice(visibleStart, visibleEnd + 1), - [primaryFullSegments, visibleStart, visibleEnd], - ); - const compareSegments = useMemo( - () => compareFullSegments.slice(visibleStart, visibleEnd + 1), - [compareFullSegments, visibleStart, visibleEnd], - ); - // Per-day totals across every segment, per layer. Used by the tooltip - // totals row and by `yDomainMax` to anchor the axis. - const primarySegmentTotals = useMemo( - () => primarySegments.map((row) => row.reduce((a, b) => a + b, 0)), - [primarySegments], - ); - const compareSegmentTotals = useMemo( - () => compareSegments.map((row) => row.reduce((a, b) => a + b, 0)), - [compareSegments], - ); - - // Explicit y-axis domain max. Covers every data-layer's flat values - // (so toggling segmentation doesn't bounce the axis) AND each layer's - // segmented stack totals (so a slight sum-rounding drift doesn't clip - // the top bar). Padded by `yDomainPadding` (default 0.1 = 10% headroom). - const yDomainMax = useMemo(() => { - const dataLayerIds = layers.filter(isHeroCanvasDataLayer).map((l) => l.id); - const layerMaxes = dataLayerIds.map((id) => - data.reduce((m, p) => Math.max(m, pointValue(p, id)), 0), - ); - const primaryStackMax = primarySegmentTotals.reduce((m, v) => Math.max(m, v), 0); - const compareStackMax = compareSegmentTotals.reduce((m, v) => Math.max(m, v), 0); - const rawMax = Math.max(0, ...layerMaxes, primaryStackMax, compareStackMax); - return Math.ceil(rawMax * (1 + yDomainPadding)); - }, [data, layers, primarySegmentTotals, compareSegmentTotals, yDomainPadding]); - - // Per-layer color ramps, sized independently so primary and compare can - // segment by different vocabularies (e.g. primary by region, compare by - // device type). Each ramp expands from the resolved palette. - const segmentColors = useMemo(() => { - return { - primary: { - light: buildRampColors(palette.primary, primarySegmentSeries.length, "light"), - dark: buildRampColors(palette.primary, primarySegmentSeries.length, "dark"), - }, - compare: { - light: buildRampColors(palette.compare, compareSegmentSeries.length, "light"), - dark: buildRampColors(palette.compare, compareSegmentSeries.length, "dark"), - }, - }; - }, [primarySegmentSeries.length, compareSegmentSeries.length, palette]); - - // visible window). Computed per layer because the two layers may have - // different segment vocabularies. The pie renders an outer ring from - // the primary layer and an inner ring from the compare layer. - const aggregatedPrimarySegments = useMemo( - () => - primarySegmentSeries.map((_, sIdx) => - primarySegments.reduce((acc, row) => acc + (row[sIdx] ?? 0), 0), - ), - [primarySegmentSeries, primarySegments], - ); - const aggregatedCompareSegments = useMemo( - () => - compareSegmentSeries.map((_, sIdx) => - compareSegments.reduce((acc, row) => acc + (row[sIdx] ?? 0), 0), - ), - [compareSegmentSeries, compareSegments], - ); - const aggregatedPrimaryTotal = useMemo( - () => aggregatedPrimarySegments.reduce((a, b) => a + b, 0), - [aggregatedPrimarySegments], - ); - const aggregatedCompareTotal = useMemo( - () => aggregatedCompareSegments.reduce((a, b) => a + b, 0), - [aggregatedCompareSegments], - ); - // Pie hover key — ephemeral hover state for the slice-isolation flow. - const [pieHoverKey, setPieHoverKey] = useState(null); - - // Annotations are now fully prop-driven. The chart filters them to the - // visible window and re-bases their indices to local coordinates. - const annotations = useMemo(() => { - return fullAnnotations - .filter((a) => a.index >= visibleStart && a.index <= visibleEnd) - .map((a) => ({ ...a, index: a.index - visibleStart })); - }, [fullAnnotations, visibleStart, visibleEnd]); - - const brushing = brushStart != null; - const N = data.length; - - // Recharts identifies each Bar/Line/Area via a string dataKey which - // must match a field on every chart row. We key those fields by - // layer.id so consumers can rename `"signups"` → anything without - // touching the renderer. Synthetic keys for the in-progress overlay - // get a `__hero_solid` / `__hero_dashed` suffix per layer so they - // don't collide with the layer's own id. Segment sub-keys keep their - // own prefix. - const primaryKey = primaryLayer?.id ?? "__hero_primary"; - const compareKey = compareLayer?.id ?? "__hero_compare"; - const primarySolidKey = `${primaryKey}__hero_solid`; - const primaryDashedKey = `${primaryKey}__hero_dashed`; - const compareSolidKey = `${compareKey}__hero_solid`; - const compareDashedKey = `${compareKey}__hero_dashed`; - const primarySegKey = useCallback( - (segKey: string) => `${primaryKey}__hero_seg_${segKey}`, - [primaryKey], - ); - const compareSegKey = useCallback( - (segKey: string) => `${compareKey}__hero_seg_${segKey}`, - [compareKey], - ); - - // Each data layer can carry an `inProgressFromIndex` (absolute index - // into `fullData`) marking where its values become incomplete. The - // renderer translates that to a local index inside the visible window - // and clamps it: `null` when the marker sits beyond the window, `0` - // when it sits before. Combined with the layer's type/segmentation, - // this gates the solid/dashed line pair below. - const computeLocalInProgressIdx = (absIdx: number | null | undefined): number | null => { - if (absIdx == null) return null; - const local = absIdx - visibleStart; - if (local >= visibleEnd - visibleStart + 1) return null; // beyond window - if (local < 0) return 0; // before window — whole window is dashed - return local; - }; - const primaryInProgressLocalIdx = computeLocalInProgressIdx(primaryLayer?.inProgressFromIndex); - const compareInProgressLocalIdx = computeLocalInProgressIdx(compareLayer?.inProgressFromIndex); - // The solid/dashed split only makes sense for line + area in flat - // mode. Segmented stacks and bars ignore the marker entirely. - const primaryHasInProgress = - primaryInProgressLocalIdx != null - && !primarySegmented - && (primaryType === "line" || primaryType === "area"); - const compareHasInProgress = - compareInProgressLocalIdx != null - && !compareSegmented - && (previousType === "line" || previousType === "area"); - - // Recharts wants one row per index with every dataKey as a sibling field. - // We project the visible window into that shape, copy every consumer- - // provided `point.values[*]` verbatim, and add synthetic keys for the - // in-progress dashing overlay (one solid/dashed pair per layer that - // has `inProgressFromIndex` set) and the per-segment stacked columns. - const chartData = useMemo(() => { - return data.map((point, i) => { - const row: Record = { - index: i, - ts: point.ts, - }; - // Copy every layer value verbatim. Consumers can drop extra layer - // ids in `point.values` and the chart will just include them — the - // renderer only iterates the ones referenced by layers. - for (const [k, v] of Object.entries(point.values)) { - row[k] = v; - } - // Solid/dashed split for primary — solid covers `[0..K-1]`, dashed - // covers `[K-1..end]`. They overlap at `K-1` so the lines join. - // `primaryHasInProgress` already implies `primaryInProgressLocalIdx` - // is non-null, so a non-null assertion here is safe. - if (primaryLayer && primaryHasInProgress) { - const primaryVal = pointValue(point, primaryLayer.id); - const k = primaryInProgressLocalIdx as number; - row[primarySolidKey] = i < k ? primaryVal : null; - row[primaryDashedKey] = i >= k - 1 ? primaryVal : null; - } - // Same treatment for compare. - if (compareLayer && compareHasInProgress) { - const compareVal = pointValue(point, compareLayer.id); - const k = compareInProgressLocalIdx as number; - row[compareSolidKey] = i < k ? compareVal : null; - row[compareDashedKey] = i >= k - 1 ? compareVal : null; - } - if (primarySegmented) { - primarySegmentSeries.forEach((s, sIdx) => { - row[primarySegKey(s.key)] = primarySegments[i]?.[sIdx] ?? 0; - }); - } - if (compareSegmented) { - compareSegmentSeries.forEach((s, sIdx) => { - row[compareSegKey(s.key)] = compareSegments[i]?.[sIdx] ?? 0; - }); - } - return row; - }); - }, [ - data, - primaryLayer, - compareLayer, - primarySolidKey, - primaryDashedKey, - compareSolidKey, - compareDashedKey, - primarySegKey, - compareSegKey, - primarySegments, - compareSegments, - primarySegmentSeries, - compareSegmentSeries, - primarySegmented, - compareSegmented, - primaryHasInProgress, - compareHasInProgress, - primaryInProgressLocalIdx, - compareInProgressLocalIdx, - ]); - - // Each dataKey gets a label + color so the shadcn `ChartContainer` injects - // matching `--color-${key}` CSS variables we reference from `` / - // `` / `` fills below. Tooltips and legends pick up the same - // mapping for free. Keys mirror the ones we emit in chartData above. - const chartConfig = useMemo(() => { - const primaryLabel = primaryLayer?.label ?? seriesLabel; - const compareLabel = compareLayer?.label ?? ""; - const config: ChartConfig = {}; - if (primaryLayer) { - config[primaryLayer.id] = { label: primaryLabel, color: primaryColor }; - if (primaryHasInProgress) { - config[primarySolidKey] = { label: primaryLabel, color: primaryColor }; - config[primaryDashedKey] = { label: primaryLabel, color: primaryColor }; - } - } - if (compareLayer) { - config[compareLayer.id] = { label: compareLabel, color: previousColor }; - if (compareHasInProgress) { - config[compareSolidKey] = { label: compareLabel, color: previousColor }; - config[compareDashedKey] = { label: compareLabel, color: previousColor }; - } - } - if (primarySegmented) { - primarySegmentSeries.forEach((s, i) => { - config[primarySegKey(s.key)] = { - label: s.label, - color: segmentColors.primary.light[i], - }; - }); - } - if (compareSegmented) { - compareSegmentSeries.forEach((s, i) => { - config[compareSegKey(s.key)] = { - label: s.label, - color: segmentColors.compare.light[i], - }; - }); - } - return config; - }, [ - primaryLayer, - compareLayer, - primarySolidKey, - primaryDashedKey, - compareSolidKey, - compareDashedKey, - primarySegKey, - compareSegKey, - seriesLabel, - primaryColor, - previousColor, - primaryHasInProgress, - compareHasInProgress, - primarySegmented, - compareSegmented, - primarySegmentSeries, - compareSegmentSeries, - segmentColors, - ]); - - // Recharts ComposedChart's mouse callbacks pass a state object with - // `activeTooltipIndex` — that's the data index the cursor is currently - // hovering. We mirror it into our own hover/brush/pin state so the rest - // of the chart's interaction logic stays the same as before. - type RechartsMouseState = { - activeTooltipIndex?: number, - isTooltipActive?: boolean, - }; - const handleChartMouseMove = useCallback( - (rechartsState: RechartsMouseState) => { - const i = rechartsState.activeTooltipIndex; - if (typeof i !== "number") return; - setHoverIndex(i); - if (dragAnchor != null && (brushStart != null || i !== dragAnchor)) { - if (brushStart == null) setBrushStart(dragAnchor); - setBrushEnd(i); - } - }, - [dragAnchor, brushStart, setHoverIndex, setBrushStart, setBrushEnd], - ); - const handleChartMouseDown = useCallback( - (rechartsState: RechartsMouseState, e: React.MouseEvent) => { - if (e.button !== 0) return; - const i = rechartsState.activeTooltipIndex; - if (typeof i !== "number") return; - e.preventDefault(); - setDragAnchor(i); - setAnnotationDraft(null); - }, - [setAnnotationDraft], - ); - const handleChartMouseUp = useCallback( - (_: RechartsMouseState, e: React.MouseEvent) => { - e.stopPropagation(); - if (brushStart != null && brushEnd != null) { - const lo = Math.min(brushStart, brushEnd); - const hi = Math.max(brushStart, brushEnd); - setBrushStart(null); - setBrushEnd(null); - setDragAnchor(null); - if (hi - lo >= 1) setCommittedRange([lo, hi]); - return; - } - setDragAnchor(null); - if (pinnedIndex != null) { - setTimeseriesField("pinnedIndex", null); - } else if (hoverIndex != null) { - setTimeseriesField("pinnedIndex", hoverIndex); - } - }, - [ - brushStart, - brushEnd, - hoverIndex, - pinnedIndex, - setTimeseriesField, - setBrushStart, - setBrushEnd, - setCommittedRange, - ], - ); - const handleChartMouseLeave = useCallback(() => { - if (!brushing) setHoverIndex(null); - }, [brushing, setHoverIndex]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "ArrowRight" || e.key === "ArrowLeft") { - e.preventDefault(); - setHoverIndex((cur) => { - const base = cur ?? pinnedIndex ?? 0; - return e.key === "ArrowRight" - ? Math.min(N - 1, base + 1) - : Math.max(0, base - 1); - }); - return; - } - if (e.key === "Home") { - e.preventDefault(); - setHoverIndex(0); - return; - } - if (e.key === "End") { - e.preventDefault(); - setHoverIndex(N - 1); - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (pinnedIndex != null) { - setTimeseriesField("pinnedIndex", null); - } else if (hoverIndex != null) { - setTimeseriesField("pinnedIndex", hoverIndex); - } - } - }, - [N, hoverIndex, pinnedIndex, setTimeseriesField, setHoverIndex], - ); - - useEffect(() => { - if (pinnedIndex == null) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setTimeseriesField("pinnedIndex", null); - }; - const onDown = (e: MouseEvent) => { - if (!wrapperRef.current) return; - if (!wrapperRef.current.contains(e.target as Node)) setTimeseriesField("pinnedIndex", null); - }; - window.addEventListener("keydown", onKey); - window.addEventListener("mousedown", onDown); - return () => { - window.removeEventListener("keydown", onKey); - window.removeEventListener("mousedown", onDown); - }; - }, [pinnedIndex, setTimeseriesField]); - - // Recharts handles the actual chart layout; for our absolute-positioned - // overlays (tooltip, brush popup, action bar, annotation flags) we - // compute the screen position from the data index and the chart's - // known left/right margins. Returns a CSS `calc()` string that aligns - // the overlay with the corresponding data point. Uses the resolved - // `plotMargin` so a consumer overriding the margin automatically gets - // matching overlay alignment. - const plotInset = resolvedPlotMargin.left + resolvedPlotMargin.right; - const indexToCss = useCallback( - (i: number): string => { - if (N <= 1) return `calc(${resolvedPlotMargin.left}px + (100% - ${plotInset}px) * 0.5)`; - const t = Math.max(0, Math.min(1, i / (N - 1))); - return `calc(${resolvedPlotMargin.left}px + (100% - ${plotInset}px) * ${t})`; - }, - [N, resolvedPlotMargin.left, plotInset], - ); - const tooltipXPct = activeIndex != null - ? N <= 1 - ? 50 - : (activeIndex / (N - 1)) * 100 - : 0; - const shouldFlip = tooltipXPct > 68; - - const activePoint = activeIndex != null ? data[activeIndex] : null; - // Delta between the primary and compare layer values at the hovered - // point — drives the outer trend pill and is included in the tooltip - // context. Null when either layer is missing or hidden. - const activeDelta = activePoint && primaryLayer && compareLayer - ? formatDelta( - pointValue(activePoint, primaryLayer.id), - pointValue(activePoint, compareLayer.id), - ) - : null; - - // Routes to PieBody when state.view is "pie". Aggregates the visible - // window by segment and renders a hover-to-isolate pie, optionally - // paired with an inner ring showing the previous-period totals when the - // previous layer is visible. - if (state.view === "pie") { - return ( - { - onChange((prev) => ({ ...prev, zoomRange: null, pinnedIndex: null })); - setCommittedRange(null); - setAnnotationDraft(null); - setHoverIndex(null); - }} - visibleStart={visibleStart} - visibleEnd={visibleEnd} - fullData={fullData} - strings={strings} - fmtValue={fmtValue} - innerRadius={pieInnerRadius} - outerRadius={pieOuterRadius} - compareInnerRadius={pieCompareInnerRadius} - compareOuterRadius={pieCompareOuterRadius} - containerClassName={pieContainerClassName} - /> - ); - } - - return ( -
{ - // Stop propagation so outside-click listener doesn't instantly unpin - e.stopPropagation(); - }} - onKeyDown={handleKeyDown} - onFocus={() => { - if (hoverIndex == null) setHoverIndex(pinnedIndex ?? Math.floor(N / 2)); - }} - tabIndex={0} - role="img" - aria-label={`${seriesLabel} over the visible ${data.length}-day range. Use arrow keys to move the cursor, Enter to pin, Escape to release. Click and drag to select a range.`} - > - - - {showGrid && } - {showXAxis && ( - { - const idx = Number(value); - if (idx < 0 || idx >= data.length) return ""; - return fmtValue(data[idx]!.ts, xFormatKind); - }} - /> - )} - {showYAxis && ( - - fmtValue(Math.round(Number(value)), yFormatKind) - } - /> - )} - - {/* annotations as reference lines (vertical dashed) */} - {showAnnotationsLayer - && annotations.map((a) => ( - - ))} - - {/* committed range — stays after the drag is released */} - {committedRange && ( - - )} - - {/* live brush preview */} - {brushStart != null && brushEnd != null && ( - - )} - - {/* Stacks the signups layer by segment using whichever chart - type the layer is configured for. Each stack gets its own - stackId so it stays independent from the previous-layer - segmented stack below. */} - {primarySegmented - && primaryType === "bar" - && primarySegmentSeries.map((s, sIdx) => { - // Recharts stacks bars in DOM order — the last declared bar - // sits on top of the stack, so only it gets rounded top - // corners. Matches the `radius={2}` on the flat-mode bar. - const isTop = sIdx === primarySegmentSeries.length - 1; - const key = primarySegKey(s.key); - return ( - - ); - })} - {primarySegmented - && primaryType === "area" - && primarySegmentSeries.map((s) => { - const key = primarySegKey(s.key); - return ( - - ); - })} - {primarySegmented - && primaryType === "line" - && primarySegmentSeries.map((s) => { - const key = primarySegKey(s.key); - return ( - - ); - })} - - {showPrimary && primaryLayer && !primarySegmented && primaryType === "bar" && ( - - )} - {showPrimary && primaryLayer && !primarySegmented && primaryType === "area" && ( - - )} - {showPrimary - && primaryLayer - && !primarySegmented - && (primaryType === "line" || primaryType === "area") - && (primaryHasInProgress - ? ( - <> - - - - ) - : ( - - ))} - - {/* Mirrors the primary-segmented block above but uses the - compare palette (amber shades) and its own stackId so it - doesn't stack on top of the primary segments. */} - {compareSegmented - && previousType === "bar" - && compareSegmentSeries.map((s, sIdx) => { - const isTop = sIdx === compareSegmentSeries.length - 1; - const key = compareSegKey(s.key); - return ( - - ); - })} - {compareSegmented - && previousType === "area" - && compareSegmentSeries.map((s) => { - const key = compareSegKey(s.key); - return ( - - ); - })} - {compareSegmented - && previousType === "line" - && compareSegmentSeries.map((s) => { - const key = compareSegKey(s.key); - return ( - - ); - })} - - {showCompare && compareLayer && !compareSegmented && previousType === "bar" && ( - - )} - {showCompare - && compareLayer - && !compareSegmented - && previousType === "area" - && (compareHasInProgress - ? ( - <> - - - - ) - : ( - - ))} - {showCompare - && compareLayer - && !compareSegmented - && previousType === "line" - && (compareHasInProgress - ? ( - <> - - - - ) - : ( - - ))} - - - - {/* Crosshair line + active dots — rendered as an absolute overlay so - we don't have to reach into Recharts' coordinate system. The - horizontal position is derived from the active index relative to - the visible window. */} - {activeIndex != null && activePoint && !brushing && ( -
-
-
- )} - - {brushStart != null && brushEnd != null && (() => { - const lo = Math.min(brushStart, brushEnd); - const hi = Math.max(brushStart, brushEnd); - const days = hi - lo + 1; - return ( -
-
- - {strings.rangeLabel} - - - {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)} - - - · {strings.daysShort(days)} - -
-
- ); - })()} - - {zoomRange && ( -
- -
- )} - - {committedRange && !brushing && (() => { - const [lo, hi] = committedRange; - const center = (lo + hi) / 2; - // Centered percentage within the visible window — used only to - // pick an edge to snap to when the range is too close to clip. - const centerPct = N <= 1 ? 50 : (center / (N - 1)) * 100; - const snapLeft = centerPct < 22; - const snapRight = centerPct > 78; - // Keep the bar inside the chart box; snap to an edge when the range - // is too close to it to be centered without overflow. - const anchorStyle: React.CSSProperties = snapLeft - ? { left: "8px" } - : snapRight - ? { right: "8px" } - : { left: indexToCss(center), transform: "translateX(-50%)" }; - const days = hi - lo + 1; - const draft = annotationDraft; - return ( -
-
- {draft == null ? ( - <> - - - {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)} - - · - {strings.daysShort(days)} - - -
- ); - })()} - - {showAnnotationsLayer && ( -
- {annotations.map((a) => { - return ( -
- - - {a.description} - -
- ); - })} -
- )} - - {/* Positioning lives on this wrapper; the body is rendered through - the `renderTooltip` slot (defaults to `DefaultHeroChartTooltip`). - Context is built once per render so custom renderers don't have - to recompute segmented totals / delta / etc. themselves. */} - {activeIndex != null && activePoint && (() => { - // Build primary layer view. - const primaryView: HeroChartTooltipLayerView | null = (showPrimary && primaryLayer) - ? { - id: primaryLayer.id, - label: primaryLayer.label || seriesLabel, - color: primaryColor, - total: primarySegmented - ? (primarySegmentTotals[activeIndex] ?? 0) - : pointValue(activePoint, primaryLayer.id), - segmented: primarySegmented, - segments: primarySegmented - ? primarySegmentSeries.map((s, sIdx) => ({ - key: s.key, - label: s.label, - value: primarySegments[activeIndex]?.[sIdx] ?? 0, - color: segmentColors.primary.light[sIdx] ?? primaryColor, - colorDark: segmentColors.primary.dark[sIdx] ?? primaryColor, - })) - : [], - } - : null; - // Build compare layer view. - const compareView: HeroChartTooltipLayerView | null = (showCompare && compareLayer) - ? { - id: compareLayer.id, - label: compareLayer.label, - color: previousColor, - total: compareSegmented - ? (compareSegmentTotals[activeIndex] ?? 0) - : pointValue(activePoint, compareLayer.id), - segmented: compareSegmented, - segments: compareSegmented - ? compareSegmentSeries.map((s, sIdx) => ({ - key: s.key, - label: s.label, - value: compareSegments[activeIndex]?.[sIdx] ?? 0, - color: segmentColors.compare.light[sIdx] ?? previousColor, - colorDark: segmentColors.compare.dark[sIdx] ?? previousColor, - })) - : [], - } - : null; - // Delta is between the final rendered totals — works regardless of - // segmented-vs-flat mode on either side. - const delta: HeroChartDelta | null = primaryView && compareView - ? formatDelta(primaryView.total, compareView.total) - : null; - const ctx: HeroChartTooltipContext = { - activeIndex, - point: activePoint, - isPinned: pinnedIndex != null, - primary: primaryView, - compare: compareView, - delta, - formatValue: (v) => fmtValue(v, yFormatKind), - formatDate: (ts) => fmtValue(ts, xFormatKind), - strings, - }; - return ( -
- {renderTooltipFn(ctx)} -
- ); - })()} -
- ); -} - - -type PieBodyProps = { - wrapperRef: React.RefObject, - /** Primary layer breakdown vocabulary. */ - primarySegmentSeries: readonly HeroBreakdownSeries[], - /** Compare layer breakdown vocabulary — may differ from primary's. */ - compareSegmentSeries: readonly HeroBreakdownSeries[], - /** Per-segment totals for the primary layer over the visible window. - * Indexed to match `primarySegmentSeries`. */ - aggregatedPrimarySegments: number[], - /** Per-segment totals for the compare layer over the visible window. - * Indexed to match `compareSegmentSeries`. */ - aggregatedCompareSegments: number[], - aggregatedPrimaryTotal: number, - aggregatedCompareTotal: number, - segmentColors: { - primary: { light: string[], dark: string[] }, - compare: { light: string[], dark: string[] }, - }, - showPrimary: boolean, - showCompare: boolean, - xFormatKind: FormatKind, - yFormatKind: FormatKind, - hoverKey: string | null, - setHoverKey: (k: string | null) => void, - zoomRange: [number, number] | null, - onResetZoom: () => void, - visibleStart: number, - visibleEnd: number, - fullData: Point[], - strings: HeroChartStrings, - fmtValue: (value: number, kind: FormatKind) => string, - innerRadius: number, - outerRadius: number, - compareInnerRadius: number, - compareOuterRadius: number, - containerClassName: string, -}; - -function PieBody({ - wrapperRef, - primarySegmentSeries, - compareSegmentSeries, - aggregatedPrimarySegments, - aggregatedCompareSegments, - aggregatedPrimaryTotal, - aggregatedCompareTotal, - segmentColors, - showPrimary, - showCompare, - xFormatKind, - yFormatKind, - hoverKey, - setHoverKey, - zoomRange, - onResetZoom, - visibleStart, - visibleEnd, - fullData, - strings, - fmtValue, - innerRadius, - outerRadius, - compareInnerRadius, - compareOuterRadius, - containerClassName, -}: PieBodyProps) { - // The legend uses the primary layer's vocabulary when the primary layer - // is segmented; if not, it falls back to the compare layer's. This - // means the legend always reflects "the layer driving the chart". If - // both primary and compare have the SAME segment keys (the common - // case), the compare value for a given key is pulled by key lookup; - // if they differ, the compare ring still renders independently but - // its slices may not perfectly align with the legend rows. - const canonicalSeries = primarySegmentSeries.length > 0 - ? primarySegmentSeries - : compareSegmentSeries; - const usePrimaryForCanonical = primarySegmentSeries.length > 0; - - // Key → value lookup tables so the legend can show cross-layer values - // even when the two series use the same keys. - const compareValueByKey = useMemo(() => { - const m = new Map(); - compareSegmentSeries.forEach((s, sIdx) => { - m.set(s.key, aggregatedCompareSegments[sIdx] ?? 0); - }); - return m; - }, [compareSegmentSeries, aggregatedCompareSegments]); - const primaryValueByKey = useMemo(() => { - const m = new Map(); - primarySegmentSeries.forEach((s, sIdx) => { - m.set(s.key, aggregatedPrimarySegments[sIdx] ?? 0); - }); - return m; - }, [primarySegmentSeries, aggregatedPrimarySegments]); - - // Canonical total — matches whichever series is driving the legend. - const canonicalTotal = usePrimaryForCanonical - ? aggregatedPrimaryTotal - : aggregatedCompareTotal; - - // Sorted-by-value listing for the legend column. Each row carries its - // own canonical value (for the sort + primary ring) and a cross-layer - // value (for the compare ring + delta pill). - const legendRows = useMemo( - () => - canonicalSeries - .map((s, sIdx) => { - const value = usePrimaryForCanonical - ? (aggregatedPrimarySegments[sIdx] ?? 0) - : (aggregatedCompareSegments[sIdx] ?? 0); - const prevValue = usePrimaryForCanonical - ? (compareValueByKey.get(s.key) ?? 0) - : (primaryValueByKey.get(s.key) ?? 0); - return { - key: s.key, - label: s.label, - sIdx, - value, - prevValue, - pct: canonicalTotal > 0 ? value / canonicalTotal : 0, - fill: segmentColors.primary.light[sIdx] ?? "#888", - fillDark: segmentColors.primary.dark[sIdx] ?? "#888", - fillCompare: segmentColors.compare.light[sIdx] ?? "#888", - fillCompareDark: segmentColors.compare.dark[sIdx] ?? "#888", - }; - }) - .sort((a, b) => b.value - a.value), - [ - canonicalSeries, - usePrimaryForCanonical, - aggregatedPrimarySegments, - aggregatedCompareSegments, - compareValueByKey, - primaryValueByKey, - canonicalTotal, - segmentColors, - ], - ); - - // ChartConfig keyed by segment id so the shadcn ChartContainer injects - // matching --color-${id} CSS variables for tooltips and legend rows. - const chartConfig = useMemo(() => { - const config: ChartConfig = {}; - canonicalSeries.forEach((s, sIdx) => { - config[s.key] = { - label: s.label, - color: segmentColors.primary.light[sIdx], - }; - }); - return config; - }, [canonicalSeries, segmentColors]); - - // The currently isolated row (if any) — drives the center stat + dim state. - const activeRow = hoverKey - ? legendRows.find((r) => r.key === hoverKey) ?? null - : null; - const activeDelta = activeRow - ? formatDelta(activeRow.value, activeRow.prevValue) - : formatDelta(aggregatedPrimaryTotal, aggregatedCompareTotal); - - const windowDays = visibleEnd - visibleStart + 1; - const startLabel = fmtValue(fullData[visibleStart]!.ts, xFormatKind); - const endLabel = fmtValue(fullData[visibleEnd]!.ts, xFormatKind); - - // Pie data uses the same row order as the legend so the slice ↔ legend - // mapping is stable and we can drive `activeIndex` from the hovered key. - const outerData = legendRows.map((r) => ({ name: r.key, value: r.value, fill: r.fill })); - const innerData = legendRows.map((r) => ({ name: r.key, value: r.prevValue, fill: r.fillCompare })); - const activeIdx = hoverKey ? legendRows.findIndex((r) => r.key === hoverKey) : -1; - - return ( -
e.stopPropagation()} - > - {/* Reset-zoom badge — same affordance as the time-series body so the - pie always reflects the visible window the user has chosen. */} - {zoomRange && ( -
- -
- )} - -
- {/* The pie SVG and its absolutely-positioned center stat live - inside `.relative`. The date-range + trend-pill caption sits - OUTSIDE the ring so the center stat only has to fit the - label + big value + (optional) percent — no more clipping - when the compare ring shrinks the available inner area. */} -
-
- - - {showPrimary && ( - - {outerData.map((d, i) => { - const inactive = activeIdx >= 0 && activeIdx !== i; - return ( - setHoverKey(d.name as string)} - onMouseLeave={() => setHoverKey(null)} - /> - ); - })} - - )} - {showCompare && ( - - {innerData.map((d, i) => { - const inactive = activeIdx >= 0 && activeIdx !== i; - return ( - setHoverKey(d.name as string)} - onMouseLeave={() => setHoverKey(null)} - /> - ); - })} - - )} - - - - {/* Center stat — strictly label + big value. Nothing else. - The compare ring (compareInnerRadius=36) leaves only ~72px - of clean vertical space in the center; any third row bleeds - into the ring. Per-segment % is already in the legend and - the date range + trend pill live in the caption row below. */} -
- - {activeRow ? activeRow.label : strings.pieTotalCenter} - - - {fmtValue( - activeRow ? activeRow.value : canonicalTotal, - yFormatKind, - )} - -
-
- - {/* Caption row below the pie — date range + (optional) trend - pill. Lives outside the ring so the center stat never has - to compete for space. */} -
- - {startLabel} – {endLabel} - - {showCompare && ( - - )} -
-
- - {/* Column widths use `min-w-*` rather than `w-*` so long values - (large numbers, 4-digit percentages like "+123.4%") can grow - past their baseline allotment instead of clipping. The label - column is flex-grow + truncate so it's the only one that - gives ground when space gets tight. */} -
    - {legendRows.map((r) => { - const isActive = hoverKey === r.key; - const dimmed = hoverKey != null && !isActive; - const rowDelta = formatDelta(r.value, r.prevValue); - return ( -
  • - -
  • - ); - })} -
-
-
- ); -} - -// HeroChart — gives you the DesignAnalyticsCard surface, an optional -// title/subtitle row, and forwards the controlled `state` / `onChange` -// pair through to the chart. Everything else is configured via props. -// HeroCanvas does NOT render any inline configuration UI of its own. -// There used to be a `controls` prop that opted into header pills -// (display type / segments / compare / format), legend dots, axis icon -// toggles, and a help-text row — those are gone. The chart's behaviour -// is fully driven by `state` and the prop surface; consumers who want a -// pill bar, legend, or axis toggles render their own UI and dispatch -// into `onChange` themselves. Keeps the chart focused on the data -// presentation and stops two parallel UIs (the chart's pills + the -// consumer's pills) from drifting out of sync. - -type HeroCanvasProps = { - /** Time-series points — each point carries `values` keyed by layer id. */ - data: Point[], - /** Annotations are fully prop-driven. The consumer owns the array. */ - annotations?: Annotation[], - /** Label used when the primary layer doesn't supply one of its own. */ - seriesLabel?: string, - /** Optional title / subtitle shown above the chart. Pure visual - * chrome — no interactive controls. Pass `null` to omit entirely. */ - title?: ReactNode, - subtitle?: ReactNode, - /** Fully-controlled state. The component owns nothing. */ - state: HeroCanvasState, - onChange: React.Dispatch>, - /** Fired when the user submits the in-chart annotation form. - Consumer is expected to append to its own annotations array. */ - onAnnotationCreate?: (annotation: Annotation) => void, - gradient?: "blue" | "cyan" | "green" | "orange" | "purple", - /** Override any user-visible copy inside the chart body (tooltip hints, - * Reset zoom, Annotate/Save/Cancel, pie center, …). Deep-merges over - * `HERO_CHART_DEFAULT_STRINGS`. */ - strings?: Partial, - /** Override the segment color ramps. Deep-merges over - * `HERO_CHART_DEFAULT_PALETTE`. */ - palette?: Partial, - /** Render slot for the tooltip body. Receives a prepared context with - * the active point, primary / compare views, pre-bound formatters, - * and resolved strings. Defaults to `DefaultHeroChartTooltip`. */ - renderTooltip?: (ctx: HeroChartTooltipContext) => ReactNode, - /** When `true`, runtime validation errors on `state.layers` become - * warnings instead of throwing. Forwarded to the inner HeroChart. */ - ignoreInvalidConfig?: boolean, - hoverIndex?: number | null, - onHoverIndexChange?: (index: number | null) => void, - committedRange?: [number, number] | null, - onCommittedRangeChange?: (range: [number, number] | null) => void, - onBrushChange?: (brush: { start: number, end: number } | null) => void, - annotationDraft?: string | null, - onAnnotationDraftChange?: (draft: string | null) => void, - plotMargin?: { top?: number, right?: number, bottom?: number, left?: number }, - yAxisWidth?: number, - yDomainPadding?: number, - pieInnerRadius?: number, - pieOuterRadius?: number, - pieCompareInnerRadius?: number, - pieCompareOuterRadius?: number, - pieContainerClassName?: string, - valueFormatter?: (value: number, kind: FormatKind) => string, -}; - -function HeroCanvas({ - data, - annotations = [], - seriesLabel = "Sign-ups", - title = "Sign-ups", - subtitle = "30-day window", - state, - onChange, - onAnnotationCreate, - gradient = "blue", - strings, - palette, - renderTooltip, - ignoreInvalidConfig = false, - hoverIndex, - onHoverIndexChange, - committedRange, - onCommittedRangeChange, - onBrushChange, - annotationDraft, - onAnnotationDraftChange, - plotMargin, - yAxisWidth, - yDomainPadding, - pieInnerRadius, - pieOuterRadius, - pieCompareInnerRadius, - pieCompareOuterRadius, - pieContainerClassName, - valueFormatter, -}: HeroCanvasProps) { - const showTitleRow = title != null || subtitle != null; - return ( - - {showTitleRow && ( -
- {title != null && ( - - {title} - - )} - {subtitle != null && ( - - {subtitle} - - )} -
- )} -
- -
-
- ); -} - -// Generates the JSX a consumer would write for the current state shape. -// Long data props are abbreviated to 1-2 representative items + a count -// comment so the snippet shows the actual data shape without dumping -// thirty rows. - -function formatFormatKindLiteral(kind: FormatKind, indent: string): string { - const fields: string[] = [`type: "${kind.type}"`]; - // Emit the option fields explicitly so the consumer sees the full shape - // they'd write. Using the literal value (not stringified) so booleans / - // numbers stay as-is. - 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 `${indent}{ ${fields.join(", ")} }`; -} - -// Emit the layer literal — dispatched on `kind`, each variant only -// prints the fields it actually owns. Segment matrices are elided to a -// `[…N×M]` summary so the usage snippet stays readable — the full values -// would swamp the panel. -function formatLayerLiteral(l: HeroCanvasLayer): 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 { - // Annotations layer — only kind left after the data-layer branch. - fields.push(`color: "${l.color}"`); - } - return `{ ${fields.join(", ")} }`; -} - -function formatStateLiteral(state: HeroCanvasState, 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, "")},`, - ]; - // Timeseries-only fields are emitted only when the state variant has - // them, so the generated snippet always type-checks against the union. - 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"); -} - -// JSON-ish but human-readable. Strings get double quotes, numbers stay raw, -// nested objects are emitted on one line so the data preview stays compact. -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); -} - -// Render an array prop as a `data={[\n item1,\n item2,\n // …N more\n]}` -// block. The number of inline items is bounded so the snippet stays -// readable for any input length. -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"); -} - -type HeroCanvasUsageData = { - data: Point[], - annotations: Annotation[], -}; - -function generateHeroCanvasUsage( - state: HeroCanvasState, - exampleData: HeroCanvasUsageData, -): string { - const lines: string[] = [ - "`); - lines.push(` setAnnotations((prev) => [...prev, annotation])`); - lines.push(` }`); - - lines.push("/>"); - return lines.join("\n"); -} - -function HeroCanvasUsageViewer({ 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 ( - - - - ); -} - -// panel (for live editing) and the formatter demo (so the same widget renders -// the editing UI for whatever variant is selected). - -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 ( -
- - -
- ); - } - } -} - -// every state slice the component reads from props — view, per-layer -// visibility + type, segments, format, axes, zoom, pin. Edits flow through -// the same `setState` setter the component uses, so the live preview, the -// usage code and the events panel all observe the exact same state. - -function HeroCanvasStatePanel({ - state, - onChange, - onReset, - dataLength, -}: { - state: HeroCanvasState, - onChange: React.Dispatch>, - onReset: () => void, - /** Used by the in-progress toggle to default the marker index to the - * last bucket when flipping it on. */ - dataLength: number, -}) { - const setTimeseriesField = ( - key: K, - value: HeroCanvasTimeseriesState[K], - ) => { - onChange((prev) => { - if (prev.view !== "timeseries") return prev; - return { ...prev, [key]: value }; - }); - }; - const setView = (next: HeroCanvasView) => { - onChange((prev) => { - if (next === prev.view) return prev; - if (next === "pie") { - // Moving into pie drops every timeseries-only field. Layer - // segmentation flags are preserved so switching back to - // timeseries restores whatever the user had configured. - return { - view: "pie", - layers: prev.layers, - xFormatKind: prev.xFormatKind, - yFormatKind: prev.yFormatKind, - }; - } - // Moving out of pie rebuilds a fresh timeseries state with sensible - // axis defaults. - 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 setDataLayerSegmented = (id: string, segmented: boolean) => { - onChange((prev) => ({ - ...prev, - layers: patchLayerById(prev.layers, id, { segmented }), - })); - }; - const setDataLayerInProgress = (id: string, inProgressFromIndex: number | null) => { - onChange((prev) => ({ - ...prev, - layers: patchLayerById(prev.layers, id, { inProgressFromIndex }), - })); - }; - - // Data-layer mutations. The helpers below look the layer up by id - // (arbitrary string) and rebuild it into whichever variant the user - // picked — line has no fillOpacity, bar has no strokeStyle, etc. - const replaceLayerById = (id: string, next: HeroCanvasLayer) => { - onChange((prev) => ({ - ...prev, - layers: setLayerById(prev.layers, id, next), - })); - }; - const rebuildDataLayer = ( - current: HeroCanvasDataLayer, - nextType: HeroCanvasLayerType, - ): HeroCanvasDataLayer => { - const prevStroke: HeroCanvasStrokeStyle = - "strokeStyle" in current ? current.strokeStyle : "solid"; - const prevFill: number = - "fillOpacity" in current ? current.fillOpacity : 0.22; - // Everything on Common carries through unchanged — only the - // type-variant fields (strokeStyle / fillOpacity) get rewritten. - // In particular `segments`, `segmentSeries`, `inProgressFromIndex` - // and the `segmented` flag all stay put so flipping bar → line - // doesn't silently drop a configured stack or the in-progress tail. - 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 }; - }; - const setDataLayerType = (id: string, nextType: HeroCanvasLayerType) => { - const current = findLayerById(state.layers, id); - if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; - replaceLayerById(id, rebuildDataLayer(current, nextType)); - }; - const setDataLayerStrokeStyle = (id: string, style: HeroCanvasStrokeStyle) => { - const current = findLayerById(state.layers, id); - if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; - if (current.type === "bar") return; // Bars have no stroke pattern. - replaceLayerById(id, { ...current, strokeStyle: style }); - }; - const setDataLayerFillOpacity = (id: string, fillOpacity: number) => { - const current = findLayerById(state.layers, id); - if (!current || (current.kind !== "primary" && current.kind !== "compare")) return; - if (current.type === "line") return; // Lines have no fill. - replaceLayerById(id, { ...current, fillOpacity }); - }; - const setLayerVisible = (id: string, visible: boolean) => { - onChange((prev) => ({ - ...prev, - layers: patchLayerById(prev.layers, id, { visible }), - })); - }; - const setLayerLabel = (id: string, label: string) => { - onChange((prev) => ({ - ...prev, - layers: patchLayerById(prev.layers, id, { label }), - })); - }; - const setLayerColor = (id: string, color: string) => { - onChange((prev) => ({ - ...prev, - layers: patchLayerById(prev.layers, id, { color }), - })); - }; - - const renderBoolField = ( - label: string, - key: keyof HeroCanvasTimeseriesState, - value: boolean, - ) => ( -
-
- {label} -
- {String(key)} -
-
- setTimeseriesField(key, (id === "on") as never)} - /> -
- ); - - const renderDataLayerRow = (layer: HeroCanvasDataLayer) => { - 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 ( -
- {/* Top row: label · type · visibility */} -
-
- {layer.label} -
- layers.{layer.id} -
-
-
- setDataLayerType(layer.id, id as HeroCanvasLayerType)} - /> - setLayerVisible(layer.id, id === "on")} - /> -
-
- {/* Bottom row: color · stroke style · fill opacity · segmented */} -
- - {supportsStroke && currentStroke !== undefined && ( - - )} - {supportsFill && currentFill !== undefined && ( - - )} - {/* Per-layer segmentation toggle. Independent of the other - data layer, so you can run a stacked signups chart against - a flat previous line or vice versa. Ignored in pie view. */} - - {/* Per-layer in-progress toggle. When on, the layer's tail - renders dashed to signal "this period isn't done yet". - Toggling on sets the marker to the last index of the - data array (`data.length - 1`); off resets to null. */} - -
-
- ); - }; - - // Simpler layer rows for the marker layer (no type picker). - const renderSimpleLayerRow = (layer: HeroCanvasAnnotationsLayer) => { - return ( -
-
- {layer.label} -
- layers.{layer.id} -
-
-
- - setLayerVisible(layer.id, id === "on")} - /> -
-
- ); - }; - return ( - - - - ); -} - -// exposes and surfaces the most recent invocations as a live log. Lets the -// reader literally watch the API fire as they interact with the preview. - -type HeroCanvasLabEvent = { - id: number, - ts: number, - name: string, - payload: string, -}; - -function HeroCanvasEventsPanel({ - events, - onClear, -}: { - events: HeroCanvasLabEvent[], - onClear: () => void, -}) { - return ( - - - - ); -} - - -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); - return ( - - {/* Databuddy pattern: the stat itself is a hover target that reveals a - richer period-comparison card. The trigger is also the card content, - so there's no extra chrome in the default state. */} -
- - {label} - -
- - {formatValue(current, formatKind)} - - -
-
- - {formatValue(previous, formatKind)} - - previous period -
- {/* Hover-only comparison card (non-interactive tooltip, kept pure-CSS) */} -
-
-
- - Current · {periodLabel} - - - {formatValue(current, formatKind)} - -
-
- - {previousPeriodLabel} - - - {formatValue(previous, formatKind)} - -
-
-
- - Change - - -
-
-
-
- ); -} - -// variant's default options so the demo stays compact; the state panel -// (above) is where consumers play with sub-options live. - -function FormatterPanel() { - // Each row supplies its own input value because the variants interpret - // numbers differently (currency wants cents, duration wants seconds / - // milliseconds, percent wants a fraction, datetime wants a timestamp). - 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"; - -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")} - > - -
- } - /> -
- )} -
- - ); -} - -function MiniSparkline({ values }: { values: number[] }) { - const W = 520, H = 160, P = 16; - const iw = W - P * 2; - const ih = H - P * 2; - const max = Math.max(...values) * 1.1; - const path = values - .map((v, i) => `${i === 0 ? "M" : "L"}${(P + (i / (values.length - 1)) * iw).toFixed(1)},${(P + ih - (v / max) * ih).toFixed(1)}`) - .join(" "); - const area = `${path} L${(P + iw).toFixed(1)},${(P + ih).toFixed(1)} L${P},${(P + ih).toFixed(1)} Z`; - return ( - - - - - - - - - - - ); -} - - -type TableRow = { - key: string, - label: string, - light: string, - dark: string, - current: number, - previous: number, - trend: number[], -}; - -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] }, -]; - -type SortKey = "current" | "previous" | "delta" | "label"; -type SortDir = "asc" | "desc"; - -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) => { - const delta = formatDelta(r.current, r.previous); - return ( - - - - - - - - ); - })} - -
- - Trend - -
-
- - - {r.label} -
-
- - - {r.current.toLocaleString("en-US")} - - {r.previous.toLocaleString("en-US")} - - -
-
-
- ); -} - -function RowSparkline({ values, light, dark }: { values: number[], light: string, dark: string }) { - const W = 90, H = 22; - const min = Math.min(...values); - const max = Math.max(...values); - const range = max - min || 1; - const stepX = W / (values.length - 1); - const points = values - .map((v, i) => `${(i * stepX).toFixed(1)},${(H - ((v - min) / range) * H).toFixed(1)}`) - .join(" "); - const last = values[values.length - 1]!; - const first = values[0]!; - const up = last >= first; - return ( - - ); -} - + 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 + // Simulated "live" heartbeat so the LIVE badge visibly breathes. useEffect(() => { const id = window.setInterval(() => setPulse((p) => p + 1), 2400); return () => window.clearInterval(id); @@ -4457,20 +50,20 @@ export default function PageClient() { const sumCurrent = SERIES.reduce((a, p) => a + pointValue(p, "signups"), 0); const sumPrev = SERIES.reduce((a, p) => a + pointValue(p, "previous"), 0); - // The HeroCanvas component is fully controlled — PageClient owns the - // entire state object. Mix-and-match presets are just initial values for - // this state; changing them at runtime takes effect immediately because - // the component reads everything from props on every render. - const [labState, setLabState] = useState(HERO_CANVAS_DEFAULT_STATE); - const resetLabState = () => setLabState(HERO_CANVAS_DEFAULT_STATE); + // 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 also a prop. The consumer (PageClient) owns the array - // and appends to it whenever HeroCanvas fires onAnnotationCreate. + // Annotations are a prop — PageClient owns the array and appends to it + // whenever the chart fires onAnnotationCreate. const [labAnnotations, setLabAnnotations] = useState(ANNOTATIONS); - const heroCanvasUsage = useMemo( + const usageCode = useMemo( () => - generateHeroCanvasUsage(labState, { + generateAnalyticsChartUsage(labState, { data: SERIES, annotations: labAnnotations, }), @@ -4480,12 +73,12 @@ export default function PageClient() { // 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 [labEvents, setLabEvents] = useState([]); const labEventIdRef = useRef(0); const logLabEvent = useCallback((name: string, payload: unknown) => { setLabEvents((prev) => { labEventIdRef.current += 1; - const next: HeroCanvasLabEvent = { + const next: AnalyticsChartLabEvent = { id: labEventIdRef.current, ts: Date.now(), name, @@ -4502,17 +95,17 @@ export default function PageClient() { const clearLabEvents = useCallback(() => setLabEvents([]), []); // Wrap setLabState so every changed field becomes a discrete event in - // the log. The events panel renders one row per state slice that - // actually changed, mirroring how a granular per-callback API would - // look without forcing the component to expose 13 separate props. - const handleLabStateChange = useCallback>>( + // 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: HeroCanvasState) => HeroCanvasState)(prev) + ? (action as (p: AnalyticsChartState) => AnalyticsChartState)(prev) : action; - for (const key of Object.keys(next) as (keyof HeroCanvasState)[]) { + for (const key of Object.keys(next) as (keyof AnalyticsChartState)[]) { if (!Object.is(next[key], prev[key])) { logLabEvent(`onChange:${key}`, next[key]); } @@ -4563,28 +156,43 @@ export default function PageClient() {
- {/* Lab playground — state panel / live preview / usage / events */} - - + {/* Card + title row + chart composed inline. Consumers wrap + AnalyticsChart in their own card as they see fit. */} + +
+ + Sign-ups + + + 30-day window + +
+
+ +
+
- - + +
diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx new file mode 100644 index 0000000000..cc474fdf5d --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx @@ -0,0 +1,330 @@ +import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; +import { MagnifyingGlassMinusIcon } from "@phosphor-icons/react"; +import { cn } from "@/components/ui"; +import { type CSSProperties, type Ref, useMemo } from "react"; +import { Cell, Pie, PieChart } from "recharts"; +import { TrendPill } from "./default-analytics-chart-tooltip"; +import { formatDelta } from "./format"; +import type { AnalyticsChartStrings } from "./strings"; +import type { + AnalyticsChartPieProps, + AnalyticsChartSeries, + FormatKind, + Point, +} from "./types"; + +type SegmentColors = { + primary: { light: string[], dark: string[] }, + compare: { light: string[], dark: string[] }, +}; + +type AnalyticsChartPieBodyProps = { + wrapperRef: Ref, + primarySegmentSeries: readonly AnalyticsChartSeries[], + compareSegmentSeries: readonly AnalyticsChartSeries[], + aggregatedPrimarySegments: number[], + aggregatedCompareSegments: number[], + aggregatedPrimaryTotal: number, + aggregatedCompareTotal: number, + segmentColors: SegmentColors, + showPrimary: boolean, + showCompare: boolean, + xFormatKind: FormatKind, + yFormatKind: FormatKind, + hoverKey: string | null, + setHoverKey: (k: string | null) => void, + zoomRange: [number, number] | null, + onResetZoom: () => void, + visibleStart: number, + visibleEnd: number, + fullData: Point[], + strings: AnalyticsChartStrings, + fmtValue: (value: number, kind: FormatKind) => string, + pie: AnalyticsChartPieProps | undefined, +}; + +const DEFAULT_PIE_CLASSNAME = "aspect-square h-[220px] w-[220px] sm:h-[240px] sm:w-[240px]"; + +export function AnalyticsChartPie({ + wrapperRef, + primarySegmentSeries, + compareSegmentSeries, + aggregatedPrimarySegments, + aggregatedCompareSegments, + aggregatedPrimaryTotal, + aggregatedCompareTotal, + segmentColors, + showPrimary, + showCompare, + xFormatKind, + yFormatKind, + hoverKey, + setHoverKey, + zoomRange, + onResetZoom, + visibleStart, + visibleEnd, + fullData, + strings, + fmtValue, + pie, +}: AnalyticsChartPieBodyProps) { + const innerRadius = pie?.innerRadius ?? 60; + const outerRadius = pie?.outerRadius ?? 84; + const compareInnerRadius = pie?.compareInnerRadius ?? 36; + const compareOuterRadius = pie?.compareOuterRadius ?? 52; + const containerClassName = pie?.className ?? DEFAULT_PIE_CLASSNAME; + + const canonicalSeries = primarySegmentSeries.length > 0 + ? primarySegmentSeries + : compareSegmentSeries; + const usePrimaryForCanonical = primarySegmentSeries.length > 0; + + const compareValueByKey = useMemo(() => { + const m = new Map(); + compareSegmentSeries.forEach((s, sIdx) => { + m.set(s.key, aggregatedCompareSegments[sIdx] ?? 0); + }); + return m; + }, [compareSegmentSeries, aggregatedCompareSegments]); + const primaryValueByKey = useMemo(() => { + const m = new Map(); + primarySegmentSeries.forEach((s, sIdx) => { + m.set(s.key, aggregatedPrimarySegments[sIdx] ?? 0); + }); + return m; + }, [primarySegmentSeries, aggregatedPrimarySegments]); + + const canonicalTotal = usePrimaryForCanonical + ? aggregatedPrimaryTotal + : aggregatedCompareTotal; + + const legendRows = useMemo( + () => + canonicalSeries + .map((s, sIdx) => { + const value = usePrimaryForCanonical + ? (aggregatedPrimarySegments[sIdx] ?? 0) + : (aggregatedCompareSegments[sIdx] ?? 0); + const prevValue = usePrimaryForCanonical + ? (compareValueByKey.get(s.key) ?? 0) + : (primaryValueByKey.get(s.key) ?? 0); + return { + key: s.key, + label: s.label, + sIdx, + value, + prevValue, + pct: canonicalTotal > 0 ? value / canonicalTotal : 0, + fill: segmentColors.primary.light[sIdx], + fillDark: segmentColors.primary.dark[sIdx], + fillCompare: segmentColors.compare.light[sIdx], + fillCompareDark: segmentColors.compare.dark[sIdx], + }; + }) + .sort((a, b) => b.value - a.value), + [ + canonicalSeries, + usePrimaryForCanonical, + aggregatedPrimarySegments, + aggregatedCompareSegments, + compareValueByKey, + primaryValueByKey, + canonicalTotal, + segmentColors, + ], + ); + + const chartConfig = useMemo(() => { + const config: ChartConfig = {}; + canonicalSeries.forEach((s, sIdx) => { + config[s.key] = { + label: s.label, + theme: { + light: segmentColors.primary.light[sIdx], + dark: segmentColors.primary.dark[sIdx], + }, + }; + config[`compare-${s.key}`] = { + label: s.label, + theme: { + light: segmentColors.compare.light[sIdx], + dark: segmentColors.compare.dark[sIdx], + }, + }; + }); + return config; + }, [canonicalSeries, segmentColors]); + + const activeRow = hoverKey + ? legendRows.find((r) => r.key === hoverKey) ?? null + : null; + const activeDelta = activeRow + ? formatDelta(activeRow.value, activeRow.prevValue) + : formatDelta(aggregatedPrimaryTotal, aggregatedCompareTotal); + + const windowDays = visibleEnd - visibleStart + 1; + const startLabel = fmtValue(fullData[visibleStart]!.ts, xFormatKind); + const endLabel = fmtValue(fullData[visibleEnd]!.ts, xFormatKind); + + const outerData = legendRows.map((r) => ({ name: r.key, value: r.value, fill: r.fill })); + const innerData = legendRows.map((r) => ({ name: r.key, value: r.prevValue, fill: r.fillCompare })); + const activeIdx = hoverKey ? legendRows.findIndex((r) => r.key === hoverKey) : -1; + + return ( +
e.stopPropagation()} + > + {zoomRange && ( +
+ +
+ )} + +
+
+
+ + + {showPrimary && ( + + {outerData.map((d, i) => { + const inactive = activeIdx >= 0 && activeIdx !== i; + return ( + setHoverKey(d.name)} + onMouseLeave={() => setHoverKey(null)} + /> + ); + })} + + )} + {showCompare && ( + + {innerData.map((d, i) => { + const inactive = activeIdx >= 0 && activeIdx !== i; + return ( + setHoverKey(d.name)} + onMouseLeave={() => setHoverKey(null)} + /> + ); + })} + + )} + + + +
+ + {activeRow ? activeRow.label : strings.pieTotalCenter} + + + {fmtValue(activeRow ? activeRow.value : canonicalTotal, yFormatKind)} + +
+
+ +
+ + {startLabel} – {endLabel} + + {showCompare && ( + + )} +
+
+ +
    + {legendRows.map((r) => { + const isActive = hoverKey === r.key; + const dimmed = hoverKey != null && !isActive; + const rowDelta = formatDelta(r.value, r.prevValue); + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx new file mode 100644 index 0000000000..898126977a --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx @@ -0,0 +1,1096 @@ +"use client"; + +import { DesignButton } from "@/components/design-components"; +import { cn } from "@/components/ui"; +import { + type ChartConfig, + ChartContainer, +} from "@/components/ui/chart"; +import { + CartesianGrid, + ComposedChart, + ReferenceArea, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; +import { + FlagIcon, + MagnifyingGlassMinusIcon, + MagnifyingGlassPlusIcon, + XIcon, +} from "@phosphor-icons/react"; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AnalyticsChartPie } from "./analytics-chart-pie"; +import { + DefaultAnalyticsChartTooltip, + type AnalyticsChartTooltipContext, + type AnalyticsChartTooltipLayerView, + type AnalyticsChartTooltipSegmentRow, +} from "./default-analytics-chart-tooltip"; +import { formatDelta, formatValue } from "./format"; +import { + buildRampColors, + resolveAnalyticsChartPalette, +} from "./palette"; +import { renderDataSeries } from "./render-data-series"; +import { + computeLocalInProgressIdx, + EMPTY_MATRIX, + EMPTY_SERIES, + findAnnotationsLayer, + findCompareLayer, + findPrimaryLayer, + isAnalyticsChartDataLayer, + isTimeseriesState, + resolveDataLayerStyle, + STROKE_DASHARRAY, + type ResolvedDataLayerStyle, +} from "./state"; +import { resolveAnalyticsChartStrings } from "./strings"; +import type { + AnalyticsChartDelta, + AnalyticsChartPalette, + AnalyticsChartPieProps, + AnalyticsChartSeries, + AnalyticsChartState, + AnalyticsChartTimeseriesState, + Annotation, + FormatKind, + Point, +} from "./types"; +import { pointValue } from "./types"; +import type { AnalyticsChartStrings } from "./strings"; + +/** Mirrors Recharts' internal `Margin` shape (not exported from their typings). */ +export type Margin = { + top?: number, + right?: number, + bottom?: number, + left?: number, +}; + +export type AnalyticsChartProps = { + /** Time-series points — each point carries `values` keyed by layer id. */ + data: Point[], + /** Annotations. Fully prop-driven; the consumer owns the array. */ + annotations?: Annotation[], + /** Fully-controlled state + dispatch. The chart reads every config and + * persistent-interaction slice from `state` and mutates it through + * `onChange`. Ephemeral interaction state (hover, brush, pin, draft) is + * managed internally and surfaces only through the state callbacks. */ + state: AnalyticsChartState, + onChange: React.Dispatch>, + /** Fired when the user submits the in-chart annotation form. The consumer + * is expected to append to its own annotations array. */ + onAnnotationCreate?: (annotation: Annotation) => void, + /** Override any user-visible copy. Shallow-merges over the defaults. */ + strings?: Partial, + /** Override segment color ramps. Each ramp is either procedural + * (hue + sat + lightness range) or explicit (concrete color lists). */ + palette?: Partial, + /** Render slot for the tooltip body. Receives a prepared context with + * the active point, primary/compare layer views, pre-bound formatters, + * and resolved strings. Defaults to `DefaultAnalyticsChartTooltip`. */ + renderTooltip?: (ctx: AnalyticsChartTooltipContext) => ReactNode, + /** Recharts plot margins. Also drives overlay positioning math so the + * crosshair, tooltip anchor, brush popup, and flag markers line up with + * the actual plot area. Defaults to `{ top: 16, right: 24, bottom: 8, left: 12 }`. */ + plotMargin?: Margin, + /** Y-axis reserved width in pixels. Defaults to 48. */ + yAxisWidth?: number, + /** Fractional headroom added to the y-axis top. Defaults to 0.1. */ + yDomainPadding?: number, + /** Grouped pie configuration. Each field has a sensible default. */ + pie?: AnalyticsChartPieProps, + /** Custom number formatter. Receives the raw value and the kind to format + * with — the same function is invoked for both x-axis and y-axis values. */ + valueFormatter?: (value: number, kind: FormatKind) => string, +}; + +type RechartsMouseState = { + activeTooltipIndex?: number, + isTooltipActive?: boolean, +}; + +const FALLBACK_PRIMARY_STYLE: ResolvedDataLayerStyle = { + color: "#2563eb", + type: "area", + strokeStyle: "solid", + fillOpacity: 0, +}; +const FALLBACK_COMPARE_STYLE: ResolvedDataLayerStyle = { + color: "#f59e0b", + type: "line", + strokeStyle: "dashed", + fillOpacity: 0, +}; +const FALLBACK_ANNOTATION_COLOR = "#f59e0b"; + +function buildTooltipLayerView(args: { + show: boolean, + layer: { id: string, label: string } | undefined, + color: string, + segmented: boolean, + segmentSeries: readonly AnalyticsChartSeries[], + segmentRows: readonly (readonly number[])[], + segmentTotals: readonly number[], + segmentColorsLight: readonly string[], + segmentColorsDark: readonly string[], + activeIndex: number, + activePoint: Point, + fallbackLabel?: string, +}): AnalyticsChartTooltipLayerView | null { + const { + show, + layer, + color, + segmented, + segmentSeries, + segmentRows, + segmentTotals, + segmentColorsLight, + segmentColorsDark, + activeIndex, + activePoint, + fallbackLabel, + } = args; + if (!show || !layer) return null; + const segments: AnalyticsChartTooltipSegmentRow[] = segmented + ? segmentSeries.map((s, sIdx) => ({ + key: s.key, + label: s.label, + value: segmentRows[activeIndex]?.[sIdx] ?? 0, + color: segmentColorsLight[sIdx], + colorDark: segmentColorsDark[sIdx], + })) + : []; + return { + id: layer.id, + label: layer.label || fallbackLabel || "", + color, + colorDark: color, + total: segmented + ? (segmentTotals[activeIndex] ?? 0) + : pointValue(activePoint, layer.id), + segmented, + segments, + }; +} + +export function AnalyticsChart({ + data: fullData, + annotations: fullAnnotations = [], + state, + onChange, + onAnnotationCreate, + strings: stringsOverride, + palette: paletteOverride, + renderTooltip, + plotMargin, + yAxisWidth = 48, + yDomainPadding = 0.1, + pie, + valueFormatter, +}: AnalyticsChartProps) { + const resolvedPlotMargin = useMemo>( + () => ({ + top: plotMargin?.top ?? 16, + right: plotMargin?.right ?? 24, + bottom: plotMargin?.bottom ?? 8, + left: plotMargin?.left ?? 12, + }), + [plotMargin], + ); + const fmtValue = valueFormatter ?? formatValue; + const strings = useMemo( + () => resolveAnalyticsChartStrings(stringsOverride), + [stringsOverride], + ); + const palette = useMemo( + () => resolveAnalyticsChartPalette(paletteOverride), + [paletteOverride], + ); + + const renderTooltipFn = useMemo( + () => renderTooltip ?? ((ctx: AnalyticsChartTooltipContext) => ), + [renderTooltip], + ); + + const { xFormatKind, yFormatKind, layers } = state; + const timeseries = isTimeseriesState(state) ? state : null; + const showGrid = timeseries?.showGrid ?? false; + const showXAxis = timeseries?.showXAxis ?? false; + const showYAxis = timeseries?.showYAxis ?? false; + const zoomRange = timeseries?.zoomRange ?? null; + const pinnedIndex = timeseries?.pinnedIndex ?? null; + + const primaryLayer = findPrimaryLayer(layers); + const compareLayer = findCompareLayer(layers); + const annotationsLayer = findAnnotationsLayer(layers); + const showPrimary = primaryLayer?.visible ?? false; + const showCompare = compareLayer?.visible ?? false; + const showAnnotationsLayer = annotationsLayer?.visible ?? false; + + const primaryStyle = primaryLayer ? resolveDataLayerStyle(primaryLayer) : FALLBACK_PRIMARY_STYLE; + const compareStyle = compareLayer ? resolveDataLayerStyle(compareLayer) : FALLBACK_COMPARE_STYLE; + const primaryColor = primaryStyle.color; + const compareColor = compareStyle.color; + const annotationColor = annotationsLayer?.color ?? FALLBACK_ANNOTATION_COLOR; + const primaryStroke = STROKE_DASHARRAY[primaryStyle.strokeStyle]; + const compareStroke = STROKE_DASHARRAY[compareStyle.strokeStyle]; + const primaryFillOpacity = primaryStyle.fillOpacity; + const compareFillOpacity = compareStyle.fillOpacity; + + const setTimeseriesField = useCallback( + ( + key: K, + value: AnalyticsChartTimeseriesState[K], + ) => { + onChange((prev) => { + if (prev.view !== "timeseries") return prev; + return { ...prev, [key]: value }; + }); + }, + [onChange], + ); + + const wrapperRef = useRef(null); + const [hoverIndex, setHoverIndex] = useState(null); + const [committedRange, setCommittedRange] = useState<[number, number] | null>(null); + const [annotationDraft, setAnnotationDraft] = useState(null); + const [dragAnchor, setDragAnchor] = useState(null); + const [brushStart, setBrushStart] = useState(null); + const [brushEnd, setBrushEnd] = useState(null); + const [pieHoverKey, setPieHoverKey] = useState(null); + + const activeIndex = pinnedIndex ?? hoverIndex; + + const primarySegmentSeries = useMemo( + () => primaryLayer?.segmentSeries ?? EMPTY_SERIES, + [primaryLayer?.segmentSeries], + ); + const compareSegmentSeries = useMemo( + () => compareLayer?.segmentSeries ?? EMPTY_SERIES, + [compareLayer?.segmentSeries], + ); + const primaryFullSegments = useMemo( + () => primaryLayer?.segments ?? EMPTY_MATRIX, + [primaryLayer?.segments], + ); + const compareFullSegments = useMemo( + () => compareLayer?.segments ?? EMPTY_MATRIX, + [compareLayer?.segments], + ); + + const primarySegmented = + (primaryLayer?.segmented ?? false) + && showPrimary + && primarySegmentSeries.length > 0 + && primaryFullSegments.length > 0; + const compareSegmented = + (compareLayer?.segmented ?? false) + && showCompare + && compareSegmentSeries.length > 0 + && compareFullSegments.length > 0; + + const visibleStart = zoomRange ? zoomRange[0] : 0; + const visibleEnd = zoomRange ? zoomRange[1] : fullData.length - 1; + + const data = useMemo( + () => fullData.slice(visibleStart, visibleEnd + 1), + [fullData, visibleStart, visibleEnd], + ); + const primarySegments = useMemo( + () => primaryFullSegments.slice(visibleStart, visibleEnd + 1), + [primaryFullSegments, visibleStart, visibleEnd], + ); + const compareSegments = useMemo( + () => compareFullSegments.slice(visibleStart, visibleEnd + 1), + [compareFullSegments, visibleStart, visibleEnd], + ); + const primarySegmentTotals = useMemo( + () => primarySegments.map((row) => row.reduce((a, b) => a + b, 0)), + [primarySegments], + ); + const compareSegmentTotals = useMemo( + () => compareSegments.map((row) => row.reduce((a, b) => a + b, 0)), + [compareSegments], + ); + + const yDomainMax = useMemo(() => { + const dataLayerIds = layers.filter(isAnalyticsChartDataLayer).map((l) => l.id); + const layerMaxes = dataLayerIds.map((id) => + data.reduce((m, p) => Math.max(m, pointValue(p, id)), 0), + ); + const primaryStackMax = primarySegmentTotals.reduce((m, v) => Math.max(m, v), 0); + const compareStackMax = compareSegmentTotals.reduce((m, v) => Math.max(m, v), 0); + const rawMax = Math.max(0, ...layerMaxes, primaryStackMax, compareStackMax); + return Math.ceil(rawMax * (1 + yDomainPadding)); + }, [data, layers, primarySegmentTotals, compareSegmentTotals, yDomainPadding]); + + const segmentColors = useMemo(() => { + return { + primary: { + light: buildRampColors(palette.primary, primarySegmentSeries.length, "light"), + dark: buildRampColors(palette.primary, primarySegmentSeries.length, "dark"), + }, + compare: { + light: buildRampColors(palette.compare, compareSegmentSeries.length, "light"), + dark: buildRampColors(palette.compare, compareSegmentSeries.length, "dark"), + }, + }; + }, [primarySegmentSeries.length, compareSegmentSeries.length, palette]); + + const aggregatedPrimarySegments = useMemo( + () => + primarySegmentSeries.map((_, sIdx) => + primarySegments.reduce((acc, row) => acc + (row[sIdx] ?? 0), 0), + ), + [primarySegmentSeries, primarySegments], + ); + const aggregatedCompareSegments = useMemo( + () => + compareSegmentSeries.map((_, sIdx) => + compareSegments.reduce((acc, row) => acc + (row[sIdx] ?? 0), 0), + ), + [compareSegmentSeries, compareSegments], + ); + const aggregatedPrimaryTotal = useMemo( + () => aggregatedPrimarySegments.reduce((a, b) => a + b, 0), + [aggregatedPrimarySegments], + ); + const aggregatedCompareTotal = useMemo( + () => aggregatedCompareSegments.reduce((a, b) => a + b, 0), + [aggregatedCompareSegments], + ); + + const annotations = useMemo(() => { + return fullAnnotations + .filter((a) => a.index >= visibleStart && a.index <= visibleEnd) + .map((a) => ({ ...a, index: a.index - visibleStart })); + }, [fullAnnotations, visibleStart, visibleEnd]); + + const brushing = brushStart != null; + const N = data.length; + + const primaryKey = primaryLayer?.id ?? "__analytics_primary"; + const compareKey = compareLayer?.id ?? "__analytics_compare"; + // Segment keys must be valid CSS `` tokens — colons break `--color-${key}` declarations. + const primarySolidKey = `${primaryKey}_solid`; + const primaryDashedKey = `${primaryKey}_dashed`; + const compareSolidKey = `${compareKey}_solid`; + const compareDashedKey = `${compareKey}_dashed`; + const primarySegKey = useCallback( + (segKey: string) => `${primaryKey}_seg_${segKey}`, + [primaryKey], + ); + const compareSegKey = useCallback( + (segKey: string) => `${compareKey}_seg_${segKey}`, + [compareKey], + ); + + const primaryInProgressLocalIdx = computeLocalInProgressIdx( + primaryLayer?.inProgressFromIndex, + visibleStart, + visibleEnd, + ); + const compareInProgressLocalIdx = computeLocalInProgressIdx( + compareLayer?.inProgressFromIndex, + visibleStart, + visibleEnd, + ); + const primaryHasInProgress = + primaryInProgressLocalIdx != null + && !primarySegmented + && (primaryStyle.type === "line" || primaryStyle.type === "area"); + const compareHasInProgress = + compareInProgressLocalIdx != null + && !compareSegmented + && (compareStyle.type === "line" || compareStyle.type === "area"); + + const chartData = useMemo(() => { + return data.map((point, i) => { + const row: Record = { + index: i, + ts: point.ts, + }; + for (const [k, v] of Object.entries(point.values)) { + row[k] = v; + } + if (primaryLayer && primaryHasInProgress) { + const k = primaryInProgressLocalIdx as number; + const v = pointValue(point, primaryLayer.id); + row[primarySolidKey] = i < k ? v : null; + row[primaryDashedKey] = i >= k - 1 ? v : null; + } + if (compareLayer && compareHasInProgress) { + const k = compareInProgressLocalIdx as number; + const v = pointValue(point, compareLayer.id); + row[compareSolidKey] = i < k ? v : null; + row[compareDashedKey] = i >= k - 1 ? v : null; + } + if (primarySegmented) { + primarySegmentSeries.forEach((s, sIdx) => { + row[primarySegKey(s.key)] = primarySegments[i]?.[sIdx] ?? 0; + }); + } + if (compareSegmented) { + compareSegmentSeries.forEach((s, sIdx) => { + row[compareSegKey(s.key)] = compareSegments[i]?.[sIdx] ?? 0; + }); + } + return row; + }); + }, [ + data, + primaryLayer, + compareLayer, + primarySolidKey, + primaryDashedKey, + compareSolidKey, + compareDashedKey, + primarySegKey, + compareSegKey, + primarySegments, + compareSegments, + primarySegmentSeries, + compareSegmentSeries, + primarySegmented, + compareSegmented, + primaryHasInProgress, + compareHasInProgress, + primaryInProgressLocalIdx, + compareInProgressLocalIdx, + ]); + + const chartConfig = useMemo(() => { + const primaryLabel = primaryLayer?.label ?? ""; + const compareLabel = compareLayer?.label ?? ""; + const config: ChartConfig = {}; + if (primaryLayer) { + config[primaryLayer.id] = { label: primaryLabel, color: primaryColor }; + if (primaryHasInProgress) { + config[primarySolidKey] = { label: primaryLabel, color: primaryColor }; + config[primaryDashedKey] = { label: primaryLabel, color: primaryColor }; + } + } + if (compareLayer) { + config[compareLayer.id] = { label: compareLabel, color: compareColor }; + if (compareHasInProgress) { + config[compareSolidKey] = { label: compareLabel, color: compareColor }; + config[compareDashedKey] = { label: compareLabel, color: compareColor }; + } + } + if (primarySegmented) { + primarySegmentSeries.forEach((s, i) => { + config[primarySegKey(s.key)] = { + label: s.label, + theme: { + light: segmentColors.primary.light[i], + dark: segmentColors.primary.dark[i], + }, + }; + }); + } + if (compareSegmented) { + compareSegmentSeries.forEach((s, i) => { + config[compareSegKey(s.key)] = { + label: s.label, + theme: { + light: segmentColors.compare.light[i], + dark: segmentColors.compare.dark[i], + }, + }; + }); + } + return config; + }, [ + primaryLayer, + compareLayer, + primarySolidKey, + primaryDashedKey, + compareSolidKey, + compareDashedKey, + primarySegKey, + compareSegKey, + primaryColor, + compareColor, + primaryHasInProgress, + compareHasInProgress, + primarySegmented, + compareSegmented, + primarySegmentSeries, + compareSegmentSeries, + segmentColors, + ]); + + const handleChartMouseMove = useCallback( + (rechartsState: RechartsMouseState) => { + const i = rechartsState.activeTooltipIndex; + if (typeof i !== "number") return; + setHoverIndex(i); + if (dragAnchor != null && (brushStart != null || i !== dragAnchor)) { + if (brushStart == null) setBrushStart(dragAnchor); + setBrushEnd(i); + } + }, + [dragAnchor, brushStart], + ); + const handleChartMouseDown = useCallback( + (rechartsState: RechartsMouseState, e: React.MouseEvent) => { + if (e.button !== 0) return; + const i = rechartsState.activeTooltipIndex; + if (typeof i !== "number") return; + e.preventDefault(); + setDragAnchor(i); + setAnnotationDraft(null); + }, + [], + ); + const handleChartMouseUp = useCallback( + (_: RechartsMouseState, e: React.MouseEvent) => { + e.stopPropagation(); + if (brushStart != null && brushEnd != null) { + const lo = Math.min(brushStart, brushEnd); + const hi = Math.max(brushStart, brushEnd); + setBrushStart(null); + setBrushEnd(null); + setDragAnchor(null); + if (hi - lo >= 1) setCommittedRange([lo, hi]); + return; + } + setDragAnchor(null); + if (pinnedIndex != null) { + setTimeseriesField("pinnedIndex", null); + } else if (hoverIndex != null) { + setTimeseriesField("pinnedIndex", hoverIndex); + } + }, + [ + brushStart, + brushEnd, + hoverIndex, + pinnedIndex, + setTimeseriesField, + ], + ); + const handleChartMouseLeave = useCallback(() => { + if (!brushing) setHoverIndex(null); + }, [brushing]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + setHoverIndex((cur) => { + const base = cur ?? pinnedIndex ?? 0; + return e.key === "ArrowRight" + ? Math.min(N - 1, base + 1) + : Math.max(0, base - 1); + }); + return; + } + if (e.key === "Home") { + e.preventDefault(); + setHoverIndex(0); + return; + } + if (e.key === "End") { + e.preventDefault(); + setHoverIndex(N - 1); + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (pinnedIndex != null) { + setTimeseriesField("pinnedIndex", null); + } else if (hoverIndex != null) { + setTimeseriesField("pinnedIndex", hoverIndex); + } + } + }, + [N, hoverIndex, pinnedIndex, setTimeseriesField], + ); + + useEffect(() => { + if (pinnedIndex == null) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setTimeseriesField("pinnedIndex", null); + }; + const onDown = (e: MouseEvent) => { + if (!wrapperRef.current) return; + if (!wrapperRef.current.contains(e.target as Node)) setTimeseriesField("pinnedIndex", null); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("mousedown", onDown); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("mousedown", onDown); + }; + }, [pinnedIndex, setTimeseriesField]); + + const plotInset = resolvedPlotMargin.left + resolvedPlotMargin.right; + const indexToCss = useCallback( + (i: number): string => { + if (N <= 1) return `calc(${resolvedPlotMargin.left}px + (100% - ${plotInset}px) * 0.5)`; + const t = Math.max(0, Math.min(1, i / (N - 1))); + return `calc(${resolvedPlotMargin.left}px + (100% - ${plotInset}px) * ${t})`; + }, + [N, resolvedPlotMargin.left, plotInset], + ); + const tooltipXPct = activeIndex != null + ? N <= 1 + ? 50 + : (activeIndex / (N - 1)) * 100 + : 0; + const shouldFlip = tooltipXPct > 68; + + const activePoint = activeIndex != null ? data[activeIndex] : null; + + const chartAriaLabel = primaryLayer?.label || "Chart"; + + if (state.view === "pie") { + return ( + { + onChange((prev) => ({ ...prev, zoomRange: null, pinnedIndex: null })); + setCommittedRange(null); + setAnnotationDraft(null); + setHoverIndex(null); + }} + visibleStart={visibleStart} + visibleEnd={visibleEnd} + fullData={fullData} + strings={strings} + fmtValue={fmtValue} + pie={pie} + /> + ); + } + + return ( +
{ + e.stopPropagation(); + }} + onKeyDown={handleKeyDown} + onFocus={() => { + if (hoverIndex == null) setHoverIndex(pinnedIndex ?? Math.floor(N / 2)); + }} + tabIndex={0} + role="img" + aria-label={`${chartAriaLabel} over the visible ${data.length}-day range. Use arrow keys to move the cursor, Enter to pin, Escape to release. Click and drag to select a range.`} + > + + + {showGrid && } + {showXAxis && ( + { + const idx = Number(value); + if (idx < 0 || idx >= data.length) return ""; + return fmtValue(data[idx]!.ts, xFormatKind); + }} + /> + )} + {showYAxis && ( + + fmtValue(Math.round(Number(value)), yFormatKind) + } + /> + )} + + {showAnnotationsLayer + && annotations.map((a) => ( + + ))} + + {committedRange && ( + + )} + + {brushStart != null && brushEnd != null && ( + + )} + + {showPrimary && primaryLayer && renderDataSeries({ + layer: primaryLayer, + segmented: primarySegmented, + segmentSeries: primarySegmentSeries, + segKey: primarySegKey, + stackId: "primary-segments", + strokeDasharray: primaryStroke, + segmentedStrokeDasharray: undefined, + fillOpacity: primaryFillOpacity, + segmentedFillOpacity: 0.78, + strokeWidth: 2, + segmentedStrokeWidth: 0.75, + inProgressKeys: primaryHasInProgress + ? { solid: primarySolidKey, dashed: primaryDashedKey } + : null, + })} + + {showCompare && compareLayer && renderDataSeries({ + layer: compareLayer, + segmented: compareSegmented, + segmentSeries: compareSegmentSeries, + segKey: compareSegKey, + stackId: "compare-segments", + strokeDasharray: compareStroke, + segmentedStrokeDasharray: compareStroke, + fillOpacity: compareFillOpacity, + segmentedFillOpacity: 0.6, + baseOpacity: compareSegmented ? 0.9 : 1, + strokeWidth: 1.5, + segmentedStrokeWidth: 0.75, + inProgressKeys: compareHasInProgress + ? { solid: compareSolidKey, dashed: compareDashedKey } + : null, + })} + + + + {activeIndex != null && activePoint && !brushing && ( +
+ )} + + {brushStart != null && brushEnd != null && (() => { + const lo = Math.min(brushStart, brushEnd); + const hi = Math.max(brushStart, brushEnd); + const days = hi - lo + 1; + return ( +
+
+ + {strings.rangeLabel} + + + {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)} + + + · {strings.daysShort(days)} + +
+
+ ); + })()} + + {zoomRange && ( +
+ +
+ )} + + {committedRange && !brushing && (() => { + const [lo, hi] = committedRange; + const center = (lo + hi) / 2; + const centerPct = N <= 1 ? 50 : (center / (N - 1)) * 100; + const snapLeft = centerPct < 22; + const snapRight = centerPct > 78; + const anchorStyle: CSSProperties = snapLeft + ? { left: "8px" } + : snapRight + ? { right: "8px" } + : { left: indexToCss(center), transform: "translateX(-50%)" }; + const days = hi - lo + 1; + const draft = annotationDraft; + return ( +
+
+ {draft == null ? ( + <> + + + {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)} + + · + {strings.daysShort(days)} + + +
+ ); + })()} + + {showAnnotationsLayer && ( +
+ {annotations.map((a) => { + return ( +
+ + + {a.description} + +
+ ); + })} +
+ )} + + {activeIndex != null && activePoint && (() => { + const primaryView = buildTooltipLayerView({ + show: showPrimary, + layer: primaryLayer, + color: primaryColor, + segmented: primarySegmented, + segmentSeries: primarySegmentSeries, + segmentRows: primarySegments, + segmentTotals: primarySegmentTotals, + segmentColorsLight: segmentColors.primary.light, + segmentColorsDark: segmentColors.primary.dark, + activeIndex, + activePoint, + fallbackLabel: "Chart", + }); + const compareView = buildTooltipLayerView({ + show: showCompare, + layer: compareLayer, + color: compareColor, + segmented: compareSegmented, + segmentSeries: compareSegmentSeries, + segmentRows: compareSegments, + segmentTotals: compareSegmentTotals, + segmentColorsLight: segmentColors.compare.light, + segmentColorsDark: segmentColors.compare.dark, + activeIndex, + activePoint, + }); + const delta: AnalyticsChartDelta | null = primaryView && compareView + ? formatDelta(primaryView.total, compareView.total) + : null; + const ctx: AnalyticsChartTooltipContext = { + activeIndex, + point: activePoint, + isPinned: pinnedIndex != null, + primary: primaryView, + compare: compareView, + delta, + formatValue: (v) => fmtValue(v, yFormatKind), + formatDate: (ts) => fmtValue(ts, xFormatKind), + strings, + }; + return ( +
+ {renderTooltipFn(ctx)} +
+ ); + })()} +
+ ); +} + diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/default-analytics-chart-tooltip.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/default-analytics-chart-tooltip.tsx new file mode 100644 index 0000000000..866d41ef9e --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/default-analytics-chart-tooltip.tsx @@ -0,0 +1,265 @@ +import { cn } from "@/components/ui"; +import { + ArrowDownIcon, + ArrowUpIcon, + CursorClickIcon, + MinusIcon, + PushPinSimpleIcon, +} from "@phosphor-icons/react"; +import type { CSSProperties } from "react"; +import type { AnalyticsChartStrings } from "./strings"; +import type { AnalyticsChartDelta, Point } from "./types"; + +/** Trend pill — small rounded badge with an up/down/flat arrow, a signed + * percentage, and an optional trailing label. Shared between the default + * tooltip, the pie view, and the demo panels (which re-export it). */ +export function TrendPill({ + delta, + label, + size = "sm", +}: { + delta: AnalyticsChartDelta, + label?: string, + size?: "sm" | "md", +}) { + const { pct, sign } = delta; + const tone = + sign === "up" ? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10" + : sign === "down" ? "text-rose-600 dark:text-rose-400 bg-rose-500/10" + : "text-muted-foreground bg-foreground/[0.06]"; + const Icon = sign === "up" ? ArrowUpIcon : sign === "down" ? ArrowDownIcon : MinusIcon; + const text = pct == null ? "—" : `${pct > 0 ? "+" : ""}${pct}%`; + return ( + + + ); +} + +export type AnalyticsChartTooltipSegmentRow = { + key: string, + label: string, + value: number, + /** Light-theme color for the dot / swatch. */ + color: string, + /** Dark-theme color for the dot / swatch. */ + colorDark: string, +}; + +export type AnalyticsChartTooltipLayerView = { + /** Stable layer id (e.g. `"signups"`, `"previous"`). */ + id: string, + /** Consumer-provided layer label. */ + label: string, + /** Resolved layer color (light theme). */ + color: string, + /** Resolved layer color (dark theme). */ + colorDark: string, + /** Flat total for this layer at the hovered index. Populated regardless + * of segmentation so consumers can render the same number in either mode. */ + total: number, + /** True iff this layer is rendered as a stacked breakdown. */ + segmented: boolean, + /** Per-segment rows — empty when `segmented === false`. Order matches + * `segmentSeries`. */ + segments: AnalyticsChartTooltipSegmentRow[], +}; + +export type AnalyticsChartTooltipContext = { + /** Index into the visible window. */ + activeIndex: number, + /** Raw point at `activeIndex` — convenient for `.ts` access. */ + point: Point, + /** True when the tooltip is pinned (via click) and stable under hover. */ + isPinned: boolean, + /** Primary layer view or null when the primary layer is hidden. */ + primary: AnalyticsChartTooltipLayerView | null, + /** Compare layer view or null when the compare layer is hidden. */ + compare: AnalyticsChartTooltipLayerView | null, + /** Flat-mode delta between primary and compare totals. Null when either + * side is hidden. Consumers should feed this into their trend pill. */ + delta: AnalyticsChartDelta | null, + /** Pre-bound value formatter for y-axis values. */ + formatValue: (v: number) => string, + /** Pre-bound formatter for x-axis values. */ + formatDate: (ts: number) => string, + /** Resolved strings — already merged with defaults. */ + strings: AnalyticsChartStrings, +}; + +export type DefaultAnalyticsChartTooltipProps = { + ctx: AnalyticsChartTooltipContext, +}; + +/** The default tooltip body. The tooltip is rendered as an + * absolutely-positioned sibling of ``, which means it sits + * OUTSIDE the `[data-chart=…]` subtree that scopes shadcn's `--color-${key}` + * CSS variables. We therefore cannot reference those variables for segment + * swatches — instead, every swatch uses a single span with `--c-l`/`--c-d` + * custom properties + Tailwind arbitrary variants so one DOM element covers + * both themes. */ +export function DefaultAnalyticsChartTooltip({ ctx }: DefaultAnalyticsChartTooltipProps) { + const { point, isPinned, primary, compare, delta, formatValue: fv, formatDate: fd, strings } = ctx; + const anySegmented = (primary?.segmented ?? false) || (compare?.segmented ?? false); + return ( +
+
+ + {fd(point.ts)} + + {isPinned && ( + + + )} +
+
+ + +
+ {anySegmented && ( +
+ {primary?.segmented && ( + + )} + {compare?.segmented && ( + + )} + {primary?.segmented && compare?.segmented && delta && ( +
+ + {strings.deltaVsPrev} + + +
+ )} +
+ )} + {!anySegmented && delta && primary && compare && ( +
+ + {strings.deltaVsPrev} + + +
+ )} +
+
+
+ ); +} + +/** Render either a single flat row or the per-segment breakdown rows for + * one tooltip layer view. Collapses what were formerly two parallel + * primary/compare branches into one component. */ +function TooltipLayerRows({ + view, + keyPrefix, + fv, + muted, +}: { + view: AnalyticsChartTooltipLayerView | null, + keyPrefix: string, + fv: (v: number) => string, + muted: boolean, +}) { + if (!view) return null; + if (!view.segmented) { + return ( + + ); + } + return ( + <> + {view.segments.map((s) => ( + + ))} + + ); +} + +/** Single row: swatch + label + value. One DOM element for the swatch — + * dark-mode color is picked up via `--c-d`. */ +function LayerTotalRow({ + light, + dark, + label, + value, + muted, +}: { + light: string, + dark: string, + label: string, + value: string, + muted: boolean, +}) { + return ( +
+ + {label} + + {value} + +
+ ); +} + +function LayerSummaryRow({ + label, + value, + muted, +}: { + label: string, + value: string, + muted: boolean, +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/format.ts b/packages/dashboard-ui-components/src/components/analytics-chart/format.ts new file mode 100644 index 0000000000..93c22fd6be --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/format.ts @@ -0,0 +1,101 @@ +import type { FormatKind, FormatKindType, AnalyticsChartDelta } from "./types"; + +export const FORMAT_KIND_TYPES: FormatKindType[] = [ + "numeric", + "short", + "currency", + "duration", + "datetime", + "percent", +]; + +export const DEFAULT_FORMAT_KIND: { [K in FormatKindType]: Extract } = { + numeric: { type: "numeric", locale: "en-US", decimals: 0 }, + short: { type: "short", precision: 1, locale: "en-US" }, + currency: { type: "currency", currency: "USD", divisor: 100, locale: "en-US" }, + duration: { type: "duration", unit: "s", showZero: false }, + datetime: { type: "datetime", style: "short", locale: "en-US" }, + percent: { type: "percent", source: "fraction", decimals: 1 }, +}; + +function formatRelative(value: number, locale: string): string { + const diff = value - Date.now(); + const absSec = Math.abs(diff) / 1000; + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + if (absSec >= 86_400) return rtf.format(Math.round(diff / 86_400_000), "day"); + if (absSec >= 3600) return rtf.format(Math.round(diff / 3_600_000), "hour"); + if (absSec >= 60) return rtf.format(Math.round(diff / 60_000), "minute"); + return "just now"; +} + +/** `short` uses compact notation (e.g. `1.2K`), not a custom `k` suffix. */ +export function formatValue(value: number, kind: FormatKind): string { + switch (kind.type) { + case "numeric": { + const decimals = kind.decimals ?? 0; + return value.toLocaleString(kind.locale ?? "en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + } + case "short": { + const precision = kind.precision ?? 1; + return new Intl.NumberFormat(kind.locale ?? "en-US", { + notation: "compact", + compactDisplay: "short", + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }).format(value); + } + case "currency": { + const divisor = kind.divisor ?? 1; + return new Intl.NumberFormat(kind.locale ?? "en-US", { + style: "currency", + currency: kind.currency ?? "USD", + }).format(value / divisor); + } + case "duration": { + const unit = kind.unit ?? "s"; + const seconds = unit === "ms" ? value / 1000 + : unit === "m" ? value * 60 + : unit === "h" ? value * 3600 + : value; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + if (unit === "ms" && seconds < 1) return `${Math.round(value)}ms`; + if (s > 0 || kind.showZero) return `${s}s`; + return "0s"; + } + case "datetime": { + const d = new Date(value); + const style = kind.style ?? "short"; + const locale = kind.locale ?? "en-US"; + if (style === "iso") return d.toISOString(); + if (style === "relative") return formatRelative(value, locale); + if (style === "long") return d.toLocaleString(locale, { + dateStyle: "medium", + timeStyle: "short", + }); + return d.toLocaleDateString(locale, { month: "short", day: "numeric" }); + } + case "percent": { + const source = kind.source ?? "fraction"; + const decimals = kind.decimals ?? 1; + const pct = source === "basis" ? value / 100 + : source === "whole" ? value + : value * 100; + return `${pct.toFixed(decimals)}%`; + } + } +} + +export function formatDelta(current: number, previous: number): AnalyticsChartDelta { + if (!Number.isFinite(current) || !Number.isFinite(previous)) return { pct: null, sign: "na" }; + if (previous === 0) return current === 0 ? { pct: 0, sign: "flat" } : { pct: null, sign: "na" }; + const pct = Number((((current - previous) / previous) * 100).toFixed(1)); + const sign = pct > 0 ? "up" : pct < 0 ? "down" : "flat"; + return { pct, sign }; +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/index.ts b/packages/dashboard-ui-components/src/components/analytics-chart/index.ts new file mode 100644 index 0000000000..c5cc60a092 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/index.ts @@ -0,0 +1,75 @@ +export { AnalyticsChart, type AnalyticsChartProps, type Margin } from "./analytics-chart"; +export { + DefaultAnalyticsChartTooltip, + TrendPill, + type AnalyticsChartTooltipContext, + type AnalyticsChartTooltipLayerView, + type AnalyticsChartTooltipSegmentRow, + type DefaultAnalyticsChartTooltipProps, +} from "./default-analytics-chart-tooltip"; +export { + formatDelta, + formatValue, + DEFAULT_FORMAT_KIND, + FORMAT_KIND_TYPES, +} from "./format"; +export { + ANALYTICS_CHART_DEFAULT_PALETTE, + buildRampColors, + buildSegmentThemeMap, + resolveAnalyticsChartPalette, +} from "./palette"; +export { + ANALYTICS_CHART_DEFAULT_LAYERS, + ANALYTICS_CHART_DEFAULT_STATE, + computeLocalInProgressIdx, + EMPTY_MATRIX, + EMPTY_SERIES, + findAnnotationsLayer, + findCompareLayer, + findLayerById, + findPrimaryLayer, + isAnalyticsChartDataLayer, + isTimeseriesState, + patchLayerById, + resolveDataLayerStyle, + setLayerById, + STROKE_DASHARRAY, + type ResolvedDataLayerStyle, +} from "./state"; +export { + ANALYTICS_CHART_DEFAULT_STRINGS, + resolveAnalyticsChartStrings, + type AnalyticsChartStrings, +} from "./strings"; +export { + pointValue, + type AnalyticsChartAnnotationsLayer, + type AnalyticsChartAreaLayer, + type AnalyticsChartBarLayer, + type AnalyticsChartDataLayer, + type AnalyticsChartDelta, + type AnalyticsChartLayer, + type AnalyticsChartLayers, + type AnalyticsChartLayerType, + type AnalyticsChartLineLayer, + type AnalyticsChartPalette, + type AnalyticsChartPieProps, + type AnalyticsChartPieState, + type AnalyticsChartSegmentRamp, + type AnalyticsChartSeries, + type AnalyticsChartState, + type AnalyticsChartStrokeStyle, + type AnalyticsChartTimeseriesState, + type AnalyticsChartView, + type Annotation, + type FormatKind, + type FormatKindCurrency, + type FormatKindDatetime, + type FormatKindDuration, + type FormatKindNumeric, + type FormatKindPercent, + type FormatKindShort, + type FormatKindType, + type Point, +} from "./types"; diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/palette.ts b/packages/dashboard-ui-components/src/components/analytics-chart/palette.ts new file mode 100644 index 0000000000..60647c401c --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/palette.ts @@ -0,0 +1,68 @@ +import type { + AnalyticsChartPalette, + AnalyticsChartSegmentRamp, + AnalyticsChartSeries, +} from "./types"; + +export const ANALYTICS_CHART_DEFAULT_PALETTE: AnalyticsChartPalette = { + primary: { + kind: "procedural", + hue: 220, + sat: 78, + shadeRangeLight: [28, 62], + shadeRangeDark: [52, 82], + }, + compare: { + kind: "procedural", + hue: 38, + sat: 92, + shadeRangeLight: [28, 62], + shadeRangeDark: [52, 82], + }, +}; + +export function resolveAnalyticsChartPalette( + override: Partial | undefined, +): AnalyticsChartPalette { + if (!override) return ANALYTICS_CHART_DEFAULT_PALETTE; + return { + primary: override.primary ?? ANALYTICS_CHART_DEFAULT_PALETTE.primary, + compare: override.compare ?? ANALYTICS_CHART_DEFAULT_PALETTE.compare, + }; +} + +/** Expand a ramp into N colors for a given theme. */ +export function buildRampColors( + ramp: AnalyticsChartSegmentRamp, + count: number, + theme: "light" | "dark", +): string[] { + if (ramp.kind === "explicit") { + const list = theme === "light" ? ramp.light : ramp.dark; + if (list.length === 0) return Array.from({ length: count }, () => "#888"); + return Array.from( + { length: count }, + (_, i) => list[i < list.length ? i : list.length - 1]!, + ); + } + const range = theme === "light" ? ramp.shadeRangeLight : ramp.shadeRangeDark; + return Array.from({ length: count }, (_, i) => { + const t = count <= 1 ? 0.5 : i / (count - 1); + const l = range[0] + t * (range[1] - range[0]); + return `hsl(${ramp.hue} ${ramp.sat}% ${l.toFixed(1)}%)`; + }); +} + +/** Per-segment light/dark colors for `ChartConfig.theme` (SVG only; siblings use inline vars). */ +export function buildSegmentThemeMap( + series: readonly AnalyticsChartSeries[], + ramp: AnalyticsChartSegmentRamp, +): Record { + const light = buildRampColors(ramp, series.length, "light"); + const dark = buildRampColors(ramp, series.length, "dark"); + const out: Record = {}; + series.forEach((s, i) => { + out[s.key] = { light: light[i]!, dark: dark[i]! }; + }); + return out; +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/render-data-series.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/render-data-series.tsx new file mode 100644 index 0000000000..8d8ffd0616 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/render-data-series.tsx @@ -0,0 +1,169 @@ +import type { ReactNode } from "react"; +import { Area, Bar, Line } from "recharts"; +import type { + AnalyticsChartDataLayer, + AnalyticsChartSeries, +} from "./types"; + +/** Area layers use fill-only `` plus `` for the top edge (not Recharts' closed-path stroke). */ +export type RenderDataSeriesArgs = { + layer: AnalyticsChartDataLayer, + segmented: boolean, + segmentSeries: readonly AnalyticsChartSeries[], + segKey: (segKey: string) => string, + stackId: string, + strokeDasharray: string | undefined, + segmentedStrokeDasharray: string | undefined, + fillOpacity: number, + segmentedFillOpacity: number, + baseOpacity?: number, + strokeWidth: number, + segmentedStrokeWidth: number, + inProgressKeys: { solid: string, dashed: string } | null, +}; + +/** Return value must be spread into `` as siblings — do not wrap in ``. */ +export function renderDataSeries(args: RenderDataSeriesArgs): ReactNode[] { + const { + layer, + segmented, + segmentSeries, + segKey, + stackId, + strokeDasharray, + segmentedStrokeDasharray, + fillOpacity, + segmentedFillOpacity, + baseOpacity = 1, + strokeWidth, + segmentedStrokeWidth, + inProgressKeys, + } = args; + + const nodes: ReactNode[] = []; + + if (segmented) { + segmentSeries.forEach((s, sIdx) => { + const key = segKey(s.key); + const nodeKey = `${layer.id}_seg_${s.key}`; + if (layer.type === "bar") { + const isTop = sIdx === segmentSeries.length - 1; + nodes.push( + , + ); + } else if (layer.type === "area") { + nodes.push( + , + ); + } else { + nodes.push( + , + ); + } + }); + return nodes; + } + + if (layer.type === "bar") { + nodes.push( + , + ); + return nodes; + } + + if (layer.type === "area") { + nodes.push( + , + ); + } + + if (inProgressKeys) { + nodes.push( + , + ); + nodes.push( + , + ); + } else { + nodes.push( + , + ); + } + + return nodes; +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/state.ts b/packages/dashboard-ui-components/src/components/analytics-chart/state.ts new file mode 100644 index 0000000000..22a3faf534 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/state.ts @@ -0,0 +1,147 @@ +import { DEFAULT_FORMAT_KIND } from "./format"; +import type { + AnalyticsChartAnnotationsLayer, + AnalyticsChartDataLayer, + AnalyticsChartLayer, + AnalyticsChartLayers, + AnalyticsChartLayerType, + AnalyticsChartSeries, + AnalyticsChartState, + AnalyticsChartStrokeStyle, + AnalyticsChartTimeseriesState, +} from "./types"; + +export const STROKE_DASHARRAY: Record = { + solid: undefined, + dashed: "5 4", + dotted: "1 4", +}; + +export const EMPTY_SERIES: readonly AnalyticsChartSeries[] = []; +export const EMPTY_MATRIX: readonly (readonly number[])[] = []; + +/** Generic non-segmented defaults; demos swap in segment data. */ +export const ANALYTICS_CHART_DEFAULT_LAYERS: AnalyticsChartLayers = [ + { + id: "primary", + kind: "primary", + label: "Current", + visible: true, + color: "#2563eb", + segmented: false, + type: "area", + strokeStyle: "solid", + fillOpacity: 0.22, + inProgressFromIndex: null, + }, + { + id: "compare", + kind: "compare", + label: "Previous period", + visible: true, + color: "#f59e0b", + segmented: false, + type: "line", + strokeStyle: "dashed", + inProgressFromIndex: null, + }, + { id: "annotations", kind: "annotations", label: "Annotations", visible: true, color: "#f59e0b" }, +]; + +export const ANALYTICS_CHART_DEFAULT_STATE: AnalyticsChartState = { + view: "timeseries", + layers: ANALYTICS_CHART_DEFAULT_LAYERS, + xFormatKind: DEFAULT_FORMAT_KIND.datetime, + yFormatKind: DEFAULT_FORMAT_KIND.short, + showGrid: true, + showXAxis: true, + showYAxis: true, + zoomRange: null, + pinnedIndex: null, +}; + +export function findPrimaryLayer(layers: AnalyticsChartLayers): AnalyticsChartDataLayer | undefined { + const l = layers.find((x) => x.kind === "primary"); + return l as AnalyticsChartDataLayer | undefined; +} +export function findCompareLayer(layers: AnalyticsChartLayers): AnalyticsChartDataLayer | undefined { + const l = layers.find((x) => x.kind === "compare"); + return l as AnalyticsChartDataLayer | undefined; +} +export function findAnnotationsLayer(layers: AnalyticsChartLayers): AnalyticsChartAnnotationsLayer | undefined { + const l = layers.find((x) => x.kind === "annotations"); + return l as AnalyticsChartAnnotationsLayer | undefined; +} + +export function findLayerById( + layers: AnalyticsChartLayers, + id: string, +): AnalyticsChartLayer | undefined { + return layers.find((l) => l.id === id); +} + +export function isAnalyticsChartDataLayer(l: AnalyticsChartLayer): l is AnalyticsChartDataLayer { + return l.kind === "primary" || l.kind === "compare"; +} + +export function isTimeseriesState( + state: AnalyticsChartState, +): state is AnalyticsChartTimeseriesState { + return state.view === "timeseries"; +} + +/** Replace a single layer (looked up by id) with a new layer object. */ +export function setLayerById( + layers: AnalyticsChartLayers, + id: string, + next: AnalyticsChartLayer, +): AnalyticsChartLayers { + return layers.map((l) => (l.id === id ? next : l)); +} + +/** Shallow-patch fields on a layer by id. The patch type is deliberately + * loose — callers are trusted to supply only fields the layer's + * `kind`/`type` actually owns. */ +export function patchLayerById( + layers: AnalyticsChartLayers, + id: string, + patch: Record, +): AnalyticsChartLayers { + return layers.map((l) => (l.id === id ? ({ ...l, ...patch } as AnalyticsChartLayer) : l)); +} + +export type ResolvedDataLayerStyle = { + color: string, + type: AnalyticsChartLayerType, + strokeStyle: AnalyticsChartStrokeStyle, + fillOpacity: number, +}; + +export function resolveDataLayerStyle( + layer: AnalyticsChartDataLayer, +): ResolvedDataLayerStyle { + return { + color: layer.color, + type: layer.type, + // Bars have no stroke pattern — default to solid for the underline. + strokeStyle: layer.type === "bar" ? "solid" : layer.strokeStyle, + // Lines have no fill — default to 0 so gradient overlays sit flat. + fillOpacity: layer.type === "line" ? 0 : layer.fillOpacity, + }; +} + +/** Translate a layer's absolute `inProgressFromIndex` into a local index + * inside the visible window. Returns `null` when the marker sits beyond + * the visible window, `0` when it sits before the window (whole window + * is dashed), or the clamped local index otherwise. */ +export function computeLocalInProgressIdx( + absIdx: number | null | undefined, + visibleStart: number, + visibleEnd: number, +): number | null { + if (absIdx == null) return null; + const local = absIdx - visibleStart; + if (local >= visibleEnd - visibleStart + 1) return null; // beyond window + if (local < 0) return 0; // before window — whole window is dashed + return local; +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/strings.ts b/packages/dashboard-ui-components/src/components/analytics-chart/strings.ts new file mode 100644 index 0000000000..65a3fb1eff --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/strings.ts @@ -0,0 +1,72 @@ +export type AnalyticsChartStrings = { + /** Reset-zoom badge in the top-right corner when `state.zoomRange` is set. */ + resetZoom: string, + /** Header label above the timestamps in the live range-brush popup. */ + rangeLabel: string, + /** Formatted day-count suffix used by the brush + action-bar. */ + daysShort: (days: number) => string, + /** "Zoom in" button inside the committed-range action bar. */ + zoomIn: string, + /** "Annotate" button inside the committed-range action bar. */ + annotate: string, + /** Placeholder for the annotation label input. */ + annotationPlaceholder: string, + /** `aria-label` for the annotation label input. */ + annotationLabelAria: string, + /** Save button in the annotation form. */ + save: string, + /** Cancel button in the annotation form. */ + cancel: string, + /** `aria-label` for the X button that clears the committed range. */ + clearSelection: string, + /** Pinned badge shown in the tooltip header when the tooltip is pinned. */ + pinnedBadge: string, + /** "Δ vs prev" row label in the tooltip (both segmented and flat modes). */ + deltaVsPrev: string, + /** Suffix appended to a layer's label in the per-layer totals section + * (e.g. "Sign-ups" → "Sign-ups total"). */ + layerTotalSuffix: string, + /** Hint row shown in the tooltip while it is floating (not pinned). */ + hintClickToPin: string, + /** Hint row shown in the tooltip while it is pinned. */ + hintClickAnywhereUnpin: string, + /** Center-stat heading shown in the pie when no segment is hovered. */ + pieTotalCenter: string, + /** Label on the TrendPill in the pie center. */ + pieVsPrev: string, + /** `aria-label` for the PieChart SVG. */ + pieAriaLabel: (ctx: { segmentCount: number, windowDays: number }) => string, + /** Percentage-of-total caption shown under an active pie slice. */ + piePercentOfTotal: (pct: number) => string, +}; + +export const ANALYTICS_CHART_DEFAULT_STRINGS: AnalyticsChartStrings = { + resetZoom: "Reset zoom", + rangeLabel: "Range", + daysShort: (days) => `${days}d`, + zoomIn: "Zoom in", + annotate: "Annotate", + annotationPlaceholder: "Label this range…", + annotationLabelAria: "Annotation label", + save: "Save", + cancel: "Cancel", + clearSelection: "Clear selection", + pinnedBadge: "Pinned", + deltaVsPrev: "Δ vs prev", + layerTotalSuffix: " total", + hintClickToPin: "Click to pin this point", + hintClickAnywhereUnpin: "Click anywhere · Esc\u00A0to unpin", + pieTotalCenter: "Total", + pieVsPrev: "vs prev", + pieAriaLabel: ({ segmentCount, windowDays }) => + `${segmentCount} segment share-of-total over the visible ${windowDays}-day range`, + piePercentOfTotal: (pct) => `${(pct * 100).toFixed(1)}% of total`, +}; + +/** Shallow merge — every field is a primitive or a flat function. */ +export function resolveAnalyticsChartStrings( + override: Partial | undefined, +): AnalyticsChartStrings { + if (!override) return ANALYTICS_CHART_DEFAULT_STRINGS; + return { ...ANALYTICS_CHART_DEFAULT_STRINGS, ...override }; +} diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/types.ts b/packages/dashboard-ui-components/src/components/analytics-chart/types.ts new file mode 100644 index 0000000000..a8f81fe65f --- /dev/null +++ b/packages/dashboard-ui-components/src/components/analytics-chart/types.ts @@ -0,0 +1,208 @@ +/** Time-series point. `values` is keyed by layer id. */ +export type Point = { + ts: number, + values: Record, +}; + +/** Missing or non-finite values become 0. */ +export function pointValue(p: Point, id: string): number { + const v = p.values[id]; + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} + +export type Annotation = { + index: number, + label: string, + description: string, +}; + +/** Breakdown category definition — `{ key, label }` tuple. */ +export type AnalyticsChartSeries = { + key: string, + label: string, +}; + +export type FormatKindType = + | "numeric" + | "short" + | "currency" + | "duration" + | "datetime" + | "percent"; + +export type FormatKindNumeric = { + type: "numeric", + /** Locale used for grouping and digit separators. Defaults to "en-US". */ + locale?: string, + /** Fixed decimal places (0-4). Defaults to 0. */ + decimals?: number, +}; +export type FormatKindShort = { + type: "short", + /** Decimal places after the unit suffix (1.2K vs 1.20K). Defaults to 1. */ + precision?: number, + locale?: string, +}; +export type FormatKindCurrency = { + type: "currency", + /** ISO 4217 code. Defaults to "USD". */ + currency?: string, + /** Divisor applied before formatting — e.g. 100 for cents → dollars. Defaults to 1. */ + divisor?: number, + locale?: string, +}; +export type FormatKindDuration = { + type: "duration", + /** Source unit of the input value. Defaults to "s". */ + unit?: "ms" | "s" | "m" | "h", + /** Show the smallest unit even when zero. Defaults to false. */ + showZero?: boolean, +}; +export type FormatKindDatetime = { + type: "datetime", + /** Render style. Defaults to "short". */ + style?: "short" | "long" | "iso" | "relative", + locale?: string, +}; +export type FormatKindPercent = { + type: "percent", + /** How to interpret the input value: + * - "fraction" → 0..1 → multiply by 100 (default) + * - "basis" → 0..10000 → divide by 100 + * - "whole" → 0..100 → no scaling + */ + source?: "fraction" | "basis" | "whole", + /** Decimal places. Defaults to 1. */ + decimals?: number, +}; + +export type FormatKind = + | FormatKindNumeric + | FormatKindShort + | FormatKindCurrency + | FormatKindDuration + | FormatKindDatetime + | FormatKindPercent; + +export type AnalyticsChartView = "timeseries" | "pie"; +export type AnalyticsChartLayerType = "line" | "area" | "bar"; +export type AnalyticsChartStrokeStyle = "solid" | "dashed" | "dotted"; + +type AnalyticsChartDataLayerCommon = { + id: string, + kind: "primary" | "compare", + label: string, + visible: boolean, + color: string, + segmented: boolean, + /** Per-day per-category values. Outer index is the day (matches the + * chart data array index); inner index is the category (matches + * `segmentSeries`). Rows should sum to `point.values[layer.id]`. */ + segments?: readonly (readonly number[])[], + /** Breakdown category definitions ordered to match the inner index of + * `segments`. */ + segmentSeries?: readonly AnalyticsChartSeries[], + /** Absolute index into the full data array at which this layer's values + * become "in progress" (incomplete and still changing). Points from this + * index onward render with a dashed overlay so users don't panic at a + * half-filled bucket. Applies to line and area rendering only. */ + inProgressFromIndex?: number | null, +}; +export type AnalyticsChartLineLayer = AnalyticsChartDataLayerCommon & { + type: "line", + strokeStyle: AnalyticsChartStrokeStyle, +}; +export type AnalyticsChartAreaLayer = AnalyticsChartDataLayerCommon & { + type: "area", + strokeStyle: AnalyticsChartStrokeStyle, + fillOpacity: number, +}; +export type AnalyticsChartBarLayer = AnalyticsChartDataLayerCommon & { + type: "bar", + fillOpacity: number, +}; + +export type AnalyticsChartDataLayer = + | AnalyticsChartLineLayer + | AnalyticsChartAreaLayer + | AnalyticsChartBarLayer; + +export type AnalyticsChartAnnotationsLayer = { + id: string, + kind: "annotations", + label: string, + visible: boolean, + color: string, +}; + +export type AnalyticsChartLayer = + | AnalyticsChartDataLayer + | AnalyticsChartAnnotationsLayer; + +export type AnalyticsChartLayers = readonly AnalyticsChartLayer[]; + +export type AnalyticsChartTimeseriesState = { + view: "timeseries", + layers: AnalyticsChartLayers, + /** Format applied to every x-axis value. Defaults to `datetime / short`. */ + xFormatKind: FormatKind, + /** Format applied to every y-axis value. Defaults to `short`. */ + yFormatKind: FormatKind, + showGrid: boolean, + showXAxis: boolean, + showYAxis: boolean, + zoomRange: [number, number] | null, + pinnedIndex: number | null, +}; +export type AnalyticsChartPieState = { + view: "pie", + layers: AnalyticsChartLayers, + xFormatKind: FormatKind, + yFormatKind: FormatKind, +}; +export type AnalyticsChartState = + | AnalyticsChartTimeseriesState + | AnalyticsChartPieState; + +export type AnalyticsChartDelta = { + pct: number | null, + sign: "up" | "down" | "flat" | "na", +}; + +/** Grouped pie config — collapses the formerly-separate + * `pieInnerRadius` / `pieOuterRadius` / `pieCompareInnerRadius` / + * `pieCompareOuterRadius` / `pieContainerClassName` props into one object. */ +export type AnalyticsChartPieProps = { + innerRadius?: number, + outerRadius?: number, + compareInnerRadius?: number, + compareOuterRadius?: number, + className?: string, +}; + +export type AnalyticsChartSegmentRamp = + | { + kind: "procedural", + /** HSL hue (0-360). */ + hue: number, + /** HSL saturation percent (0-100). */ + sat: number, + /** Lightness range `[start, end]` for the light theme (0-100). */ + shadeRangeLight: [number, number], + /** Lightness range `[start, end]` for the dark theme (0-100). */ + shadeRangeDark: [number, number], + } + | { + kind: "explicit", + /** Concrete light-theme color list. Indexed by segment. */ + light: readonly string[], + /** Concrete dark-theme color list. Indexed by segment. */ + dark: readonly string[], + }; + +export type AnalyticsChartPalette = { + /** Color ramp for the primary data layer (current period). */ + primary: AnalyticsChartSegmentRamp, + /** Color ramp for the compare data layer (previous period). */ + compare: AnalyticsChartSegmentRamp, +}; From dec11b44430fcde2a62306aa347e0f959602e562 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 8 Apr 2026 14:35:48 -0700 Subject: [PATCH 22/30] Refactor analytics chart components and update imports - Renamed and refactored various components related to analytics charts, including changing `HeroAnalyticsWidget` to `AnalyticsChartWidget` and `HeroInChartPill` to `AnalyticsInChartPill` for improved clarity. - Updated import paths to utilize `@stackframe/dashboard-ui-components` for better modularity and maintainability. - Removed unnecessary comments and cleaned up code for enhanced readability. - Adjusted related tests and documentation to reflect the new component names and structures. --- .../[projectId]/(overview)/metrics-page.tsx | 112 ++++-------------- .../demo/analytics-chart-state-panel.tsx | 2 +- .../demo/analytics-chart-usage-viewer.tsx | 2 +- .../chart-demo/demo/fixtures.ts | 9 +- .../chart-demo/demo/panels.tsx | 16 +-- .../chart-demo/page-client.tsx | 4 +- .../endpoints/api/v1/internal-metrics.test.ts | 2 +- claude/CLAUDE-KNOWLEDGE.md | 6 +- .../analytics-chart/analytics-chart-pie.tsx | 12 +- .../analytics-chart/analytics-chart.tsx | 19 ++- .../default-analytics-chart-tooltip.tsx | 2 +- packages/dashboard-ui-components/src/index.ts | 2 + 12 files changed, 50 insertions(+), 138 deletions(-) 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 0978908668..79d1ad6160 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 @@ -44,8 +44,6 @@ import { } from "./line-chart"; import { MetricsLoadingFallback } from "./metrics-loading"; -// ── Chart configs ──────────────────────────────────────────────────────────── - const dailySignUpsConfig: LineChartDisplayConfig = { name: 'Daily Sign-Ups', chart: { @@ -56,8 +54,6 @@ const dailySignUpsConfig: LineChartDisplayConfig = { }, }; -// ── Helpers ─────────────────────────────────────────────────────────────────── - function formatUsdFromCents(cents: number): string { return `$${(cents / 100).toLocaleString(undefined, { maximumFractionDigits: 0 })}`; } @@ -117,8 +113,6 @@ function SetupAppPrompt({ ); } -// ── Hero analytics widget (stat pills + composed bar+line chart) ───────────── - type AnalyticsStatPill = { label: string, value: string, @@ -159,9 +153,9 @@ function StatCard({ ); } -type HeroChartMode = 'default' | 'dau' | 'visitors' | 'revenue'; +type AnalyticsChartMode = 'default' | 'dau' | 'visitors' | 'revenue'; -function HeroInChartPill({ +function AnalyticsInChartPill({ label, value, delta, @@ -227,7 +221,6 @@ function HeroInChartPill({ : "hover:bg-foreground/[0.03]" )} > - {/* Color dot */} - {/* Label + value stacked */}
{label} @@ -258,7 +250,7 @@ function HeroInChartPill({ ); } -function HeroAnalyticsWidget({ +function AnalyticsChartWidget({ composedData, dauStackedData, visitorsData, @@ -297,12 +289,9 @@ function HeroAnalyticsWidget({ projectId: string, compact?: boolean, }) { - // selectedMode is the user's "sticky" choice, set by click/keyboard. - // previewMode reflects an in-flight hover preview; while a hover is active, - // the chart shows previewMode, otherwise it falls back to selectedMode. - const [selectedMode, setSelectedMode] = useState('default'); - const [previewMode, setPreviewMode] = useState(null); - const [displayMode, setDisplayMode] = useState('default'); + 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); @@ -310,18 +299,15 @@ function HeroAnalyticsWidget({ const fadeInRaf2Ref = useRef(null); const FADE_OUT_MS = 140; - // Stable IDs so tab/tabpanel ARIA wiring is correct. - const tablistInstanceId = useRef(`hero-chart-tablist-${Math.random().toString(36).slice(2, 8)}`); + const tablistInstanceId = useRef(`analytics-chart-tablist-${Math.random().toString(36).slice(2, 8)}`); const tabpanelId = `${tablistInstanceId.current}-panel`; const dauTabId = `${tablistInstanceId.current}-tab-dau`; const visitorsTabId = `${tablistInstanceId.current}-tab-visitors`; const revenueTabId = `${tablistInstanceId.current}-tab-revenue`; - const activeMode: HeroChartMode = previewMode ?? selectedMode; + const activeMode: AnalyticsChartMode = previewMode ?? selectedMode; - // Switch the actual rendered chart with a fade transition. Idempotent: if - // the target mode is already displayed we noop. - const switchToMode = (mode: HeroChartMode) => { + const switchToMode = (mode: AnalyticsChartMode) => { if (mode === displayMode) return; if (fadeTimerRef.current != null) { clearTimeout(fadeTimerRef.current); @@ -331,7 +317,6 @@ function HeroAnalyticsWidget({ setDisplayMode(mode); setFadingOut(false); setFadingIn(true); - // Let the browser paint the new chart at opacity-0 first, then fade in fadeInRaf1Ref.current = requestAnimationFrame(() => { fadeInRaf2Ref.current = requestAnimationFrame(() => { setFadingIn(false); @@ -343,11 +328,9 @@ function HeroAnalyticsWidget({ }, FADE_OUT_MS); }; - // Whenever the active mode changes, drive the rendered displayMode to match. useEffect(() => { switchToMode(activeMode); - // switchToMode is intentionally not in deps — it's a stable ref-driven function. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- switchToMode closes over displayMode/fade state }, [activeMode]); useEffect(() => { @@ -364,9 +347,6 @@ function HeroAnalyticsWidget({ }; }, []); - // Hover preview: show the mode the cursor is over without changing the - // sticky selection. Mouse-leaving the pills row clears the preview and the - // chart snaps back to the user's clicked/keyboard-selected mode. const handleHoverPreview = (mode: 'dau' | 'visitors' | 'revenue') => { setPreviewMode(mode); }; @@ -375,8 +355,7 @@ function HeroAnalyticsWidget({ setPreviewMode(null); }; - // Click / Enter / Space / focus → sticky select. - const handleSelect = (mode: HeroChartMode) => { + const handleSelect = (mode: AnalyticsChartMode) => { setSelectedMode(mode); setPreviewMode(null); }; @@ -413,7 +392,6 @@ function HeroAnalyticsWidget({ return (
- {/* Outer stat cards row */}
3 ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3", @@ -423,7 +401,6 @@ function HeroAnalyticsWidget({ ))}
- {/* Chart card with in-card pills */} - {/* In-card pills row — `tablist` semantics so screen readers - understand this is a tab strip controlling the chart panel - below it. */}
- handleArrowNavigate('dau', direction)} />
- handleArrowNavigate('visitors', direction)} />
-
- {/* Chart area with fade transition */}
- {/* Icon badge */}
- {/* Subject */}
{email.subject}
- {/* Status pill */}
void }) { const includeAnonymous = false; const [timeRange, setTimeRange] = useState("30d"); @@ -982,8 +939,6 @@ export default function MetricsPage(props: { toSetup: () => void }) { ); } -// ── Metrics content ────────────────────────────────────────────────────────── - function MetricsContent({ includeAnonymous, timeRange, @@ -1016,7 +971,6 @@ function MetricsContent({ const recentEmails = email.recent_emails; const topReferrers = analytics.top_referrers; - // ── DAU split stacked data for sign-ups chart ───────────────────────────── const dauSplit = auth.daily_active_users_split; const dauStackedData = useMemo(() => { const dateSet = new Set([ @@ -1052,13 +1006,11 @@ function MetricsContent({ [dauStackedData], ); - // ── Email stacked data (ok/error/in_progress per day) ──────────────────── const emailStackedData = useMemo( () => email.daily_emails_by_status, [email.daily_emails_by_status], ); - // ── Composed chart data (visitors bars + revenue line) ─────────────────── const allComposedData = useMemo(() => { const dailyRev = analytics.daily_revenue; const dailyVis = analytics.daily_visitors; @@ -1099,7 +1051,6 @@ function MetricsContent({ return countries.slice(0, 3); }, [data.users_by_country]); - // ── Visitors hover chart data (page views with top countries) ───────────── const visitorsHoverData = useMemo(() => { if (!analyticsEnabled) { return []; @@ -1118,7 +1069,6 @@ function MetricsContent({ return filterStackedDatapointsByTimeRange(points, timeRange, customDateRange); }, [analytics.daily_page_views, timeRange, customDateRange, topCountries, analyticsEnabled]); - // ── Revenue hover chart data (new_cents + refund_cents) ─────────────────── const revenueHoverData = useMemo(() => { if (!paymentsEnabled) { return []; @@ -1133,8 +1083,7 @@ function MetricsContent({ return filterStackedDatapointsByTimeRange(points, timeRange, customDateRange); }, [analytics.daily_revenue, timeRange, customDateRange, paymentsEnabled]); - // ── Hero outer stats: MAUs, Total Emails sent, Session time ─────────────── - const heroOuterStats = useMemo(() => { + const analyticsOuterStats = useMemo(() => { const totalUsers = data.total_users; const mau = Math.min(auth.mau, totalUsers); const totalEmailsSent = email.emails_sent; @@ -1154,7 +1103,6 @@ function MetricsContent({ ]; }, [auth.mau, email.emails_sent, analytics.avg_session_seconds, data.total_users, analyticsEnabled]); - // ── In-chart pill values: Visitors and Revenue ──────────────────────────── const inChartPillValues = useMemo(() => { const latestDauPoint = dauStackedData.at(-1); const latestDau = latestDauPoint == null @@ -1181,10 +1129,8 @@ function MetricsContent({ const previousRevenueTotalCents = previousComposedWindow.reduce((sum, row) => sum + row.new_cents, 0); return { - // This pill is a point-in-time metric (latest day), not a range aggregate. dauTotal: formatCompact(latestDau), dauLabel: "Daily Active Users", - // DAU delta is day-over-day and independent from the selected time range. dauDelta: previousDau == null ? undefined : calculatePeriodDelta(latestDau, previousDau), visitorsTotal: analyticsEnabled ? formatCompact(visitorsTotalInRange) : "—", visitorsLabel: "Unique Visitors", @@ -1197,7 +1143,6 @@ function MetricsContent({ }; }, [allComposedData, composedData, dauStackedData, analyticsEnabled, paymentsEnabled]); - // ── Globe visibility ────────────────────────────────────────────────────── const gridContainerRef = useRef(null); const [gridContainerWidth, setGridContainerWidth] = useState(0); const [isLgViewport, setIsLgViewport] = useState(false); @@ -1218,7 +1163,6 @@ function MetricsContent({ }, []); useResizeObserver(gridContainerRef, (entry) => setGridContainerWidth(entry.contentRect.width)); - // Show the globe when the 5-column slot is wide enough to look good const GLOBE_MIN_WIDTH = 352.5; const globeColumnWidth = (() => { if (!gridContainerWidth) return 0; @@ -1227,9 +1171,9 @@ function MetricsContent({ return (availableWidth / 12) * 5 + gap * 4; })(); const shouldShowGlobe = isLgViewport && globeColumnWidth >= GLOBE_MIN_WIDTH; - const heroOuterStatsForLayout = useMemo(() => { + const analyticsOuterStatsForLayout = useMemo(() => { if (shouldShowGlobe) { - return heroOuterStats; + return analyticsOuterStats; } return [ @@ -1237,16 +1181,13 @@ function MetricsContent({ label: "Total Users", value: formatCompact(data.total_users), }, - ...heroOuterStats, + ...analyticsOuterStats, ]; - }, [shouldShowGlobe, heroOuterStats, data.total_users]); + }, [shouldShowGlobe, analyticsOuterStats, data.total_users]); return (
- {/* ────────────────────────────────────────────────────────────────────── - HERO — Globe + KPIs + Daily Active Users (stacked bar) - ────────────────────────────────────────────────────────────────────── */}
-
- {/* ────────────────────────────────────────────────────────────────────── - QUICK ACCESS — App shortcuts - ────────────────────────────────────────────────────────────────────── */} - {/* ────────────────────────────────────────────────────────────────────── - ROW 2 — Daily Sign-ups + Emails trend - ────────────────────────────────────────────────────────────────────── */}
- {/* ────────────────────────────────────────────────────────────────────── - ROW 3 — Breakdown - ────────────────────────────────────────────────────────────────────── */}
{ 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), // Mar 7, 2026 → ~Apr 5 + ts: Date.UTC(2026, 2, 7 + i), values: { signups: Math.max(0, Math.round(base + trend + wave)), previous: Math.max(0, Math.round(prev)), @@ -42,8 +42,6 @@ export function allocateByWeight(total: number, weights: number[]): number[] { const floors = raw.map((r) => Math.floor(r)); const base = floors.reduce((a, b) => a + b, 0); const remainder = total - base; - // Distribute the remainder one unit at a time to the segments with the - // largest fractional parts — Hamilton's method for apportioning seats. const order = raw .map((r, idx) => ({ idx, frac: r - Math.floor(r) })) .sort((a, b) => b.frac - a.frac); @@ -76,9 +74,6 @@ export const ANNOTATIONS: Annotation[] = [ { index: 24, label: "Exp", description: "A/B test launched — signup copy" }, ]; -/** Demo-specific initial state: the same layer union the reusable chart - * ships with, but pre-wired with the sign-ups breakdown matrix and an - * in-progress marker on "today" (the last day of the demo window). */ function wireDemoLayer(layer: AnalyticsChartLayer): AnalyticsChartLayer { if (layer.kind === "primary") { return { 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 index bab7a9fd4f..e5661c177c 100644 --- 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 @@ -21,11 +21,9 @@ import { formatValue, TrendPill, type FormatKind, -} from "../analytics-chart"; +} from "@stackframe/dashboard-ui-components"; import { TABLE_ROWS, type TableRow } from "./fixtures"; -// Re-export TrendPill from the demo barrel so demo files never reach into -// analytics-chart/ directly for this primitive. export { TrendPill }; export function SectionHeading({ @@ -59,10 +57,6 @@ export function SectionHeading({ ); } -/** Unified sparkline. Replaces the former MiniSparkline (wide + area) and - * RowSparkline (tight + line-only) via a single prop-driven SVG. Uses one - * `` with CSS custom properties + Tailwind arbitrary variants so - * light/dark colors resolve without duplicating DOM. */ export function Sparkline({ values, width = 90, @@ -101,14 +95,9 @@ export function Sparkline({ .join(" "); const viewBox = `0 0 ${width} ${height}`; const style = { "--s-l": stroke, "--s-d": strokeDark } as CSSProperties; - // For the area variant we need a closed path. Building it manually from - // the points string keeps the single-polyline pattern for the line and - // adds an explicit `` underneath for the gradient fill. 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; - // useId gives a stable unique id across SSR/CSR without the collision risk - // of Math.random. The colon React uses gets escaped for CSS selectors. const gradientId = `spark-${useId().replace(/:/g, "")}`; return ( @@ -216,9 +205,6 @@ export function KpiBlock({ } export function FormatterPanel() { - // Each row supplies its own input value because the variants interpret - // numbers differently (currency wants cents, duration wants seconds / ms, - // percent wants a fraction, datetime wants a timestamp). 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" }, 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 index 197a9b1782..1645d5aff3 100644 --- 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 @@ -13,7 +13,7 @@ import { pointValue, type AnalyticsChartState, type Annotation, -} from "./analytics-chart"; +} from "@stackframe/dashboard-ui-components"; import { AnalyticsChartEventsPanel, type AnalyticsChartLabEvent, @@ -166,8 +166,6 @@ export default function PageClient() { onReset={resetLabState} dataLength={SERIES.length} /> - {/* Card + title row + chart composed inline. Consumers wrap - AnalyticsChart in their own card as they see fit. */} (() => { - const config: ChartConfig = {}; + const chartConfig = useMemo(() => { + const config: DesignChartConfig = {}; canonicalSeries.forEach((s, sIdx) => { config[s.key] = { label: s.label, @@ -193,7 +193,7 @@ export function AnalyticsChartPie({
- + )} - +
diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx index 898126977a..c8b06eb546 100644 --- a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx +++ b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx @@ -1,11 +1,11 @@ "use client"; -import { DesignButton } from "@/components/design-components"; -import { cn } from "@/components/ui"; +import { cn } from "@stackframe/stack-ui"; +import { DesignButton } from "../button"; import { - type ChartConfig, - ChartContainer, -} from "@/components/ui/chart"; + type DesignChartConfig, + DesignChartContainer, +} from "../chart-container"; import { CartesianGrid, ComposedChart, @@ -472,10 +472,10 @@ export function AnalyticsChart({ compareInProgressLocalIdx, ]); - const chartConfig = useMemo(() => { + const chartConfig = useMemo(() => { const primaryLabel = primaryLayer?.label ?? ""; const compareLabel = compareLayer?.label ?? ""; - const config: ChartConfig = {}; + const config: DesignChartConfig = {}; if (primaryLayer) { config[primaryLayer.id] = { label: primaryLabel, color: primaryColor }; if (primaryHasInProgress) { @@ -711,7 +711,7 @@ export function AnalyticsChart({ role="img" aria-label={`${chartAriaLabel} over the visible ${data.length}-day range. Use arrow keys to move the cursor, Enter to pin, Escape to release. Click and drag to select a range.`} > - @@ -824,7 +824,7 @@ export function AnalyticsChart({ : null, })} - + {activeIndex != null && activePoint && !brushing && ( - - {/* 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) @@ -642,11 +851,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) @@ -664,6 +881,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) ──────────────────────────────────────── @@ -697,20 +1006,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: @@ -734,17 +1041,51 @@ 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. + + [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. `, diff --git a/apps/backend/src/lib/ai/tools/sql-query.ts b/apps/backend/src/lib/ai/tools/sql-query.ts index fa6c5f7800..0b3c6e14fd 100644 --- a/apps/backend/src/lib/ai/tools/sql-query.ts +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -5,19 +5,21 @@ import { z } from "zod"; 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: "Run a read-only ClickHouse SQL query against the project's analytics database for INSPECTION. Only SELECT queries are allowed. Project filtering is automatic. Results are capped at 50 rows for your context — always include a LIMIT clause and prefer aggregates (count, sum, min, max, avg, quantile, GROUP BY) over SELECT *.", 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 (≤20 for row samples)."), }), execute: async ({ query }: { query: string }) => { const client = getClickhouseExternalClient(); @@ -37,10 +39,17 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI }) .then(async (resultSet) => { const rows = await resultSet.json[]>(); + const truncated = rows.length > MAX_ROWS_FOR_AI; + const returnedRows = truncated ? rows.slice(0, MAX_ROWS_FOR_AI) : rows; return { success: true as const, - rowCount: rows.length, - result: rows, + 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, }; }) .catch((error: unknown) => ({ 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 79d1ad6160..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 @@ -18,7 +18,7 @@ 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 { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, type ElementType } from "react"; +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"; @@ -299,11 +299,11 @@ function AnalyticsChartWidget({ const fadeInRaf2Ref = useRef(null); const FADE_OUT_MS = 140; - const tablistInstanceId = useRef(`analytics-chart-tablist-${Math.random().toString(36).slice(2, 8)}`); - const tabpanelId = `${tablistInstanceId.current}-panel`; - const dauTabId = `${tablistInstanceId.current}-tab-dau`; - const visitorsTabId = `${tablistInstanceId.current}-tab-visitors`; - const revenueTabId = `${tablistInstanceId.current}-tab-revenue`; + 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; 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..958d2ee265 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,22 @@ "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 { 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 +84,8 @@ export default function PageClient() { 0; const [isChatOpen, setIsChatOpen] = useState(!hasSource); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -129,6 +127,77 @@ function DashboardDetailContent({ const [savedTsxSource, setSavedTsxSource] = useState(tsxSource); 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 +224,17 @@ 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") { + setCurrentTsxSource(toolCall.args.content); + } }, []); const handleSaveDashboard = useCallback(async () => { @@ -212,6 +282,8 @@ function DashboardDetailContent({ onBack={handleBack} onEditToggle={handleEditToggle} onNavigate={handleNavigate} + onRuntimeError={handleDashboardRuntimeError} + onWidgetSelected={handleWidgetSelected} isChatOpen={isChatOpen} /> ) : ( @@ -238,7 +310,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 +357,19 @@ 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} + composerAttachments + onComposerReady={handleComposerReady} />
@@ -311,78 +396,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/components/assistant-ui/image-attachment-adapter.ts b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts new file mode 100644 index 0000000000..3714c9b61c --- /dev/null +++ b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts @@ -0,0 +1,51 @@ +import { + type AttachmentAdapter, + type CompleteAttachment, + type PendingAttachment, +} from "@assistant-ui/react"; +import { + MAX_IMAGE_BYTES_PER_FILE, + MAX_IMAGE_MB_PER_FILE, +} from "@stackframe/stack-shared/dist/ai/image-limits"; +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 { + if (state.file.size > MAX_IMAGE_BYTES_PER_FILE) { + throw new Error( + `"${state.file.name}" is larger than ${MAX_IMAGE_MB_PER_FILE}MB.`, + ); + } + 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/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index 73392e502d..1aff274b68 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -1,3 +1,6 @@ +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 { ActionBarPrimitive, @@ -5,59 +8,89 @@ import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, + useComposer, + useComposerRuntime, + useMessage, } 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_BYTES_PER_FILE, + 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); -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); + +function useComposerAttachmentsEnabled() { + return useContext(ComposerAttachmentsEnabledContext); +} -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, + composerAttachments?: boolean, +}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, composerAttachments = false }) => { return ( - + - + - - - - - -
- + > + + + + + +
+ -
- - -
- - + + +
+ + + ); }; @@ -123,16 +156,306 @@ 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; +} + +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 = () => { + if (composerRuntime.getState().attachments.length >= MAX_IMAGES_PER_MESSAGE) 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 oversized = selected.filter((file) => file.size > MAX_IMAGE_BYTES_PER_FILE); + const valid = selected.filter((file) => file.size <= MAX_IMAGE_BYTES_PER_FILE); + + if (oversized.length > 0) { + toast({ + variant: "destructive", + description: + oversized.length === 1 + ? `"${oversized[0].name}" is larger than ${MAX_IMAGE_MB_PER_FILE}MB and was skipped.` + : `${oversized.length} images exceeded the ${MAX_IMAGE_MB_PER_FILE}MB limit and were skipped.`, + }); + } + + if (picked.length > remaining) { + toast({ + description: `Only ${MAX_IMAGES_PER_MESSAGE} images per message — extras ignored.`, + }); + } + + 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 +495,14 @@ const ComposerAction: FC = () => { const UserMessage: FC = () => { return ( +
-
- -
+ +
+ +
+
@@ -202,17 +528,24 @@ const UserActionBar: FC = () => { }; const EditComposer: FC = () => { + const attachmentsEnabled = useComposerAttachmentsEnabled(); return ( + {attachmentsEnabled && } -
- - - - - - +
+
+ {attachmentsEnabled && } +
+
+ + + + + + +
); diff --git a/apps/dashboard/src/components/code-block.tsx b/apps/dashboard/src/components/code-block.tsx index ae32339e8a..8cff7dea4a 100644 --- a/apps/dashboard/src/components/code-block.tsx +++ b/apps/dashboard/src/components/code-block.tsx @@ -8,11 +8,12 @@ import type { ReactNode } from 'react'; import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash'; import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'; +import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql'; import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx'; import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; import { dark, prism } from 'react-syntax-highlighter/dist/esm/styles/prism'; -Object.entries({ tsx, bash, typescript, python }).forEach(([key, value]) => { +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..ee3ef4d041 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 @@ -78,7 +78,7 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ try { const userMessages: Array<{ role: string, content: string }> = [{ role: "user", content: prompt }]; - const { toolCall } = await generateDashboardCode(backendBaseUrl, currentUser, userMessages, { enabledAppIds, abortSignal: controller.signal }); + const { toolCall } = await generateDashboardCode(backendBaseUrl, currentUser, userMessages, { enabledAppIds, abortSignal: controller.signal, projectId }); if (controller.signal.aborted) return; 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..907ae386df 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,155 @@ 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, + onRuntimeError, + onWidgetSelected, isChatOpen, }: { artifact: DashboardArtifact, onBack?: () => void, onEditToggle?: () => void, onNavigate?: (path: string) => 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 +635,10 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onEditToggleRef.current = onEditToggle; const onNavigateRef = useRef(onNavigate); onNavigateRef.current = onNavigate; + const onRuntimeErrorRef = useRef(onRuntimeError); + onRuntimeErrorRef.current = onRuntimeError; + const onWidgetSelectedRef = useRef(onWidgetSelected); + onWidgetSelectedRef.current = onWidgetSelected; const user = useUser({ or: "redirect" }); const { resolvedTheme } = useTheme(); @@ -544,10 +735,41 @@ 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") { return; } }; diff --git a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx index 5966c70280..7d5b19b350 100644 --- a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx +++ b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx @@ -1,19 +1,57 @@ -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, + /** 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 +61,37 @@ export default function AssistantChat({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, + 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..ae4e43523f 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, ); @@ -159,13 +333,7 @@ export function createChatAdapter( return { async run({ messages, abortSignal }: ChatModelRunOptions) { 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]; @@ -215,19 +383,15 @@ export function createDashboardChatAdapter( onToolCall: (toolCall: ToolCallContent) => void, currentUser?: CurrentUser, enabledAppIds?: AppId[], + projectId?: string, ): ChatModelAdapter { return { - async run({ messages, abortSignal }: ChatModelRunOptions) { + async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { 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,17 +399,22 @@ 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."); } 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/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx index 739738d34c..1dc9d807f1 100644 --- a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx +++ b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart-pie.tsx @@ -6,11 +6,12 @@ import { Cell, Pie, PieChart } from "recharts"; import { TrendPill } from "./default-analytics-chart-tooltip"; import { formatDelta } from "./format"; import type { AnalyticsChartStrings } from "./strings"; -import type { - AnalyticsChartPieProps, - AnalyticsChartSeries, - FormatKind, - Point, +import { + cssIdent, + type AnalyticsChartPieProps, + type AnalyticsChartSeries, + type FormatKind, + type Point, } from "./types"; type SegmentColors = { @@ -138,14 +139,14 @@ export function AnalyticsChartPie({ const chartConfig = useMemo(() => { const config: DesignChartConfig = {}; canonicalSeries.forEach((s, sIdx) => { - config[s.key] = { + config[cssIdent(s.key)] = { label: s.label, theme: { light: segmentColors.primary.light[sIdx], dark: segmentColors.primary.dark[sIdx], }, }; - config[`compare-${s.key}`] = { + config[`compare-${cssIdent(s.key)}`] = { label: s.label, theme: { light: segmentColors.compare.light[sIdx], @@ -167,8 +168,8 @@ export function AnalyticsChartPie({ const startLabel = fmtValue(fullData[visibleStart]!.ts, xFormatKind); const endLabel = fmtValue(fullData[visibleEnd]!.ts, xFormatKind); - const outerData = legendRows.map((r) => ({ name: r.key, value: r.value, fill: r.fill })); - const innerData = legendRows.map((r) => ({ name: r.key, value: r.prevValue, fill: r.fillCompare })); + const outerData = legendRows.map((r) => ({ name: cssIdent(r.key), hoverKey: r.key, value: r.value, fill: r.fill })); + const innerData = legendRows.map((r) => ({ name: cssIdent(r.key), hoverKey: r.key, value: r.prevValue, fill: r.fillCompare })); const activeIdx = hoverKey ? legendRows.findIndex((r) => r.key === hoverKey) : -1; return ( @@ -223,7 +224,7 @@ export function AnalyticsChartPie({ key={`outer-${d.name}`} fill={`var(--color-${d.name})`} opacity={inactive ? 0.22 : 1} - onMouseEnter={() => setHoverKey(d.name)} + onMouseEnter={() => setHoverKey(d.hoverKey)} onMouseLeave={() => setHoverKey(null)} /> ); @@ -252,7 +253,7 @@ export function AnalyticsChartPie({ key={`inner-${d.name}`} fill={`var(--color-compare-${d.name})`} opacity={inactive ? 0.22 : 0.95} - onMouseEnter={() => setHoverKey(d.name)} + onMouseEnter={() => setHoverKey(d.hoverKey)} onMouseLeave={() => setHoverKey(null)} /> ); diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx index c8b06eb546..4cc9db8f5b 100644 --- a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx +++ b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx @@ -67,7 +67,7 @@ import type { FormatKind, Point, } from "./types"; -import { pointValue } from "./types"; +import { cssIdent, pointValue } from "./types"; import type { AnalyticsChartStrings } from "./strings"; /** Mirrors Recharts' internal `Margin` shape (not exported from their typings). */ @@ -78,8 +78,307 @@ export type Margin = { left?: number, }; +/** + * Props for {@link AnalyticsChart}. + * + * ## HOW TO REFERENCE THIS COMPONENT + * + * **In the custom dashboard sandbox** (AI-generated dashboard code): every + * export lives on the global `DashboardUI` object. Use + * `DashboardUI.AnalyticsChart`, `DashboardUI.ANALYTICS_CHART_DEFAULT_STATE`, + * `DashboardUI.pointValue`, etc. **Never** use bare identifiers like + * `` inside the sandbox — there is no module system and + * nothing is destructured into scope. Types (`AnalyticsChartState`, + * `Point`, …) don't exist at runtime anyway, so just drop the type + * annotations in sandbox code. + * + * **In a regular TypeScript app** (anywhere importing `@stackframe/dashboard-ui-components` + * directly): `import { AnalyticsChart, ANALYTICS_CHART_DEFAULT_STATE } from + * "@stackframe/dashboard-ui-components"` and use the bare name. Drop the + * `DashboardUI.` prefix from the examples below when doing so. + * + * ## Data shape in 30 seconds + * + * - `data` is `Point[]`. Each `Point` is `{ ts: number, values: Record }`. + * `ts` is a Unix millisecond timestamp; `values` is keyed by **layer id**. + * - `state` is fully controlled. Start from + * `DashboardUI.ANALYTICS_CHART_DEFAULT_STATE` (which ships with a + * `"primary"` + `"compare"` + `"annotations"` layer set) and override + * what you need. Do **not** hand-build the layer array from scratch. + * - For a breakdown (e.g. signups by region), add `segments` (a `number[][]` + * with one row per `data` point) and `segmentSeries` (the category labels) + * to the primary layer. Rows of `segments` should sum to the point's layer + * value. Same for the compare layer if you want a compared breakdown. + * + * ## **SCALE YOUR DATA BEFORE PUTTING IT ON A POINT** (critical) + * + * The chart renders every visible layer on a **single shared y-axis**. If + * two layers are on different orders of magnitude (e.g. revenue in cents + * `1_200_000` and sign-ups `450`), the smaller series collapses to a flat + * line at the bottom and the chart looks broken. You **must** normalize + * both metrics into the same range before building `data`. Rules of thumb: + * + * - **Cents → dollars / units**: divide money amounts by 100 (or 1000 for + * large currencies). Use a `valueFormatter` to render the original unit + * in tooltips so the UX still reads as "$12,543". + * - **Counts vs rates**: if one layer is a count (e.g. requests) and the + * other is a ratio (e.g. error rate 0.02), multiply the ratio by the + * count's scale (or by `max(counts)`) so both sit in the same band. + * - **Very different counts** (e.g. page views `120_000` vs sign-ups `430`): + * either divide the large metric (`views / 100`) or promote the small + * one to a rate (`signups / views * 1000`). Note the transformation in + * the layer `label` ("Sign-ups per 1k views") so it's honest. + * - **Pick the target range from the layer with the most natural scale** + * — usually the metric the user actually cares about — and normalize + * everything else into it. Don't normalize by fighting Recharts with + * `yDomain` hacks; do it in the data. + * + * If the two metrics truly can't share an axis (e.g. latency ms vs error + * count), render them as **two separate `AnalyticsChart` instances** stacked + * in the layout instead of jamming them into one chart. + * + * ## Example 1 — simplest possible: one area layer, no compare + * + * ```jsx + * // Sandbox dashboard code — everything prefixed with DashboardUI.* + * function Dashboard() { + * const data = [ + * { ts: Date.UTC(2026, 2, 1), values: { primary: 420 } }, + * { ts: Date.UTC(2026, 2, 2), values: { primary: 512 } }, + * { ts: Date.UTC(2026, 2, 3), values: { primary: 604 } }, + * // ...one row per time bucket + * ]; + * + * // Start from defaults, hide the compare layer so it's a single series. + * const [state, setState] = React.useState({ + * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, + * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => + * l.kind === "compare" ? { ...l, visible: false } : l, + * ), + * }); + * + * return ( + * + * + * + * ); + * } + * ``` + * + * ## Example 2 — current vs previous period (compare) + * + * Each point carries both layer values under their layer ids. The default + * state's `"primary"` and `"compare"` layers are already visible, so no + * state customization is needed. + * + * ```jsx + * function Dashboard() { + * const data = rows.map((r) => ({ + * ts: r.bucketTs, + * values: { + * primary: r.signupsThisPeriod, // keyed by layer id "primary" + * compare: r.signupsLastPeriod, // keyed by layer id "compare" + * }, + * })); + * + * const [state, setState] = React.useState( + * DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, + * ); + * + * return ( + * + * ); + * } + * ``` + * + * ## Example 3 — stacked bar with region breakdown (segmented) + * + * ```jsx + * function Dashboard() { + * const regions = [ + * { key: "us", label: "United States" }, + * { key: "eu", label: "European Union" }, + * { key: "asia", label: "Asia-Pacific" }, + * ]; + * + * // Row index matches `data` index; column index matches `regions`. + * // Each row MUST sum to data[i].values.primary. + * const segments = [ + * [210, 140, 70], // day 0 → total 420 + * [250, 170, 92], // day 1 → total 512 + * [300, 200, 104], // day 2 → total 604 + * ]; + * + * const data = [ + * { ts: Date.UTC(2026, 2, 1), values: { primary: 420 } }, + * { ts: Date.UTC(2026, 2, 2), values: { primary: 512 } }, + * { ts: Date.UTC(2026, 2, 3), values: { primary: 604 } }, + * ]; + * + * const [state, setState] = React.useState({ + * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, + * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => { + * if (l.kind === "primary") { + * return { + * ...l, + * type: "bar", // switch from area → stacked bars + * segmented: true, + * segments, + * segmentSeries: regions, + * }; + * } + * if (l.kind === "compare") { + * return { ...l, visible: false }; + * } + * return l; + * }), + * }); + * + * return ( + * + * ); + * } + * ``` + * + * ## Example 4 — mixing display types (e.g. revenue bars + signups area) + * + * Use two layers. The "primary" layer holds one metric; reuse the "compare" + * layer slot for the second metric by overriding its `id`, `label`, and + * `type`. Then key both values in each `Point`. + * + * **IMPORTANT**: the two metrics share a single y-axis, so scale them into + * the same range before putting them on the point. A common trick is to + * pass a `valueFormatter` that reports each layer's number with its own + * unit so tooltips still read correctly. + * + * ```jsx + * function Dashboard() { + * // Sign-ups are already in range; revenue cents would dwarf them, so + * // we normalize revenue (cents → dollars) onto the same scale. + * const data = rows.map((r) => ({ + * ts: r.bucketTs, + * values: { + * revenue: r.revenueCents / 100, + * signups: r.signups, + * }, + * })); + * + * const [state, setState] = React.useState({ + * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, + * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => { + * if (l.kind === "primary") { + * return { ...l, id: "revenue", label: "Revenue", type: "bar" }; + * } + * if (l.kind === "compare") { + * return { + * ...l, + * id: "signups", + * label: "Sign-ups", + * type: "area", + * visible: true, + * }; + * } + * return l; + * }), + * }); + * + * // Per-layer formatter: `kind` lets you branch per-axis vs per-layer + * // using the layer id passed in via the tooltip context. For most + * // cases formatting by raw value is enough. + * const valueFormatter = (value, kind) => { + * if (kind.type === "currency") return `$${value.toFixed(0)}`; + * return value.toLocaleString(); + * }; + * + * return ( + * + * ); + * } + * ``` + * + * ## Example 5 — segmented sign-ups stacked with a revenue line (mix + segment) + * + * Combines Example 3 and Example 4: primary layer is revenue as a line + * (un-segmented), compare layer is sign-ups as a stacked bar (segmented + * by region). Remember: row sums of the compare segments must equal + * `point.values.signups`, and both metrics share one y-axis. + * + * ```jsx + * function Dashboard() { + * const regions = [ + * { key: "us", label: "United States" }, + * { key: "eu", label: "European Union" }, + * { key: "asia", label: "Asia-Pacific" }, + * ]; + * + * // Normalize revenue to the same order of magnitude as sign-ups. + * const data = rows.map((r) => ({ + * ts: r.bucketTs, + * values: { + * revenue: r.revenueCents / 100, + * signups: r.signupsTotal, + * }, + * })); + * + * // Row index matches `data` index. Each row sums to signupsTotal. + * const signupSegments = rows.map((r) => [ + * r.signupsUs, + * r.signupsEu, + * r.signupsAsia, + * ]); + * + * const [state, setState] = React.useState({ + * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, + * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => { + * if (l.kind === "primary") { + * return { ...l, id: "revenue", label: "Revenue", type: "line" }; + * } + * if (l.kind === "compare") { + * return { + * ...l, + * id: "signups", + * label: "Sign-ups", + * type: "bar", + * visible: true, + * segmented: true, + * segments: signupSegments, + * segmentSeries: regions, + * }; + * } + * return l; + * }), + * }); + * + * return ( + * + * ); + * } + * ``` + */ export type AnalyticsChartProps = { - /** Time-series points — each point carries `values` keyed by layer id. */ + /** Time-series points — each point carries `values` keyed by layer id. + * See {@link AnalyticsChartProps} for full data-shape examples. */ data: Point[], /** Annotations. Fully prop-driven; the consumer owns the array. */ annotations?: Annotation[], @@ -390,11 +689,11 @@ export function AnalyticsChart({ const compareSolidKey = `${compareKey}_solid`; const compareDashedKey = `${compareKey}_dashed`; const primarySegKey = useCallback( - (segKey: string) => `${primaryKey}_seg_${segKey}`, + (segKey: string) => `${primaryKey}_seg_${cssIdent(segKey)}`, [primaryKey], ); const compareSegKey = useCallback( - (segKey: string) => `${compareKey}_seg_${segKey}`, + (segKey: string) => `${compareKey}_seg_${cssIdent(segKey)}`, [compareKey], ); @@ -948,7 +1247,7 @@ export function AnalyticsChart({ const mid = Math.round((lo + hi) / 2); onAnnotationCreate?.({ index: visibleStart + mid, - label: label.length > 5 ? label.slice(0, 5) : label, + label, description: label, }); setCommittedRange(null); diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/types.ts b/packages/dashboard-ui-components/src/components/analytics-chart/types.ts index a8f81fe65f..f2278656de 100644 --- a/packages/dashboard-ui-components/src/components/analytics-chart/types.ts +++ b/packages/dashboard-ui-components/src/components/analytics-chart/types.ts @@ -10,6 +10,17 @@ export function pointValue(p: Point, id: string): number { return typeof v === "number" && Number.isFinite(v) ? v : 0; } +/** Sanitize a string into a valid CSS `` token. + * Replaces characters not allowed in CSS identifiers (like `$`, spaces, + * slashes, dots) with underscores. Used to build safe `var(--color-xxx)` + * custom property names from arbitrary segment keys. */ +export function cssIdent(raw: string): string { + // Replace everything that isn't a letter, digit, hyphen, or underscore. + // Prefix with `_` if the result starts with a digit (not valid as ident start). + const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); + return /^\d/.test(cleaned) ? `_${cleaned}` : cleaned; +} + export type Annotation = { index: number, label: string, diff --git a/packages/stack-shared/src/ai/image-limits.ts b/packages/stack-shared/src/ai/image-limits.ts new file mode 100644 index 0000000000..67186a4d10 --- /dev/null +++ b/packages/stack-shared/src/ai/image-limits.ts @@ -0,0 +1,50 @@ +/** Shared image attachment limits for AI chat (client composer + `/api/latest/ai/query/[mode]`). */ + +export const MAX_IMAGES_PER_MESSAGE = 3; +export const MAX_IMAGE_BYTES_PER_FILE = 3 * 1024 * 1024; +export const MAX_IMAGE_MB_PER_FILE = MAX_IMAGE_BYTES_PER_FILE / (1024 * 1024); + +/** Decoded byte length of a base64 data URL or raw base64 (padding error ≤ 2 bytes). */ +export function estimateBase64ByteLength(dataUrl: string): number { + const commaIdx = dataUrl.indexOf(","); + const base64 = commaIdx === -1 ? dataUrl : dataUrl.slice(commaIdx + 1); + if (base64.length === 0) return 0; + let padding = 0; + if (base64.endsWith("==")) padding = 2; + else if (base64.endsWith("=")) padding = 1; + return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); +} + +type ValidationResult = { ok: true } | { ok: false, reason: string }; +type UnknownPart = { type?: unknown, image?: unknown }; +type MessageLike = { role?: unknown, content?: unknown }; + +/** Validates per-message image count and per-file size for user messages. */ +export function validateImageAttachments(messages: readonly MessageLike[]): ValidationResult { + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue; + let imageCount = 0; + for (const rawPart of msg.content as unknown[]) { + if (!rawPart || typeof rawPart !== "object") continue; + const part = rawPart as UnknownPart; + if (part.type !== "image") continue; + imageCount++; + if (imageCount > MAX_IMAGES_PER_MESSAGE) { + return { + ok: false, + reason: `Maximum ${MAX_IMAGES_PER_MESSAGE} images per message.`, + }; + } + if (typeof part.image === "string") { + const bytes = estimateBase64ByteLength(part.image); + 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 }; +} From 11014b036a368944022ec9747df9a508a80d3fcc Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 10 Apr 2026 16:42:00 -0700 Subject: [PATCH 24/30] Refactor backend seed scripts and enhance data grid functionality - Removed the `seed-internal-signups.ts` script and its associated command from `package.json`. - Updated `seed-dummy-data.ts` to include new bulk activity region data structures and functions for generating dummy data. - Integrated a new `DataGrid` component in the playground page, allowing for dynamic user data display with customizable columns and selection modes. - Adjusted analytics table components to utilize the new `DataGrid` for improved data handling and rendering. - Enhanced Tailwind configuration to include styles from the new dashboard UI components. --- apps/backend/package.json | 1 - apps/backend/scripts/seed-internal-signups.ts | 427 ------ apps/backend/src/lib/ai/prompts.ts | 341 ++--- apps/backend/src/lib/seed-dummy-data.ts | 361 +++++ .../playground/page-client.tsx | 186 +++ .../analytics/tables/page-client.tsx | 765 ++++------ .../dashboards/[dashboardId]/page-client.tsx | 134 +- .../datagrid-demo/demo/fixtures.tsx | 212 +++ .../datagrid-demo/page-client.tsx | 543 +++++++ .../design-language/datagrid-demo/page.tsx | 9 + .../email-drafts/[draftId]/page-client.tsx | 17 +- .../[templateId]/page-client.tsx | 17 +- .../email-themes/[themeId]/page-client.tsx | 17 +- .../src/components/assistant-ui/thread.tsx | 141 +- .../create-dashboard-preview.tsx | 257 +++- .../dashboard-sandbox-host.tsx | 5 + .../src/components/streaming-code-viewer.tsx | 181 +++ .../components/vibe-coding/assistant-chat.tsx | 3 + .../components/vibe-coding/chat-adapters.ts | 10 + .../src/lib/ai-dashboard/shared-prompt.ts | 19 +- apps/dashboard/tailwind.config.ts | 1 + packages/dashboard-ui-components/package.json | 1 + .../analytics-chart/analytics-chart.tsx | 191 +++ .../src/components/analytics-chart/state.ts | 18 + .../src/components/badge.tsx | 16 + .../src/components/button.tsx | 14 + .../src/components/card.tsx | 28 + .../src/components/chart-card.tsx | 29 + .../components/data-grid/data-grid-sizing.ts | 40 + .../data-grid/data-grid-toolbar.tsx | 354 +++++ .../src/components/data-grid/data-grid.tsx | 1252 +++++++++++++++++ .../src/components/data-grid/index.ts | 68 + .../src/components/data-grid/state.ts | 414 ++++++ .../src/components/data-grid/strings.ts | 45 + .../src/components/data-grid/types.ts | 363 +++++ .../components/data-grid/use-data-source.ts | 355 +++++ .../src/components/empty-state.tsx | 16 + .../src/components/metric-card.tsx | 24 + .../src/components/progress-bar.tsx | 10 + .../src/components/separator.tsx | 9 + .../src/components/skeleton.tsx | 12 + .../src/components/table.tsx | 25 + packages/dashboard-ui-components/src/index.ts | 2 + pnpm-lock.yaml | 110 ++ 44 files changed, 5760 insertions(+), 1283 deletions(-) delete mode 100644 apps/backend/scripts/seed-internal-signups.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/demo/fixtures.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/datagrid-demo/page.tsx create mode 100644 apps/dashboard/src/components/streaming-code-viewer.tsx create mode 100644 packages/dashboard-ui-components/src/components/data-grid/data-grid-sizing.ts create mode 100644 packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx create mode 100644 packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx create mode 100644 packages/dashboard-ui-components/src/components/data-grid/index.ts create mode 100644 packages/dashboard-ui-components/src/components/data-grid/state.ts create mode 100644 packages/dashboard-ui-components/src/components/data-grid/strings.ts create mode 100644 packages/dashboard-ui-components/src/components/data-grid/types.ts create mode 100644 packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 7746baa984..f387744017 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -36,7 +36,6 @@ "db:migration-gen": "pnpm run with-env:dev tsx scripts/db-migrations.ts generate-migration-file", "db:reset": "pnpm run with-env:dev tsx scripts/db-migrations.ts reset", "db:seed": "pnpm run with-env:dev tsx scripts/db-migrations.ts seed", - "db:seed-activity": "pnpm run with-env:dev tsx scripts/seed-internal-signups.ts", "db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init", "db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate", "generate-migration-imports": "pnpm run with-env tsx scripts/generate-migration-imports.ts", diff --git a/apps/backend/scripts/seed-internal-signups.ts b/apps/backend/scripts/seed-internal-signups.ts deleted file mode 100644 index 11334823ce..0000000000 --- a/apps/backend/scripts/seed-internal-signups.ts +++ /dev/null @@ -1,427 +0,0 @@ -/* eslint-disable no-restricted-syntax */ -/** - * 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 (via usersCrudHandlers.adminCreate) with back-dated - * signedUpAt/createdAt so the Postgres sign-up metrics show a - * realistic curve. - * 2. $token-refresh events in ClickHouse with geolocated ip_info so the - * DAU/MAU splits and "users by country" widgets reflect varied data. - * 3. $page-view events in ClickHouse so the analytics overview shows - * realistic daily visitors, page views, and top referrers. - * 4. $click events in ClickHouse so the clicks chart is populated. - * - * Usage: - * pnpm --filter @stackframe/backend run db:seed-signups - * pnpm --filter @stackframe/backend run db:seed-signups -- --count 500 --days 60 - * - * The script is keyed on deterministic emails, so re-running it will not - * duplicate users — it will update the existing rows' timestamps instead. - */ -import { usersCrudHandlers } from '@/app/api/latest/users/crud'; -import { getClickhouseAdminClient } from '@/lib/clickhouse'; -import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenancies'; -import { getPrismaClientForTenancy } from '@/prisma-client'; -import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; - -const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063'; - -// ── CLI args ───────────────────────────────────────────────────────────────── -function parseArgs() { - const args = process.argv.slice(2); - let count = 500; - let days = 60; - for (let i = 0; i < args.length; i++) { - if (args[i] === '--count' && args[i + 1]) { - count = parseInt(args[++i]!, 10); - } else if (args[i] === '--days' && args[i + 1]) { - days = parseInt(args[++i]!, 10); - } - } - return { count, days }; -} - -// ── Deterministic PRNG (mulberry32) ────────────────────────────────────────── -function prng(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; - }; -} - -// ── Geographic fixtures ────────────────────────────────────────────────────── -type Region = { - country: string, - region: string, - city: string, - lat: number, - lon: number, - tz: string, - weight: number, - ipPrefix: string, -}; - -const REGIONS: Region[] = [ - // 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 REGION_WEIGHT_TOTAL = REGIONS.reduce((sum, r) => sum + r.weight, 0); -function pickRegion(rand: () => number): Region { - const roll = rand() * REGION_WEIGHT_TOTAL; - let acc = 0; - for (const r of REGIONS) { - acc += r.weight; - if (roll < acc) return r; - } - return REGIONS[REGIONS.length - 1]!; -} - -// ── Name fixtures ──────────────────────────────────────────────────────────── -const 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 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 OAUTH_PROVIDERS = ['google', 'github', 'microsoft']; - -// ── Referrer fixtures for realistic $page-view events ──────────────────────── -const 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 REFERRER_WEIGHT_TOTAL = REFERRERS.reduce((sum, r) => sum + r.weight, 0); -function pickReferrer(rand: () => number): string { - const roll = rand() * REFERRER_WEIGHT_TOTAL; - let acc = 0; - for (const r of REFERRERS) { - acc += r.weight; - if (roll < acc) return r.url; - } - return ''; -} - -// ── Page path fixtures for $page-view events ───────────────────────────────── -const 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 fakeIp(prefix: string, rand: () => number): string { - const c = Math.floor(rand() * 256); - const d = Math.floor(rand() * 254) + 1; - return `${prefix}.${c}.${d}`; -} - -// ── Time distribution ──────────────────────────────────────────────────────── -function randomTimestampOnDay(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; -} - -/** - * Distribute `count` sign-ups across `days` with a gentle growth curve: - * - linear ramp from ~0.5× average at the oldest day to ~1.5× at the newest - * - ±25% jitter per day - * - light weekend dip (Sat/Sun) to look realistic - * Returns an array of `daysAgo` offsets, one per user. - */ -function distributeSignups(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); -} - -// ── Main ───────────────────────────────────────────────────────────────────── -async function main() { - process.env.STACK_SEED_MODE = 'true'; - - const { count, days } = parseArgs(); - const now = new Date(); - const rand = prng(0xC0FFEE); - - console.log(`[seed-activity] Loading dummy project tenancy...`); - const tenancy = await getSoleTenancyFromProjectBranch(DUMMY_PROJECT_ID, DEFAULT_BRANCH_ID); - const prisma = await getPrismaClientForTenancy(tenancy); - const clickhouse = getClickhouseAdminClient(); - - console.log(`[seed-activity] Target: ${count} users across ${days} days in project "${tenancy.project.id}" branch "${tenancy.branchId}"`); - - const dayOffsets = distributeSignups(count, days, rand, now); - const clickhouseRows: Array> = []; - - let created = 0; - let updated = 0; - - // Track all user IDs + their signup day offset for activity generation - const userActivity: Array<{ userId: string, signupDaysAgo: number, region: Region }> = []; - - for (let i = 0; i < count; i++) { - const firstName = FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]!; - const lastName = LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]!; - const displayName = `${firstName} ${lastName}`; - const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}.signupseed${i}@dummy.dev`; - const signedUpAt = randomTimestampOnDay(now, dayOffsets[i]!, rand); - const region = pickRegion(rand); - const hasOauth = rand() > 0.55; - const oauthProvider = hasOauth - ? [{ id: OAUTH_PROVIDERS[Math.floor(rand() * 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 }); - - // One $token-refresh at signup time - const ipInfoForUser = { - ip: fakeIp(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 each user, generate returning activity on subsequent days after signup. - // ~70% of users are active on multiple days; active users revisit 2–8 times - // spread across the window between signup and today. - for (const { userId, signupDaysAgo, region } of userActivity) { - if (signupDaysAgo === 0) continue; // signed up today, no return visits yet - const isReturning = rand() < 0.7; - if (!isReturning) continue; - - const returnVisits = 2 + Math.floor(rand() * 7); // 2-8 return days - const ipInfo = { - ip: fakeIp(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++) { - // Pick a random day between signup and today (exclusive of signup day) - const visitDaysAgo = Math.floor(rand() * signupDaysAgo); - const visitTime = randomTimestampOnDay(now, visitDaysAgo, rand); - - // $token-refresh (drives DAU/MAU) - clickhouseRows.push({ - event_type: '$token-refresh', - event_at: formatClickhouseTimestamp(visitTime), - data: { - refresh_token_id: generateUuid(), - is_anonymous: false, - ip_info: ipInfo, - }, - project_id: tenancy.project.id, - branch_id: tenancy.branchId, - user_id: userId, - team_id: null, - }); - - // $page-view (drives daily_visitors, daily_page_views, top_referrers) - const pageViewCount = 1 + Math.floor(rand() * 4); // 1-4 page views per visit - for (let p = 0; p < pageViewCount; p++) { - const pvOffset = Math.floor(rand() * 3600) * 1000; // within the hour - const pvTime = new Date(visitTime.getTime() + pvOffset); - clickhouseRows.push({ - event_type: '$page-view', - event_at: formatClickhouseTimestamp(pvTime), - data: { - path: PAGE_PATHS[Math.floor(rand() * PAGE_PATHS.length)], - referrer: p === 0 ? pickReferrer(rand) : '', - is_anonymous: false, - }, - project_id: tenancy.project.id, - branch_id: tenancy.branchId, - user_id: userId, - team_id: null, - }); - } - - // $click (drives daily_clicks) — ~40% of visits produce a click - 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, - }); - } - } - } - - 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', - 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}`); -} - -main().then(() => process.exit(0)).catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index 2003821b54..fbd28b97b7 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -512,264 +512,93 @@ Important: - The SDK handles auth/retries/errors; still show graceful UI states ──────────────────────────────────────── -CHART RULES +LAYOUT & DESIGN RULES ──────────────────────────────────────── -- Every dashboard MUST include at least one chart. -- **DEFAULT TO \`DashboardUI.AnalyticsChart\`** for ALL time-series / trend visualizations. - It handles tooltips, legends, compare layers, segments, zoom, and annotations out of the box. - Fall back to raw \`Recharts.*\` ONLY for non-time-series visuals (static bar ranking, pie distribution). -- Choose chart types that match the question: - - Trends over time → **AnalyticsChart** (area, line, or bar via layer \`type\`) - - Current vs previous period → **AnalyticsChart** with both primary + compare layers visible - - Breakdowns (by region, provider, etc.) → **AnalyticsChart** with \`segmented: true\` (stacked bars or segmented area) - - Distribution/composition → **AnalyticsChart** with \`view: "pie"\` and segmented primary layer - - Static comparisons/top-N → Recharts.BarChart (non-time-series) -- If the query is time-series, ALWAYS use AnalyticsChart. -- If two metrics have different magnitudes, normalize them into the same range before putting - them on a Point (see AnalyticsChart docs below), or render two separate AnalyticsChart - instances stacked. Do NOT use dual y-axis hacks — they don't exist in AnalyticsChart. -- Do not overwhelm: 1–2 charts maximum. +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. -──────────────────────────────────────── -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: -- **ALWAYS prefer DashboardUI.AnalyticsChart** for any time-series chart. It handles area, line, - bar, compare layers, segments, tooltips, zoom, and annotations automatically. -- Wrap AnalyticsChart in DashboardUI.DesignChartCard for the title/description chrome. -- Only fall back to raw Recharts for non-time-series visuals (static rankings, pie charts). - When using raw Recharts, wrap in DashboardUI.DesignChartCard + DashboardUI.DesignChartContainer. -- Use DashboardUI.getDesignChartColor(index) for consistent colors in raw Recharts charts. - -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. - -ANALYTICS CHART (preferred for all time-series): - - Data shape: Point[] where Point = { ts: number (Unix ms), values: Record } - State: fully controlled. ALWAYS start from DashboardUI.ANALYTICS_CHART_DEFAULT_STATE and override. - The default state ships with "primary", "compare", and "annotations" layers. - Do NOT hand-build the layer array from scratch. - - onChange HANDLER (CRITICAL — GET THIS RIGHT): - AnalyticsChart's onChange fires with an AnalyticsChartState object (NOT your custom wrapper). - If you store chart data and state together, the onChange MUST only update the state part: - - // WRONG — overwrites your data with a bare state object, crashes on next render: - onChange={setChartState} - - // RIGHT — keep data and state in separate hooks: - const [data, setData] = React.useState([]); - const [chartState, setChartState] = React.useState({ ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, ... }); - - - NEVER pass a setState that manages a combined { data, state } object directly to onChange. - - RUNTIME OBJECT SHAPES (what these actually look like in JS — no TypeScript at runtime): - // Point — one per time bucket - { ts: 1743465600000, values: { primary: 420 } } - { ts: 1743465600000, values: { primary: 420, compare: 380 } } // with compare layer - - // DashboardUI.ANALYTICS_CHART_DEFAULT_STATE — the starting point you ALWAYS spread from: - { - view: "timeseries", - layers: [ - { id: "primary", kind: "primary", label: "Current", visible: true, color: "#2563eb", - segmented: false, type: "area", strokeStyle: "solid", fillOpacity: 0.22, inProgressFromIndex: null }, - { id: "compare", kind: "compare", label: "Previous period", visible: true, color: "#f59e0b", - segmented: false, type: "line", strokeStyle: "dashed", inProgressFromIndex: null }, - { id: "annotations", kind: "annotations", label: "Annotations", visible: true, color: "#f59e0b" }, - ], - xFormatKind: { type: "datetime", style: "short" }, - yFormatKind: { type: "short" }, - showGrid: true, showXAxis: true, showYAxis: true, - zoomRange: null, pinnedIndex: null, - } +Container baseline: +
- Example 1 — simplest (one area layer, no compare): - const data = rows.map(r => ({ ts: r.bucketTs, values: { primary: r.count } })); - const [state, setState] = React.useState({ - ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, - layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => - l.kind === "compare" ? { ...l, visible: false } : l - ), - }); - - - - - Example 2 — current vs previous period (compare): - const data = rows.map(r => ({ - ts: r.bucketTs, - values: { primary: r.thisPeriod, compare: r.lastPeriod }, - })); - const [state, setState] = React.useState(DashboardUI.ANALYTICS_CHART_DEFAULT_STATE); - - - SEGMENT DATA CONTRACT (MUST follow precisely when segmented: true): - segments is a 2D array: segments[dayIndex][categoryIndex] = number - - Outer length MUST equal data.length (one row per Point) - - Inner length MUST equal segmentSeries.length (one value per category) - - Each row MUST sum to data[dayIndex].values[layerId] (the layer's total for that day) - - segmentSeries defines the category labels, in the SAME order as segment columns - Example: if segmentSeries = [{ key: "us", label: "US" }, { key: "eu", label: "EU" }] - and data[0].values.primary = 420, then segments[0] must be [usValue, euValue] - where usValue + euValue === 420. - If rows don't sum to the layer total, the stacked bars will render incorrectly (gaps or overflow). - - PALETTE: AnalyticsChart auto-generates segment colors (blue shades for primary, amber for compare). - You do NOT need to pass a palette prop — it just works. Segment keys can be any string; the component - sanitizes them for CSS purposes internally. - - Example 3 — stacked bar with breakdown (segmented): - const regions = [{ key: "us", label: "US" }, { key: "eu", label: "EU" }]; - const segments = rows.map(r => [r.signupsUs, r.signupsEu]); // rows MUST sum to primary value - const [state, setState] = React.useState({ - ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, - layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => { - if (l.kind === "primary") return { ...l, type: "bar", segmented: true, segments, segmentSeries: regions }; - if (l.kind === "compare") return { ...l, visible: false }; - return l; - }), - }); - - - Example 4 — two metrics (revenue bars + signups area): - // IMPORTANT: metrics share one y-axis, so normalize into the same range. - const data = rows.map(r => ({ - ts: r.bucketTs, - values: { revenue: r.revenueCents / 100, signups: r.signups }, - })); - const [state, setState] = React.useState({ - ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, - layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => { - if (l.kind === "primary") return { ...l, id: "revenue", label: "Revenue", type: "bar" }; - if (l.kind === "compare") return { ...l, id: "signups", label: "Sign-ups", type: "area", visible: true }; - return l; - }), - }); - - - Layer type options: "area" | "line" | "bar" - Layer kinds: "primary", "compare", "annotations" - To hide a layer: { ...l, visible: false } - To switch chart type: { ...l, type: "bar" } (or "line", "area") - To rename a layer: { ...l, id: "myMetric", label: "My Metric" } - - FORMATTING (xFormatKind / yFormatKind on state): - - { type: "numeric" } — plain number - - { type: "short" } — abbreviated (1.2K, 3.4M) — good default for y-axis - - { type: "currency", currency: "USD", divisor: 100 } — for cents → dollars - - { type: "percent", source: "fraction" } — for 0..1 → "45.2%" - - { type: "datetime", style: "short" } — good default for x-axis timestamps - Set these on state: { ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, yFormatKind: { type: "currency", currency: "USD" } } - - PIE VIEW — for distribution/breakdown charts (non-time-series): - // Pie needs: one data point, segments with one row, segmentSeries with labels - const categories = [{ key: "verified", label: "Verified" }, { key: "unverified", label: "Unverified" }, { key: "anonymous", label: "Anonymous" }]; - const total = verified + unverified + anonymous; - const data = [{ ts: 0, values: { primary: total } }]; - const segments = [[verified, unverified, anonymous]]; // one row, values sum to total - const [state, setState] = React.useState({ - ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE, - view: "pie", - layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => { - if (l.kind === "primary") return { ...l, segmented: true, segments, segmentSeries: categories }; - if (l.kind === "compare") return { ...l, visible: false }; - return l; - }), - }); - - - SCALE WARNING: All visible layers share ONE y-axis. If magnitudes differ wildly - (e.g. revenue cents vs signup count), normalize the data BEFORE building Points. - If normalization is impossible, use two separate AnalyticsChart instances stacked vertically. - -RAW RECHARTS CHART COMPONENTS (fallback for non-time-series only): - - - - - - - } /> - - - - - - chartConfig format: { [dataKey]: { label: "Human Name", color: DashboardUI.getDesignChartColor(index) } } - - TABLE: - - - - Name - Email - - - - {rows.map(row => ( - - {row.name} - {row.email} - - ))} - - - -OTHER COMPONENTS: - - - - - - +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 ──────────────────────────────────────── @@ -1063,7 +892,9 @@ 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. + 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, diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 3db25ddcde..a037db4d6c 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -130,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, @@ -1097,6 +1108,141 @@ 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; @@ -1239,6 +1385,216 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO 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: formatClickhouseTimestamp(visitTime), + data: { + refresh_token_id: generateUuid(), + is_anonymous: false, + ip_info: ipInfo, + }, + project_id: tenancy.project.id, + branch_id: tenancy.branchId, + user_id: userId, + team_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, + }); + } + } + } + + 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', + 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}`); +} + /** * Creates a new project and fills it with dummy data (users, teams, payments, emails, analytics events). * Used by both the seed script and the preview project creation endpoint. @@ -1416,6 +1772,11 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis }), ]); + await seedBulkSignupsAndActivity({ + tenancy: dummyTenancy, + prisma: dummyPrisma, + }); + return projectId; } 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 e01098195a..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 @@ -31,6 +31,12 @@ import { } from "@phosphor-icons/react"; import { CursorBlastEffect, + DataGrid, + useDataSource, + type DataGridColumnDef, + type DataGridPaginationMode, + type DataGridSelectionMode, + createDefaultDataGridState, DesignAlert, DesignBadge, type DesignBadgeColor, @@ -54,6 +60,7 @@ type ComponentId = | "card" | "category-tabs" | "cursor-blast" + | "data-grid" | "data-table" | "editable-grid" | "input" @@ -71,6 +78,7 @@ const COMPONENT_LIST: Array<{ value: ComponentId, label: string }> = [ { 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" }, @@ -218,6 +226,107 @@ const DEMO_ANALYTICS_POINTS = [ { 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() { @@ -277,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(""); @@ -765,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 (
@@ -1410,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 (
@@ -1866,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]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx index ddca58c8fa..380c3b5369 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,7 +1,7 @@ "use client"; import { Link } from "@/components/link"; -import { Alert, Button, Skeleton, Typography } from "@/components/ui"; +import { Alert, Button, Typography } from "@/components/ui"; import { Dialog, DialogBody, @@ -9,80 +9,68 @@ import { 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 { 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 { + createDefaultDataGridState, + DataGrid, + useDataSource, + type DataGridColumnDef, + type DataGridDataSource, + type DataGridState, +} from "@stackframe/dashboard-ui-components"; +import { useCallback, useMemo, useRef, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; import { - isDateValue, isJsonValue, JsonValue, parseClickHouseDate, RowData, } from "../shared"; -// Context for date display preference (specific to tables page for toggle feature) -const DateDisplayContext = createContext<{ relative: boolean }>({ relative: true }); +type TableId = "events"; + +type TableConfig = { + displayName: string, + baseQuery: string, + defaultOrderBy: string, + defaultOrderDir: "ASC" | "DESC", +}; // Available tables in the analytics database -const AVAILABLE_TABLES = new Map([ +const AVAILABLE_TABLES = new Map([ ["events", { displayName: "Events", baseQuery: "SELECT * FROM default.events", defaultOrderBy: "event_at", - defaultOrderDir: "DESC" as const, + defaultOrderDir: "DESC", }], ]); -type TableId = "events"; -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()}; +// Date detection for dynamic columns — the grid now handles actual +// rendering for `type: "dateTime"` columns, but we still need a runtime +// check to decide which columns should be marked as dates. +function isDateColumnName(name: string): boolean { + return name.endsWith("_at") || name === "date" || /(^|_)date($|_)/.test(name); } -// Format a cell value for display +// Format a non-date cell value for display. Date values never reach this +// component because date columns get `type: "dateTime"` and the grid's +// built-in date renderer kicks in. function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) { if (value === null || value === undefined) { return ; } - if (isDateValue(value)) { - return ; - } - if (isJsonValue(value)) { return ; } @@ -99,6 +87,16 @@ function CellValue({ value, truncate = true }: { value: unknown, truncate?: bool return {str}; } +// `parseValue` adapter — ClickHouse emits "YYYY-MM-DD HH:MM:SS.mmm" +// strings. Grid's default `new Date()` would interpret those as local +// time; `parseClickHouseDate` treats them correctly as UTC and returns +// `null` for invalid values 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; +} + // Row detail dialog function RowDetailDialog({ row, @@ -144,445 +142,286 @@ function RowDetailDialog({ ); } -// 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]); +// ─── Column width heuristic ────────────────────────────────────────── - 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 - )} -
- ); - } - - return ( -
onRowClick(row)} - > - {columns.map((column) => ( -
- -
- ))} -
- ); - })} -
-
-
-
- ); +/** 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; } function TableContent({ tableId }: { tableId: TableId }) { const adminApp = useAdminApp(); - const [columns, setColumns] = useState([]); - const [rows, setRows] = useState([]); + const tableConfig = AVAILABLE_TABLES.get(tableId)!; + + const [discoveredColumns, setDiscoveredColumns] = 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); + + // Ref mirror of discoveredColumns so the async generator (memoised + // against adminApp + tableConfig) can read the latest column list + // without being re-created every time the schema updates. The + // generator builds its WHERE clause from this ref. + const discoveredColumnsRef = useRef([]); + + // Grid state — initialize with the table's default sort so the very + // first fetch already uses the right ORDER BY. + const [gridState, setGridState] = useState(() => { + const base = createDefaultDataGridState([]); + return { + ...base, + sorting: [{ + columnId: tableConfig.defaultOrderBy, + direction: tableConfig.defaultOrderDir === "DESC" ? "desc" : "asc", + }], + pagination: { pageIndex: 0, pageSize: PAGE_SIZE }, + }; + }); + + // DataGrid column defs built from the discovered column names. + // Empty on first render — the grid renders blank until the first page + // comes back, then re-renders with populated columns. This is fine + // because the initial sort is by columnId string, not by column ref. + // Columns are sortable server-side via the ORDER BY in the generator. + // + // Date columns are detected by name (`*_at`, `date`). They get + // `type: "dateTime"` which enables the grid's built-in date cell + // renderer, and a `parseValue` override so ClickHouse's space- + // separated UTC strings parse correctly. The date format toggle + // (relative / absolute) lives in the grid's Columns popover and is + // wired up automatically once any date column exists. + 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], + ); + + // Async data source — server-side sort + search + paginated fetch + // via SQL. The generator also discovers the schema from the first + // row of the first page (on every refetch, in case the schema + // changes). + const dataSource = useMemo>(() => { + return async function* (params) { + setError(null); + try { + // `sorting` is a tuple of zero or one item in practice — multi-sort + // isn't wired up server-side. Handle the empty case explicitly so + // we don't rely on `params.sorting[0]` being defined. + let orderBy: string; + let orderDir: "ASC" | "DESC"; + if (params.sorting.length > 0) { + const first = params.sorting[0]!; + orderBy = first.columnId; + orderDir = first.direction === "asc" ? "ASC" : "DESC"; + } else { + orderBy = tableConfig.defaultOrderBy; + orderDir = tableConfig.defaultOrderDir; + } + const pageSize = params.pagination.pageSize; + const offset = params.pagination.pageIndex * pageSize; + + // Build a WHERE clause from the quick-search text. We can only + // do this once the schema has been discovered (i.e. after the + // first unfiltered fetch), otherwise there are no columns to + // search against. Cast every column to String and OR ILIKE + // across all of them — generic enough for any events-style + // table. Single quotes in the query are escaped to prevent + // trivial injection via the search box; backslashes are + // doubled first so the escape itself doesn't re-introduce + // unescaped quotes. + const search = params.quickSearch.trim(); + const searchableCols = discoveredColumnsRef.current; + let whereClause = ""; + if (search && searchableCols.length > 0) { + const escaped = search.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + const clauses = searchableCols + .map((c) => `toString(\`${c}\`) ILIKE '%${escaped}%'`) + .join(" OR "); + whereClause = ` WHERE ${clauses}`; + } + + const query = `${tableConfig.baseQuery}${whereClause} ORDER BY ${orderBy} ${orderDir} LIMIT ${pageSize} OFFSET ${offset}`; + + const response = await adminApp.queryAnalytics({ + query, + include_all_branches: false, + timeout_ms: 30000, + }); + + const newRows = response.result as RowData[]; + + // Refresh the column list only when the schema actually differs, + // otherwise every page load would cause a spurious re-render. + // Mirror to the ref so subsequent generator runs (including + // the one fired by a search-box keystroke) can build a WHERE + // clause without waiting for another re-render. + if (newRows.length > 0) { + const cols = Object.keys(newRows[0]!); + 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 === pageSize, + }; + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to load table data"; + setError(message); + yield { rows: [], hasMore: false }; } + }; + }, [adminApp, tableConfig]); + + // Stable row ID — prefer explicit ID fields, fall back to a JSON + // fingerprint so infinite-scroll dedup in useDataSource still works + // for tables without a dedicated ID column. + const getRowId = useCallback((row: RowData): string => { + if (row.id != null) return String(row.id); + if (row.event_id != null) return String(row.event_id); + return JSON.stringify(row); + }, []); + + // The async data source handles server-side sort + search + infinite + // scroll. `quickSearch` flows straight from grid state into the + // generator via `params.quickSearch`, and the hook re-fires the + // generator on change (same mechanism as sorting). + const gridData = useDataSource({ + dataSource, + columns, + getRowId, + sorting: gridState.sorting, + quickSearch: gridState.quickSearch, + pagination: gridState.pagination, + paginationMode: "infinite", + }); - 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) => { + const handleRowClick = useCallback((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} - -
- ); - } - - if (rows.length === 0) { - return ( -
- No data available - -
- ); - } + }, []); + + const showEmptyError = + error != null && !gridData.isLoading && gridData.rows.length === 0; + + // Rendered inside the DataGrid's default toolbar, to the left of the + // built-in columns / export actions. The date-format toggle now lives + // inside the grid's Columns popover (auto-wired because the grid sees + // `type: "dateTime"` columns), so this extras slot only has refresh + + // row count now. + const toolbarExtra = ( +
+ + + + {gridData.rows.length.toLocaleString()} rows + {gridData.hasMore && "+"} + +
+ ); return ( - -
- {/* Toolbar */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="pl-9 h-8 bg-transparent border-border/50" - /> -
- - {/* Date toggle */} -
- -
- - - -
-
-
+
+ {/* Non-fatal error banner — shown while data is still visible. */} + {error != null && !showEmptyError && ( +
+ {error} +
+ )} - {/* Refresh */} - +
+ )} - {/* Row count */} - - {filteredRows.length.toLocaleString()} rows - {hasMore && "+"} - + {/* Data grid — fills remaining space via `flex-1 min-h-0`. + Uses the DataGrid's default toolbar (column visibility, CSV + export) and slots refresh + row count in via `toolbarExtra`. + The date format toggle shows up automatically inside the + Columns popover because at least one column is `dateTime`. */} + {!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" + toolbarExtra={toolbarExtra} + footer={false} + exportFilename={`${tableId}-export`} + onRowClick={handleRowClick} + emptyState={ +
+ No data available +
+ } + />
+ )} - {/* Table */} - 0} - /> - - -
- + +
); } @@ -592,10 +431,10 @@ export default function PageClient() { return ( -
+
{/* Left sidebar - table list (doesn't scroll, border extends full height) */} -
-
+
+
Tables
{[...AVAILABLE_TABLES.entries()].map(([id, config]) => ( @@ -626,7 +465,7 @@ export default function PageClient() {
{/* Right content - table data (scrolls independently, extends to edge) */} -
+
{selectedTable ? ( ) : ( 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 958d2ee265..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 @@ -3,6 +3,7 @@ import { DashboardSandboxHost, type DashboardRuntimeError, type WidgetSelection } from "@/components/commands/create-dashboard/dashboard-sandbox-host"; import { useRouter, useRouterConfirm } from "@/components/router"; +import { StreamingCodeViewer } from "@/components/streaming-code-viewer"; import { ActionDialog, Button, Typography, useToast } from "@/components/ui"; import { Input } from "@/components/ui/input"; import { @@ -125,6 +126,11 @@ function DashboardDetailContent({ 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(); @@ -233,10 +239,35 @@ function DashboardDetailContent({ const handleCodeUpdate = useCallback((toolCall: ToolCallContent) => { 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 () => { await updateConfig({ adminApp, @@ -276,30 +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 ( @@ -363,11 +448,12 @@ function DashboardDetailContent({ />
} useOffWhiteLightMode composerPlaceholder={currentHasSource ? undefined : DASHBOARD_COMPOSER_PLACEHOLDER} + runningStatusMessages={!isCreating ? UPDATE_STATUS_MESSAGES : undefined} composerAttachments onComposerReady={handleComposerReady} /> 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-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/components/assistant-ui/thread.tsx b/apps/dashboard/src/components/assistant-ui/thread.tsx index 1aff274b68..868720c983 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -22,6 +22,7 @@ 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); const ComposerAttachmentsEnabledContext = createContext(false); @@ -45,52 +46,61 @@ export const Thread: FC<{ useOffWhiteLightMode?: boolean, composerPlaceholder?: ComposerPlaceholder, hideMessageActions?: boolean, + runningStatusMessages?: string[], composerAttachments?: boolean, -}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, composerAttachments = false }) => { +}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false }) => { return ( - - - + + - - - - - -
- - -
- - -
- - - + + + + + + {runningStatusMessages && ( + + + + )} + + +
+ + +
+ + +
+ + + + ); }; @@ -552,16 +562,23 @@ const EditComposer: FC = () => { }; const AssistantMessage: FC = () => { + const hasRunningStatus = useContext(HasRunningStatusContext); return (
- -
- - + {hasRunningStatus ? (
- + ) : ( + <> + +
+ + +
+ + + )}
@@ -665,6 +682,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 ( ") + .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, projectId }); - - 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,6 +260,8 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ ); } + const isGenerating = phase === "streaming" || phase === "booting" || phase === "waiting"; + return (
@@ -152,7 +271,7 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({
{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 907ae386df..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 @@ -613,6 +613,7 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onBack, onEditToggle, onNavigate, + onReady, onRuntimeError, onWidgetSelected, isChatOpen, @@ -621,6 +622,7 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ 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, @@ -635,6 +637,8 @@ 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); @@ -770,6 +774,7 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ } if (type === "stack-ai-dashboard-ready") { + onReadyRef.current?.(); return; } }; 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 7d5b19b350..e45c99e0f4 100644 --- a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx +++ b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx @@ -29,6 +29,7 @@ type AssistantChatProps = { /** 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, /** @@ -61,6 +62,7 @@ export default function AssistantChat({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, + runningStatusMessages, composerAttachments = false, onComposerReady, }: AssistantChatProps) { @@ -87,6 +89,7 @@ export default function AssistantChat({ useOffWhiteLightMode={useOffWhiteLightMode} composerPlaceholder={composerPlaceholder} hideMessageActions={hideMessageActions} + runningStatusMessages={runningStatusMessages} composerAttachments={composerAttachments} /> diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index ae4e43523f..d05baf31e7 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -329,9 +329,12 @@ 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 = formatThreadMessagesForBackend(messages); @@ -372,6 +375,8 @@ export function createChatAdapter( return {}; } throw new Error("Failed to get AI response. Please try again."); + } finally { + onRunEnd?.(); } }, }; @@ -384,9 +389,12 @@ export function createDashboardChatAdapter( currentUser?: CurrentUser, enabledAppIds?: AppId[], projectId?: string, + onRunStart?: () => void, + onRunEnd?: () => void, ): ChatModelAdapter { return { async *run({ messages, abortSignal }: ChatModelRunOptions): AsyncGenerator { + onRunStart?.(); try { const formattedMessages = formatThreadMessagesForBackend(messages); @@ -417,6 +425,8 @@ export function createDashboardChatAdapter( return; } throw new Error("Failed to get AI response. Please try again."); + } finally { + onRunEnd?.(); } }, }; 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/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/packages/dashboard-ui-components/package.json b/packages/dashboard-ui-components/package.json index aaa046cc6b..14bffb330e 100644 --- a/packages/dashboard-ui-components/package.json +++ b/packages/dashboard-ui-components/package.json @@ -57,6 +57,7 @@ "@react-hook/resize-observer": "^2.0.2", "@stackframe/stack-shared": "workspace:*", "@stackframe/stack-ui": "workspace:*", + "@tanstack/react-virtual": "^3.13.0", "class-variance-authority": "^0.7.0" }, "devDependencies": { diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx index 4cc9db8f5b..21aabec778 100644 --- a/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx +++ b/packages/dashboard-ui-components/src/components/analytics-chart/analytics-chart.tsx @@ -485,6 +485,197 @@ function buildTooltipLayerView(args: { }; } +/** + * Preferred chart for all time-series: area, line, bar, compare layers, + * segmented stacks, tooltips, zoom, and annotations. Wrap in + * `DesignChartCard` for the title/description chrome. Only fall back to + * raw Recharts for non-time-series visuals (static rankings etc.). + * + * ## Data shape + * + * `data` is `Point[]`, where `Point = { ts: number, values: Record }`. + * `ts` is a Unix milliseconds timestamp. `values` maps layer id → numeric value + * at that bucket. Example: + * + * ```ts + * { ts: 1743465600000, values: { primary: 420 } } + * { ts: 1743465600000, values: { primary: 420, compare: 380 } } // with compare layer + * ``` + * + * ## State is fully controlled — start from ANALYTICS_CHART_DEFAULT_STATE + * + * The default state ships with three pre-configured layers: `"primary"`, + * `"compare"`, and `"annotations"`. ALWAYS spread from + * `ANALYTICS_CHART_DEFAULT_STATE` and map over `layers` to override. Do NOT + * hand-build the layer array from scratch — you will miss fields and crash. + * + * ```ts + * // Default state shape (for reference — spread from the constant, don't copy): + * { + * view: "timeseries", + * layers: [ + * { id: "primary", kind: "primary", label: "Current", visible: true, color: "#2563eb", + * segmented: false, type: "area", strokeStyle: "solid", fillOpacity: 0.22, inProgressFromIndex: null }, + * { id: "compare", kind: "compare", label: "Previous period", visible: true, color: "#f59e0b", + * segmented: false, type: "line", strokeStyle: "dashed", inProgressFromIndex: null }, + * { id: "annotations", kind: "annotations", label: "Annotations", visible: true, color: "#f59e0b" }, + * ], + * xFormatKind: { type: "datetime", style: "short" }, + * yFormatKind: { type: "short" }, + * showGrid: true, showXAxis: true, showYAxis: true, + * zoomRange: null, pinnedIndex: null, + * } + * ``` + * + * ## onChange — CRITICAL, get this right + * + * `onChange` fires with an `AnalyticsChartState` object — NOT your custom + * wrapper. If you store chart data and state together, `onChange` MUST only + * update the state part. Keep data and state in SEPARATE hooks: + * + * ```tsx + * // WRONG — overwrites your data with a bare state object, crashes on next render: + * const [combined, setCombined] = React.useState({ data: [], state: ANALYTICS_CHART_DEFAULT_STATE }); + * + * + * // RIGHT — two hooks: + * const [data, setData] = React.useState([]); + * const [chartState, setChartState] = React.useState({ ...ANALYTICS_CHART_DEFAULT_STATE }); + * + * ``` + * + * NEVER pass a setter that manages a combined `{ data, state }` object directly to `onChange`. + * + * ## Common patterns + * + * ### 1. Simplest — one area layer, no compare + * + * ```tsx + * const data = rows.map(r => ({ ts: r.bucketTs, values: { primary: r.count } })); + * const [state, setState] = React.useState({ + * ...ANALYTICS_CHART_DEFAULT_STATE, + * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => + * l.kind === "compare" ? { ...l, visible: false } : l + * ), + * }); + * + * + * + * ``` + * + * ### 2. Current vs previous period (compare) + * + * ```tsx + * const data = rows.map(r => ({ + * ts: r.bucketTs, + * values: { primary: r.thisPeriod, compare: r.lastPeriod }, + * })); + * const [state, setState] = React.useState(ANALYTICS_CHART_DEFAULT_STATE); + * + * ``` + * + * ### 3. Stacked bar with breakdown (segmented) + * + * ```tsx + * const regions = [{ key: "us", label: "US" }, { key: "eu", label: "EU" }]; + * const segments = rows.map(r => [r.signupsUs, r.signupsEu]); // MUST sum to primary value per row + * const [state, setState] = React.useState({ + * ...ANALYTICS_CHART_DEFAULT_STATE, + * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => { + * if (l.kind === "primary") return { ...l, type: "bar", segmented: true, segments, segmentSeries: regions }; + * if (l.kind === "compare") return { ...l, visible: false }; + * return l; + * }), + * }); + * + * ``` + * + * ### 4. Two metrics on one chart (revenue bars + signups area) + * + * ```tsx + * // IMPORTANT: metrics share one y-axis, so normalize into the same range. + * const data = rows.map(r => ({ + * ts: r.bucketTs, + * values: { revenue: r.revenueCents / 100, signups: r.signups }, + * })); + * const [state, setState] = React.useState({ + * ...ANALYTICS_CHART_DEFAULT_STATE, + * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => { + * if (l.kind === "primary") return { ...l, id: "revenue", label: "Revenue", type: "bar" }; + * if (l.kind === "compare") return { ...l, id: "signups", label: "Sign-ups", type: "area", visible: true }; + * return l; + * }), + * }); + * + * ``` + * + * ### 5. Pie view (distribution / breakdown, non-time-series) + * + * Pie needs one data point, `segments` with one row, and `segmentSeries` with labels: + * + * ```tsx + * const categories = [{ key: "verified", label: "Verified" }, { key: "unverified", label: "Unverified" }, { key: "anonymous", label: "Anonymous" }]; + * const total = verified + unverified + anonymous; + * const data = [{ ts: 0, values: { primary: total } }]; + * const segments = [[verified, unverified, anonymous]]; // one row; values sum to total + * const [state, setState] = React.useState({ + * ...ANALYTICS_CHART_DEFAULT_STATE, + * view: "pie", + * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => { + * if (l.kind === "primary") return { ...l, segmented: true, segments, segmentSeries: categories }; + * if (l.kind === "compare") return { ...l, visible: false }; + * return l; + * }), + * }); + * + * ``` + * + * ## Segment data contract (MUST follow when segmented: true) + * + * `segments` is a 2D array: `segments[dayIndex][categoryIndex] = number`. + * + * - Outer length MUST equal `data.length` (one row per Point). + * - Inner length MUST equal `segmentSeries.length` (one value per category). + * - Each row MUST sum to `data[dayIndex].values[layerId]` (the layer's total for that day). + * - `segmentSeries` defines the category labels, in the SAME order as segment columns. + * + * Example: if `segmentSeries = [{ key: "us", label: "US" }, { key: "eu", label: "EU" }]` + * and `data[0].values.primary = 420`, then `segments[0]` must be `[usValue, euValue]` + * where `usValue + euValue === 420`. If rows don't sum to the layer total, stacked bars + * will render incorrectly (gaps or overflow). + * + * ## Palette + * + * AnalyticsChart auto-generates segment colors (blue shades for primary, amber for + * compare). You do NOT need to pass a palette prop — it just works. Segment keys + * can be any string; the component sanitizes them for CSS purposes internally. + * + * ## Layer quick reference + * + * - Layer `type` options: `"area" | "line" | "bar"` + * - Layer `kind` values: `"primary" | "compare" | "annotations"` + * - To hide a layer: `{ ...l, visible: false }` + * - To switch chart type: `{ ...l, type: "bar" }` (or `"line"`, `"area"`) + * - To rename a layer: `{ ...l, id: "myMetric", label: "My Metric" }` + * + * ## Formatting (xFormatKind / yFormatKind on state) + * + * - `{ type: "numeric" }` — plain number + * - `{ type: "short" }` — abbreviated (1.2K, 3.4M) — good default for y-axis + * - `{ type: "currency", currency: "USD", divisor: 100 }` — for cents → dollars + * - `{ type: "percent", source: "fraction" }` — for 0..1 → "45.2%" + * - `{ type: "datetime", style: "short" }` — good default for x-axis timestamps + * + * Set these on state: + * `{ ...ANALYTICS_CHART_DEFAULT_STATE, yFormatKind: { type: "currency", currency: "USD" } }` + * + * ## Scale warning + * + * All visible layers share ONE y-axis. If magnitudes differ wildly (e.g. revenue + * cents vs signup count), normalize the data BEFORE building Points. If + * normalization is impossible, use two separate `AnalyticsChart` instances stacked + * vertically. + */ export function AnalyticsChart({ data: fullData, annotations: fullAnnotations = [], diff --git a/packages/dashboard-ui-components/src/components/analytics-chart/state.ts b/packages/dashboard-ui-components/src/components/analytics-chart/state.ts index 22a3faf534..471b573dfb 100644 --- a/packages/dashboard-ui-components/src/components/analytics-chart/state.ts +++ b/packages/dashboard-ui-components/src/components/analytics-chart/state.ts @@ -48,6 +48,24 @@ export const ANALYTICS_CHART_DEFAULT_LAYERS: AnalyticsChartLayers = [ { id: "annotations", kind: "annotations", label: "Annotations", visible: true, color: "#f59e0b" }, ]; +/** + * Default state for `AnalyticsChart`. ALWAYS spread from this when + * initializing state; never build the state object by hand. Ships with + * three pre-configured layers (primary, compare, annotations) — map over + * `layers` to override individual ones. + * + * ```tsx + * const [state, setState] = React.useState({ + * ...ANALYTICS_CHART_DEFAULT_STATE, + * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => + * l.kind === "compare" ? { ...l, visible: false } : l + * ), + * }); + * ``` + * + * See the JSDoc on `AnalyticsChart` for the full contract, examples, and + * the segment data format. + */ export const ANALYTICS_CHART_DEFAULT_STATE: AnalyticsChartState = { view: "timeseries", layers: ANALYTICS_CHART_DEFAULT_LAYERS, diff --git a/packages/dashboard-ui-components/src/components/badge.tsx b/packages/dashboard-ui-components/src/components/badge.tsx index 037d78c958..73bd6d3bac 100644 --- a/packages/dashboard-ui-components/src/components/badge.tsx +++ b/packages/dashboard-ui-components/src/components/badge.tsx @@ -58,6 +58,22 @@ function getShowLabelShowIcon( } } +/** + * Small pill used for status tags, roles, categories, and other short + * labels. Not a variant-based component — pick a semantic `color` and + * optionally pass an `icon` (as a component type, not a rendered node). + * + * ```tsx + * + * + * + * ``` + * + * Notes: + * - Props are `label` + `color`, NOT `variant` + children. + * - `color` is one of: `"blue" | "cyan" | "purple" | "green" | "orange" | "red"`. + * - `icon` is optional but, if set via `contentMode: "icon"`, is required. + */ export function DesignBadge({ label, color, diff --git a/packages/dashboard-ui-components/src/components/button.tsx b/packages/dashboard-ui-components/src/components/button.tsx index 4edf200ebf..e60483eaa8 100644 --- a/packages/dashboard-ui-components/src/components/button.tsx +++ b/packages/dashboard-ui-components/src/components/button.tsx @@ -62,6 +62,20 @@ export type DesignButtonProps = { loadingStyle?: "spinner" | "disabled", } & DesignOriginalButtonProps; +/** + * Standard button. Variants: `default | destructive | outline | secondary | ghost | link | plain`. + * Sizes: `default | sm | lg | icon`. + * + * ```tsx + * Save + * Cancel + * + * ``` + * + * Pass an async `onClick` and the button will automatically show a spinner + * while the promise is pending (set `loadingStyle="disabled"` if you prefer + * a simple disabled state instead). + */ export const DesignButton = forwardRefIfNeeded( ({ onClick, loading: loadingProp, loadingStyle = "spinner", children, size, ...props }, ref) => { const [handleClick, isLoading] = useAsyncCallback(async (e: React.MouseEvent) => { diff --git a/packages/dashboard-ui-components/src/components/card.tsx b/packages/dashboard-ui-components/src/components/card.tsx index a4e63c9c52..d7af23494a 100644 --- a/packages/dashboard-ui-components/src/components/card.tsx +++ b/packages/dashboard-ui-components/src/components/card.tsx @@ -80,6 +80,34 @@ type WithoutTitleProps = { export type DesignCardProps = DesignCardBaseProps & (WithTitleProps | WithoutTitleProps); +/** + * General-purpose card for grouping related content: section headers, + * description blocks, chart-less panels, etc. If the content is a + * big-number metric, use `DesignMetricCard` instead. If it contains a + * chart, wrap it in `DesignChartCard`. + * + * Two shapes, picked automatically by the props you pass: + * + * ```tsx + * // With a header (title requires icon): + * + * ...body... + * + * + * // Body-only (no header): + * + *

Dashboard

+ *

Overview of your user base

+ *
+ * ``` + * + * Rules: + * - DO NOT add padding classes (`p-6`, `p-5`, etc.) to `className` — the + * component already has built-in padding and extra padding will look wrong. + * - If you set `title`, you MUST also set `icon`. The TS types enforce this, + * but writing `title` without `icon` will fail. + * - Body-only cards (no `title`) automatically go transparent in dark mode. + */ export function DesignCard({ title, subtitle, diff --git a/packages/dashboard-ui-components/src/components/chart-card.tsx b/packages/dashboard-ui-components/src/components/chart-card.tsx index 5e7bb7769a..71355d7f7b 100644 --- a/packages/dashboard-ui-components/src/components/chart-card.tsx +++ b/packages/dashboard-ui-components/src/components/chart-card.tsx @@ -20,6 +20,35 @@ export type DesignChartCardProps = { description?: React.ReactNode, } & Omit, "title">; +/** + * Card chrome (title + description + border) for a chart. Wrap every + * `AnalyticsChart` in this so the chart has context. Also used around raw + * Recharts components paired with `DesignChartContainer` for non-time-series + * fallbacks. + * + * ```tsx + * // Time-series chart (preferred): + * + * + * + * + * // Non-time-series fallback (static ranking, distribution, etc.): + * + * + * + * + * + * + * } /> + * + * + * + * + * ``` + * + * `chartConfig` for `DesignChartContainer` maps each `dataKey` to its label and color: + * `{ count: { label: "Count", color: getDesignChartColor(0) } }`. + */ export function DesignChartCard({ gradient = "default", title, diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid-sizing.ts b/packages/dashboard-ui-components/src/components/data-grid/data-grid-sizing.ts new file mode 100644 index 0000000000..e52cd466f0 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid-sizing.ts @@ -0,0 +1,40 @@ +import type { CSSProperties } from "react"; +import type { DataGridColumnDef } from "./types"; + +// CSS variable names for column widths set on the grid container. +// Cells read these via var(...) so a single setProperty during drag +// resizes every cell in a column with zero React re-renders. + +function colVar(id: string): `--col-${string}` { + return `--col-${id}`; +} + +export function getColumnSizingStyle(col: DataGridColumnDef): CSSProperties { + const w = `var(${colVar(col.id)})`; + return { flex: `0 0 ${w}`, width: w, minWidth: col.minWidth ?? 50, maxWidth: col.maxWidth ?? 800 }; +} + +export function createGridSizingStyle( + widths: ReadonlyMap, + totalWidth: number, +): Record { + const style: Record = { "--grid-total-w": `${totalWidth}px` }; + for (const [id, w] of widths) { + style[colVar(id)] = `${w}px`; + } + return style; +} + +export function applyDraggedColumnWidth( + el: HTMLElement, + columnId: string, + width: number, + totalWidth: number, +) { + el.style.setProperty(colVar(columnId), `${width}px`); + el.style.setProperty("--grid-total-w", `${totalWidth}px`); +} + +export function clampColumnWidth(col: DataGridColumnDef, width: number): number { + return Math.max(col.minWidth ?? 50, Math.min(col.maxWidth ?? 800, width)); +} diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx new file mode 100644 index 0000000000..80fc17d184 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid-toolbar.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { cn } from "@stackframe/stack-ui"; +import { + Check, + DownloadSimple, + Eye, + EyeSlash, + MagnifyingGlass, + X, +} from "@phosphor-icons/react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import type { + DataGridColumnDef, + DataGridDateDisplay, + DataGridStrings, + DataGridToolbarContext, +} from "./types"; + +// ─── Popover primitive ─────────────────────────────────────────────── + +function usePopover() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + React.useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + React.useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [open]); + + return { open, setOpen, ref }; +} + +function PopoverPanel({ + children, + className, + popoverRef, +}: { + children: React.ReactNode; + className?: string; + popoverRef: React.Ref; +}) { + return ( +
+ {children} +
+ ); +} + +// ─── Quick search ──────────────────────────────────────────────────── + +function QuickSearch({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( +
+ + onChange(e.target.value)} + /> + {value && ( + + )} +
+ ); +} + +// ─── Toolbar button ────────────────────────────────────────────────── + +function ToolbarButton({ + children, + onClick, + active, + className: extraClassName, +}: { + children: React.ReactNode; + onClick?: () => void; + active?: boolean; + className?: string; +}) { + return ( + + ); +} + +// ─── Column manager ────────────────────────────────────────────────── + +function ColumnManager({ + columns, + visibility, + onChange, + strings, + dateDisplay, + onDateDisplayChange, + hasDateColumns, +}: { + columns: readonly DataGridColumnDef[]; + visibility: Record; + onChange: (visibility: Record) => void; + strings: DataGridStrings; + dateDisplay: DataGridDateDisplay; + onDateDisplayChange: (mode: DataGridDateDisplay) => void; + hasDateColumns: boolean; +}) { + const hideableColumns = useMemo( + () => columns.filter((c) => c.hideable !== false), + [columns], + ); + + const toggleColumn = (id: string) => { + const current = visibility[id] !== false; + onChange({ ...visibility, [id]: !current }); + }; + + const showAll = () => { + const next = { ...visibility }; + for (const col of hideableColumns) next[col.id] = true; + onChange(next); + }; + + const hideAll = () => { + const next = { ...visibility }; + for (const col of hideableColumns) next[col.id] = false; + onChange(next); + }; + + return ( +
+
+ {hideableColumns.map((col) => { + const visible = visibility[col.id] !== false; + return ( + + ); + })} +
+
+ + | + +
+ + {/* Date format toggle — only rendered when at least one column + uses `type: "date"` or `"dateTime"`. Toggling writes to + `state.dateDisplay` and the grid re-renders every date cell. */} + {hasDateColumns && ( +
+
+ + {strings.dateFormat} + +
+ + +
+
+
+ )} +
+ ); +} + +// ─── Main toolbar ──────────────────────────────────────────────────── + +export function DataGridToolbar({ + ctx, + extra, +}: { + ctx: DataGridToolbarContext; + /** Extra content rendered inside the toolbar row, to the left of the + * built-in columns / export actions. Use this to add table-specific + * affordances (refresh, custom toggles, row counts) without giving up + * the default actions. */ + extra?: React.ReactNode; +}) { + const { state, onChange, columns, strings, exportCsv } = ctx; + + const columnPopover = usePopover(); + + const updateVisibility = useCallback( + (visibility: Record) => { + onChange((s) => ({ ...s, columnVisibility: visibility })); + }, + [onChange], + ); + + const updateDateDisplay = useCallback( + (mode: DataGridDateDisplay) => { + onChange((s) => ({ ...s, dateDisplay: mode })); + }, + [onChange], + ); + + const updateQuickSearch = useCallback( + (value: string) => { + onChange((s) => ({ + ...s, + quickSearch: value, + // Reset to first page whenever the search text changes, + // otherwise you can end up on a page index that no longer + // exists in the filtered / refetched result set. + pagination: { ...s.pagination, pageIndex: 0 }, + })); + }, + [onChange], + ); + + const hasDateColumns = useMemo( + () => columns.some((c) => c.type === "date" || c.type === "dateTime"), + [columns], + ); + + return ( +
+ +
+ + {extra} + +
+ columnPopover.setOpen(!columnPopover.open)} + active={columnPopover.open} + > + + {strings.columns} + + {columnPopover.open && ( + + + + )} +
+ + + + {strings.export} + +
+ ); +} diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx new file mode 100644 index 0000000000..fa9e7f41bc --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx @@ -0,0 +1,1252 @@ +"use client"; + +import { + ArrowDown, + ArrowUp, + CaretDown, + CaretUp, + CheckSquare, + MinusSquare, + Square, +} from "@phosphor-icons/react"; +import { cn } from "@stackframe/stack-ui"; +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; + +import { DesignSkeleton } from "../skeleton"; +import { DataGridToolbar } from "./data-grid-toolbar"; +import { + applyDraggedColumnWidth, + clampColumnWidth, + createGridSizingStyle, + getColumnSizingStyle, +} from "./data-grid-sizing"; +import { + clearSelection, + exportToCsv, + formatGridDate, + getSortDirection, + getSortIndex, + isColumnVisible, + resolveColumnValue, + resolveColumnWidth, + selectAll, + toggleRowSelection, + toggleSort, +} from "./state"; +import { resolveDataGridStrings } from "./strings"; +import type { + DataGridCellContext, + DataGridColumnDef, + DataGridDateDisplay, + DataGridFooterContext, + DataGridHeaderContext, + DataGridPaginationMode, + DataGridProps, + DataGridState, + DataGridStrings, + DataGridToolbarContext, + RowId +} from "./types"; +// ─── Resize handle ─────────────────────────────────────────────────── + +function ResizeHandle({ + onResize, + onResizeEnd, +}: { + onResize: (delta: number) => void; + onResizeEnd: () => void; +}) { + const startXRef = useRef(0); + const rafRef = useRef(0); + const latestDeltaRef = useRef(0); + const callbacksRef = useRef({ onResize, onResizeEnd }); + + callbacksRef.current = { onResize, onResizeEnd }; + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + startXRef.current = e.clientX; + latestDeltaRef.current = 0; + const el = e.currentTarget as HTMLElement; + el.setPointerCapture(e.pointerId); + let finished = false; + + const onMove = (ev: PointerEvent) => { + latestDeltaRef.current = ev.clientX - startXRef.current; + if (rafRef.current !== 0) { + return; + } + + rafRef.current = requestAnimationFrame(() => { + rafRef.current = 0; + callbacksRef.current.onResize(latestDeltaRef.current); + }); + }; + const finish = () => { + if (finished) return; + finished = true; + if (rafRef.current !== 0) { + cancelAnimationFrame(rafRef.current); + rafRef.current = 0; + callbacksRef.current.onResize(latestDeltaRef.current); + } + el.removeEventListener("pointermove", onMove); + el.removeEventListener("pointerup", finish); + el.removeEventListener("pointercancel", finish); + el.removeEventListener("lostpointercapture", finish); + if (el.hasPointerCapture(e.pointerId)) { + el.releasePointerCapture(e.pointerId); + } + callbacksRef.current.onResizeEnd(); + }; + + el.addEventListener("pointermove", onMove); + el.addEventListener("pointerup", finish); + el.addEventListener("pointercancel", finish); + el.addEventListener("lostpointercapture", finish); + }, + [], + ); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + onPointerDown={onPointerDown} + /> + ); +} + +// ─── Header cell ───────────────────────────────────────────────────── + +function HeaderCell({ + col, + isSorted, + sortIndex, + resizable, + onSort, + onResize, + onResizeEnd, +}: { + col: DataGridColumnDef; + isSorted: false | "asc" | "desc"; + sortIndex: number | null; + resizable: boolean; + onSort: (columnId: string, multi: boolean) => void; + onResize: (columnId: string, delta: number) => void; + onResizeEnd: () => void; +}) { + const ctx: DataGridHeaderContext = { + columnId: col.id, + columnDef: col, + isSorted, + sortIndex, + }; + const label = + typeof col.header === "function" ? col.header(ctx) : col.header; + + const sortable = col.sortable !== false; + + return ( +
sortable && onSort(col.id, e.metaKey || e.ctrlKey)} + role="columnheader" + aria-sort={isSorted === "asc" ? "ascending" : isSorted === "desc" ? "descending" : "none"} + > + + {label} + + + {/* Sort indicator */} + {isSorted && ( + + {isSorted === "asc" ? ( + + ) : ( + + )} + {sortIndex != null && ( + {sortIndex} + )} + + )} + + {/* Unsorted hint on hover */} + {!isSorted && sortable && ( + + + + + )} + + {/* Resize handle */} + {resizable && col.resizable !== false && ( + onResize(col.id, delta)} + onResizeEnd={onResizeEnd} + /> + )} +
+ ); +} + +// ─── Data cell ─────────────────────────────────────────────────────── + +function DataCell({ + col, + row, + rowId, + rowIndex, + isSelected, + dateDisplay, +}: { + col: DataGridColumnDef; + row: TRow; + rowId: RowId; + rowIndex: number; + isSelected: boolean; + dateDisplay: DataGridDateDisplay; +}) { + const value = resolveColumnValue(col, row); + const ctx: DataGridCellContext = { + row, + rowId, + rowIndex, + value, + columnId: col.id, + isSelected, + dateDisplay, + }; + + const isDateCol = col.type === "date" || col.type === "dateTime"; + let content: React.ReactNode; + if (col.renderCell) { + content = col.renderCell(ctx); + } else if (isDateCol) { + content = renderDateCell(value, dateDisplay, col); + } else { + content = formatCellValue(value); + } + const hasCellClick = col.onCellClick || col.onCellDoubleClick; + + return ( +
{ + e.stopPropagation(); + col.onCellClick!(ctx, e); + } : undefined} + onDoubleClick={col.onCellDoubleClick ? (e) => { + e.stopPropagation(); + col.onCellDoubleClick!(ctx, e); + } : undefined} + > + {content} +
+ ); +} + +function formatCellValue(value: unknown): React.ReactNode { + if (value == null) return -; + if (typeof value === "boolean") { + return ( + + {value ? "Yes" : "No"} + + ); + } + if (value instanceof Date) { + return ( + + {value.toLocaleDateString()} + + ); + } + return {String(value)}; +} + +/** Built-in date cell — mirrors what `formatGridDate` returns but wraps + * the display in a `` with a `title` tooltip showing the absolute + * datetime. Only used when the column has `type: "date" | "dateTime"` + * and no custom `renderCell`. */ +function renderDateCell( + value: unknown, + dateDisplay: DataGridDateDisplay, + col: DataGridColumnDef, +): React.ReactNode { + const { display, tooltip } = formatGridDate(value, dateDisplay, { + parseValue: col.parseValue, + dateFormat: col.dateFormat, + }); + if (display == null) return -; + return ( + + {display} + + ); +} + +// ─── Skeleton row ──────────────────────────────────────────────────── + +function SkeletonRow({ + columns, + height, + showCheckbox, +}: { + columns: readonly DataGridColumnDef[]; + height: number; + showCheckbox?: boolean; +}) { + return ( +
+ {showCheckbox && ( +
+ +
+ )} + {columns.map((col) => ( +
+ +
+ ))} +
+ ); +} + +// ─── Checkbox cell ─────────────────────────────────────────────────── + +function SelectionCheckbox({ + checked, + indeterminate, + onChange, + ariaLabel, +}: { + checked: boolean; + indeterminate?: boolean; + onChange: (event: React.MouseEvent) => void; + ariaLabel: string; +}) { + const Icon = indeterminate ? MinusSquare : checked ? CheckSquare : Square; + return ( + + ); +} + +// ─── Infinite scroll sentinel ──────────────────────────────────────── + +function InfiniteScrollSentinel({ + onIntersect, + isLoading, + strings, +}: { + onIntersect: () => void; + isLoading: boolean; + strings: DataGridStrings; +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + onIntersect(); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [onIntersect]); + + return ( +
+ {isLoading && ( +
+
+ {strings.loadingMore} +
+ )} +
+ ); +} + +// ─── Footer ────────────────────────────────────────────────────────── + +function DefaultFooter({ + ctx, + pagination, + onChange, +}: { + ctx: DataGridFooterContext; + pagination: DataGridPaginationMode; + onChange: React.Dispatch>; +}) { + const { state, totalRowCount, visibleRowCount, selectedRowCount, strings } = ctx; + const totalPages = totalRowCount != null + ? Math.max(1, Math.ceil(totalRowCount / state.pagination.pageSize)) + : undefined; + + const setPage = (pageIndex: number) => + onChange((s) => ({ + ...s, + pagination: { ...s.pagination, pageIndex }, + })); + + const setPageSize = (pageSize: number) => + onChange((s) => ({ + ...s, + pagination: { ...s.pagination, pageSize, pageIndex: 0 }, + })); + + return ( +
+
+ {selectedRowCount > 0 && ( + + {strings.rowsSelected(selectedRowCount)} + + )} + {totalRowCount != null && ( + + {visibleRowCount} of {totalRowCount} rows + + )} +
+ + {pagination !== "infinite" && totalPages != null && ( +
+ {/* Page size selector */} +
+ {strings.rowsPerPage} + +
+ + {/* Page navigation */} +
+ + + {strings.pageOf(state.pagination.pageIndex + 1, totalPages)} + + +
+
+ )} +
+ ); +} + +// ─── Main DataGrid ─────────────────────────────────────────────────── + +/** + * Interactive table with sorting, quick search, pagination, selection, + * and virtualization. Handles 10k+ rows smoothly. Pair with + * `useDataSource` for client-side data; use an async `dataSource` + * generator for server or infinite-scroll modes. + * + * ## Mental model (read this first — everything else depends on it) + * + * DataGrid is a **display** component. It does NOT sort, search, or + * paginate your data directly — you own that, but `useDataSource` does + * it for you. The `rows` prop is always the already-processed slice to + * show. The grid tracks user intent in `state` (sort model, quick + * search text, page index). You feed that state into `useDataSource`, + * and its output goes back in as `rows`. + * + * `useDataSource` IS the processor. Given your full dataset and the + * grid's state, it returns the searched + sorted + paginated rows + * ready to pass to DataGrid. This is the ONLY correct pattern for + * client-side data — do NOT pass a raw array to `rows`. + * + * ## Search (client vs async) + * + * - **Client mode** (`useDataSource` with `data`): a case-insensitive + * substring match across every column is applied automatically. + * Override the matcher with `matchRow` for fuzzy / weighted search, + * or disable by passing `matchRow: () => true`. + * - **Async mode** (`useDataSource` with `dataSource`): `state.quickSearch` + * is forwarded to the generator as `params.quickSearch`. Same + * mechanism as `params.sorting` — a change triggers a refetch, and + * the generator is the "matching logic" (typically a WHERE / ILIKE + * clause in the backend query). The grid does NO client-side + * filtering in async mode. + * + * ## The canonical pattern + * + * ```tsx + * // 1. Columns — define OUTSIDE the component or inside a useMemo. Must be stable. + * const columns = React.useMemo(() => [ + * { id: "name", header: "Name", accessor: "name", width: 180, type: "string" }, + * { id: "email", header: "Email", accessor: "email", width: 240, type: "string" }, + * { id: "role", header: "Role", accessor: "role", width: 120, type: "singleSelect", + * valueOptions: [{ value: "admin", label: "Admin" }, { value: "member", label: "Member" }] }, + * { id: "signUps", header: "Sign-ups", accessor: "signUps", width: 120, type: "number", align: "right", + * renderCell: ({ value }) => {Number(value).toLocaleString()} }, + * ], []); + * + * // 2. Grid state — one hook, initialized from the columns. NEVER build the state object by hand. + * const [gridState, setGridState] = React.useState(() => createDefaultDataGridState(columns)); + * + * // 3. Data source — wires your raw array through the grid state. ALWAYS call this + * // hook unconditionally at the top level (no if/return before it). + * const gridData = useDataSource({ + * data: users, // your raw array (can be [] while loading) + * columns, + * getRowId: (row) => row.id, + * sorting: gridState.sorting, + * quickSearch: gridState.quickSearch, + * pagination: gridState.pagination, + * paginationMode: "client", // "client" | "server" | "infinite" + * }); + * + * // 4. Render — `rows` comes from gridData.rows, NOT from your raw array. + * row.id} + * totalRowCount={gridData.totalRowCount} + * isLoading={gridData.isLoading} + * state={gridState} + * onChange={setGridState} + * selectionMode="none" // "none" | "single" | "multiple" + * maxHeight={480} + * /> + * ``` + * + * ## Iron rules (violating any of these breaks the grid) + * + * 1. The prop is `rows`, NOT `data`. There is no `data` prop on DataGrid. + * `data` belongs on `useDataSource`. + * 2. `rows` is ALWAYS `gridData.rows`. Never pass your raw array to + * `rows` — the grid won't search, sort, or paginate it. + * 3. Columns must be stable across renders. Define them outside the + * component or wrap in `React.useMemo`. A fresh columns array every + * render will reset sorting state. + * 4. Initialize grid state with `createDefaultDataGridState(columns)`. + * Do NOT spell out the state object manually — you will miss fields + * and crash. + * 5. `onChange` takes a `SetStateAction` (the setter you got from + * `useState`). Pass `setGridState` directly. Do NOT wrap it unless + * you know exactly what you're doing. + * 6. Call `useDataSource` ONCE per grid, at the top level, before any + * early return. It contains hooks. + * 7. `renderCell` is a PURE function of its context. NEVER call React + * hooks inside it (no `useState`, `useMemo`, `useEffect`, nothing). + * If you need derived data per row, compute it BEFORE the render — + * e.g. build a `Map` in a `useMemo` and look + * it up in `renderCell`. + * 8. `toolbar` accepts `false` (hide it) or a render function + * `(ctx) => ReactNode`. Anything else — `true`, `undefined`, a state + * variable — will either show the default toolbar or crash. If you + * just want the default toolbar, omit the prop entirely. + * 9. The toolbar's search input writes to `state.quickSearch`. That + * value is consumed by `useDataSource` — client mode filters + * client-side, async mode forwards to the generator. Do NOT wire + * a separate "controlled" search prop, everything flows through + * grid state. + * + * ## renderCell — what you can and cannot do inside it + * + * ```tsx + * // OK — pure rendering from ctx: + * renderCell: ({ value }) => {Number(value).toLocaleString()} + * renderCell: ({ row }) => {row.status} + * + * // OK — looking up pre-computed data by row id: + * // BEFORE the return, in the component body: + * const sparklinesById = React.useMemo(() => { + * const m = new Map(); + * for (const u of users) { + * m.set(u.id, u.recentActivity.map((n, i) => ({ ts: i, values: { primary: n } }))); + * } + * return m; + * }, [users]); + * // Then inside the column def: + * renderCell: ({ rowId }) => + * + * // NOT OK — hooks inside renderCell: + * renderCell: ({ row }) => { + * const [hovered, setHovered] = React.useState(false); // ← crashes the grid + * const data = React.useMemo(() => ..., []); // ← crashes the grid + * return ...; + * } + * + * // NOT OK — embedding AnalyticsChart (or any other controlled, stateful chart) per row: + * // AnalyticsChart owns its own state, tooltips, zoom, and virtualized data + * // pipeline. Instantiating one per row is expensive and fights the grid's + * // virtualizer. Don't do it. + * ``` + * + * ## Sparklines and mini-charts in cells — use raw Recharts + * + * If you want a tiny chart (sparkline, micro bar chart, trend line) inside + * a cell, drop down to raw `Recharts.*` components — they are lightweight + * and stateless, so they render cleanly per row without owning any state. + * Read pre-computed points off the row (or off a `Map` you + * built in a `useMemo` above) and pass them directly to the Recharts + * primitive. Do NOT wrap them in `DesignChartContainer` or + * `DesignChartCard` inside a cell — those add chrome meant for full-size + * charts. + * + * ```tsx + * // OK — raw Recharts sparkline per row: + * renderCell: ({ rowId }) => { + * const points = sparklinesById.get(rowId) ?? []; + * return ( + * + * + * + * + * + * ); + * } + * ``` + * + * Keep in-cell Recharts configs minimal: no axes, no tooltips, no animation + * (`isAnimationActive={false}`), tight margins, fixed height. The goal is a + * visual summary, not an interactive chart. + * + * ## State shape (from `createDefaultDataGridState`) + * + * ```ts + * { + * sorting: [], // { columnId, direction: "asc" | "desc" }[] + * quickSearch: "", // search input text + * dateDisplay: "relative", // "relative" | "absolute" + * columnVisibility: {}, columnWidths: {...}, + * columnPinning: { left: [], right: [] }, columnOrder: [...], + * pagination: { pageIndex: 0, pageSize: 50 }, + * selection: { selectedIds: new Set(), anchorId: null }, + * } + * ``` + * + * Everything is updated through `setGridState` — the toolbar, header, + * and footer all call it for you. You do not need to wire any of this + * manually. + * + * ## Height and scrolling + * + * DataGrid is NOT a card. It has no border, rounded corners, or shadow of + * its own. Wrap it in whatever chrome you want — a `DesignCard`, a section, + * or just raw layout. The grid itself fills its parent's height via + * `h-full`. + * + * How the grid gets its height (pick ONE): + * 1. Bounded parent — put the grid inside a flex/grid container with a + * definite height (e.g. `flex-1 min-h-0` inside a page-filling flex + * column). The grid stretches to that height and scrolls its body. + * 2. `maxHeight` prop — pass a number (pixels) or CSS string + * (`"480px"`, `"60vh"`, `"100%"`). The grid caps at that size and + * scrolls its body. + * 3. Unbounded — omit `maxHeight` and let the parent grow freely. The + * grid renders at its full content height and the page scrolls. Fine + * for small lists; bad UX for thousands of rows. + * + * The toolbar, header, and footer are always `shrink-0`; only the body + * scrolls. You do NOT need to subtract toolbar/footer heights from + * `maxHeight` — the grid's internal flex layout handles that. + * + * ## When to use what + * + * - Simple static list, < 20 rows, no interaction → use a plain table component instead. + * - Interactive table, sortable + searchable, any size → `DataGrid` + + * `useDataSource` with `paginationMode: "client"`. + * - Infinite scroll over a huge dataset you fetch in pages → `dataSource` async + * generator + `paginationMode: "infinite"`. Only reach for this if you actually + * need pagination over a remote source. For anything that fits in memory, + * `"client"` is simpler and faster. + * + * ## Features you get for free + * + * Quick search, sortable columns (shift-click for multi-sort), column + * visibility toggle, column resize, CSV export, virtualized rendering + * for 10k+ rows, keyboard navigation, and a relative/absolute date + * toggle for `date` / `dateTime` columns. + */ +export function DataGrid(props: DataGridProps) { + const { + columns: allColumns, + rows, + getRowId, + totalRowCount, + isLoading = false, + isRefetching = false, + hasMore = false, + isLoadingMore = false, + onLoadMore, + state, + onChange, + paginationMode = "paginated", + selectionMode = "none", + resizable = true, + rowHeight = 44, + headerHeight = 44, + overscan = 5, + maxHeight, + toolbar, + toolbarExtra, + emptyState, + loadingState, + footer, + footerExtra, + exportFilename = "export", + strings: stringsOverride, + className, + // Callbacks + onRowClick, + onRowDoubleClick, + onSelectionChange, + onSortChange, + } = props; + + const strings = useMemo( + () => resolveDataGridStrings(stringsOverride), + [stringsOverride], + ); + + // ── Visible columns ────────────────────────────────────────── + const visibleColumns = useMemo( + () => + (state.columnOrder.length > 0 + ? state.columnOrder + .map((id) => allColumns.find((c) => c.id === id)) + .filter(Boolean) as DataGridColumnDef[] + : allColumns + ).filter((col) => isColumnVisible(col.id, state.columnVisibility)), + [allColumns, state.columnOrder, state.columnVisibility], + ); + + // ── Row IDs (stable) ───────────────────────────────────────── + const rowIds = useMemo(() => rows.map(getRowId), [rows, getRowId]); + + // ── Column widths ──────────────────────────────────────────── + const visibleColumnMetrics = useMemo(() => { + const widths = new Map(); + let totalWidth = selectionMode !== "none" ? 44 : 0; + + for (const col of visibleColumns) { + const width = resolveColumnWidth(col, state.columnWidths[col.id]); + widths.set(col.id, width); + totalWidth += width; + } + + return { widths, totalWidth }; + }, [selectionMode, state.columnWidths, visibleColumns]); + + const gridSizingStyle = useMemo( + () => createGridSizingStyle(visibleColumnMetrics.widths, visibleColumnMetrics.totalWidth), + [visibleColumnMetrics], + ); + + // Resize drag tracked via ref — zero React re-renders during drag. + // CSS variables on gridRef are mutated directly; committed on pointer up. + const resizeRef = useRef<{ columnId: string; baseWidth: number; baseTotalWidth: number; latestWidth: number } | null>(null); + const gridRef = useRef(null); + + // ── Handlers ───────────────────────────────────────────────── + const handleSort = useCallback( + (columnId: string, multi: boolean) => { + onChange((s) => { + const next = toggleSort(s.sorting, columnId, multi); + onSortChange?.(next); + return { ...s, sorting: next }; + }); + }, + [onChange, onSortChange], + ); + + const handleResize = useCallback( + (columnId: string, delta: number) => { + const col = allColumns.find((c) => c.id === columnId); + if (!col) return; + if (!resizeRef.current || resizeRef.current.columnId !== columnId) { + const baseWidth = visibleColumnMetrics.widths.get(columnId) ?? resolveColumnWidth(col, state.columnWidths[columnId]); + resizeRef.current = { columnId, baseWidth, baseTotalWidth: visibleColumnMetrics.totalWidth, latestWidth: baseWidth }; + } + const newWidth = clampColumnWidth(col, resizeRef.current.baseWidth + delta); + resizeRef.current.latestWidth = newWidth; + if (gridRef.current) { + applyDraggedColumnWidth(gridRef.current, columnId, newWidth, resizeRef.current.baseTotalWidth + (newWidth - resizeRef.current.baseWidth)); + } + }, + [allColumns, state.columnWidths, visibleColumnMetrics], + ); + + // Re-apply CSS vars after React re-renders (e.g. sort during drag) + useLayoutEffect(() => { + const r = resizeRef.current; + if (r && gridRef.current) { + applyDraggedColumnWidth(gridRef.current, r.columnId, r.latestWidth, r.baseTotalWidth + (r.latestWidth - r.baseWidth)); + } + }, [gridSizingStyle]); + + const handleResizeEnd = useCallback(() => { + const r = resizeRef.current; + resizeRef.current = null; + if (!r || r.latestWidth === r.baseWidth) return; + onChange((s) => ({ ...s, columnWidths: { ...s.columnWidths, [r.columnId]: r.latestWidth } })); + }, [onChange]); + + const handleRowClick = useCallback( + (row: TRow, rowId: RowId, event: React.MouseEvent) => { + // Selection + if (selectionMode !== "none") { + onChange((s) => { + const next = toggleRowSelection( + s.selection, + rowId, + selectionMode, + event.shiftKey, + event.metaKey || event.ctrlKey, + rowIds, + ); + // Fire callback after state update + if (onSelectionChange) { + const selectedRows = rows.filter((r) => + next.selectedIds.has(getRowId(r)), + ); + setTimeout(() => onSelectionChange(next.selectedIds, selectedRows), 0); + } + return { ...s, selection: next }; + }); + } + + onRowClick?.(row, rowId, event); + }, + [selectionMode, onChange, onRowClick, onSelectionChange, rowIds, rows, getRowId], + ); + + const handleRowSelectionCheckboxClick = useCallback( + ( + row: TRow, + rowId: RowId, + event: React.MouseEvent, + ) => { + handleRowClick(row, rowId, event); + }, + [handleRowClick], + ); + + const handleSelectAll = useCallback(() => { + onChange((s) => { + const allSelected = rowIds.every((id) => s.selection.selectedIds.has(id)); + const next = allSelected ? clearSelection() : selectAll(rowIds); + if (onSelectionChange) { + const selectedRows = allSelected + ? [] + : rows; + setTimeout(() => onSelectionChange(next.selectedIds, [...selectedRows]), 0); + } + return { ...s, selection: next }; + }); + }, [onChange, rowIds, rows, onSelectionChange]); + + const handleExportCsv = useCallback(() => { + exportToCsv(rows, visibleColumns, exportFilename); + }, [rows, visibleColumns, exportFilename]); + + // ── Virtualizer ────────────────────────────────────────────── + const scrollContainerRef = useRef(null); + const headerScrollRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => rowHeight, + overscan, + }); + + // Sync horizontal scroll from body to header + const handleBodyScroll = useCallback(() => { + const body = scrollContainerRef.current; + const header = headerScrollRef.current; + if (body && header) { + header.scrollLeft = body.scrollLeft; + } + }, []); + + // ── Toolbar context ────────────────────────────────────────── + const toolbarCtx: DataGridToolbarContext = useMemo( + () => ({ + state, + onChange, + columns: allColumns, + visibleColumns, + totalRowCount, + selectedRowCount: state.selection.selectedIds.size, + strings, + exportCsv: handleExportCsv, + }), + [state, onChange, allColumns, visibleColumns, totalRowCount, strings, handleExportCsv], + ); + + // ── Footer context ─────────────────────────────────────────── + const footerCtx: DataGridFooterContext = useMemo( + () => ({ + state, + totalRowCount, + visibleRowCount: rows.length, + selectedRowCount: state.selection.selectedIds.size, + paginationMode, + strings, + }), + [state, totalRowCount, rows.length, paginationMode, strings], + ); + + // ── Selection state for header checkbox ────────────────────── + const allSelected = rowIds.length > 0 && rowIds.every((id) => state.selection.selectedIds.has(id)); + const someSelected = !allSelected && rowIds.some((id) => state.selection.selectedIds.has(id)); + + // ── Render ─────────────────────────────────────────────────── + // + // Height model: + // - Root is `flex flex-col h-full min-h-0 bg-transparent`. `h-full` + // makes the grid fill a bounded parent; in an unbounded parent it + // resolves to `auto` and the grid takes the content's intrinsic size. + // - Toolbar/header/footer are `shrink-0`; the scroll body is + // `flex-1 min-h-0 overflow-auto`, so the scroll area naturally takes + // whatever space remains after the fixed-size chrome — regardless of + // toolbar/footer size. + // - `maxHeight` is applied directly to the root; the scroll body never + // subtracts chrome sizes manually (that math breaks when the toolbar + // wraps, the footer grows, etc.). + return ( +
+ {/* Toolbar. When a custom `toolbar` render function is supplied, + it owns the whole row — `toolbarExtra` is ignored because the + custom render can consume `ctx` and include whatever it wants. + When the default toolbar is used, `toolbarExtra` is injected + into its row to the left of the columns / export actions. */} + {toolbar !== false && ( +
+ {toolbar ? ( + toolbar(toolbarCtx) + ) : ( + + )} +
+ )} + + {/* Grid content */} +
+ {/* Refetch indicator — thin progress bar, no layout shift */} + {isRefetching && ( +
+
+
+ )} + + {/* Header row — separate from scroll body, syncs horizontal scroll */} +
+
+ {selectionMode !== "none" && ( +
+ {selectionMode === "multiple" && ( + + )} +
+ )} + {visibleColumns.map((col) => ( + + ))} +
+
+ + {/* Scrollable body — flex-1 + min-h-0 makes it take the remaining + space inside the `flex flex-col` grid wrapper, no manual math. */} +
+ {/* Loading initial */} + {isLoading && ( +
+ {loadingState ?? + Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} + + {/* Empty state */} + {!isLoading && rows.length === 0 && ( +
+ {emptyState ?? strings.noData} +
+ )} + + {/* Virtualized rows */} + {!isLoading && rows.length > 0 && ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]!; + const rowId = getRowId(row); + const isSelected = state.selection.selectedIds.has(rowId); + + const isOddRow = virtualRow.index % 2 === 1; + return ( +
handleRowClick(row, rowId, e)} + onDoubleClick={(e) => onRowDoubleClick?.(row, rowId, e)} + role="row" + aria-rowindex={virtualRow.index + 2} + aria-selected={isSelected} + data-row-id={rowId} + data-state={isSelected ? "selected" : undefined} + > + {/* Selection checkbox */} + {selectionMode !== "none" && ( +
+ handleRowSelectionCheckboxClick(row, rowId, event)} + ariaLabel={`Select row ${rowId}`} + /> +
+ )} + + {/* Data cells */} + {visibleColumns.map((col) => ( + + ))} +
+ ); + })} +
+ )} + + {/* Infinite scroll sentinel */} + {paginationMode === "infinite" && hasMore && !isLoading && ( + {})} + isLoading={isLoadingMore} + strings={strings} + /> + )} +
+
+ + {/* Footer */} + {footer !== false && ( +
+ {footer ? ( + footer(footerCtx) + ) : ( + + )} + {footerExtra && ( + typeof footerExtra === "function" ? footerExtra(footerCtx) : footerExtra + )} +
+ )} +
+ ); +} diff --git a/packages/dashboard-ui-components/src/components/data-grid/index.ts b/packages/dashboard-ui-components/src/components/data-grid/index.ts new file mode 100644 index 0000000000..ef64820035 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/index.ts @@ -0,0 +1,68 @@ +export { DataGrid } from "./data-grid"; + +export { DataGridToolbar } from "./data-grid-toolbar"; + +export { useDataSource } from "./use-data-source"; +export type { UseDataSourceResult } from "./use-data-source"; + +export { + createDefaultDataGridState, + resolveColumnValue, + resolveColumnWidth, + isColumnVisible, + toggleSort, + getSortDirection, + getSortIndex, + buildRowComparator, + paginateRows, + getTotalPages, + toggleRowSelection, + selectAll, + clearSelection, + exportToCsv, + defaultParseDate, + defaultFormatRelative, + defaultFormatAbsolute, + formatGridDate, + defaultMatchRow, + applyQuickSearch, + EMPTY_SORT_MODEL, + EMPTY_SELECTION, + DEFAULT_PAGINATION, +} from "./state"; + +export { + DATA_GRID_DEFAULT_STRINGS, + resolveDataGridStrings, +} from "./strings"; + +export type { + RowId, + DataGridColumnType, + DataGridColumnAlign, + DataGridColumnPin, + DataGridDateDisplay, + DataGridDateFormat, + DataGridCellContext, + DataGridHeaderContext, + DataGridColumnDef, + DataGridSelectOption, + DataGridSortItem, + DataGridSortModel, + DataGridSelectionMode, + DataGridSelectionModel, + DataGridColumnVisibility, + DataGridColumnPinning, + DataGridPaginationMode, + DataGridDataPaginationMode, + DataGridPaginationModel, + DataGridState, + DataGridFetchParams, + DataGridFetchResult, + DataGridDataSource, + DataGridCallbacks, + DataGridProps, + DataGridToolbarContext, + DataGridFooterContext, + DataGridStrings, +} from "./types"; diff --git a/packages/dashboard-ui-components/src/components/data-grid/state.ts b/packages/dashboard-ui-components/src/components/data-grid/state.ts new file mode 100644 index 0000000000..1e0047f165 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/state.ts @@ -0,0 +1,414 @@ +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import type { + DataGridColumnDef, + DataGridDateDisplay, + DataGridDateFormat, + DataGridPaginationModel, + DataGridSelectionModel, + DataGridSortModel, + DataGridState, +} from "./types"; + +// ─── Default state ─────────────────────────────────────────────────── + +export const EMPTY_SORT_MODEL: DataGridSortModel = []; +export const EMPTY_SELECTION: DataGridSelectionModel = { + selectedIds: new Set(), + anchorId: null, +}; +export const DEFAULT_PAGINATION: DataGridPaginationModel = { + pageIndex: 0, + pageSize: 50, +}; + +/** + * Build the initial `DataGridState` for a set of columns. Pass this as the + * lazy initializer to `useState` — NEVER hand-assemble the state object. + * + * ```tsx + * const [gridState, setGridState] = React.useState(() => + * createDefaultDataGridState(columns) + * ); + * ``` + * + * `columns` must be defined BEFORE this call (obvious, but a common TDZ + * mistake: if you declare columns after the `useState`, you'll crash on + * the first render). Keep the columns reference stable across renders + * (define them outside the component or wrap in `React.useMemo`). + */ +export function createDefaultDataGridState( + columns: readonly DataGridColumnDef[], +): DataGridState { + const columnWidths: Record = {}; + const columnOrder: string[] = []; + + for (const col of columns) { + columnWidths[col.id] = col.width ?? 150; + columnOrder.push(col.id); + } + + return { + sorting: EMPTY_SORT_MODEL, + columnVisibility: {}, + columnWidths, + columnPinning: { left: [], right: [] }, + columnOrder, + pagination: DEFAULT_PAGINATION, + selection: EMPTY_SELECTION, + dateDisplay: "relative", + quickSearch: "", + }; +} + +// ─── Column helpers ────────────────────────────────────────────────── + +export function resolveColumnValue( + col: DataGridColumnDef, + row: TRow, +): unknown { + if (typeof col.accessor === "function") return col.accessor(row); + const key = (col.accessor ?? col.id) as keyof TRow; + return row[key]; +} + +export function resolveColumnWidth( + col: DataGridColumnDef, + storedWidth: number | undefined, +): number { + return storedWidth ?? col.width ?? 150; +} + +export function isColumnVisible( + columnId: string, + visibility: Record, +): boolean { + return visibility[columnId] !== false; +} + +// ─── Sort helpers ──────────────────────────────────────────────────── + +export function toggleSort( + model: DataGridSortModel, + columnId: string, + multiSort: boolean, +): DataGridSortModel { + const existing = model.find((s) => s.columnId === columnId); + + if (!existing) { + const item = { columnId, direction: "asc" as const }; + return multiSort ? [...model, item] : [item]; + } + + if (existing.direction === "asc") { + const updated = { columnId, direction: "desc" as const }; + return model.map((s) => (s.columnId === columnId ? updated : s)); + } + + // desc → remove + return model.filter((s) => s.columnId !== columnId); +} + +export function getSortDirection( + model: DataGridSortModel, + columnId: string, +): false | "asc" | "desc" { + const item = model.find((s) => s.columnId === columnId); + return item ? item.direction : false; +} + +export function getSortIndex( + model: DataGridSortModel, + columnId: string, +): number | null { + if (model.length <= 1) return null; + const idx = model.findIndex((s) => s.columnId === columnId); + return idx >= 0 ? idx + 1 : null; +} + +// ─── Default sort comparator ───────────────────────────────────────── + +function defaultComparator(a: unknown, b: unknown): number { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + + if (typeof a === "number" && typeof b === "number") return a - b; + if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime(); + return stringCompare(String(a), String(b)); +} + +export function buildRowComparator( + sortModel: DataGridSortModel, + columns: readonly DataGridColumnDef[], +): ((a: TRow, b: TRow) => number) | null { + if (sortModel.length === 0) return null; + + const colMap = new Map(columns.map((c) => [c.id, c])); + + return (a, b) => { + for (const { columnId, direction } of sortModel) { + const col = colMap.get(columnId); + if (!col) continue; + + const va = resolveColumnValue(col, a); + const vb = resolveColumnValue(col, b); + const cmp = col.sortComparator + ? col.sortComparator(va, vb) + : defaultComparator(va, vb); + if (cmp !== 0) return direction === "asc" ? cmp : -cmp; + } + return 0; + }; +} + +// ─── Pagination helpers ────────────────────────────────────────────── + +export function paginateRows( + rows: readonly TRow[], + pagination: DataGridPaginationModel, +): TRow[] { + const start = pagination.pageIndex * pagination.pageSize; + return rows.slice(start, start + pagination.pageSize) as TRow[]; +} + +export function getTotalPages( + totalRows: number, + pageSize: number, +): number { + return Math.max(1, Math.ceil(totalRows / pageSize)); +} + +// ─── Selection helpers ─────────────────────────────────────────────── + +export function toggleRowSelection( + selection: DataGridSelectionModel, + rowId: string, + mode: "single" | "multiple", + shiftKey: boolean, + ctrlKey: boolean, + allRowIds: readonly string[], +): DataGridSelectionModel { + if (mode === "single") { + const isSelected = selection.selectedIds.has(rowId); + return { + selectedIds: isSelected ? new Set() : new Set([rowId]), + anchorId: isSelected ? null : rowId, + }; + } + + // Multiple mode + if (shiftKey && selection.anchorId != null) { + const anchorIdx = allRowIds.indexOf(selection.anchorId); + const currentIdx = allRowIds.indexOf(rowId); + if (anchorIdx >= 0 && currentIdx >= 0) { + const start = Math.min(anchorIdx, currentIdx); + const end = Math.max(anchorIdx, currentIdx); + const rangeIds = allRowIds.slice(start, end + 1); + + const next = ctrlKey ? new Set(selection.selectedIds) : new Set(); + for (const id of rangeIds) next.add(id); + + return { selectedIds: next, anchorId: selection.anchorId }; + } + } + + if (ctrlKey) { + // Toggle single in multi mode + const next = new Set(selection.selectedIds); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return { selectedIds: next, anchorId: rowId }; + } + + // Plain click in multi mode — select only this row + return { + selectedIds: new Set([rowId]), + anchorId: rowId, + }; +} + +export function selectAll( + allRowIds: readonly string[], +): DataGridSelectionModel { + return { + selectedIds: new Set(allRowIds), + anchorId: null, + }; +} + +export function clearSelection(): DataGridSelectionModel { + return EMPTY_SELECTION; +} + +// ─── Quick search ──────────────────────────────────────────────────── + +/** Default row matcher used by `applyQuickSearch`. Case-insensitive + * substring match across every column's resolved cell value. Columns + * with `null` / `undefined` values are skipped. The query is expected + * to be pre-trimmed and lowercased by `applyQuickSearch` — this helper + * does NOT trim or lowercase it again, so if you wire it up yourself, + * do that first. */ +export function defaultMatchRow( + row: TRow, + query: string, + columns: readonly DataGridColumnDef[], +): boolean { + for (const col of columns) { + const v = resolveColumnValue(col, row); + if (v == null) continue; + if (String(v).toLowerCase().includes(query)) return true; + } + return false; +} + +/** Client-side quick-search filter. Returns the original array + * reference when `query` is empty, so calling this in a hot `useMemo` + * is cheap in the common "no search" case. + * + * Used by `useDataSource` in client mode. Exported so consumers driving + * the grid manually (or doing their own pre-filtering before feeding + * rows to an async data source) can stay consistent with the built-in + * search behaviour. + * + * Override `matchRow` for custom matching logic — e.g. fuzzy matching, + * field-specific weighting, or skipping some columns. */ +export function applyQuickSearch( + rows: readonly TRow[], + query: string, + columns: readonly DataGridColumnDef[], + matchRow: ( + row: TRow, + query: string, + columns: readonly DataGridColumnDef[], + ) => boolean = defaultMatchRow, +): readonly TRow[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return rows; + return rows.filter((r) => matchRow(r, trimmed, columns)); +} + +// ─── Date helpers ──────────────────────────────────────────────────── + +/** Parse a raw cell value into a `Date`. Returns `null` for nullish, + * unparseable, or invalid dates. Accepts strings (including ISO and + * "YYYY-MM-DD HH:MM:SS"-style ClickHouse output), numbers (ms since + * epoch), and `Date` instances. For truly weird formats, override via + * `col.parseValue`. */ +export function defaultParseDate(value: unknown): Date | null { + if (value == null) return null; + if (value instanceof Date) return isNaN(value.getTime()) ? null : value; + if (typeof value === "number") { + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + if (typeof value === "string") { + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + return null; +} + +const DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [ + { amount: 60, unit: "second" }, + { amount: 60, unit: "minute" }, + { amount: 24, unit: "hour" }, + { amount: 7, unit: "day" }, + { amount: 4.34524, unit: "week" }, + { amount: 12, unit: "month" }, + { amount: Number.POSITIVE_INFINITY, unit: "year" }, +]; + +/** Default relative formatter — "1 day ago" / "in 2 hours" via + * `Intl.RelativeTimeFormat`. Pure function of the date; does NOT + * re-render as real time passes. */ +export function defaultFormatRelative(date: Date): string { + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + let duration = (date.getTime() - Date.now()) / 1000; + for (const div of DIVISIONS) { + if (Math.abs(duration) < div.amount) { + return rtf.format(Math.round(duration), div.unit); + } + duration /= div.amount; + } + return rtf.format(Math.round(duration), "year"); +} + +/** Default absolute formatter — full locale date + time. */ +export function defaultFormatAbsolute(date: Date): string { + return date.toLocaleString(); +} + +/** Format a raw cell value for display in a `date` / `dateTime` column. + * Returns both the inline display string and the tooltip string (which + * is always the absolute form so users can read the exact datetime). + * + * Used internally by the grid's default date cell renderer, and exported + * so consumers writing a custom `renderCell` for a date column can stay + * visually consistent with the built-in behaviour. + * + * ```tsx + * renderCell: ({ value, dateDisplay }) => { + * const { display, tooltip } = formatGridDate(value, dateDisplay); + * if (!display) return ; + * return {display}; + * } + * ``` */ +export function formatGridDate( + value: unknown, + mode: DataGridDateDisplay, + opts?: { + parseValue?: (value: unknown) => Date | null; + dateFormat?: DataGridDateFormat; + }, +): { display: string | null; tooltip: string | null } { + const parse = opts?.parseValue ?? defaultParseDate; + const date = parse(value); + if (!date) return { display: null, tooltip: null }; + + const relative = opts?.dateFormat?.relative ?? defaultFormatRelative; + const absolute = opts?.dateFormat?.absolute ?? defaultFormatAbsolute; + + const tooltip = absolute(date); + const display = mode === "relative" ? relative(date) : tooltip; + return { display, tooltip }; +} + +// ─── CSV Export ────────────────────────────────────────────────────── + +export function exportToCsv( + rows: readonly TRow[], + columns: readonly DataGridColumnDef[], + filename: string, +): void { + const header = columns.map((col) => + typeof col.header === "string" ? col.header : col.id, + ); + + const csvRows = rows.map((row) => + columns.map((col) => { + const val = resolveColumnValue(col, row); + const formatted = col.formatValue ? col.formatValue(val, row) : String(val ?? ""); + // Escape CSV special characters + if (formatted.includes(",") || formatted.includes('"') || formatted.includes("\n")) { + return `"${formatted.replace(/"/g, '""')}"`; + } + return formatted; + }), + ); + + const csvContent = [ + header.join(","), + ...csvRows.map((row) => row.join(",")), + ].join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}.csv`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/packages/dashboard-ui-components/src/components/data-grid/strings.ts b/packages/dashboard-ui-components/src/components/data-grid/strings.ts new file mode 100644 index 0000000000..3ad3028747 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/strings.ts @@ -0,0 +1,45 @@ +import type { DataGridStrings } from "./types"; + +export const DATA_GRID_DEFAULT_STRINGS: DataGridStrings = { + // toolbar + searchPlaceholder: "Search\u2026", + columns: "Columns", + export: "Export", + density: "Density", + // column manager + showAll: "Show all", + hideAll: "Hide all", + resetColumns: "Reset", + // date display + dateFormat: "Date format", + dateFormatRelative: "Relative", + dateFormatAbsolute: "Absolute", + // selection + rowsSelected: (count) => `${count} row${count === 1 ? "" : "s"} selected`, + // pagination + rowsPerPage: "Rows per page", + pageOf: (page, total) => `${page} of ${total}`, + // empty / loading + noData: "No data", + loading: "Loading\u2026", + loadingMore: "Loading more\u2026", + // export + exportCsv: "Export CSV", + exportCopied: "Copied!", + // sort + sortAsc: "Sort ascending", + sortDesc: "Sort descending", + unsort: "Remove sort", + // misc + pinLeft: "Pin left", + pinRight: "Pin right", + unpin: "Unpin", + hideColumn: "Hide column", +}; + +export function resolveDataGridStrings( + override: Partial | undefined, +): DataGridStrings { + if (!override) return DATA_GRID_DEFAULT_STRINGS; + return { ...DATA_GRID_DEFAULT_STRINGS, ...override }; +} diff --git a/packages/dashboard-ui-components/src/components/data-grid/types.ts b/packages/dashboard-ui-components/src/components/data-grid/types.ts new file mode 100644 index 0000000000..82b8d70648 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/types.ts @@ -0,0 +1,363 @@ +import type { ReactNode } from "react"; + +// ─── Row identity ──────────────────────────────────────────────────── +/** Every row must be uniquely identifiable. The grid resolves identity + * through the top-level `getRowId` prop. */ +export type RowId = string; + +// ─── Column definition ────────────────────────────────────────────── +export type DataGridColumnType = + | "string" + | "number" + | "date" + | "dateTime" + | "boolean" + | "singleSelect" + | "custom"; + +export type DataGridColumnAlign = "left" | "center" | "right"; + +export type DataGridColumnPin = "left" | "right" | false; + +/** How `date` / `dateTime` cells render their value. `"relative"` shows + * "1 day ago"–style text with the full datetime in a tooltip; `"absolute"` + * shows the full datetime inline. */ +export type DataGridDateDisplay = "relative" | "absolute"; + +/** Per-column overrides for how a `date` / `dateTime` cell is formatted. + * If either function is omitted the grid falls back to its default + * (Intl.RelativeTimeFormat + `toLocaleString()`). */ +export type DataGridDateFormat = { + relative?: (date: Date) => string; + absolute?: (date: Date) => string; +}; + +/** Context passed to `renderCell`. */ +export type DataGridCellContext = { + row: TRow; + rowId: RowId; + rowIndex: number; + value: unknown; + columnId: string; + isSelected: boolean; + /** Current date display mode — consumers writing custom `renderCell` + * for `date` / `dateTime` columns should branch on this to match the + * grid's built-in behaviour. */ + dateDisplay: DataGridDateDisplay; +}; + +/** Context passed to `renderHeader`. */ +export type DataGridHeaderContext = { + columnId: string; + columnDef: DataGridColumnDef; + isSorted: false | "asc" | "desc"; + sortIndex: number | null; +}; + +/** A single column's full configuration. Generic over the row type. */ +export type DataGridColumnDef = { + /** Unique identifier for this column. */ + id: string; + + /** Display label. If a function is given it receives the header context. */ + header: string | ((ctx: DataGridHeaderContext) => ReactNode); + + /** Accessor — either a key of TRow or a function. If omitted, `id` is + * used as the key. */ + accessor?: keyof TRow | ((row: TRow) => unknown); + + /** Custom cell renderer. Falls back to plain text of the resolved value. */ + renderCell?: (ctx: DataGridCellContext) => ReactNode; + + // ── Sizing ────────────────────────────────────────────────── + /** Initial width in pixels. Defaults to 150. */ + width?: number; + /** Minimum width during resize. Defaults to 50. */ + minWidth?: number; + /** Maximum width during resize. Defaults to 800. */ + maxWidth?: number; + /** Flex grow factor. When set, remaining space is distributed among flex + * columns proportionally. */ + flex?: number; + + // ── Feature flags ────────────────────────────────────────── + sortable?: boolean; + resizable?: boolean; + hideable?: boolean; + /** Pin position. Defaults to `false` (unpinned). */ + pin?: DataGridColumnPin; + + // ── Display ────────────────────────────────────────────────── + align?: DataGridColumnAlign; + /** Column type affects default sorting. */ + type?: DataGridColumnType; + /** For `singleSelect` type — available value options. */ + valueOptions?: readonly DataGridSelectOption[]; + + // ── Overrides ────────────────────────────────────────────── + /** Custom sort comparator. Receives two resolved cell values. + * Return negative if a < b, positive if a > b, 0 if equal. */ + sortComparator?: (a: unknown, b: unknown) => number; + /** Format a cell value to a plain string — used for export and + * clipboard copy. Defaults to `String(value)`. */ + formatValue?: (value: unknown, row: TRow) => string; + + // ── Date / dateTime ───────────────────────────────────────── + /** Parse a raw cell value into a `Date`. Only consulted when `type` is + * `"date"` or `"dateTime"`. Defaults to `new Date(value)` with graceful + * handling of `null` / `undefined` / invalid dates. Override for + * non-standard formats (e.g. ClickHouse's space-separated UTC strings). */ + parseValue?: (value: unknown) => Date | null; + /** Per-column override for the relative / absolute date formatters. */ + dateFormat?: DataGridDateFormat; + + // ── Cell-level callbacks ────────────────────────────────────── + /** Fired when a cell in this column is clicked. */ + onCellClick?: (ctx: DataGridCellContext, event: React.MouseEvent) => void; + /** Fired when a cell in this column is double-clicked. */ + onCellDoubleClick?: (ctx: DataGridCellContext, event: React.MouseEvent) => void; +}; + +export type DataGridSelectOption = { + value: string; + label: string; +}; + +// ─── Sorting ───────────────────────────────────────────────────────── +export type DataGridSortItem = { + columnId: string; + direction: "asc" | "desc"; +}; +export type DataGridSortModel = readonly DataGridSortItem[]; + +// ─── Selection ─────────────────────────────────────────────────────── +export type DataGridSelectionMode = "none" | "single" | "multiple"; + +export type DataGridSelectionModel = { + selectedIds: ReadonlySet; + /** Tracks the last-clicked row for shift-range selection. */ + anchorId: RowId | null; +}; + +// ─── Column visibility / pinning ───────────────────────────────────── +export type DataGridColumnVisibility = Record; + +export type DataGridColumnPinning = { + left: readonly string[]; + right: readonly string[]; +}; + +// ─── Pagination ────────────────────────────────────────────────────── +/** UI display mode — "paginated" shows page controls, "infinite" shows scroll sentinel. */ +export type DataGridPaginationMode = "paginated" | "infinite"; + +/** Data-fetching strategy used by `useDataSource`. */ +export type DataGridDataPaginationMode = "client" | "server" | "infinite"; + +export type DataGridPaginationModel = { + pageIndex: number; + pageSize: number; +}; + +// ─── Combined grid state ───────────────────────────────────────────── +export type DataGridState = { + sorting: DataGridSortModel; + columnVisibility: DataGridColumnVisibility; + columnWidths: Record; + columnPinning: DataGridColumnPinning; + columnOrder: readonly string[]; + pagination: DataGridPaginationModel; + selection: DataGridSelectionModel; + /** How `date` / `dateTime` columns render. Defaults to `"relative"` + * via `createDefaultDataGridState`. Toggled from the Columns popover + * whenever the grid has at least one date column. */ + dateDisplay: DataGridDateDisplay; + /** Current quick-search text. Written by the built-in search input in + * the toolbar. `useDataSource` in client mode auto-filters by this + * value (via `applyQuickSearch`); in async mode it's passed through + * to the generator as `params.quickSearch`, where the consumer owns + * the "how do I match?" decision (typically by modifying the backend + * query). Defaults to `""`. */ + quickSearch: string; +}; + +// ─── Data source ───────────────────────────────────────────────────── +/** Params sent to the async data source on each fetch. */ +export type DataGridFetchParams = { + sorting: DataGridSortModel; + pagination: DataGridPaginationModel; + /** Current quick-search text. Passed through from `state.quickSearch` + * so the async generator can fold it into its query (e.g. a SQL WHERE + * clause). Empty string when the search box is empty. A change in + * this value triggers a refetch, same mechanism as sorting. */ + quickSearch: string; + /** For cursor-based: the last row of the previous page. */ + cursor: unknown; +}; + +/** Return type from a data source fetch. */ +export type DataGridFetchResult = { + rows: TRow[]; + /** Total row count if known. `-1` or `undefined` for unknown (infinite). */ + totalRowCount?: number; + /** Cursor for the next page (for cursor-based pagination). */ + nextCursor?: unknown; + /** If `false`, there are no more pages. */ + hasMore?: boolean; +}; + +/** An async-generator data source yields pages of rows. The generator + * receives fetch params as its argument and yields pages. Yielding + * allows the grid to display partial results during loading. */ +export type DataGridDataSource = ( + params: DataGridFetchParams, +) => AsyncGenerator, void, undefined>; + +// ─── Callbacks ─────────────────────────────────────────────────────── +export type DataGridCallbacks = { + onRowClick?: (row: TRow, rowId: RowId, event: React.MouseEvent) => void; + onRowDoubleClick?: (row: TRow, rowId: RowId, event: React.MouseEvent) => void; + onCellClick?: (row: TRow, columnId: string, value: unknown, event: React.MouseEvent) => void; + onSelectionChange?: (selectedIds: ReadonlySet, selectedRows: TRow[]) => void; + onSortChange?: (model: DataGridSortModel) => void; + onColumnResize?: (columnId: string, width: number) => void; + onColumnVisibilityChange?: (model: DataGridColumnVisibility) => void; +}; + +// ─── Main props ────────────────────────────────────────────────────── +export type DataGridProps = { + /** Column definitions. */ + columns: readonly DataGridColumnDef[]; + + // ── Data (pre-resolved by the consumer) ──────────────────────── + /** The rows to display. The consumer is responsible for sorting + * and paginating before passing them in. */ + rows: readonly TRow[]; + /** Extract a unique identifier from each row. */ + getRowId: (row: TRow) => RowId; + /** Total row count across all pages (used for pagination UI). */ + totalRowCount?: number; + /** True while the initial data load is in progress (shows skeleton). */ + isLoading?: boolean; + /** True during a background refetch (shows subtle indicator, keeps rows). */ + isRefetching?: boolean; + + // ── Infinite scroll ──────────────────────────────────────────── + /** Whether more rows can be loaded (shows infinite scroll sentinel). */ + hasMore?: boolean; + /** True while loading the next page of infinite scroll. */ + isLoadingMore?: boolean; + /** Called when the infinite scroll sentinel becomes visible. */ + onLoadMore?: () => void; + + // ── State (fully controlled) ─────────────────────────────────── + state: DataGridState; + onChange: React.Dispatch>; + + // ── Modes ────────────────────────────────────────────────────── + /** UI mode for pagination. "paginated" shows page controls in the + * footer. "infinite" shows a scroll sentinel instead. Defaults to + * "paginated". */ + paginationMode?: "paginated" | "infinite"; + /** Selection behaviour. Defaults to "none". */ + selectionMode?: DataGridSelectionMode; + /** Whether columns can be resized by dragging. Defaults to true. */ + resizable?: boolean; + + // ── Layout ───────────────────────────────────────────────────── + /** Row height in pixels. Defaults to 44. */ + rowHeight?: number; + /** Header row height in pixels. Defaults to 44. */ + headerHeight?: number; + /** Number of rows to render outside the visible area. Defaults to 5. */ + overscan?: number; + /** Grid max height. If omitted, grid takes available space. */ + maxHeight?: number | string; + + // ── Callbacks ────────────────────────────────────────────────── +} & DataGridCallbacks & { + // ── Customisation ────────────────────────────────────────────── + /** Custom toolbar renderer. When `false`, toolbar is hidden entirely. */ + toolbar?: false | ((ctx: DataGridToolbarContext) => ReactNode); + /** Extra content rendered inside the default toolbar row, to the left of + * the built-in columns / export actions. Use this to slot in + * refresh buttons, custom toggles, row counts, etc. without giving up + * the built-in actions. Ignored if a custom `toolbar` render function + * is provided — that function owns the entire row. */ + toolbarExtra?: ReactNode | ((ctx: DataGridToolbarContext) => ReactNode); + /** Custom empty state. Defaults to a centered "No data" message. */ + emptyState?: ReactNode; + /** Custom loading state. Defaults to skeleton rows. */ + loadingState?: ReactNode; + /** Custom footer. When `false`, footer is hidden. */ + footer?: false | ((ctx: DataGridFooterContext) => ReactNode); + /** Extra content rendered to the right of the default footer info. */ + footerExtra?: ReactNode | ((ctx: DataGridFooterContext) => ReactNode); + + /** Filename stem for CSV export (without extension). */ + exportFilename?: string; + /** i18n overrides. */ + strings?: Partial; + + className?: string; +}; + +// ─── Toolbar / footer context ──────────────────────────────────────── +export type DataGridToolbarContext = { + state: DataGridState; + onChange: React.Dispatch>; + columns: readonly DataGridColumnDef[]; + visibleColumns: readonly DataGridColumnDef[]; + totalRowCount: number | undefined; + selectedRowCount: number; + strings: DataGridStrings; + /** Trigger a CSV export. */ + exportCsv: () => void; +}; + +export type DataGridFooterContext = { + state: DataGridState; + totalRowCount: number | undefined; + visibleRowCount: number; + selectedRowCount: number; + paginationMode: DataGridPaginationMode; + strings: DataGridStrings; +}; + +// ─── Strings ───────────────────────────────────────────────────────── +export type DataGridStrings = { + // toolbar + searchPlaceholder: string; + columns: string; + export: string; + density: string; + // column manager + showAll: string; + hideAll: string; + resetColumns: string; + // date display + dateFormat: string; + dateFormatRelative: string; + dateFormatAbsolute: string; + // selection + rowsSelected: (count: number) => string; + // pagination + rowsPerPage: string; + pageOf: (page: number, total: number) => string; + // empty / loading + noData: string; + loading: string; + loadingMore: string; + // export + exportCsv: string; + exportCopied: string; + // sort + sortAsc: string; + sortDesc: string; + unsort: string; + // misc + pinLeft: string; + pinRight: string; + unpin: string; + hideColumn: string; +}; diff --git a/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts new file mode 100644 index 0000000000..c38cb5c4a8 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts @@ -0,0 +1,355 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + DataGridColumnDef, + DataGridDataSource, + DataGridFetchParams, + DataGridDataPaginationMode, + DataGridPaginationModel, + DataGridSortModel, + RowId, +} from "./types"; +import { + applyQuickSearch, + buildRowComparator, + defaultMatchRow, + paginateRows, +} from "./state"; + +export type UseDataSourceResult = { + /** All rows currently loaded (for infinite mode, the accumulated set). */ + rows: readonly TRow[]; + /** Total row count if known. */ + totalRowCount: number | undefined; + /** Whether the initial load is in progress (no data at all yet). */ + isLoading: boolean; + /** Whether a background refetch is happening (data already shown). */ + isRefetching: boolean; + /** Whether more rows are being fetched (infinite scroll). */ + isLoadingMore: boolean; + /** Request the next page (infinite scroll). */ + loadMore: () => void; + /** Whether there are more pages to load. */ + hasMore: boolean; + /** Reload from scratch. */ + reload: () => void; +}; + +// ─── Client-side hook ──────────────────────────────────────────────── +// Memoised so resize / selection / other unrelated state changes +// don't recompute or create new array references. + +function useClientDataSource(opts: { + data: readonly TRow[]; + columns: readonly DataGridColumnDef[]; + sorting: DataGridSortModel; + quickSearch: string; + matchRow: ( + row: TRow, + query: string, + columns: readonly DataGridColumnDef[], + ) => boolean; + pagination: DataGridPaginationModel; + paginationMode: DataGridDataPaginationMode; +}): UseDataSourceResult { + const { data, columns, sorting, quickSearch, matchRow, pagination, paginationMode } = opts; + + // Stable serialised keys so useMemo only fires on real changes + const sortingKey = JSON.stringify(sorting); + + const processed = useMemo(() => { + // Quick search is applied FIRST, on the full input. If nothing is + // typed this is a zero-cost no-op (applyQuickSearch returns the + // original array reference). Sort and paginate operate on the + // already-filtered set so the result counts are search-aware. + const searched = applyQuickSearch(data, quickSearch, columns, matchRow); + const comparator = buildRowComparator(sorting, columns); + const sorted = comparator ? [...searched].sort(comparator) : searched; + const totalRowCount = sorted.length; + const paged = + paginationMode === "client" + ? paginateRows(sorted as readonly TRow[], pagination) + : sorted; + return { rows: paged, totalRowCount }; + }, [data, sortingKey, quickSearch, matchRow, pagination.pageIndex, pagination.pageSize, paginationMode, columns]); + + return useMemo(() => ({ + rows: processed.rows, + totalRowCount: processed.totalRowCount, + isLoading: false, + isRefetching: false, + isLoadingMore: false, + loadMore: () => {}, + hasMore: false, + reload: () => {}, + }), [processed]); +} + +// ─── Async data source hook ────────────────────────────────────────── +// Key behaviour: when refetching (sort change), we keep showing the old +// rows and set `isRefetching` instead of `isLoading`. This avoids the +// jarring flash-to-skeleton on every sort toggle. + +function useAsyncDataSource(opts: { + dataSource: DataGridDataSource; + getRowId: (row: TRow) => RowId; + sorting: DataGridSortModel; + quickSearch: string; + pagination: DataGridPaginationModel; + paginationMode: DataGridDataPaginationMode; +}): UseDataSourceResult { + const { + dataSource, + getRowId, + sorting, + quickSearch, + pagination, + paginationMode, + } = opts; + + const [rows, setRows] = useState([]); + const [totalRowCount, setTotalRowCount] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [isRefetching, setIsRefetching] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const cursorRef = useRef(undefined); + const abortRef = useRef(null); + const pageIndexRef = useRef(0); + const hasDataRef = useRef(false); + + const latestArgsRef = useRef({ + dataSource, + getRowId, + sorting, + quickSearch, + pagination, + }); + latestArgsRef.current = { dataSource, getRowId, sorting, quickSearch, pagination }; + + const sortingKey = JSON.stringify(sorting); + const quickSearchKey = quickSearch; + + const fetchPage = useCallback( + async (append: boolean) => { + const { + dataSource: currentDataSource, + getRowId: currentGetRowId, + sorting: currentSorting, + quickSearch: currentQuickSearch, + pagination: currentPagination, + } = latestArgsRef.current; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + if (append) { + setIsLoadingMore(true); + } else { + // First load → skeleton. Subsequent → subtle refetch indicator. + if (hasDataRef.current) { + setIsRefetching(true); + } else { + setIsLoading(true); + } + cursorRef.current = undefined; + pageIndexRef.current = 0; + } + + try { + const params: DataGridFetchParams = { + sorting: currentSorting, + quickSearch: currentQuickSearch, + pagination: append + ? { pageIndex: pageIndexRef.current, pageSize: currentPagination.pageSize } + : currentPagination, + cursor: cursorRef.current, + }; + + const gen = currentDataSource(params); + + for await (const result of gen) { + if (controller.signal.aborted) return; + + if (result.totalRowCount != null) { + setTotalRowCount(result.totalRowCount); + } + if (result.nextCursor !== undefined) { + cursorRef.current = result.nextCursor; + } + setHasMore(result.hasMore !== false); + + if (append) { + setRows((prev) => { + const existingIds = new Set(prev.map(currentGetRowId)); + const newRows = result.rows.filter( + (r) => !existingIds.has(currentGetRowId(r)), + ); + return [...prev, ...newRows]; + }); + } else { + setRows(result.rows); + } + + hasDataRef.current = true; + pageIndexRef.current++; + } + } catch (err) { + if (controller.signal.aborted) return; + console.error("[DataGrid] Data source error:", err); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + setIsRefetching(false); + setIsLoadingMore(false); + } + } + }, + [], + ); + + useEffect(() => { + fetchPage(false).catch(() => {}); + return () => abortRef.current?.abort(); + }, [fetchPage, sortingKey, quickSearchKey, pagination.pageSize]); + + useEffect(() => { + if (paginationMode === "server") { + fetchPage(false).catch(() => {}); + } + }, [fetchPage, paginationMode, pagination.pageIndex]); + + const loadMore = useCallback(() => { + if (!isLoadingMore && hasMore && paginationMode === "infinite") { + fetchPage(true).catch(() => {}); + } + }, [isLoadingMore, hasMore, paginationMode, fetchPage]); + + const reload = useCallback(() => { + fetchPage(false).catch(() => {}); + }, [fetchPage]); + + return { + rows, + totalRowCount, + isLoading, + isRefetching, + isLoadingMore, + loadMore, + hasMore, + reload, + }; +} + +// ─── Noop data source (stable reference) ───────────────────────────── +const NOOP_DATA_SOURCE: DataGridDataSource = async function* () {}; +const NOOP_GET_ROW_ID = () => ""; + +// ─── Public hook ───────────────────────────────────────────────────── +// Both inner hooks are always called (React rules-of-hooks) but only +// one provides the returned result. + +/** + * Hook that processes raw data through the grid's sort/pagination state + * and returns the `rows` slice ready to pass to `DataGrid`. This is the + * only correct way to feed client-side data into a grid. + * + * Two modes, picked by which prop you pass: + * - `data: TRow[]` → client-side mode. In-memory sort + paginate. + * - `dataSource: (params) => AsyncGenerator` → server / infinite mode. + * The generator yields pages as you scroll or change pages. + * + * ```tsx + * // Client-side (most common): + * const gridData = useDataSource({ + * data: users, + * columns, + * getRowId: (row) => row.id, + * sorting: gridState.sorting, + * quickSearch: gridState.quickSearch, + * pagination: gridState.pagination, + * paginationMode: "client", + * }); + * + * row.id} + * /> + * ``` + * + * Rules: + * - Call this hook unconditionally at the top level, before any early return. + * - `rows` on `DataGrid` must ALWAYS be `gridData.rows`, never your raw array. + * - For server or infinite pagination, use `dataSource` — see the + * `DataGridDataSource` type for the generator signature. + * + * Quick search: + * - Client mode (`data` prop): the hook auto-filters rows via + * `applyQuickSearch` using a default case-insensitive substring match + * across every column. Override with `matchRow` for custom matching + * (fuzzy, weighted, field-specific, etc.). + * - Async mode (`dataSource` prop): the hook passes `quickSearch` into + * `params.quickSearch` and re-runs the generator whenever the search + * string changes. The consumer owns the matching logic (typically by + * folding it into a backend query). The grid performs NO client-side + * filtering in async mode. + */ +export function useDataSource(opts: { + data?: readonly TRow[]; + dataSource?: DataGridDataSource; + columns: readonly DataGridColumnDef[]; + getRowId: (row: TRow) => RowId; + sorting: DataGridSortModel; + /** Current quick-search text, typically `gridState.quickSearch`. */ + quickSearch: string; + /** Override the default client-mode matcher. Ignored in async mode + * (there the generator is the matcher). */ + matchRow?: ( + row: TRow, + query: string, + columns: readonly DataGridColumnDef[], + ) => boolean; + pagination: DataGridPaginationModel; + paginationMode: DataGridDataPaginationMode; +}): UseDataSourceResult { + const { + data, + dataSource, + columns, + getRowId, + sorting, + quickSearch, + matchRow = defaultMatchRow, + pagination, + paginationMode, + } = opts; + + const isClientMode = data != null && !dataSource; + + const clientResult = useClientDataSource({ + data: data ?? [], + columns, + sorting, + quickSearch, + matchRow, + pagination, + paginationMode, + }); + + const asyncResult = useAsyncDataSource({ + dataSource: dataSource ?? NOOP_DATA_SOURCE, + getRowId: dataSource ? getRowId : NOOP_GET_ROW_ID, + sorting, + quickSearch, + pagination, + paginationMode, + }); + + return isClientMode ? clientResult : asyncResult; +} diff --git a/packages/dashboard-ui-components/src/components/empty-state.tsx b/packages/dashboard-ui-components/src/components/empty-state.tsx index 4be0d18ddd..94b9a6c109 100644 --- a/packages/dashboard-ui-components/src/components/empty-state.tsx +++ b/packages/dashboard-ui-components/src/components/empty-state.tsx @@ -11,6 +11,22 @@ export type DesignEmptyStateProps = { className?: string, }; +/** + * Centered "no data" placeholder. Show this inside a `DataGrid` via the + * `emptyState` prop, inside a chart when a query returns zero rows, or + * inside a card when a section has nothing to display. + * + * ```tsx + * + * ``` + * + * Prefer this over a raw "No data" div — it handles spacing, typography, + * and the optional icon for you. `icon` is a component type, not a rendered node. + */ export function DesignEmptyState({ icon: Icon, title = "No data available", diff --git a/packages/dashboard-ui-components/src/components/metric-card.tsx b/packages/dashboard-ui-components/src/components/metric-card.tsx index 73cdf702fa..ffb3da7765 100644 --- a/packages/dashboard-ui-components/src/components/metric-card.tsx +++ b/packages/dashboard-ui-components/src/components/metric-card.tsx @@ -29,6 +29,30 @@ export type DesignMetricCardProps = { gradient?: DesignMetricCardGradient, } & Omit, "children">; +/** + * KPI card for big-number metrics (users, revenue, signups, etc.). + * Use this instead of a plain `DesignCard` when the value is the focal point. + * + * ```tsx + * + * ``` + * + * Notes: + * - `label` is the short caption (NOT `title`). + * - `value` can be a pre-formatted string ("1,234") or a number — prefer + * strings so you control the format. + * - `description` is the subline (NOT `subtitle`). + * - `trend.value` is a NUMBER, not a pre-formatted "12%" string. The card + * renders the arrow and formatting. + * - `icon` is a component type (e.g. `UsersIcon`), not a rendered node. + */ export function DesignMetricCard({ label, value, diff --git a/packages/dashboard-ui-components/src/components/progress-bar.tsx b/packages/dashboard-ui-components/src/components/progress-bar.tsx index e3448f7336..6413677442 100644 --- a/packages/dashboard-ui-components/src/components/progress-bar.tsx +++ b/packages/dashboard-ui-components/src/components/progress-bar.tsx @@ -23,6 +23,16 @@ export type DesignProgressBarProps = { className?: string, }; +/** + * Horizontal progress bar for quota / fill-level indicators. Takes a + * `value` and optional `max` (defaults to 100 — pass raw values, the + * component computes the percentage). Optional label + percentage display. + * + * ```tsx + * + * + * ``` + */ export function DesignProgressBar({ value, max = 100, diff --git a/packages/dashboard-ui-components/src/components/separator.tsx b/packages/dashboard-ui-components/src/components/separator.tsx index b97487de64..0c04595d17 100644 --- a/packages/dashboard-ui-components/src/components/separator.tsx +++ b/packages/dashboard-ui-components/src/components/separator.tsx @@ -6,6 +6,15 @@ export type DesignSeparatorProps = { orientation?: "horizontal" | "vertical", } & React.HTMLAttributes; +/** + * Thin divider line. Use `orientation="vertical"` inside a flex row to + * separate inline groups, or omit for a horizontal rule between sections. + * + * ```tsx + * + * + * ``` + */ export function DesignSeparator({ orientation = "horizontal", className, diff --git a/packages/dashboard-ui-components/src/components/skeleton.tsx b/packages/dashboard-ui-components/src/components/skeleton.tsx index 248d8d31f4..470a4dc939 100644 --- a/packages/dashboard-ui-components/src/components/skeleton.tsx +++ b/packages/dashboard-ui-components/src/components/skeleton.tsx @@ -5,6 +5,18 @@ import type React from "react"; export type DesignSkeletonProps = React.HTMLAttributes; +/** + * Animated placeholder block. Use while data is loading — size it via + * `className` to match the content it's standing in for. + * + * ```tsx + * + * + * ``` + * + * Rule: always show a skeleton during initial load, not a spinner or + * "Loading..." text. Skeletons preserve layout and feel faster. + */ export function DesignSkeleton({ className, ...props }: DesignSkeletonProps) { return (
+ * + * + * Name + * Email + * + * + * + * {rows.map(row => ( + * + * {row.name} + * {row.email} + * + * ))} + * + * + * ``` + */ export const DesignTable = forwardRefIfNeeded< HTMLTableElement, React.HTMLAttributes diff --git a/packages/dashboard-ui-components/src/index.ts b/packages/dashboard-ui-components/src/index.ts index e986db3daf..71c2da44f9 100644 --- a/packages/dashboard-ui-components/src/index.ts +++ b/packages/dashboard-ui-components/src/index.ts @@ -55,3 +55,5 @@ export { DesignEmptyState } from "./components/empty-state"; export type { DesignEmptyStateProps } from "./components/empty-state"; export * from "./components/analytics-chart"; + +export * from "./components/data-grid"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a7cebe56e..a12e451107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1545,6 +1545,9 @@ importers: '@stackframe/stack-ui': specifier: workspace:* version: link:../stack-ui + '@tanstack/react-virtual': + specifier: ^3.13.0 + version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -10726,6 +10729,23 @@ packages: bare-events@2.4.2: resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.6.0: + resolution: {integrity: sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-os@3.8.7: resolution: {integrity: sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==} engines: {bare: '>=1.14.0'} @@ -10733,6 +10753,23 @@ packages: bare-path@3.0.0: resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + bare-stream@2.12.0: + resolution: {integrity: sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.0: + resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -12598,6 +12635,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -17104,6 +17144,9 @@ packages: streamx@2.18.0: resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + string-width@3.1.0: resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==} engines: {node: '>=6'} @@ -22061,6 +22104,8 @@ snapshots: - '@radix-ui/react-popover' - '@types/node' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -22209,6 +22254,8 @@ snapshots: transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -22290,6 +22337,8 @@ snapshots: transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -22327,6 +22376,8 @@ snapshots: transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -22360,6 +22411,8 @@ snapshots: transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -22392,6 +22445,8 @@ snapshots: transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -24051,6 +24106,8 @@ snapshots: unbzip2-stream: 1.4.3 yargs: 17.7.2 transitivePeerDependencies: + - bare-abort-controller + - bare-buffer - supports-color '@quansync/fs@1.0.0': @@ -29924,6 +29981,20 @@ snapshots: bare-events@2.4.2: optional: true + bare-events@2.8.2: + optional: true + + bare-fs@4.6.0: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.12.0(bare-events@2.8.2) + bare-url: 2.4.0 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + optional: true + bare-os@3.8.7: optional: true @@ -29932,6 +30003,19 @@ snapshots: bare-os: 3.8.7 optional: true + bare-stream@2.12.0(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + optional: true + + bare-url@2.4.0: + dependencies: + bare-path: 3.0.0 + optional: true + base64-arraybuffer@1.0.2: {} base64-js@1.5.1: {} @@ -32437,6 +32521,13 @@ snapshots: eventemitter3@4.0.7: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + optional: true + events@3.3.0: {} eventsource-parser@3.0.3: {} @@ -35450,6 +35541,8 @@ snapshots: - '@radix-ui/react-popover' - '@types/node' - '@types/react' + - bare-abort-controller + - bare-buffer - bufferutil - debug - encoding @@ -36875,6 +36968,8 @@ snapshots: devtools-protocol: 0.0.1312386 ws: 8.18.3 transitivePeerDependencies: + - bare-abort-controller + - bare-buffer - bufferutil - supports-color - utf-8-validate @@ -36886,6 +36981,8 @@ snapshots: devtools-protocol: 0.0.1312386 puppeteer-core: 22.14.0 transitivePeerDependencies: + - bare-abort-controller + - bare-buffer - bufferutil - supports-color - typescript @@ -38593,6 +38690,15 @@ snapshots: optionalDependencies: bare-events: 2.4.2 + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.1.0 + transitivePeerDependencies: + - bare-abort-controller + optional: true + string-width@3.1.0: dependencies: emoji-regex: 7.0.3 @@ -39035,7 +39141,11 @@ snapshots: pump: 3.0.4 tar-stream: 3.1.7 optionalDependencies: + bare-fs: 4.6.0 bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer tar-stream@2.2.0: dependencies: From 7ff0cf8a26905d08bae20723da6bb1f3d12589ab Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 10 Apr 2026 17:20:35 -0700 Subject: [PATCH 25/30] Enhance image attachment validation and refactor related components - Implemented image attachment validation in the AI query handler to enforce size limits and count restrictions. - Created a new validation module for image attachments, including functions for checking byte length and count. - Updated the image attachment adapter to utilize the new validation logic, improving error handling for oversized images. - Refactored the thread component to support the new attachment adapter, ensuring seamless integration with the composer. - Added tests to verify the new validation rules for image attachments in the AI query endpoint. --- .../app/api/latest/ai/query/[mode]/route.ts | 5 + .../app/api/latest/internal/metrics/route.tsx | 154 ++++++----- .../assistant-ui/image-attachment-adapter.ts | 12 +- .../image-attachment-validation.ts | 27 ++ .../src/components/assistant-ui/thread.tsx | 245 ++++++++++++++---- .../components/vibe-coding/assistant-chat.tsx | 1 + .../backend/endpoints/api/v1/ai-query.test.ts | 54 ++++ .../endpoints/api/v1/internal-metrics.test.ts | 193 ++++++++++++++ packages/stack-shared/src/ai/image-limits.ts | 36 ++- 9 files changed, 596 insertions(+), 131 deletions(-) create mode 100644 apps/dashboard/src/components/assistant-ui/image-attachment-validation.ts 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 44c7dd8636..03ff905c71 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 @@ -46,6 +46,11 @@ 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 }); 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 1e71f1c825..d355f71dd1 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@/generated/prisma/client"; import { EmailOutboxSimpleStatus } from "@/generated/prisma/enums"; -import { getClickhouseAdminClient } from "@/lib/clickhouse"; +import { getClickhouseAdminClient, getClickhouseExternalClient } from "@/lib/clickhouse"; import { ClickHouseError } from "@clickhouse/client"; import { ActivitySplit, buildSplitFromDailyEntitySets } from "@/lib/metrics-activity-split"; import { Tenancy } from "@/lib/tenancies"; @@ -217,10 +217,12 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno }); const activeUserIds = [...new Set(sanitizedUserRows.map((row) => row.user_id))]; - const users: { projectUserId: string, createdAt: Date }[] = activeUserIds.length === 0 + const users: { projectUserId: string, signedUpAtOrCreatedAt: Date }[] = activeUserIds.length === 0 ? [] - : await prisma.$replica().$queryRaw<{ projectUserId: string, createdAt: Date }[]>` - SELECT "projectUserId"::text AS "projectUserId", "createdAt" + : 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`))}) @@ -243,7 +245,7 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno } const createdDayByUserId = new Map( - users.map((user) => [user.projectUserId, user.createdAt.toISOString().split('T')[0]]) + users.map((user) => [user.projectUserId, user.signedUpAtOrCreatedAt.toISOString().split('T')[0]]) ); return buildSplitFromDailyEntitySets({ @@ -794,7 +796,11 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); - const clickhouseClient = getClickhouseAdminClient(); + const clickhouseClient = getClickhouseExternalClient(); + const clickhouseSettings = { + SQL_project_id: tenancy.project.id, + SQL_branch_id: tenancy.branchId, + }; // Session replay aggregates come from Postgres and have nothing to do with // ClickHouse availability. Run them in parallel with the ClickHouse queries @@ -813,82 +819,109 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo } | null = null; try { + const analyticsUserJoin = ` + LEFT JOIN ( + SELECT + user_id, + argMax(CAST(data.is_anonymous, 'UInt8'), event_at) AS latest_is_anonymous + FROM events + WHERE event_type = '$token-refresh' + 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 + LEFT JOIN users AS u + ON e.user_id = toString(u.id) + `; + const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(u.is_anonymous, token_refresh_users.latest_is_anonymous, 1) = 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(event_at) AS day, - countIf(event_type = '$page-view') AS pv, - countIf(event_type = '$click') AS cl, + 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(user_id), - event_type = '$page-view' - AND user_id IS NOT NULL - AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) + assumeNotNull(e.user_id), + e.event_type = '$page-view' + AND e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} ) AS visitors - FROM analytics_internal.events - WHERE project_id = {projectId:String} - AND branch_id = {branchId:String} - AND event_type IN ('$page-view', '$click') - AND event_at >= {since:DateTime} - AND event_at < {untilExclusive:DateTime} + FROM events AS e + ${analyticsUserJoin} + WHERE e.event_type IN ('$page-view', '$click') + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} GROUP BY day ORDER BY day ASC `, query_params: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), includeAnonymous: includeAnonymous ? 1 : 0, }, + clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ query: ` SELECT - uniqExact(assumeNotNull(user_id)) AS visitors - FROM analytics_internal.events - WHERE event_type = '$page-view' - 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) + uniqExactIf( + assumeNotNull(e.user_id), + e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS visitors + FROM events AS e + ${analyticsUserJoin} + WHERE e.event_type = '$page-view' + AND e.user_id IS NOT NULL + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} `, query_params: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), includeAnonymous: includeAnonymous ? 1 : 0, }, + clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ query: ` SELECT - nullIf(CAST(data.referrer, 'String'), '') AS referrer, - count() AS cnt - FROM analytics_internal.events - WHERE event_type = '$page-view' - AND project_id = {projectId:String} - AND branch_id = {branchId:String} - AND event_at >= {since:DateTime} - AND event_at < {untilExclusive:DateTime} + nullIf(CAST(e.data.referrer, 'String'), '') AS referrer, + uniqExactIf( + assumeNotNull(e.user_id), + e.user_id IS NOT NULL + AND ${nonAnonymousAnalyticsUserFilter} + ) AS visitors + FROM events AS e + ${analyticsUserJoin} + WHERE e.event_type = '$page-view' + AND e.event_at >= {since:DateTime} + AND e.event_at < {untilExclusive:DateTime} GROUP BY referrer - ORDER BY cnt DESC + HAVING visitors > 0 + ORDER BY visitors DESC LIMIT ${TOP_REFERRERS_PAGE_SIZE} `, query_params: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, }, + clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ @@ -896,43 +929,46 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo SELECT CAST(data.ip_info.country_code, 'Nullable(String)') AS country_code, CAST(data.ip_info.region_code, 'Nullable(String)') AS region_code, - count() AS cnt - FROM analytics_internal.events + uniqExactIf( + assumeNotNull(user_id), + user_id IS NOT NULL + AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) + ) AS visitors + FROM 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 - ORDER BY cnt DESC + HAVING visitors > 0 + ORDER BY visitors DESC LIMIT 1 `, query_params: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, }, + clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ query: ` SELECT uniqExact(assumeNotNull(user_id)) AS online - FROM analytics_internal.events + FROM 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: { - projectId: tenancy.project.id, - branchId: tenancy.branchId, onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + includeAnonymous: includeAnonymous ? 1 : 0, }, + clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), ]); @@ -961,8 +997,8 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo dailyVisitors.push({ date: key, activity: visitorByDay.get(key) ?? 0 }); } - const referrers: { referrer: string | null, cnt: number }[] = await referrerResult.json(); - const topRegionRows: { country_code: string | null, region_code: string | null, cnt: number }[] = await topRegionResult.json(); + 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 = { @@ -973,12 +1009,12 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo onlineLive: Number(onlineRows[0]?.online ?? 0), topReferrers: referrers.map((row) => ({ referrer: row.referrer ?? '(direct)', - visitors: Number(row.cnt), + visitors: Number(row.visitors), })), topRegion: topRegionRows[0] ? { country_code: topRegionRows[0].country_code, region_code: topRegionRows[0].region_code, - count: Number(topRegionRows[0].cnt), + count: Number(topRegionRows[0].visitors), } : null, }; } catch (error) { diff --git a/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts index 3714c9b61c..7062edca96 100644 --- a/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts +++ b/apps/dashboard/src/components/assistant-ui/image-attachment-adapter.ts @@ -1,12 +1,9 @@ +import { validateComposerImageByteLength } from "@/components/assistant-ui/image-attachment-validation"; import { type AttachmentAdapter, type CompleteAttachment, type PendingAttachment, } from "@assistant-ui/react"; -import { - MAX_IMAGE_BYTES_PER_FILE, - MAX_IMAGE_MB_PER_FILE, -} from "@stackframe/stack-shared/dist/ai/image-limits"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; /** Chat composer attachments: UUID ids, shared max file size (see `image-limits`). */ @@ -14,10 +11,9 @@ export class ImageAttachmentAdapter implements AttachmentAdapter { public readonly accept = "image/*"; public async add(state: { file: File }): Promise { - if (state.file.size > MAX_IMAGE_BYTES_PER_FILE) { - throw new Error( - `"${state.file.name}" is larger than ${MAX_IMAGE_MB_PER_FILE}MB.`, - ); + const sizeValidation = validateComposerImageByteLength(state.file.size); + if (!sizeValidation.ok) { + throw new Error(`"${state.file.name}": ${sizeValidation.reason}`); } return { id: generateUuid(), 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 868720c983..af27c79e4b 100644 --- a/apps/dashboard/src/components/assistant-ui/thread.tsx +++ b/apps/dashboard/src/components/assistant-ui/thread.tsx @@ -3,6 +3,13 @@ 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, @@ -11,11 +18,12 @@ import { useComposer, useComposerRuntime, useMessage, + useThreadRuntime, + type PendingAttachment, } from "@assistant-ui/react"; import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle, XIcon } from "@phosphor-icons/react"; import { MAX_IMAGES_PER_MESSAGE, - MAX_IMAGE_BYTES_PER_FILE, MAX_IMAGE_MB_PER_FILE, } from "@stackframe/stack-shared/dist/ai/image-limits"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; @@ -25,11 +33,16 @@ const HideMessageActionsContext = createContext(false); const HasRunningStatusContext = createContext(false); const ComposerAttachmentsEnabledContext = createContext(false); +const ComposerAttachmentAdapterContext = createContext(null); function useComposerAttachmentsEnabled() { return useContext(ComposerAttachmentsEnabledContext); } +function useComposerAttachmentAdapter() { + return useContext(ComposerAttachmentAdapterContext); +} + /** Static placeholder string, or config for the typing-animation input. */ export type ComposerPlaceholder = | string @@ -48,58 +61,61 @@ export const Thread: FC<{ hideMessageActions?: boolean, runningStatusMessages?: string[], composerAttachments?: boolean, -}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false }) => { + attachmentAdapter?: AttachmentAdapter, +}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter }) => { return ( - - + + - + - - - - - {runningStatusMessages && ( - - + > + + + + + {runningStatusMessages && ( + + + + )} + + +
- )} - - -
- -
- - -
- - - + + +
+ + + + ); @@ -184,6 +200,62 @@ function extractImageUrlFromContent(content: readonly unknown[] | undefined): st 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, @@ -277,7 +349,14 @@ const ComposerAttachmentsAddButton: FC = () => { const atLimit = count >= MAX_IMAGES_PER_MESSAGE; const handleClick = () => { - if (composerRuntime.getState().attachments.length >= MAX_IMAGES_PER_MESSAGE) return; + 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"; @@ -293,22 +372,33 @@ const ComposerAttachmentsAddButton: FC = () => { const remaining = Math.max(0, MAX_IMAGES_PER_MESSAGE - liveCount); const picked = Array.from(files); const selected = picked.slice(0, remaining); - const oversized = selected.filter((file) => file.size > MAX_IMAGE_BYTES_PER_FILE); - const valid = selected.filter((file) => file.size <= MAX_IMAGE_BYTES_PER_FILE); + 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); + } + } - if (oversized.length > 0) { + const countValidation = validateComposerImageCount(liveCount + picked.length); + if (!countValidation.ok) { toast({ variant: "destructive", - description: - oversized.length === 1 - ? `"${oversized[0].name}" is larger than ${MAX_IMAGE_MB_PER_FILE}MB and was skipped.` - : `${oversized.length} images exceeded the ${MAX_IMAGE_MB_PER_FILE}MB limit and were skipped.`, + description: countValidation.reason, }); } - if (picked.length > remaining) { + if (oversized.length > 0) { + const firstOversizedValidation = validateComposerImageByteLength(oversized[0]!.size); toast({ - description: `Only ${MAX_IMAGES_PER_MESSAGE} images per message — extras ignored.`, + 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.`, }); } @@ -350,8 +440,10 @@ const ComposerAttachmentsAddButton: FC = () => { @@ -539,6 +631,50 @@ 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 && } @@ -552,9 +688,14 @@ const EditComposer: FC = () => { - - - +
diff --git a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx index e45c99e0f4..aa9ad1e339 100644 --- a/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx +++ b/apps/dashboard/src/components/vibe-coding/assistant-chat.tsx @@ -91,6 +91,7 @@ export default function AssistantChat({ hideMessageActions={hideMessageActions} runningStatusMessages={runningStatusMessages} composerAttachments={composerAttachments} + attachmentAdapter={attachmentAdapter} /> {toolComponents} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts index f02c41ffcc..26a7969223 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts @@ -1,3 +1,4 @@ +import { MAX_IMAGE_BYTES_PER_FILE, MAX_IMAGES_PER_MESSAGE } from "@stackframe/stack-shared/dist/ai/image-limits"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { describe } from "vitest"; import { it } from "../../../../helpers"; @@ -218,6 +219,59 @@ describe("AI Query Endpoint - Validation", () => { expect(response.body).toMatchObject({ code: "SCHEMA_ERROR", error: expect.stringContaining("messages") }); }); + it("rejects user messages with too many image attachments", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [ + { + role: "user", + content: new Array(MAX_IMAGES_PER_MESSAGE + 1).fill(null).map(() => ({ + type: "image", + image: "data:image/png;base64,AA==", + })), + }, + ], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual(expect.stringContaining(`Maximum ${MAX_IMAGES_PER_MESSAGE} images per message.`)); + }); + + it("rejects user messages with oversized image attachments", async ({ expect }) => { + const oversizedBase64 = "A".repeat(Math.ceil(((MAX_IMAGE_BYTES_PER_FILE + 1) * 4) / 3)); + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [ + { + role: "user", + content: [ + { + type: "image", + image: `data:image/png;base64,${oversizedBase64}`, + }, + ], + }, + ], + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual(expect.stringContaining("Image exceeds")); + }); + }); describeWithAi("AI Query Endpoint - Authentication", () => { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 7286d7811e..67934cc61e 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { expect } from "vitest"; @@ -12,6 +13,24 @@ type LoginMethodMetric = { count: number, }; +async function uploadAnalyticsEventBatch(options: { + sessionReplaySegmentId: string, + batchId: string, + sentAtMs: number, + events: { event_type: string, event_at_ms: number, data: unknown }[], +}) { + return await niceBackendFetch("/api/v1/analytics/events/batch", { + method: "POST", + accessType: "client", + body: { + session_replay_segment_id: options.sessionReplaySegmentId, + batch_id: options.batchId, + sent_at_ms: options.sentAtMs, + events: options.events, + }, + }); +} + async function ensureAnonymousUsersAreStillExcluded(metricsResponse: NiceResponse) { const baselineTotalUsers = metricsResponse.body.total_users as number; const baselineUsersByCountry = metricsResponse.body.users_by_country as Record; @@ -63,6 +82,47 @@ async function waitForMetricsToIncludeUsersByCountry(options: { countryCode: str return response; } +async function waitForMetricsMatch( + includeAnonymous: boolean, + predicate: (response: NiceResponse) => boolean, +): Promise { + let response!: NiceResponse; + const suffix = includeAnonymous ? "?include_anonymous=true" : ""; + for (let i = 0; i < 20; i++) { + response = await niceBackendFetch(`/api/v1/internal/metrics${suffix}`, { accessType: 'admin' }); + if (predicate(response)) { + return response; + } + await wait(1_000); + } + return response; +} + +async function waitForAnalyticsRowsForSessionReplaySegment( + sessionReplaySegmentId: string, + expectedCount: number, +): Promise { + for (let i = 0; i < 30; i++) { + const response = await niceBackendFetch("/api/v1/internal/analytics/query", { + method: "POST", + accessType: "admin", + body: { + query: ` + SELECT count() AS count + FROM events + WHERE session_replay_segment_id = {segId:String} + `, + params: { segId: sessionReplaySegmentId }, + }, + }); + if (response.status === 200 && Number(response.body.result?.[0]?.count ?? 0) >= expectedCount) { + return; + } + await wait(500); + } + throw new Error(`Timed out waiting for ${expectedCount} analytics rows for session replay segment ${sessionReplaySegmentId}`); +} + it("should return metrics data", async ({ expect }) => { await Project.createAndSwitch({ config: { @@ -381,3 +441,136 @@ it("should return correct auth_overview breakdown including teams", async ({ exp expect(nonAnonFromOverview).toBeGreaterThanOrEqual(1); expect(authOverview.total_teams).toBeGreaterThanOrEqual(1); }); + +it("should count top referrers by unique visitors and exclude anonymous analytics by default", async ({ expect }) => { + await Project.createAndSwitch({ + config: { + magic_link_enabled: true, + } + }); + await Project.updateConfig({ + "apps.installed.analytics": { enabled: true }, + }); + + await InternalApiKey.createAndSetProjectKeys(); + + backendContext.set({ + mailbox: createMailbox(), + ipData: { + country: "US", + ipAddress: "127.0.0.11", + city: "New York", + region: "NY", + latitude: 40.7128, + longitude: -74.0060, + tzIdentifier: "America/New_York", + }, + }); + await Auth.Otp.signIn(); + + const regularSessionReplaySegmentId = randomUUID(); + const regularNow = Date.now(); + const regularReferrer = "https://regular.example/source"; + const regularBatchResponse = await uploadAnalyticsEventBatch({ + sessionReplaySegmentId: regularSessionReplaySegmentId, + batchId: randomUUID(), + sentAtMs: regularNow, + events: [ + { + event_type: "$page-view", + event_at_ms: regularNow - 200, + data: { + url: "https://stack-auth.example/regular-1", + path: "/regular-1", + referrer: regularReferrer, + title: "Regular Page 1", + entry_type: "initial", + viewport_width: 1920, + viewport_height: 1080, + screen_width: 1920, + screen_height: 1080, + }, + }, + { + event_type: "$page-view", + event_at_ms: regularNow - 100, + data: { + url: "https://stack-auth.example/regular-2", + path: "/regular-2", + referrer: regularReferrer, + title: "Regular Page 2", + entry_type: "push", + viewport_width: 1920, + viewport_height: 1080, + screen_width: 1920, + screen_height: 1080, + }, + }, + ], + }); + expect(regularBatchResponse.status).toBe(200); + await waitForAnalyticsRowsForSessionReplaySegment(regularSessionReplaySegmentId, 2); + + backendContext.set({ + ipData: { + country: "CA", + ipAddress: "127.0.0.12", + city: "Toronto", + region: "ON", + latitude: 43.6532, + longitude: -79.3832, + tzIdentifier: "America/Toronto", + }, + }); + await Auth.Anonymous.signUp(); + + const anonymousSessionReplaySegmentId = randomUUID(); + const anonymousNow = Date.now(); + const anonymousReferrer = "https://anonymous.example/source"; + const anonymousBatchResponse = await uploadAnalyticsEventBatch({ + sessionReplaySegmentId: anonymousSessionReplaySegmentId, + batchId: randomUUID(), + sentAtMs: anonymousNow, + events: [ + { + event_type: "$page-view", + event_at_ms: anonymousNow - 100, + data: { + url: "https://stack-auth.example/anonymous-1", + path: "/anonymous-1", + referrer: anonymousReferrer, + title: "Anonymous Page 1", + entry_type: "initial", + viewport_width: 1920, + viewport_height: 1080, + screen_width: 1920, + screen_height: 1080, + }, + }, + ], + }); + expect(anonymousBatchResponse.status).toBe(200); + await waitForAnalyticsRowsForSessionReplaySegment(anonymousSessionReplaySegmentId, 1); + + const metricsWithoutAnonymous = await waitForMetricsMatch(false, (response) => { + const topReferrers = response.body.analytics_overview.top_referrers as Array<{ referrer: string, visitors: number }>; + return response.body.analytics_overview.online_live === 1 + && topReferrers.some((item) => item.referrer === regularReferrer && item.visitors === 1) + && !topReferrers.some((item) => item.referrer === anonymousReferrer); + }); + const topReferrersWithoutAnonymous = metricsWithoutAnonymous.body.analytics_overview.top_referrers as Array<{ referrer: string, visitors: number }>; + expect(topReferrersWithoutAnonymous).toContainEqual({ referrer: regularReferrer, visitors: 1 }); + expect(topReferrersWithoutAnonymous.some((item) => item.referrer === anonymousReferrer)).toBe(false); + expect(metricsWithoutAnonymous.body.analytics_overview.online_live).toBe(1); + + const metricsWithAnonymous = await waitForMetricsMatch(true, (response) => { + const topReferrers = response.body.analytics_overview.top_referrers as Array<{ referrer: string, visitors: number }>; + return response.body.analytics_overview.online_live === 2 + && topReferrers.some((item) => item.referrer === regularReferrer && item.visitors === 1) + && topReferrers.some((item) => item.referrer === anonymousReferrer && item.visitors === 1); + }); + const topReferrersWithAnonymous = metricsWithAnonymous.body.analytics_overview.top_referrers as Array<{ referrer: string, visitors: number }>; + expect(topReferrersWithAnonymous).toContainEqual({ referrer: regularReferrer, visitors: 1 }); + expect(topReferrersWithAnonymous).toContainEqual({ referrer: anonymousReferrer, visitors: 1 }); + expect(metricsWithAnonymous.body.analytics_overview.online_live).toBe(2); +}); diff --git a/packages/stack-shared/src/ai/image-limits.ts b/packages/stack-shared/src/ai/image-limits.ts index 67186a4d10..687b8af8c0 100644 --- a/packages/stack-shared/src/ai/image-limits.ts +++ b/packages/stack-shared/src/ai/image-limits.ts @@ -19,6 +19,26 @@ type ValidationResult = { ok: true } | { ok: false, reason: string }; type UnknownPart = { type?: unknown, image?: unknown }; type MessageLike = { role?: unknown, content?: unknown }; +export function validateImageCount(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 validateImageByteLength(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 }; +} + /** Validates per-message image count and per-file size for user messages. */ export function validateImageAttachments(messages: readonly MessageLike[]): ValidationResult { for (const msg of messages) { @@ -29,20 +49,12 @@ export function validateImageAttachments(messages: readonly MessageLike[]): Vali const part = rawPart as UnknownPart; if (part.type !== "image") continue; imageCount++; - if (imageCount > MAX_IMAGES_PER_MESSAGE) { - return { - ok: false, - reason: `Maximum ${MAX_IMAGES_PER_MESSAGE} images per message.`, - }; - } + const countValidation = validateImageCount(imageCount); + if (!countValidation.ok) return countValidation; if (typeof part.image === "string") { const bytes = estimateBase64ByteLength(part.image); - 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).`, - }; - } + const sizeValidation = validateImageByteLength(bytes); + if (!sizeValidation.ok) return sizeValidation; } } } From c17eb7cc1a55f0b4b4e6e7212912fb0387bc11a9 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 13 Apr 2026 13:13:16 -0700 Subject: [PATCH 26/30] Enhance AI Query Functionality and UI Components - Added support for the "build-analytics-query" system prompt in the AI query handler, allowing for more complex SQL query generation. - Updated the step limit logic in the AI query handler to accommodate the new prompt. - Introduced new components: AiQueryBar and AiQueryDialog for improved user interaction with AI-driven analytics queries. - Implemented a new QueryDataGrid component to display results from AI-generated queries. - Enhanced the toolbar in the DataGrid to support custom search functionalities and improved layout. - Updated documentation and SQL query guidelines to reflect changes in event data structure and extraction requirements. This update significantly improves the user experience for querying analytics data through AI, providing a more intuitive interface and robust backend support. --- .../app/api/latest/ai/query/[mode]/route.ts | 13 +- apps/backend/src/lib/ai/prompts.ts | 202 +++++- apps/backend/src/lib/ai/schema.ts | 1 + apps/backend/src/lib/ai/tools/sql-query.ts | 4 +- .../analytics/tables/ai-query-bar.tsx | 133 ++++ .../analytics/tables/ai-query-dialog.tsx | 645 +++++++++++++++++ .../analytics/tables/page-client.tsx | 502 +++---------- .../analytics/tables/query-data-grid.tsx | 670 ++++++++++++++++++ .../analytics/tables/use-ai-query-chat.ts | 185 +++++ .../create-dashboard-preview.tsx | 4 +- .../data-grid/data-grid-toolbar.tsx | 46 +- 11 files changed, 1958 insertions(+), 447 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts 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 9b273e1d4c..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 @@ -59,7 +59,18 @@ export const POST = createSmartRouteHandler({ // 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"; - const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : isCreateDashboard ? 12 : 5; + // 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/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index 324c1ee4ba..72b5a40fae 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -41,6 +41,7 @@ export type SystemPromptId = | "email-assistant-draft" | "create-dashboard" | "run-query" + | "build-analytics-query" | "rewrite-template-source"; /** @@ -74,34 +75,37 @@ 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 appropriate date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. -- For counting, use COUNT(*) or COUNT(DISTINCT column) -- Example queries: - - 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 +- Use relative date ranges: now() - INTERVAL X DAY +- Use date functions: toDate(), toStartOfDay(), toStartOfWeek(), etc. +- For counting, use count() or count(DISTINCT column) `, "docs-ask-ai": ` # Stack Auth AI Assistant System Prompt @@ -949,42 +953,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 0b3c6e14fd..8bcfabc972 100644 --- a/apps/backend/src/lib/ai/tools/sql-query.ts +++ b/apps/backend/src/lib/ai/tools/sql-query.ts @@ -15,11 +15,11 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI const MAX_ROWS_FOR_AI = 50; return tool({ - description: "Run a read-only ClickHouse SQL query against the project's analytics database for INSPECTION. Only SELECT queries are allowed. Project filtering is automatic. Results are capped at 50 rows for your context — always include a LIMIT clause and prefer aggregates (count, sum, min, max, avg, quantile, GROUP BY) over SELECT *.", + 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 a LIMIT clause (≤20 for row samples)."), + .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(); 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 380c3b5369..5dfe6abbba 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,39 +1,21 @@ "use client"; import { Link } from "@/components/link"; -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 { Button, Typography } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { - ArrowClockwiseIcon, - SparkleIcon, -} from "@phosphor-icons/react"; -import { - createDefaultDataGridState, - DataGrid, - useDataSource, - type DataGridColumnDef, - type DataGridDataSource, - type DataGridState, -} from "@stackframe/dashboard-ui-components"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { ArrowClockwiseIcon, CodeIcon } from "@phosphor-icons/react"; +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 { - isJsonValue, - JsonValue, - parseClickHouseDate, - RowData, -} from "../shared"; + QueryDataGrid, + type QueryDataGridMode, +} from "./query-data-grid"; +import { useAiQueryChat } from "./use-ai-query-chat"; + +// ─── Available tables ─────────────────────────────────────────────── type TableId = "events"; @@ -41,385 +23,97 @@ type TableConfig = { displayName: string, baseQuery: string, defaultOrderBy: string, - defaultOrderDir: "ASC" | "DESC", + defaultOrderDir: "asc" | "desc", }; -// Available tables in the analytics database const AVAILABLE_TABLES = new Map([ - ["events", { - displayName: "Events", - baseQuery: "SELECT * FROM default.events", - defaultOrderBy: "event_at", - defaultOrderDir: "DESC", - }], + [ + "events", + { + displayName: "Events", + baseQuery: "SELECT * FROM default.events", + defaultOrderBy: "event_at", + defaultOrderDir: "desc", + }, + ], ]); -const PAGE_SIZE = 50; - -// Date detection for dynamic columns — the grid now handles actual -// rendering for `type: "dateTime"` columns, but we still need a runtime -// check to decide which columns should be marked as dates. -function isDateColumnName(name: string): boolean { - return name.endsWith("_at") || name === "date" || /(^|_)date($|_)/.test(name); -} - -// Format a non-date cell value for display. Date values never reach this -// component because date columns get `type: "dateTime"` and the grid's -// built-in date renderer kicks in. -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}; -} - -// `parseValue` adapter — ClickHouse emits "YYYY-MM-DD HH:MM:SS.mmm" -// strings. Grid's default `new Date()` would interpret those as local -// time; `parseClickHouseDate` treats them correctly as UTC and returns -// `null` for invalid values 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; -} - -// 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 width heuristic ────────────────────────────────────────── - -/** 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; -} +// ─── Per-table content ────────────────────────────────────────────── function TableContent({ tableId }: { tableId: TableId }) { - const adminApp = useAdminApp(); const tableConfig = AVAILABLE_TABLES.get(tableId)!; - - const [discoveredColumns, setDiscoveredColumns] = useState([]); - const [error, setError] = useState(null); - const [selectedRow, setSelectedRow] = useState(null); - const [detailDialogOpen, setDetailDialogOpen] = useState(false); - - // Ref mirror of discoveredColumns so the async generator (memoised - // against adminApp + tableConfig) can read the latest column list - // without being re-created every time the schema updates. The - // generator builds its WHERE clause from this ref. - const discoveredColumnsRef = useRef([]); - - // Grid state — initialize with the table's default sort so the very - // first fetch already uses the right ORDER BY. - const [gridState, setGridState] = useState(() => { - const base = createDefaultDataGridState([]); - return { - ...base, - sorting: [{ - columnId: tableConfig.defaultOrderBy, - direction: tableConfig.defaultOrderDir === "DESC" ? "desc" : "asc", - }], - pagination: { pageIndex: 0, pageSize: PAGE_SIZE }, - }; - }); - - // DataGrid column defs built from the discovered column names. - // Empty on first render — the grid renders blank until the first page - // comes back, then re-renders with populated columns. This is fine - // because the initial sort is by columnId string, not by column ref. - // Columns are sortable server-side via the ORDER BY in the generator. - // - // Date columns are detected by name (`*_at`, `date`). They get - // `type: "dateTime"` which enables the grid's built-in date cell - // renderer, and a `parseValue` override so ClickHouse's space- - // separated UTC strings parse correctly. The date format toggle - // (relative / absolute) lives in the grid's Columns popover and is - // wired up automatically once any date column exists. - 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 [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} + /> ); - // Async data source — server-side sort + search + paginated fetch - // via SQL. The generator also discovers the schema from the first - // row of the first page (on every refetch, in case the schema - // changes). - const dataSource = useMemo>(() => { - return async function* (params) { - setError(null); - try { - // `sorting` is a tuple of zero or one item in practice — multi-sort - // isn't wired up server-side. Handle the empty case explicitly so - // we don't rely on `params.sorting[0]` being defined. - let orderBy: string; - let orderDir: "ASC" | "DESC"; - if (params.sorting.length > 0) { - const first = params.sorting[0]!; - orderBy = first.columnId; - orderDir = first.direction === "asc" ? "ASC" : "DESC"; - } else { - orderBy = tableConfig.defaultOrderBy; - orderDir = tableConfig.defaultOrderDir; - } - const pageSize = params.pagination.pageSize; - const offset = params.pagination.pageIndex * pageSize; - - // Build a WHERE clause from the quick-search text. We can only - // do this once the schema has been discovered (i.e. after the - // first unfiltered fetch), otherwise there are no columns to - // search against. Cast every column to String and OR ILIKE - // across all of them — generic enough for any events-style - // table. Single quotes in the query are escaped to prevent - // trivial injection via the search box; backslashes are - // doubled first so the escape itself doesn't re-introduce - // unescaped quotes. - const search = params.quickSearch.trim(); - const searchableCols = discoveredColumnsRef.current; - let whereClause = ""; - if (search && searchableCols.length > 0) { - const escaped = search.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); - const clauses = searchableCols - .map((c) => `toString(\`${c}\`) ILIKE '%${escaped}%'`) - .join(" OR "); - whereClause = ` WHERE ${clauses}`; - } - - const query = `${tableConfig.baseQuery}${whereClause} ORDER BY ${orderBy} ${orderDir} LIMIT ${pageSize} OFFSET ${offset}`; - - const response = await adminApp.queryAnalytics({ - query, - include_all_branches: false, - timeout_ms: 30000, - }); - - const newRows = response.result as RowData[]; - - // Refresh the column list only when the schema actually differs, - // otherwise every page load would cause a spurious re-render. - // Mirror to the ref so subsequent generator runs (including - // the one fired by a search-box keystroke) can build a WHERE - // clause without waiting for another re-render. - if (newRows.length > 0) { - const cols = Object.keys(newRows[0]!); - 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 === pageSize, - }; - } catch (e: unknown) { - const message = e instanceof Error ? e.message : "Failed to load table data"; - setError(message); - yield { rows: [], hasMore: false }; - } - }; - }, [adminApp, tableConfig]); - - // Stable row ID — prefer explicit ID fields, fall back to a JSON - // fingerprint so infinite-scroll dedup in useDataSource still works - // for tables without a dedicated ID column. - const getRowId = useCallback((row: RowData): string => { - if (row.id != null) return String(row.id); - if (row.event_id != null) return String(row.event_id); - return JSON.stringify(row); - }, []); - - // The async data source handles server-side sort + search + infinite - // scroll. `quickSearch` flows straight from grid state into the - // generator via `params.quickSearch`, and the hook re-fires the - // generator on change (same mechanism as sorting). - const gridData = useDataSource({ - dataSource, - columns, - getRowId, - sorting: gridState.sorting, - quickSearch: gridState.quickSearch, - pagination: gridState.pagination, - paginationMode: "infinite", - }); - - const handleRowClick = useCallback((row: RowData) => { - setSelectedRow(row); - setDetailDialogOpen(true); - }, []); - - const showEmptyError = - error != null && !gridData.isLoading && gridData.rows.length === 0; - - // Rendered inside the DataGrid's default toolbar, to the left of the - // built-in columns / export actions. The date-format toggle now lives - // inside the grid's Columns popover (auto-wired because the grid sees - // `type: "dateTime"` columns), so this extras slot only has refresh + - // row count now. - const toolbarExtra = ( -
- - - - {gridData.rows.length.toLocaleString()} rows - {gridData.hasMore && "+"} - -
+ const renderToolbarExtra = useCallback( + (ctx: { rowCount: number, hasMore: boolean, reload: () => void }) => ( +
+ + + {ctx.hasMore + ? `${ctx.rowCount.toLocaleString()}+ rows` + : `${ctx.rowCount.toLocaleString()} rows`} + +
+ ), + [], ); return (
- {/* Non-fatal error banner — shown while data is still visible. */} - {error != null && !showEmptyError && ( -
- {error} -
- )} - - {/* Fatal error panel — no data to fall back to. */} - {showEmptyError && ( -
- {error} - -
- )} - - {/* Data grid — fills remaining space via `flex-1 min-h-0`. - Uses the DataGrid's default toolbar (column visibility, CSV - export) and slots refresh + row count in via `toolbarExtra`. - The date format toggle shows up automatically inside the - Columns popover because at least one column is `dateTime`. */} - {!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" - toolbarExtra={toolbarExtra} - footer={false} - exportFilename={`${tableId}-export`} - onRowClick={handleRowClick} - emptyState={ -
- No data available -
- } - /> -
- )} + -
); @@ -431,21 +125,23 @@ export default function PageClient() { return ( -
- {/* Left sidebar - table list (doesn't scroll, border extends full height) */} -
+
+ {/* Left sidebar — hidden on mobile */} +
- Tables + + 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..44eafcdd31 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx @@ -0,0 +1,670 @@ +"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[], +}; + +// ─── 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[]; + + if (newRows.length > 0) { + const cols = Object.keys(newRows[0]!); + 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 (row.id != null) return String(row.id); + if (row.event_id != null) return String(row.event_id); + return JSON.stringify(row); + }, []); + + 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.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts new file mode 100644 index 0000000000..2f97d00745 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.ts @@ -0,0 +1,185 @@ +"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, +}; + +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-"); +} + +/** + * 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. + */ +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 the tool input to be fully committed before surfacing + // the query to the grid — otherwise we'd re-run partially + // streamed SQL on every chunk. + if (part.state === "input-streaming") 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/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx index 20c5165afc..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 @@ -266,11 +266,11 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({
-
+
Create Dashboard
{prompt}
-
+
{phase === "ready" && artifact && ( @@ -266,6 +270,8 @@ function ColumnManager({ export function DataGridToolbar({ ctx, extra, + extraLeading, + hideQuickSearch, }: { ctx: DataGridToolbarContext; /** Extra content rendered inside the toolbar row, to the left of the @@ -273,6 +279,16 @@ export function DataGridToolbar({ * affordances (refresh, custom toggles, row counts) without giving up * the default actions. */ extra?: React.ReactNode; + /** Extra content rendered at the START of the toolbar row — occupies + * the same position as the built-in quick search (after it, if the + * quick search is visible). Use this together with `hideQuickSearch` + * to fully replace the quick search with a custom input, e.g. an + * AI-powered search bar. */ + extraLeading?: React.ReactNode; + /** Whether to hide the built-in quick-search input. When `true`, + * callers are expected to provide their own search UI via + * `extraLeading`. */ + hideQuickSearch?: boolean; }) { const { state, onChange, columns, strings, exportCsv } = ctx; @@ -312,23 +328,26 @@ export function DataGridToolbar({ ); return ( -
- -
+
+ {!hideQuickSearch && ( + + )} + {extraLeading} +
{extra} -
+
columnPopover.setOpen(!columnPopover.open)} active={columnPopover.open} + title={strings.columns} > - {strings.columns} {columnPopover.open && ( @@ -345,9 +364,8 @@ export function DataGridToolbar({ )}
- + - {strings.export}
); From de3de70898d9d56a6330ed23f1327fd468e9dd72 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 13 Apr 2026 13:55:09 -0700 Subject: [PATCH 27/30] Add Metrics Helper Functions and Tests - Introduced new utility functions `isMetricsRevenueInvoiceStatus` and `getMetricsWindowBounds` to enhance metrics calculations. - Updated the `loadMonthlyActiveUsers`, `loadDailyRevenue`, and `loadPaymentsOverview` functions to utilize the new metrics helper functions for improved date handling and revenue status checks. - Added unit tests for the metrics helper functions to ensure correct behavior and validation of revenue invoice statuses. - Created a new test file for the metrics route to validate the functionality of the introduced helpers. This update improves the accuracy and maintainability of metrics-related calculations in the application. --- .../api/latest/internal/metrics/route.test.ts | 20 ++++++ .../app/api/latest/internal/metrics/route.tsx | 64 ++++++++++++------- .../analytics/tables/query-data-grid.tsx | 12 +++- .../tables/use-ai-query-chat.test.ts | 44 +++++++++++++ .../analytics/tables/use-ai-query-chat.ts | 18 ++++-- .../components/data-grid/use-data-source.ts | 11 +++- 6 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/metrics/route.test.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/use-ai-query-chat.test.ts 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 d355f71dd1..cac43f1aef 100644 --- a/apps/backend/src/app/api/latest/internal/metrics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/metrics/route.tsx @@ -29,6 +29,29 @@ 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); + +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. @@ -374,13 +397,8 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boole return dbUsers.map((user) => userPrismaToCrud(user, tenancy.config)); } -async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolean = false): Promise { - const now = new Date(); - const todayUtc = new Date(now); - todayUtc.setUTCHours(0, 0, 0, 0); - // 30-day rolling window for MAU - const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); - const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); +async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise { + const { since, untilExclusive } = getMetricsWindowBounds(now); const clickhouseClient = getClickhouseAdminClient(); try { @@ -439,9 +457,7 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, includeAnonymous: boolea async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise> { const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); - const todayUtc = new Date(now); - todayUtc.setUTCHours(0, 0, 0, 0); - const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); + const { since } = getMetricsWindowBounds(now); const rows = await prisma.$replica().$queryRaw<{ day: string, new_cents: bigint }[]>` SELECT @@ -450,7 +466,7 @@ async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise= ${since} GROUP BY day ORDER BY day @@ -471,12 +487,11 @@ async function loadDailyRevenue(tenancy: Tenancy, now: Date): Promise= ${thirtyDaysAgo}) AS mrr_cents `, // Daily subscription signups for the last 30 days @@ -582,11 +598,10 @@ async function loadPaymentsOverview(tenancy: Tenancy) { // ── Email Aggregates ───────────────────────────────────────────────────────── -async function loadEmailOverview(tenancy: Tenancy) { +async function loadEmailOverview(tenancy: Tenancy, now: Date) { const schema = await getPrismaSchemaForTenancy(tenancy); const prisma = await getPrismaClientForTenancy(tenancy); - const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - METRICS_WINDOW_MS); + const { since: thirtyDaysAgo } = getMetricsWindowBounds(now); const [ counts, @@ -777,7 +792,8 @@ async function loadSessionReplayAggregates(tenancy: Tenancy, since: Date): Promi (SELECT COALESCE(SUM("amountTotal"), 0)::bigint FROM ${sqlQuoteIdent(schema)}."SubscriptionInvoice" WHERE "tenancyId" = ${tenancy.id}::UUID - AND "amountTotal" IS NOT NULL) AS total_revenue_cents + AND "amountTotal" IS NOT NULL + AND "status" IN (${METRICS_REVENUE_INVOICE_STATUSES_SQL})) AS total_revenue_cents `; const row = result[0]; @@ -1117,7 +1133,7 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now `, loadDailyActiveUsersSplit(tenancy, now, includeAnonymous), loadDailyActiveTeamsSplit(tenancy, now), - loadMonthlyActiveUsers(tenancy, includeAnonymous), + loadMonthlyActiveUsers(tenancy, now, includeAnonymous), ]); const totalUsers = Number(counts[0].total_users); @@ -1216,8 +1232,8 @@ export const GET = createSmartRouteHandler({ loadRecentlyActiveUsers(req.auth.tenancy, includeAnonymous), loadLoginMethods(req.auth.tenancy), loadAuthOverview(req.auth.tenancy, includeAnonymous, now), - loadPaymentsOverview(req.auth.tenancy), - loadEmailOverview(req.auth.tenancy), + loadPaymentsOverview(req.auth.tenancy, now), + loadEmailOverview(req.auth.tenancy, now), loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous), loadDailyRevenue(req.auth.tenancy, now), ] as const); 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 index 44eafcdd31..004f5f6fa7 100644 --- 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 @@ -136,6 +136,8 @@ export type QueryDataGridHandle = { getDiscoveredColumns: () => string[], }; +const INTERNAL_ROW_ID_KEY = "__stack_row_id"; + // ─── Utility helpers ──────────────────────────────────────────────── /** Detect whether a column name refers to a date/time value. */ @@ -460,10 +462,13 @@ export const QueryDataGrid = forwardRef timeout_ms: 30000, }); - const newRows = response.result as RowData[]; + 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]!); + 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])) { @@ -487,9 +492,10 @@ export const QueryDataGrid = forwardRef }, [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); - return JSON.stringify(row); + throw new Error("QueryDataGrid row is missing an internal row id"); }, []); const gridData = useDataSource({ 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 index 2f97d00745..72cdfaf1fa 100644 --- 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 @@ -13,6 +13,7 @@ type ToolPart = { type: string, state: string, input?: Record, + output?: Record, }; function isToolPart(part: unknown): part is ToolPart { @@ -21,13 +22,20 @@ function isToolPart(part: unknown): part is ToolPart { 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. */ -function extractLatestQuery(messages: UIMessage[]): { +export function extractLatestQuery(messages: UIMessage[]): { query: string, state: string, toolCallIndex: number, @@ -52,10 +60,10 @@ function extractLatestQuery(messages: UIMessage[]): { const part = msg.parts[j]!; if (!isToolPart(part)) continue; if (!part.type.endsWith("queryAnalytics")) continue; - // Wait for the tool input to be fully committed before surfacing - // the query to the grid — otherwise we'd re-run partially - // streamed SQL on every chunk. - if (part.state === "input-streaming") 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) { diff --git a/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts index c38cb5c4a8..78d2614d9f 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts +++ b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts @@ -117,6 +117,7 @@ function useAsyncDataSource(opts: { const abortRef = useRef(null); const pageIndexRef = useRef(0); const hasDataRef = useRef(false); + const hasMountedServerPaginationRef = useRef(false); const latestArgsRef = useRef({ dataSource, @@ -215,9 +216,15 @@ function useAsyncDataSource(opts: { }, [fetchPage, sortingKey, quickSearchKey, pagination.pageSize]); useEffect(() => { - if (paginationMode === "server") { - fetchPage(false).catch(() => {}); + if (paginationMode !== "server") { + hasMountedServerPaginationRef.current = false; + return; } + if (!hasMountedServerPaginationRef.current) { + hasMountedServerPaginationRef.current = true; + return; + } + fetchPage(false).catch(() => {}); }, [fetchPage, paginationMode, pagination.pageIndex]); const loadMore = useCallback(() => { From 31d3a8d4167b84ada4d30530926b99320519b533 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 13 Apr 2026 15:44:28 -0700 Subject: [PATCH 28/30] Refactor Analytics Metrics Queries - Replaced the external ClickHouse client with the admin client for improved access control. - Updated SQL queries to reference the `analytics_internal.events` table instead of the generic `events` table, ensuring better data accuracy. - Adjusted query parameters to include project and branch IDs for enhanced filtering. - Modified test snapshots to reflect changes in expected analytics data, setting activity metrics to zero for consistency. This refactor enhances the reliability and specificity of analytics data retrieval in the application. --- .../app/api/latest/internal/metrics/route.tsx | 53 +- .../internal-metrics.test.ts.snap | 946 +++++++++--------- 2 files changed, 487 insertions(+), 512 deletions(-) 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 cac43f1aef..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,6 +1,6 @@ import { Prisma } from "@/generated/prisma/client"; import { EmailOutboxSimpleStatus } from "@/generated/prisma/enums"; -import { getClickhouseAdminClient, getClickhouseExternalClient } from "@/lib/clickhouse"; +import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { ClickHouseError } from "@clickhouse/client"; import { ActivitySplit, buildSplitFromDailyEntitySets } from "@/lib/metrics-activity-split"; import { Tenancy } from "@/lib/tenancies"; @@ -812,11 +812,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS); const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS); - const clickhouseClient = getClickhouseExternalClient(); - const clickhouseSettings = { - SQL_project_id: tenancy.project.id, - SQL_branch_id: tenancy.branchId, - }; + 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 @@ -839,18 +835,18 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo LEFT JOIN ( SELECT user_id, - argMax(CAST(data.is_anonymous, 'UInt8'), event_at) AS latest_is_anonymous - FROM events + 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 - LEFT JOIN users AS u - ON e.user_id = toString(u.id) `; - const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(u.is_anonymous, token_refresh_users.latest_is_anonymous, 1) = 0)"; + 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. @@ -874,9 +870,11 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo AND e.user_id IS NOT NULL AND ${nonAnonymousAnalyticsUserFilter} ) AS visitors - FROM events AS e + 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 @@ -885,9 +883,10 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, }, - clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ @@ -898,9 +897,11 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo e.user_id IS NOT NULL AND ${nonAnonymousAnalyticsUserFilter} ) AS visitors - FROM events AS e + 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} @@ -908,9 +909,10 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, }, - clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ @@ -922,9 +924,11 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo e.user_id IS NOT NULL AND ${nonAnonymousAnalyticsUserFilter} ) AS visitors - FROM events AS e + 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 @@ -935,9 +939,10 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, }, - clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ @@ -950,8 +955,10 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo user_id IS NOT NULL AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0) ) AS visitors - FROM events + 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} @@ -963,17 +970,20 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo query_params: { since: formatClickhouseDateTimeParam(since), untilExclusive: formatClickhouseDateTimeParam(untilExclusive), + projectId: tenancy.project.id, + branchId: tenancy.branchId, includeAnonymous: includeAnonymous ? 1 : 0, }, - clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), clickhouseClient.query({ query: ` SELECT uniqExact(assumeNotNull(user_id)) AS online - FROM events + 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} @@ -982,9 +992,10 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo 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, }, - clickhouse_settings: clickhouseSettings, format: "JSONEachRow", }), ]); 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 de05c1f116..61a6972951 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 @@ -5,584 +5,549 @@ NiceResponse { "status": 200, "body": { "analytics_overview": { - "avg_session_seconds": 597, - "bounce_rate": 72.1, - "conversion_rate": 0.38, + "avg_session_seconds": 0, "daily_clicks": [ { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, ], "daily_page_views": [ { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, ], "daily_revenue": [ { "date": , - "new_cents": 300, - "refund_cents": 98, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 58, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 104, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 65, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 90, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 73, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 72, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 85, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 65, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 79, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 105, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 112, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 88, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 88, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 55, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 74, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 68, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 99, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 72, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 119, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 73, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 72, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 65, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 94, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 62, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 100, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 112, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 58, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 65, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 50, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 300, - "refund_cents": 55, + "new_cents": 0, + "refund_cents": 0, }, ], "daily_visitors": [ { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, ], - "deltas": { - "bounce_rate": 3.6, - "conversion_rate": -13.3, - "revenue": 12.3, - "revenue_per_visitor": -5.1, - "session_time": -14.4, - "visitors": 3.9, - }, - "online_live": 1, + "online_live": 0, "recent_replays": 0, "revenue_per_visitor": 0, - "top_referrers": [ - { - "referrer": "google.com", - "visitors": 9, - }, - { - "referrer": "github.com", - "visitors": 0, - }, - { - "referrer": "twitter.com", - "visitors": 1, - }, - { - "referrer": "producthunt.com", - "visitors": 3, - }, - { - "referrer": "(direct)", - "visitors": 2, - }, - ], - "top_region": { - "count": 0, - "country_code": "US", - "region_code": "CA", - }, + "top_referrers": [], + "top_region": null, "total_replays": 0, - "total_revenue_cents": 9300, + "total_revenue_cents": 0, "visitors": 0, }, "auth_overview": { @@ -1100,7 +1065,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1140,7 +1105,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1156,15 +1121,15 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1172,7 +1137,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1184,7 +1149,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1192,7 +1157,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1200,23 +1165,23 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, ], @@ -1326,7 +1291,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1348,27 +1313,27 @@ NiceResponse { ], "retained": [ { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1376,125 +1341,125 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, ], "total": [ { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { @@ -1502,105 +1467,106 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 1, + "activity": 0, "date": , }, { - "activity": 4, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, ], }, - "mau": 1, + "mau": 0, "total_teams": 0, + "total_users_filtered": 0, "unverified_users": 0, "verified_users": 0, }, @@ -2335,585 +2301,554 @@ NiceResponse { "status": 200, "body": { "analytics_overview": { - "avg_session_seconds": 597, - "bounce_rate": 72.1, - "conversion_rate": 0.38, + "avg_session_seconds": 0, "daily_clicks": [ { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 9, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 9, + "activity": 0, "date": , }, { - "activity": 9, + "activity": 0, "date": , }, { - "activity": 11, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 2, + "activity": 0, "date": , }, { - "activity": 4, + "activity": 0, "date": , }, { - "activity": 4, + "activity": 0, "date": , }, { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 7, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 3, + "activity": 0, "date": , }, { - "activity": 4, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, ], "daily_page_views": [ { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 21, + "activity": 0, "date": , }, { - "activity": 15, + "activity": 0, "date": , }, { - "activity": 22, + "activity": 0, "date": , }, { - "activity": 18, + "activity": 0, "date": , }, { - "activity": 24, + "activity": 0, "date": , }, { - "activity": 13, + "activity": 0, "date": , }, { - "activity": 7, + "activity": 0, "date": , }, { - "activity": 24, + "activity": 0, "date": , }, { - "activity": 23, + "activity": 0, "date": , }, { - "activity": 25, + "activity": 0, "date": , }, { - "activity": 18, + "activity": 0, "date": , }, { - "activity": 14, + "activity": 0, "date": , }, { - "activity": 14, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 22, + "activity": 0, "date": , }, { - "activity": 31, + "activity": 0, "date": , }, { - "activity": 27, + "activity": 0, "date": , }, { - "activity": 25, + "activity": 0, "date": , }, { - "activity": 16, + "activity": 0, "date": , }, { - "activity": 9, + "activity": 0, "date": , }, { - "activity": 11, + "activity": 0, "date": , }, { - "activity": 21, + "activity": 0, "date": , }, { - "activity": 24, + "activity": 0, "date": , }, { - "activity": 33, + "activity": 0, "date": , }, { - "activity": 23, + "activity": 0, "date": , }, { - "activity": 30, + "activity": 0, "date": , }, { - "activity": 17, + "activity": 0, "date": , }, { - "activity": 16, + "activity": 0, "date": , }, { - "activity": 18, + "activity": 0, "date": , }, { - "activity": 21, + "activity": 0, "date": , }, ], "daily_revenue": [ { "date": , - "new_cents": 335, - "refund_cents": 110, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 350, - "refund_cents": 68, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 367, - "refund_cents": 127, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 352, - "refund_cents": 76, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 361, - "refund_cents": 108, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 357, - "refund_cents": 87, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 338, - "refund_cents": 81, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 343, - "refund_cents": 97, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 377, - "refund_cents": 82, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 355, - "refund_cents": 94, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 357, - "refund_cents": 125, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 380, - "refund_cents": 142, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 366, - "refund_cents": 107, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 339, - "refund_cents": 99, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 333, - "refund_cents": 62, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 375, - "refund_cents": 93, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 377, - "refund_cents": 85, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 355, - "refund_cents": 117, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 395, - "refund_cents": 94, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 383, - "refund_cents": 152, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 332, - "refund_cents": 81, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 352, - "refund_cents": 85, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 360, - "refund_cents": 78, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 387, - "refund_cents": 121, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 388, - "refund_cents": 80, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 402, - "refund_cents": 133, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 378, - "refund_cents": 141, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 352, - "refund_cents": 68, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 341, - "refund_cents": 74, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 360, - "refund_cents": 60, + "new_cents": 0, + "refund_cents": 0, }, { "date": , - "new_cents": 388, - "refund_cents": 71, + "new_cents": 0, + "refund_cents": 0, }, ], "daily_visitors": [ { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, { - "activity": 6, + "activity": 0, "date": , }, { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, { - "activity": 7, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, { - "activity": 18, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, { - "activity": 11, + "activity": 0, "date": , }, { - "activity": 8, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 5, + "activity": 0, "date": , }, { - "activity": 10, + "activity": 0, "date": , }, { - "activity": 11, + "activity": 0, "date": , }, { - "activity": 16, + "activity": 0, "date": , }, { - "activity": 11, + "activity": 0, "date": , }, { - "activity": 17, + "activity": 0, "date": , }, { - "activity": 9, + "activity": 0, "date": , }, { - "activity": 9, + "activity": 0, "date": , }, { - "activity": 11, + "activity": 0, "date": , }, { - "activity": 12, + "activity": 0, "date": , }, ], - "deltas": { - "bounce_rate": 3.6, - "conversion_rate": -13.3, - "revenue": 12.3, - "revenue_per_visitor": -5.1, - "session_time": -14.4, - "visitors": 3.9, - }, - "online_live": 1, - "recent_replays": 2, - "revenue_per_visitor": 6.24, - "top_referrers": [ - { - "referrer": "google.com", - "visitors": 15, - }, - { - "referrer": "github.com", - "visitors": 3, - }, - { - "referrer": "twitter.com", - "visitors": 3, - }, - { - "referrer": "producthunt.com", - "visitors": 4, - }, - { - "referrer": "(direct)", - "visitors": 3, - }, - ], + "online_live": 10, + "recent_replays": 0, + "revenue_per_visitor": 0, + "top_referrers": [], "top_region": { - "count": 5, - "country_code": "US", - "region_code": "CA", + "count": 10, + "country_code": null, + "region_code": null, }, - "total_replays": 5, - "total_revenue_cents": 11235, - "visitors": 18, + "total_replays": 0, + "total_revenue_cents": 0, + "visitors": 0, }, "auth_overview": { "anonymous_users": 0, @@ -3040,7 +2975,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, ], @@ -3418,7 +3353,7 @@ NiceResponse { "date": , }, { - "activity": 1, + "activity": 0, "date": , }, ], @@ -3546,7 +3481,7 @@ NiceResponse { "date": , }, { - "activity": 10, + "activity": 9, "date": , }, ], @@ -3672,7 +3607,7 @@ NiceResponse { "date": , }, { - "activity": 0, + "activity": 1, "date": , }, ], @@ -3931,6 +3866,7 @@ NiceResponse { }, "mau": 10, "total_teams": 0, + "total_users_filtered": 9, "unverified_users": 0, "verified_users": 9, }, @@ -4773,8 +4709,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -4807,8 +4743,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -4841,8 +4777,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -4875,8 +4811,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -4909,8 +4845,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -4945,8 +4881,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -4979,8 +4915,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -5013,8 +4949,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -5047,8 +4983,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -5081,8 +5017,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 0, - "free_trial_abuse": 0, + "bot": 38, + "free_trial_abuse": 38, }, }, "selected_team": null, @@ -5094,6 +5030,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -5112,6 +5049,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 38, + "free_trial_abuse": 38, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -5121,6 +5064,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -5139,6 +5083,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 38, + "free_trial_abuse": 38, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -5148,6 +5098,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": null, "display_name": null, "has_password": false, "id": "", @@ -5166,6 +5117,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 38, + "free_trial_abuse": 38, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, @@ -5175,6 +5132,7 @@ NiceResponse { "auth_with_email": true, "client_metadata": null, "client_read_only_metadata": null, + "country_code": "AQ", "display_name": null, "has_password": false, "id": "", @@ -5193,6 +5151,12 @@ NiceResponse { "restricted_by_admin_private_details": null, "restricted_by_admin_reason": null, "restricted_reason": null, + "risk_scores": { + "sign_up": { + "bot": 38, + "free_trial_abuse": 38, + }, + }, "selected_team": null, "selected_team_id": null, "server_metadata": null, From a52cba2690d2f60fa995308443971e61175b5f19 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 13 Apr 2026 16:28:46 -0700 Subject: [PATCH 29/30] Update test snapshots to reflect changes in risk scores for sign-up metrics - Adjusted expected values in test snapshots for risk scores related to sign-up, setting both "bot" and "free_trial_abuse" metrics to zero for consistency with recent analytics data updates. --- .../internal-metrics.test.ts.snap | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) 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 61a6972951..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 @@ -4709,8 +4709,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4743,8 +4743,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4777,8 +4777,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4811,8 +4811,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4845,8 +4845,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4881,8 +4881,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4915,8 +4915,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4949,8 +4949,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -4983,8 +4983,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -5017,8 +5017,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -5051,8 +5051,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -5085,8 +5085,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -5119,8 +5119,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, @@ -5153,8 +5153,8 @@ NiceResponse { "restricted_reason": null, "risk_scores": { "sign_up": { - "bot": 38, - "free_trial_abuse": 38, + "bot": 0, + "free_trial_abuse": 0, }, }, "selected_team": null, From 7076f77f49ed2cc92709606f4dc954bcb574a38d Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 13 Apr 2026 18:57:28 -0700 Subject: [PATCH 30/30] Increase timeout for internal metrics and team invitations tests to 120 seconds for improved stability. Adjusted polling logic in internal metrics test to ensure accurate comparison of user data while excluding anonymous users. --- apps/backend/src/lib/seed-dummy-data.ts | 120 ++++++++++++++++++ .../endpoints/api/v1/internal-metrics.test.ts | 23 +++- .../endpoints/api/v1/team-invitations.test.ts | 2 + 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index a037db4d6c..8000166ef5 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -341,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', @@ -989,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) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index 67934cc61e..59a0a7c08b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -236,11 +236,20 @@ it("should exclude anonymous users from metrics", async ({ expect }) => { await Auth.Anonymous.signUp(); } - // the event log is async, so let's give it some time to be written to the DB + // Poll until the core metrics (which exclude anonymous users) stabilize. + // We can't compare the entire body because auth_overview.anonymous_users + // will have changed. let result!: NiceResponse; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 10; i++) { result = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' }); - if (deepPlainEquals(result.body, beforeMetrics.body)) { + if ( + result.body.total_users === beforeMetrics.body.total_users && + deepPlainEquals(result.body.users_by_country, beforeMetrics.body.users_by_country) && + deepPlainEquals( + (result.body.recently_registered as Array<{ id: string }>).map((u: { id: string }) => u.id), + (beforeMetrics.body.recently_registered as Array<{ id: string }>).map((u: { id: string }) => u.id), + ) + ) { break; } await wait(2_000); @@ -264,6 +273,8 @@ it("should exclude anonymous users from metrics", async ({ expect }) => { expect(result.body.users_by_country["US"]).toBe(1); await ensureAnonymousUsersAreStillExcluded(result); +}, { + timeout: 120_000, }); it("should handle anonymous users with activity correctly", async ({ expect }) => { @@ -304,6 +315,8 @@ it("should handle anonymous users with activity correctly", async ({ expect }) = expect(response.body.users_by_country["US"]).toBeUndefined(); await ensureAnonymousUsersAreStillExcluded(response); +}, { + timeout: 120_000, }); it("should handle mixed auth methods excluding anonymous users", async ({ expect }) => { @@ -346,6 +359,8 @@ it("should handle mixed auth methods excluding anonymous users", async ({ expect expect(totalMethodCount).toBe(2); // 1 OTP + 1 password, no anonymous await ensureAnonymousUsersAreStillExcluded(response); +}, { + timeout: 120_000, }); it("should return cross-product aggregates in the metrics response", async ({ expect }) => { @@ -573,4 +588,6 @@ it("should count top referrers by unique visitors and exclude anonymous analytic expect(topReferrersWithAnonymous).toContainEqual({ referrer: regularReferrer, visitors: 1 }); expect(topReferrersWithAnonymous).toContainEqual({ referrer: anonymousReferrer, visitors: 1 }); expect(metricsWithAnonymous.body.analytics_overview.online_live).toBe(2); +}, { + timeout: 120_000, }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts index 97b791f5e5..c908757518 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts @@ -844,6 +844,8 @@ it("should allow a restricted user to accept invitation after verifying email", method: "GET", }); expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined(); +}, { + timeout: 120_000, });