From 453ca7603827fe5ed4afe7fe5b6c3da950397c7d Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Thu, 2 Apr 2026 22:30:52 +0530 Subject: [PATCH 1/5] revenue improvements --- apps/api/src/query/builders/revenue.ts | 165 ++++++++++++++---- .../revenue/_components/revenue-chart.tsx | 53 +++++- .../revenue/_components/revenue-content.tsx | 85 +++++---- 3 files changed, 231 insertions(+), 72 deletions(-) diff --git a/apps/api/src/query/builders/revenue.ts b/apps/api/src/query/builders/revenue.ts index 935da0660..02dcd3ef9 100644 --- a/apps/api/src/query/builders/revenue.ts +++ b/apps/api/src/query/builders/revenue.ts @@ -221,50 +221,139 @@ const ATTRIBUTION_CTE = ` export const RevenueBuilders: Record = { revenue_overview: { - customSql: (websiteId: string, startDate: string, endDate: string) => ({ - sql: ` - WITH ${ATTRIBUTION_CTE} - SELECT - sumIf(amount, type != 'refund') as total_revenue, - countIf(type != 'refund') as total_transactions, - sumIf(amount, type = 'refund') as refund_amount, - countIf(type = 'refund') as refund_count, - sumIf(amount, type = 'subscription') as subscription_revenue, - countIf(type = 'subscription') as subscription_count, - sumIf(amount, type = 'sale') as sale_revenue, - countIf(type = 'sale') as sale_count, - uniq(r_customer_id) as unique_customers, - countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions, - sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue - FROM revenue_attributed - `, - params: { websiteId, startDate, endDate }, - }), + meta: { + title: "Revenue Overview", + description: "Total revenue and transaction metrics.", + category: "Revenue", + tags: ["overview", "revenue", "transactions"], + output_fields: [ + { name: "total_revenue", type: "number", label: "Total Revenue" }, + { name: "total_transactions", type: "number", label: "Total Transactions" }, + { name: "refund_amount", type: "number", label: "Refund Amount" }, + { name: "refund_count", type: "number", label: "Refund Count" }, + { name: "unique_customers", type: "number", label: "Unique Customers" }, + { name: "attributed_revenue", type: "number", label: "Attributed Revenue" }, + { name: "attributed_transactions", type: "number", label: "Attributed Transactions" }, + { name: "total_visitors", type: "number", label: "Total Visitors" }, + ], + default_visualization: "metric", + supports_granularity: [], + version: "1.0", + }, + customSql: ( + websiteId: string, + startDate: string, + endDate: string, + _filters?: Filter[], + _granularity?: TimeUnit, + _limit?: number, + _offset?: number, + timezone?: string + ) => { + const tz = timezone || "UTC"; + return { + sql: ` + WITH + ${ATTRIBUTION_CTE}, + visitors AS ( + SELECT uniq(anonymous_id) as total_visitors + FROM analytics.events + WHERE client_id = {websiteId:String} + AND time >= toDateTime({startDate:String}) + AND time <= toDateTime(concat({endDate:String}, ' 23:59:59')) + ) + SELECT + sumIf(amount, type != 'refund') as total_revenue, + countIf(type != 'refund') as total_transactions, + sumIf(amount, type = 'refund') as refund_amount, + countIf(type = 'refund') as refund_count, + sumIf(amount, type = 'subscription') as subscription_revenue, + countIf(type = 'subscription') as subscription_count, + sumIf(amount, type = 'sale') as sale_revenue, + countIf(type = 'sale') as sale_count, + uniq(r_customer_id) as unique_customers, + countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions, + sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue, + any(total_visitors) as total_visitors + FROM revenue_attributed + CROSS JOIN visitors + `, + params: { websiteId, startDate, endDate, timezone: tz }, + }; + }, timeField: "created", customizable: false, }, revenue_time_series: { - customSql: (websiteId: string, startDate: string, endDate: string) => ({ - sql: ` - WITH ${ATTRIBUTION_CTE} + meta: { + title: "Revenue Time Series", + description: "Daily or hourly breakdown of revenue metrics.", + category: "Revenue", + tags: ["timeseries", "revenue", "trends"], + output_fields: [ + { name: "date", type: "datetime", label: "Date" }, + { name: "revenue", type: "number", label: "Revenue" }, + { name: "transactions", type: "number", label: "Transactions" }, + { name: "customers", type: "number", label: "Customers" }, + ], + default_visualization: "timeseries", + supports_granularity: ["hour", "day"], + version: "1.0", + }, + customSql: ( + websiteId: string, + startDate: string, + endDate: string, + _filters?: Filter[], + _granularity?: TimeUnit, + _limit?: number, + _offset?: number, + timezone?: string + ) => { + const tz = timezone || "UTC"; + const isHourly = _granularity === "hour" || _granularity === "hourly"; + const timeBucketFn = isHourly ? "toStartOfHour" : "toDate"; + const dateFormat = isHourly + ? "formatDateTime(time_bucket, '%Y-%m-%d %H:00:00')" + : "toDate(time_bucket)"; + + return { + sql: ` + WITH + ${ATTRIBUTION_CTE}, + base_revenue AS ( + SELECT + toTimeZone(created, {timezone:String}) as normalized_time, + amount, + type, + r_customer_id, + is_attributed + FROM revenue_attributed + ), + revenue_agg AS ( + SELECT + ${timeBucketFn}(normalized_time) as time_bucket, + sumIf(amount, type != 'refund') as revenue, + countIf(type != 'refund') as transactions, + uniq(r_customer_id) as customers, + sumIf(amount, type = 'refund') as refund_amount, + countIf(type = 'refund') as refund_count, + sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue, + countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions + FROM base_revenue + GROUP BY time_bucket + ) SELECT - toDate(created) as date, - sumIf(amount, type != 'refund') as revenue, - countIf(type != 'refund') as transactions, - uniq(r_customer_id) as customers, - sumIf(amount, type = 'refund') as refund_amount, - countIf(type = 'refund') as refund_count, - sumIf(amount, is_attributed = 1 AND type != 'refund') as attributed_revenue, - countIf(is_attributed = 1 AND type != 'refund') as attributed_transactions - FROM revenue_attributed - GROUP BY date - ORDER BY date ASC - `, - params: { websiteId, startDate, endDate }, - }), + ${dateFormat} as date, + * + FROM revenue_agg + ORDER BY time_bucket ASC`, + params: { websiteId, startDate, endDate, timezone: tz }, + }; + }, timeField: "created", - customizable: false, + customizable: true, }, revenue_by_provider: { @@ -786,4 +875,4 @@ export const RevenueBuilders: Record = { normalizeGeo: true, }, }, -}; +}; \ No newline at end of file diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx index 4071b1b03..f0ba65fbd 100644 --- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx @@ -1,6 +1,7 @@ "use client"; import { ChartLineIcon } from "@phosphor-icons/react"; +import dayjs from "dayjs"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { SkeletonChart } from "@/components/charts/skeleton-chart"; @@ -98,18 +99,45 @@ const REVENUE_METRICS: RevenueChartMetric[] = [ maximumFractionDigits: 0, }).format(Math.abs(v)), }, + { + key: "revenue", + label: "Revenue", + color: "#10b981", + formatValue: (v) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(v), + }, ]; +const CURRENCY_METRICS = ["revenue", "avg_transaction", "refunds"]; + interface RevenueChartProps { data: RevenueChartDataPoint[]; isLoading: boolean; + granularity?: string; height?: number; className?: string; } +function formatChartDate(value: string, granularity?: string): string { + const parsed = dayjs(value); + if (!parsed.isValid()) { + return value; + } + if (granularity === "hour" || granularity === "hourly") { + return parsed.format("MMM D, h:mm A"); + } + return parsed.format("MMM D, YYYY"); +} + export function RevenueChart({ data, isLoading, + granularity, height = 350, className, }: RevenueChartProps) { @@ -204,13 +232,31 @@ export function RevenueChart({ axisLine={false} dataKey="date" tick={chartAxisTickDefault} + tickFormatter={(value) => formatChartDate(value, granularity)} tickLine={false} /> + new Intl.NumberFormat("en-US", { + notation: "compact", + style: "currency", + currency: "USD", + }).format(value) + } tickLine={false} width={chartAxisYWidthDefault} + yAxisId="currency" + /> + ( @@ -274,8 +320,11 @@ export function RevenueChart({ key={metric.key} name={metric.label} stroke={metric.color} - strokeWidth={2.5} + strokeWidth={metric.key === "revenue" ? 3 : 2} type="monotone" + yAxisId={ + CURRENCY_METRICS.includes(metric.key) ? "currency" : "count" + } /> ))} @@ -283,4 +332,4 @@ export function RevenueChart({ ); -} +} \ No newline at end of file diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx index e7b537344..a19d07fee 100644 --- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx @@ -13,7 +13,6 @@ import { EyeIcon } from "@phosphor-icons/react/dist/ssr/Eye"; import { EyeSlashIcon } from "@phosphor-icons/react/dist/ssr/EyeSlash"; import { GearIcon } from "@phosphor-icons/react/dist/ssr/Gear"; import { LinkIcon } from "@phosphor-icons/react/dist/ssr/Link"; -import { ReceiptIcon } from "@phosphor-icons/react/dist/ssr/Receipt"; import { SpinnerIcon } from "@phosphor-icons/react/dist/ssr/Spinner"; import { StripeLogoIcon } from "@phosphor-icons/react/dist/ssr/StripeLogo"; import { TrendUpIcon } from "@phosphor-icons/react/dist/ssr/TrendUp"; @@ -45,6 +44,7 @@ import { cn } from "@/lib/utils"; import { addDynamicFilterAtom, dynamicQueryFiltersAtom, + type TimeGranularity, } from "@/stores/jotai/filterAtoms"; import { WebsitePageHeader } from "../../_components/website-page-header"; import { RevenueAttributionTables } from "./revenue-attribution-tables"; @@ -66,6 +66,7 @@ interface RevenueOverview { unique_customers: number; attributed_transactions: number; attributed_revenue: number; + total_visitors: number; } interface RevenueTimeSeries { @@ -107,28 +108,36 @@ function padTimeSeriesData( data: T[], startDate: string, endDate: string, - defaultValues: Omit -): T[] { - if (data.length === 0) { - return []; - } + defaultValues: Omit, + granularity: TimeGranularity = "daily" +) { + const unit = granularity === "hourly" ? "hour" : "day"; + const format = unit === "hour" ? "YYYY-MM-DD HH:00:00" : "YYYY-MM-DD"; + + // Use numeric timestamps for robust matching + const dataMap = new Map( + data.map((d) => [dayjs(d.date).startOf(unit).valueOf(), d]) + ); - const dataMap = new Map(data.map((d) => [d.date, d])); const result: T[] = []; - let current = dayjs(startDate); - const end = dayjs(endDate); + let current = dayjs(startDate).startOf(unit); + const end = + unit === "hour" + ? dayjs(endDate).endOf("day") + : dayjs(endDate).startOf(unit); - while (current.isBefore(end) || current.isSame(end, "day")) { - const dateStr = current.format("YYYY-MM-DD"); - const existing = dataMap.get(dateStr); + while (current.isBefore(end) || current.isSame(end, unit)) { + const timestamp = current.valueOf(); + const dateStr = current.format(format); + const existing = dataMap.get(timestamp); if (existing) { - result.push(existing); + result.push({ ...existing, date: dateStr }); } else { result.push({ date: dateStr, ...defaultValues } as T); } - current = current.add(1, "day"); + current = current.add(1, unit); } return result; @@ -339,7 +348,7 @@ function RevenueSettingsSheet({

@@ -672,7 +681,10 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { ) ?? []) as RevenueTimeSeries[]; const overview = overviewData[0]; - const hasData = overview && overview.total_transactions > 0; + const visitors = overview?.total_visitors ?? 0; + const totalRevenue = Number(overview?.total_revenue ?? 0); + const hasData = + overview && (overview.total_transactions > 0 || totalRevenue > 0); const isConfigured = config?.stripeConfigured || config?.paddleConfigured; const paddedTimeSeriesData = useMemo(() => { @@ -686,9 +698,15 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { customers: 0, refund_amount: 0, refund_count: 0, - } + }, + dateRange.granularity ); - }, [timeSeriesData, dateRange.start_date, dateRange.end_date]); + }, [ + timeSeriesData, + dateRange.start_date, + dateRange.end_date, + dateRange.granularity, + ]); const chartData = useMemo(() => { return paddedTimeSeriesData.map((row) => ({ @@ -721,6 +739,8 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { return "Not configured"; }; + console.log({overview,visitors}); + return ( <>
@@ -812,6 +832,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { @@ -851,4 +872,4 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { /> ); -} +} \ No newline at end of file From 96fbdd329dc9e9b8962483689ef16c00e8b6128c Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Thu, 2 Apr 2026 22:53:55 +0530 Subject: [PATCH 2/5] review fixes --- apps/api/src/query/builders/revenue.ts | 2 +- .../[id]/revenue/_components/revenue-chart.tsx | 14 +------------- .../[id]/revenue/_components/revenue-content.tsx | 5 ++--- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/apps/api/src/query/builders/revenue.ts b/apps/api/src/query/builders/revenue.ts index 02dcd3ef9..022f6b437 100644 --- a/apps/api/src/query/builders/revenue.ts +++ b/apps/api/src/query/builders/revenue.ts @@ -875,4 +875,4 @@ export const RevenueBuilders: Record = { normalizeGeo: true, }, }, -}; \ No newline at end of file +}; diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx index f0ba65fbd..80563a03b 100644 --- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-chart.tsx @@ -98,19 +98,7 @@ const REVENUE_METRICS: RevenueChartMetric[] = [ minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(Math.abs(v)), - }, - { - key: "revenue", - label: "Revenue", - color: "#10b981", - formatValue: (v) => - new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(v), - }, + } ]; const CURRENCY_METRICS = ["revenue", "avg_transaction", "refunds"]; diff --git a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx index a19d07fee..9773ccc71 100644 --- a/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/revenue/_components/revenue-content.tsx @@ -739,8 +739,7 @@ export function RevenueContent({ websiteId }: RevenueContentProps) { return "Not configured"; }; - console.log({overview,visitors}); - + return ( <> From 5f8ff1699b9f54d4546c5e602ee94de555560f2d Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Mon, 6 Apr 2026 01:51:40 +0530 Subject: [PATCH 3/5] feat(revenue): imporving revenu attribution --- apps/basket/src/routes/webhooks/stripe.ts | 161 ++++++++++++++---- .../app/(main)/billing/hooks/use-billing.ts | 2 +- packages/db/src/clickhouse/client.ts | 1 + packages/tracker/src/index.ts | 49 ++++-- 4 files changed, 171 insertions(+), 42 deletions(-) diff --git a/apps/basket/src/routes/webhooks/stripe.ts b/apps/basket/src/routes/webhooks/stripe.ts index 055df1d4b..0e9fc2515 100644 --- a/apps/basket/src/routes/webhooks/stripe.ts +++ b/apps/basket/src/routes/webhooks/stripe.ts @@ -81,10 +81,10 @@ interface WebhookEvent { type: string; data: { object: - | WebhookPaymentIntent - | WebhookCharge - | WebhookInvoice - | WebhookSubscription; + | WebhookPaymentIntent + | WebhookCharge + | WebhookInvoice + | WebhookSubscription; }; } @@ -207,9 +207,46 @@ async function getConfig( }; } +async function fetchSessionFromCheckoutSessionId( + checkoutSessionId: string, + ownerId: string +) { + const results = await clickHouse.query({ + query: ` + SELECT + anonymous_id, + session_id + FROM analytics.custom_events + WHERE + event_name = 'checkout_session' + AND owner_id = {ownerId:String} + AND JSONExtractString(properties, 'checkout_session_id') = {checkoutSessionId:String} + ORDER BY timestamp DESC + LIMIT 1 + `, + query_params: { + ownerId, + checkoutSessionId, + }, + format: "JSONEachRow", + }); + + const data = (await results.json()) as any[]; + if (data && data.length > 0) { + return { + anonymous_id: data[0].anonymous_id, + session_id: data[0].session_id, + }; + } + + return null; +} + async function handlePaymentIntent( pi: WebhookPaymentIntent, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(pi.metadata); @@ -243,8 +280,8 @@ async function handlePaymentIntent( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: pi.description || undefined, metadata: JSON.stringify(metadata), @@ -259,10 +296,13 @@ async function handlePaymentIntent( async function handleFailedPayment( pi: WebhookPaymentIntent, config: WebhookConfig, - status: "failed" | "canceled" + status: "failed" | "canceled", + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(pi.metadata); + const customerId = extractCustomerId(pi.customer); const amount = (pi.amount_received ?? pi.amount) / 100; const currency = pi.currency.toUpperCase(); @@ -293,8 +333,8 @@ async function handleFailedPayment( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: pi.description || undefined, metadata: JSON.stringify(metadata), @@ -308,7 +348,9 @@ async function handleFailedPayment( async function handleInvoicePaid( invoice: WebhookInvoice, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); @@ -354,8 +396,8 @@ async function handleInvoicePaid( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: invoice.description || undefined, metadata: JSON.stringify(metadata), @@ -369,7 +411,9 @@ async function handleInvoicePaid( async function handleInvoiceFailed( invoice: WebhookInvoice, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(invoice.metadata); @@ -404,8 +448,8 @@ async function handleInvoiceFailed( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: invoice.description || undefined, metadata: JSON.stringify(metadata), @@ -420,7 +464,9 @@ async function handleInvoiceFailed( async function handleSubscriptionEvent( sub: WebhookSubscription, config: WebhookConfig, - eventType: string + eventType: string, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(sub.metadata); @@ -473,8 +519,8 @@ async function handleSubscriptionEvent( original_amount: amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: firstItem?.plan?.product || undefined, metadata: JSON.stringify(subscriptionMetadata), @@ -488,7 +534,9 @@ async function handleSubscriptionEvent( async function handleRefund( charge: WebhookCharge, - config: WebhookConfig + config: WebhookConfig, + anonymous_id: string, + session_id?: string, ): Promise { const log = useLogger(); const metadata = extractAnalyticsMetadata(charge.metadata); @@ -522,8 +570,8 @@ async function handleRefund( original_amount: -amount, original_currency: currency, currency, - anonymous_id: metadata.anonymous_id || undefined, - session_id: metadata.session_id || undefined, + anonymous_id, + session_id, customer_id: customerId, product_name: "Refund", metadata: JSON.stringify(metadata), @@ -579,13 +627,50 @@ export const stripeWebhook = new Elysia().post( const event = verification.event; log.set({ eventType: event.type, eventId: event.id }); + const stripeObject = event.data.object as any; + console.log({ stripeObject }); + + const checkoutSessionId = + stripeObject.metadata?.checkout_session_id || + stripeObject.payment_details?.order_reference; + + if (!checkoutSessionId) { + log.warn( + `Invalid webhook, No checkout session id found for ${event.id}` + ); + set.status = 400; + return { error: "invalid_webhook" }; + } + + let anonymous_id = stripeObject.metadata?.anonymous_id; + let session_id = stripeObject.metadata?.session_id; + + const existingSession = await fetchSessionFromCheckoutSessionId( + checkoutSessionId, + result.ownerId + ); + + if (existingSession) { + anonymous_id = existingSession.anonymous_id; + session_id = existingSession.session_id; + } + + if (!anonymous_id) { + log.warn( + `Attribution pending for ${event.id} (checkout: ${checkoutSessionId}), returning 503 for retry` + ); + set.status = 503; + return { error: "attribution_pending" }; + } try { switch (event.type) { case "payment_intent.succeeded": { await handlePaymentIntent( event.data.object as WebhookPaymentIntent, - result + result, + anonymous_id, + session_id ); break; } @@ -593,7 +678,9 @@ export const stripeWebhook = new Elysia().post( await handleFailedPayment( event.data.object as WebhookPaymentIntent, result, - "failed" + "failed", + anonymous_id, + session_id ); break; } @@ -601,18 +688,27 @@ export const stripeWebhook = new Elysia().post( await handleFailedPayment( event.data.object as WebhookPaymentIntent, result, - "canceled" + "canceled", + anonymous_id, + session_id ); break; } case "invoice.paid": { - await handleInvoicePaid(event.data.object as WebhookInvoice, result); + await handleInvoicePaid( + event.data.object as WebhookInvoice, + result, + anonymous_id, + session_id + ); break; } case "invoice.payment_failed": { await handleInvoiceFailed( event.data.object as WebhookInvoice, - result + result, + anonymous_id, + session_id ); break; } @@ -625,12 +721,19 @@ export const stripeWebhook = new Elysia().post( await handleSubscriptionEvent( event.data.object as WebhookSubscription, result, - subEventType + subEventType, + anonymous_id, + session_id ); break; } case "charge.refunded": { - await handleRefund(event.data.object as WebhookCharge, result); + await handleRefund( + event.data.object as WebhookCharge, + result, + anonymous_id, + session_id + ); break; } default: { diff --git a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts index c562c7119..3e6ea1740 100644 --- a/apps/dashboard/app/(main)/billing/hooks/use-billing.ts +++ b/apps/dashboard/app/(main)/billing/hooks/use-billing.ts @@ -33,7 +33,7 @@ export function useBilling(refetch?: () => void) { try { await attach({ planId, - successUrl: `${window.location.origin}/billing`, + successUrl: `${window.location.origin}/billing?session_id={CHECKOUT_SESSION_ID}`, metadata: getStripeMetadata(), }); } catch (error) { diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index 483b57b2a..0bcbb5f21 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -13,6 +13,7 @@ export const TABLE_NAMES = { error_spans: "analytics.error_spans", web_vitals_spans: "analytics.web_vitals_spans", custom_events: "analytics.custom_events", + revenue: "analytics.revenue", ai_call_spans: "observability.ai_call_spans", ai_traffic_spans: "analytics.ai_traffic_spans", }; diff --git a/packages/tracker/src/index.ts b/packages/tracker/src/index.ts index b3c2890b0..1ffaeb24d 100644 --- a/packages/tracker/src/index.ts +++ b/packages/tracker/src/index.ts @@ -52,7 +52,7 @@ export class Databuddy extends BaseTracker { this.flushTrack(), this.flushVitals(), this.flushErrors(), - ]).catch(() => {}); + ]).catch(() => { }); }, clear: () => this.clear(), setGlobalProperties: (props: Record) => @@ -100,6 +100,31 @@ export class Databuddy extends BaseTracker { if (this.options.trackInteractions) { initInteractionTracking(this); } + this.checkCheckoutSession(); + } + + private checkCheckoutSession(): void { + if (this.isServer()) { + return; + } + + const urlParams = new URLSearchParams(window.location.search); + const sessionId = urlParams.get("session_id"); + + if (sessionId && (sessionId.startsWith("cs_") || sessionId.startsWith("checkout_"))) { + this.track("checkout_session", { + checkout_session_id: sessionId, + provider: "stripe", + }); + } + + const paddleCheckoutId = urlParams.get("checkout_id"); + if (paddleCheckoutId) { + this.track("checkout_session", { + checkout_session_id: paddleCheckoutId, + provider: "paddle", + }); + } } trackScreenViews() { @@ -214,9 +239,9 @@ export class Databuddy extends BaseTracker { } private handlePageUnload() { - this.flushTrack().catch(() => {}); - this.flushVitals().catch(() => {}); - this.flushErrors().catch(() => {}); + this.flushTrack().catch(() => { }); + this.flushVitals().catch(() => { }); + this.flushErrors().catch(() => { }); this.pauseEngagement(); if (this.hasSentExitBeacon) { return; @@ -360,7 +385,7 @@ export class Databuddy extends BaseTracker { sessionStorage.removeItem("did_session"); sessionStorage.removeItem("did_session_timestamp"); sessionStorage.removeItem("did_session_start"); - } catch {} + } catch { } } this.clearUrlParamStorage(); this.anonymousId = this.generateAnonymousId(); @@ -398,11 +423,11 @@ function initializeDatabuddy() { if (isOptedOut()) { window.databuddy = { - track: () => {}, - screenView: () => {}, - clear: () => {}, - flush: () => {}, - setGlobalProperties: () => {}, + track: () => { }, + screenView: () => { }, + clear: () => { }, + flush: () => { }, + setGlobalProperties: () => { }, options: { clientId: "", disabled: true }, }; window.db = window.databuddy; @@ -422,7 +447,7 @@ if (typeof window !== "undefined") { try { localStorage.setItem("databuddy_opt_out", "true"); localStorage.setItem("databuddy_disabled", "true"); - } catch {} + } catch { } window.databuddyOptedOut = true; window.databuddyDisabled = true; if (window.databuddy) { @@ -434,7 +459,7 @@ if (typeof window !== "undefined") { try { localStorage.removeItem("databuddy_opt_out"); localStorage.removeItem("databuddy_disabled"); - } catch {} + } catch { } window.databuddyOptedOut = false; window.databuddyDisabled = false; }; From d3e7d91cc1b7b46bfadcb74d01711dd6b53d9403 Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Thu, 16 Apr 2026 11:16:55 +0530 Subject: [PATCH 4/5] revenue merged view --- CONTRIBUTING.md | 9 +- .../[id]/_components/tabs/overview-tab.tsx | 189 +++++++++++- .../tabs/overview/_components/revenue-bar.tsx | 123 ++++++++ .../_components/traffic-trends-chart.tsx | 270 ++++++++++++++++-- apps/dashboard/app/globals.css | 2 + .../components/charts/metrics-constants.ts | 37 +++ apps/dashboard/lib/chart-presentation.ts | 1 + apps/dashboard/stores/jotai/chartAtoms.ts | 4 + package.json | 2 +- packages/db/src/seed.ts | 69 ++++- setup.ts | 2 +- 11 files changed, 663 insertions(+), 45 deletions(-) create mode 100644 apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/revenue-bar.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 440234362..6aff0c0de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,13 +58,13 @@ bun run dev:dashboard 8. Seed the database with sample data (optional): ```bash -bun run db:seed [EVENT_COUNT] +bun run db:seed [EVENT_COUNT] [--revenue] ``` -**Examples:** - +Example: ```bash -bun run db:seed g0zlgMtBaXzIP1EGY2ieG 10000 +bun run db:seed g0zlgMtBaXzIP1EGY2ieG 10000 --revenue +``` bun run db:seed d7zlgMtBaSzIL1EGR2ieR 5000 ``` @@ -73,6 +73,7 @@ bun run db:seed d7zlgMtBaSzIL1EGR2ieR 5000 - Default event count is 10,000 if not specified - Seeds events, outgoing links, errors, and web vitals data - You can find your website ID in your website overview settings +- Pass revenu flag to generate revenu ## 💻 Development diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx index 399004f28..3baf1ecbf 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx @@ -1,5 +1,11 @@ "use client"; +import { + ArrowsCounterClockwiseIcon, + CreditCardIcon, + CurrencyDollarIcon, + TrendUpIcon, +} from "@phosphor-icons/react"; import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine"; import { CursorIcon } from "@phosphor-icons/react/dist/ssr/Cursor"; import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe"; @@ -58,6 +64,8 @@ interface ChartDataPoint { sessions?: number; bounce_rate?: number; median_session_duration?: number; + revenue?: number; + refunds?: number; [key: string]: unknown; } @@ -212,6 +220,23 @@ export function WebsiteOverviewTab({ granularity: dateRange.granularity, filters, }, + { + id: "overview-revenue", + parameters: [ + "revenue_overview", + "revenue_time_series", + { + name: "revenue_overview", + start_date: previousPeriodRange.start_date, + end_date: previousPeriodRange.end_date, + granularity: previousPeriodRange.granularity, + id: "previous_revenue_overview", + }, + ], + limit: QUERY_CONFIG.limit, + granularity: dateRange.granularity, + filters, + }, ], [dateRange.granularity, filters, previousPeriodRange] ); @@ -253,6 +278,108 @@ export function WebsiteOverviewTab({ countries: getDataForQuery("overview-geo", "country") || [], }; + const revenueOverview = + getDataForQuery("overview-revenue", "revenue_overview")?.[0] || null; + const previousRevenueOverview = + getDataForQuery("overview-revenue", "previous_revenue_overview")?.[0] || + null; + const revenueTimeSeries = + getDataForQuery("overview-revenue", "revenue_time_series") || []; + + const revenueTimeSeriesMap = useMemo(() => { + const map = new Map(); + for (const row of revenueTimeSeries) { + const key = dayjs(row.date).format("YYYY-MM-DD"); + map.set(key, { + revenue: row.revenue ?? 0, + refunds: Math.abs(row.refund_amount ?? 0), + }); + } + return map; + }, [revenueTimeSeries]); + + const revenueData = useMemo(() => { + const totalRevenue = Number(revenueOverview?.total_revenue ?? 0); + const totalTransactions = revenueOverview?.total_transactions ?? 0; + const refundAmount = Number(revenueOverview?.refund_amount ?? 0); + const uniqueCustomers = revenueOverview?.unique_customers ?? 0; + const avgTransaction = + totalTransactions > 0 ? totalRevenue / totalTransactions : 0; + const conversionRate = revenueOverview?.total_visitors + ? ((uniqueCustomers / revenueOverview.total_visitors) * 100).toFixed(2) + : "0"; + + return { + totalRevenue, + totalTransactions, + refundAmount, + uniqueCustomers, + avgTransaction, + conversionRate, + }; + }, [revenueOverview]); + + const formatCurrency = (amount: number): string => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + }; + + const hasRevenueData = + revenueData.totalTransactions > 0 || revenueData.totalRevenue > 0; + + const calculateRevenueTrends = useMemo(() => { + if (!(revenueOverview && previousRevenueOverview)) { + return {}; + } + + const currentRevenue = Number(revenueOverview.total_revenue ?? 0); + const previousRevenue = Number(previousRevenueOverview.total_revenue ?? 0); + const currentTransactions = revenueOverview.total_transactions ?? 0; + const previousTransactions = + previousRevenueOverview.total_transactions ?? 0; + const currentRefunds = Number(revenueOverview.refund_amount ?? 0); + const previousRefunds = Number(previousRevenueOverview.refund_amount ?? 0); + + const currentUniqueCustomers = revenueOverview.unique_customers ?? 0; + const previousUniqueCustomers = + previousRevenueOverview.unique_customers ?? 0; + const currentVisitors = revenueOverview.total_visitors ?? 0; + const previousVisitors = previousRevenueOverview.total_visitors ?? 0; + + const currentConversionRate = + currentVisitors > 0 + ? (currentUniqueCustomers / currentVisitors) * 100 + : 0; + const previousConversionRate = + previousVisitors > 0 + ? (previousUniqueCustomers / previousVisitors) * 100 + : 0; + + const createTrend = (current: number, previous: number) => { + if (previous === 0 && current === 0) { + return { change: 0 }; + } + if (previous === 0) { + return undefined; + } + const change = calculatePercentChange(current, previous); + return { + change: Math.max(-100, Math.min(1000, Math.round(change))), + }; + }; + + return { + revenue: createTrend(currentRevenue, previousRevenue), + transactions: createTrend(currentTransactions, previousTransactions), + refunds: createTrend(currentRefunds, previousRefunds), + conversion: createTrend(currentConversionRate, previousConversionRate), + }; + }, [revenueOverview, previousRevenueOverview]); + const createPercentageCell = () => (info: CellInfo) => { const percentage = info.getValue() as number; return ; @@ -426,8 +553,10 @@ export function WebsiteOverviewTab({ const chartData = useMemo( () => - processedEventsData.map( - (event: MetricPoint): ChartDataPoint => ({ + processedEventsData.map((event: MetricPoint): ChartDataPoint => { + const dateKey = dayjs(event.date).format("YYYY-MM-DD"); + const revenueEntry = revenueTimeSeriesMap.get(dateKey); + return { date: formatDateByGranularity(event.date, dateRange.granularity), rawDate: event.date, ...(visibleMetrics.pageviews && { @@ -448,9 +577,16 @@ export function WebsiteOverviewTab({ ...(visibleMetrics.median_session_duration && { median_session_duration: event.median_session_duration as number, }), - }) - ), - [processedEventsData, dateRange.granularity, visibleMetrics] + revenue: revenueEntry?.revenue ?? 0, + refunds: revenueEntry?.refunds ?? 0, + }; + }), + [ + processedEventsData, + dateRange.granularity, + visibleMetrics, + revenueTimeSeriesMap, + ] ); const miniChartData = useMemo(() => { @@ -938,10 +1074,53 @@ export function WebsiteOverviewTab({ ))}
+ {hasRevenueData && ( +
+ + + + +
+ )} + { + const { + x = 0, + y = 0, + width = 0, + height = 0, + fill, + index, + hoveredIndex, + barSize = 6, + payload, + } = props; + + const revenue = payload?.revenue ?? 0; + const refunds = payload?.refunds ?? 0; + + const isHighlighted = + index !== undefined && hoveredIndex !== null && index === hoveredIndex; + const isHovering = hoveredIndex !== null; + const barFill = fill; + const barOpacity = isHovering && !isHighlighted ? 0.3 : 1; + const radius = Math.max(1, Math.min(4, Math.floor(barSize / 3))); + + // Calculate additional height for refunds (proportional to revenue height) + // If revenue = 100 gives height H, then refunds = 10 should add H * (10/100) = H * 0.1 + const refundHeight = revenue > 0 ? (refunds / revenue) * height : 0; + const hasRefunds = refundHeight >= 1; + + // If no refunds, render simple solid bar with rounded top + if (!hasRefunds) { + const path = ` + M${x + radius},${y} + L${x + width - radius},${y} + Q${x + width},${y} ${x + width},${y + radius} + L${x + width},${y + height} + L${x},${y + height} + L${x},${y + radius} + Q${x},${y} ${x + radius},${y} + Z + `; + return ( + + + + ); + } + + // Bar with refunds portion added on top + const refundY = y - refundHeight; // Refund portion extends upward + const strokeW = 1.5; + const halfStroke = strokeW / 2; + + // Solid revenue bar (no rounded top since refund connects above) + const solidPath = ` + M${x},${y} + L${x + width},${y} + L${x + width},${y + height} + L${x},${y + height} + Z + `; + + // Hollow refund portion on top (with rounded top corners) + const hollowFillPath = ` + M${x + radius},${refundY} + L${x + width - radius},${refundY} + Q${x + width},${refundY} ${x + width},${refundY + radius} + L${x + width},${y} + L${x},${y} + L${x},${refundY + radius} + Q${x},${refundY} ${x + radius},${refundY} + Z + `; + + // Dashed border path for refund portion - top and sides only + const dashedBorderPath = ` + M${x + halfStroke},${y} + L${x + halfStroke},${refundY + radius} + Q${x + halfStroke},${refundY + halfStroke} ${x + radius},${refundY + halfStroke} + L${x + width - radius},${refundY + halfStroke} + Q${x + width - halfStroke},${refundY + halfStroke} ${x + width - halfStroke},${refundY + radius} + L${x + width - halfStroke},${y} + `; + + return ( + + {/* Solid revenue portion */} + + {/* Hollow refund portion on top - accent color background */} + + {/* Dashed border for refund portion */} + + + ); +}; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx index 4eb835b35..afc988b25 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx @@ -13,6 +13,7 @@ import { AnnotationsPanel } from "@/components/charts/annotations-panel"; import { type ChartDataRow, METRICS, + REVENUE_METRICS, } from "@/components/charts/metrics-constants"; import { RangeSelectionPopup } from "@/components/charts/range-selection-popup"; import { useDynamicDasharray } from "@/components/charts/use-dynamic-dasharray"; @@ -54,6 +55,7 @@ import type { CreateAnnotationData, } from "@/types/annotations"; import type { DateRange } from "../../../utils/types"; +import { RevenueBar } from "./revenue-bar"; const { Area, @@ -67,6 +69,7 @@ const { Tooltip, XAxis, YAxis, + Bar, } = Chart.Recharts; interface TooltipPayloadEntry { @@ -91,6 +94,30 @@ const CustomTooltip = ({ isDragging, justFinishedDragging, }: TooltipProps) => { + const { uniquePayload, refundsValue, hasRefunds, revenueEntry } = + useMemo(() => { + const map = new Map(); + + let refunds: number | undefined; + + for (const entry of payload ?? []) { + if (!map.has(entry.dataKey)) { + map.set(entry.dataKey, entry); + + if (entry.dataKey === "revenue") { + refunds = entry.payload?.refunds as number | undefined; + } + } + } + + return { + uniquePayload: Array.from(map.values()), + refundsValue: refunds, + hasRefunds: !!refunds && refunds > 0, + revenueEntry: map.get("revenue"), + }; + }, [payload]); + // Hide tooltip during or immediately after dragging if (isDragging || justFinishedDragging) { return null; @@ -107,12 +134,15 @@ const CustomTooltip = ({

{label}

- {payload.map((entry) => { + {uniquePayload.map((entry) => { const metric = METRICS.find((m) => m.key === entry.dataKey); if (!metric || entry.value === undefined || entry.value === null) { return null; } + const indicatorColor = + entry.dataKey === "revenue" ? "var(--color-chart-6)" : metric.color; + const value = metric.formatValue ? metric.formatValue(entry.value, entry.payload as ChartDataRow) : formatLocaleNumber(entry.value); @@ -125,7 +155,7 @@ const CustomTooltip = ({
{metric.label} @@ -137,6 +167,27 @@ const CustomTooltip = ({
); })} + {/* Show refunds if present in the data */} + {hasRefunds && refundsValue && ( +
+
+
+ Refunds +
+ + {REVENUE_METRICS.find((m) => m.key === "refunds")?.formatValue?.( + refundsValue, + revenueEntry?.payload as ChartDataRow + ) ?? formatLocaleNumber(refundsValue)} + +
+ )}
); @@ -367,6 +418,7 @@ export function TrafficTrendsRechartsPlot({ const [isDragging, setIsDragging] = useState(false); const [suppressTooltip, setSuppressTooltip] = useState(false); const [hasAnimated, setHasAnimated] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); const { chartStepType } = useChartPreferences("overview-main"); @@ -399,6 +451,26 @@ export function TrafficTrendsRechartsPlot({ [rawData] ); + const barSize = useMemo(() => { + const dataLength = chartData.length; + if (dataLength <= 8) { + return 24; + } + if (dataLength <= 16) { + return 10; + } + if (dataLength <= 30) { + return 8; + } + if (dataLength <= 60) { + return 6; + } + if (dataLength <= 90) { + return 5; + } + return 10; + }, [chartData.length, granularity]); + const [DasharrayCalculator, lineDasharrays] = useDynamicDasharray({ splitIndex: chartData.length - 2, chartType: chartStepType, @@ -554,7 +626,11 @@ export function TrafficTrendsRechartsPlot({
)} - + setHoveredIndex(null)} + onMouseMove={(state) => { + if (state.activeTooltipIndex === undefined) { + setHoveredIndex(null); + } else { + setHoveredIndex(state.activeTooltipIndex); + } + if (mergedFeatures.rangeSelection) { + handleMouseMove(state); + } + }} onMouseUp={ mergedFeatures.rangeSelection ? handleMouseUp : undefined } @@ -595,6 +679,20 @@ export function TrafficTrendsRechartsPlot({ /> ))} + + + + + + + + ({ + value: metric.label, + type: "circle" as const, + color: hiddenMetrics[metric.key] + ? "var(--muted-foreground)" + : metric.color, + dataKey: metric.key, + })), + { + value: "Revenue", + type: "circle" as const, + color: hiddenMetrics.revenue + ? "var(--muted-foreground)" + : "var(--color-chart-6)", + dataKey: "revenue", + }, + ]} verticalAlign="bottom" wrapperStyle={chartRechartsLegendInteractiveWrapperStyle} /> )} - {metrics.map((metric) => ( - { - setHasAnimated(true); - }} - stroke={metric.color} - strokeDasharray={ - lineDasharrays.find((line) => line.name === metric.key) - ?.strokeDasharray || "0 0" + {metrics.map((metric) => { + const isHovered = hoveredIndex !== null && !isDragging; + return ( + { + setHasAnimated(true); + }} + stroke={metric.color} + strokeDasharray={ + lineDasharrays.find((line) => line.name === metric.key) + ?.strokeDasharray || "0 0" + } + strokeOpacity={isHovered ? 0.3 : 1} + strokeWidth={2.5} + style={{ + transition: + "stroke-opacity 150ms ease-out, fill-opacity 150ms ease-out", + }} + type={chartStepType} + /> + ); + })} + {metrics.map((metric) => { + return ( + line.name === metric.key) + ?.strokeDasharray || "0 0" + } + strokeOpacity={1} + strokeWidth={2.5} + tooltipType="none" + type={chartStepType} + /> + ); + })} + ( + + )} + stackId="revenue" + /> + { + const xAxis = xAxisMap[0]; + if (!xAxis || hoveredIndex === null || isDragging) { + return null; } - strokeWidth={2.5} - type={chartStepType} - /> - ))} + const xPos = xAxis.scale(chartData[hoveredIndex].xKey); + const prevX = + hoveredIndex > 0 + ? xAxis.scale(chartData[hoveredIndex - 1].xKey) + : xPos - + (xAxis.scale(chartData[1]?.xKey || xPos) - xPos); + const nextX = + hoveredIndex < chartData.length - 1 + ? xAxis.scale(chartData[hoveredIndex + 1].xKey) + : xPos + + (xPos - xAxis.scale(chartData.at(-2)?.xKey || xPos)); + + const halfWidth = (xPos - prevX) / 2; + const startPos = hoveredIndex === 0 ? 0 : xPos; + const endPos = + (hoveredIndex === chartData.length - 1 + ? xAxis.scale.range()[1] + : xPos + (nextX - xPos) / 2) + halfWidth; + + return ( + + + + + + ); + }} + /> @@ -921,6 +1130,7 @@ interface TrafficTrendsChartProps { dateRange: DateRange; chartData: ChartDataRow[]; dateDiff: number; + hasRevenueData?: boolean; isError: boolean; isLoading: boolean; isMobile: boolean; diff --git a/apps/dashboard/app/globals.css b/apps/dashboard/app/globals.css index 3802371f0..80b5264ad 100644 --- a/apps/dashboard/app/globals.css +++ b/apps/dashboard/app/globals.css @@ -46,6 +46,7 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-chart-6: var(--chart-6); /* Sidebar Colors */ --color-sidebar: var(--sidebar); @@ -345,6 +346,7 @@ --chart-3: #E3A514; --chart-4: #2D9CDB; --chart-5: #27AE60; + --chart-6: #e78468; --sidebar: #FAFAFB; --sidebar-foreground: #27282D; diff --git a/apps/dashboard/components/charts/metrics-constants.ts b/apps/dashboard/components/charts/metrics-constants.ts index adabeb884..7aef1c434 100644 --- a/apps/dashboard/components/charts/metrics-constants.ts +++ b/apps/dashboard/components/charts/metrics-constants.ts @@ -10,6 +10,7 @@ import { Zap, } from "lucide-react"; import { formatLocaleNumber } from "@/lib/format-locale-number"; +import { formatCurrency } from "@/lib/formatters"; import { formatDuration } from "@/lib/utils"; const createColorSet = ( @@ -55,6 +56,18 @@ export const METRIC_COLORS = { "#fee2e2", "from-red-500/20 to-red-600/5" ), + revenue: createColorSet( + "#e78468", + "#059669", + "#d1fae5", + "from-emerald-500/20 to-emerald-600/5" + ), + refunds: createColorSet( + "#e78468", + "#e11d48", + "#ffe4e6", + "from-rose-500/20 to-rose-600/5" + ), // Core Web Vitals avg_fcp: createColorSet( "#06b6d4", @@ -159,6 +172,9 @@ export interface ChartDataRow { avg_inp?: number; p50_inp?: number; measurements?: number; + // Revenue metrics + revenue?: number; + refunds?: number; [key: string]: unknown; } @@ -363,9 +379,30 @@ export const ERROR_METRICS: MetricConfig[] = [ ), ]; +// Revenue metrics +export const REVENUE_METRICS: MetricConfig[] = [ + createMetric( + "revenue", + "Revenue", + "revenue", + TrendingUp, + (value) => formatCurrency(value, "USD"), + "analytics" + ), + createMetric( + "refunds", + "Refunds", + "revenue", + TrendingUp, + (value) => formatCurrency(value, "USD"), + "analytics" + ), +]; + export const METRICS = [ ...ANALYTICS_METRICS, ...PERFORMANCE_METRICS, ...CORE_WEB_VITALS_METRICS, ...ERROR_METRICS, + ...REVENUE_METRICS, ]; diff --git a/apps/dashboard/lib/chart-presentation.ts b/apps/dashboard/lib/chart-presentation.ts index 705d50f66..4ec335ee4 100644 --- a/apps/dashboard/lib/chart-presentation.ts +++ b/apps/dashboard/lib/chart-presentation.ts @@ -95,6 +95,7 @@ export const chartSeriesPalette = [ "var(--color-chart-3)", "var(--color-chart-4)", "var(--color-chart-5)", + "var(--color-chart-6)", ] as const; export function chartSeriesColorAtIndex(index: number): string { diff --git a/apps/dashboard/stores/jotai/chartAtoms.ts b/apps/dashboard/stores/jotai/chartAtoms.ts index 096cffd18..b37b1fc10 100644 --- a/apps/dashboard/stores/jotai/chartAtoms.ts +++ b/apps/dashboard/stores/jotai/chartAtoms.ts @@ -7,6 +7,8 @@ export interface MetricVisibilityState { sessions: boolean; bounce_rate: boolean; median_session_duration: boolean; + revenue: boolean; + refunds: boolean; } const defaultVisibleMetrics: MetricVisibilityState = { @@ -15,6 +17,8 @@ const defaultVisibleMetrics: MetricVisibilityState = { sessions: false, bounce_rate: false, median_session_duration: false, + revenue: true, + refunds: false, }; export const metricVisibilityAtom = atomWithStorage( diff --git a/package.json b/package.json index c8160a0ec..c8bfa3471 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "db:push": "dotenv -- turbo run db:push", "db:migrate": "dotenv -- turbo run db:migrate", "db:deploy": "dotenv -- turbo run db:deploy", - "db:seed": "dotenv -- sh -c 'cd packages/db && bun run db:seed \"$@\"' --", + "db:seed": "dotenv -- bun run packages/db/src/seed.ts", "email:dev": "dotenv -- sh -c 'cd packages/email && bun run dev'", "sdk:build": "turbo run build --filter @databuddy/sdk --filter @databuddy/cache", "dev:dashboard": "dotenv -- turbo dev --filter @databuddy/dashboard --filter @databuddy/api", diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index b889701dc..6791b34e5 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -4,8 +4,10 @@ import { clickHouse, TABLE_NAMES } from "./clickhouse/client"; import { db } from "./client"; import { websites } from "./drizzle/schema"; -const clientId = process.argv[2] || faker.string.uuid(); -const eventCount = Number(process.argv[3]) || 10_000; +const generateRevenue = process.argv.includes("--rev"); +const args = process.argv.filter((arg) => !arg.startsWith("--")); +const clientId = args[2] || faker.string.uuid(); +const eventCount = Number(args[3]) || 10_000; const PATHS = [ "/", @@ -147,8 +149,8 @@ function generatePageTitle(path: string): string { downlink: undefined, time_on_page: isPageExit ? Math.round( - faker.number.float({ min: 5, max: 600, fractionDigits: 1 }) - ) + faker.number.float({ min: 5, max: 600, fractionDigits: 1 }) + ) : undefined, scroll_depth: isPageExit ? faker.number.float({ min: 10, max: 100, fractionDigits: 1 }) @@ -339,6 +341,53 @@ function generatePageTitle(path: string): string { webVitals.sort((a, b) => a.timestamp - b.timestamp); + const revenueTransactions = generateRevenue + ? Array.from({ length: Math.floor(eventCount / 50) }, (_, index) => { + const sessionIndex = Math.floor( + index / (Math.floor(eventCount / 50) / TOTAL_SESSIONS) + ); + const session = + SESSION_POOL[Math.min(sessionIndex, SESSION_POOL.length - 1)]; + + const maxSessionDuration = 2 * 60 * 60 * 1000; + const sessionProgress = + (index % Math.ceil(Math.floor(eventCount / 50) / TOTAL_SESSIONS)) / + Math.ceil(Math.floor(eventCount / 50) / TOTAL_SESSIONS); + const timestamp = + session.sessionStartTime + sessionProgress * maxSessionDuration; + + const amount = faker.number.int({ min: 40, max: 50 }); + const isRefund = faker.helpers.maybe(() => true, { probability: 0.08 }); + const status = isRefund ? "refunded" : "completed"; + const type = isRefund + ? "refund" + : faker.helpers.arrayElement(["sale", "subscription"]); + + return { + owner_id: clientId, + website_id: clientId, + transaction_id: `txn_${faker.string.uuid()}`, + provider: faker.helpers.arrayElement(["stripe", "paddle"]), + type, + status, + amount, + original_amount: amount, + original_currency: "USD", + currency: "USD", + anonymous_id: session.anonymousId, + session_id: session.sessionId, + customer_id: `cus_${faker.string.uuid()}`, + product_id: `prod_${faker.string.uuid()}`, + product_name: faker.commerce.productName(), + metadata: "{}", + created: Math.floor(timestamp / 1000), + synced_at: Math.floor(Date.now() / 1000), + }; + }) + : []; + + revenueTransactions.sort((a, b) => a.created - b.created); + console.log( `Generating seed data for client: ${clientId} on domain: ${domain}` ); @@ -367,9 +416,21 @@ function generatePageTitle(path: string): string { format: "JSONEachRow", values: webVitals, }), + ...(generateRevenue && revenueTransactions.length > 0 + ? [ + clickHouse.insert({ + table: TABLE_NAMES.revenue, + format: "JSONEachRow", + values: revenueTransactions, + }), + ] + : []), ]); console.log( `Inserted ${events.length} events, ${outgoingLinks.length} outgoing links, ${errors.length} errors, ${webVitals.length} web vitals for client ${clientId}` ); + if (generateRevenue) { + console.log(`Inserted ${revenueTransactions.length} revenue transactions`); + } })(); diff --git a/setup.ts b/setup.ts index 03943cbc1..7668ff47f 100755 --- a/setup.ts +++ b/setup.ts @@ -310,7 +310,7 @@ async function main() { log(chalk.cyan(" 2. Run 'bun run dev' to start development servers")); log( chalk.cyan( - " 3. (Optional) Run 'bun run db:seed [DOMAIN] [EVENT_COUNT]' to seed sample data" + " 3. (Optional) Run 'bun run db:seed [EVENT_COUNT] [--revenue]' to seed sample data" ) ); log(chalk.bold("\nHappy coding! 🎉")); From e847763495f75adc5999a7acb7e55c29b3154823 Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Fri, 17 Apr 2026 14:56:30 +0530 Subject: [PATCH 5/5] fix mmetric toggle --- .../overview/_components/traffic-trends-chart.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx index a26addf8a..bcb7016b3 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx @@ -59,6 +59,7 @@ import type { } from "@/types/annotations"; import type { DateRange } from "../../../utils/types"; import { RevenueBar } from "./revenue-bar"; +import type { Payload } from "recharts/types/component/DefaultLegendContent"; const { Area, @@ -914,7 +915,9 @@ export function TrafficTrendsRechartsPlot({ const metric = metrics.find((m) => m.label === label); const isHidden = metric ? hiddenMetrics[metric.key] - : false; + : label === "Revenue" + ? hiddenMetrics.revenue + : false; return ( { - const metric = metrics.find( - (m) => m.label === payload.value - ); - if (metric) { - toggleMetric(metric.key as keyof typeof visibleMetrics); + onClick={(payload: Payload) => { + if (payload.dataKey) { + toggleMetric(payload.dataKey as keyof typeof visibleMetrics); } }} payload={[