// The Monday scoreboard, as queries. // // Two decisions here are worth stating, because they are what make the numbers // readable rather than merely present: // // 1. Rates, not just counts. A funnel is a set of ratios; the step with the // worst ratio is the thing to fix, and a column of absolute numbers hides it. // 2. Every rate carries its denominator. At this volume a weekly per-channel // cell holds single digits, and 1 out of 3 renders as "33%" exactly as // confidently as 340 out of 1020. The channel view therefore runs on a // rolling 28-day window rather than a week, and still reports `n`. import type { AcquisitionChannel } from '@prisma/client'; import { db } from '@/lib/db'; import { getCachedStripeStats } from '@/lib/admin-stats'; /** What "using the product" means for a paying account. */ export const VALUE_EVENT_NAMES = [ 'VIDEO_ADDED', 'SHARE_LINK_CREATED', 'FIRST_GUEST_COMMENT', 'APPROVAL_COMPLETED', 'PROJECT_CREATED', ] as const; /** A paid account that has produced nothing for this long is drifting away. */ export const AT_RISK_SILENT_DAYS = 14; const DEFAULT_WEEKS = 12; const CHANNEL_WINDOW_DAYS = 28; export interface WeeklyRow { weekStart: Date; visitors: number; ctaClicks: number; signupStarted: number; signups: number; emailVerified: number; firstVideo: number; shareLinks: number; externalFeedback: number; trials: number; newPaid: number; canceled: number; /** Running net of started minus canceled. Derived, not a Stripe snapshot. */ activePaid: number; mrrCents: number; } export interface ChannelRow { channel: AcquisitionChannel; visitors: number; signups: number; trials: number; paid: number; } export interface PaidAccountRow { userId: string; name: string | null; email: string | null; status: string; valueEvents7: number; valueEvents30: number; lastValueEventAt: Date | null; channel: AcquisitionChannel | null; selfReported: AcquisitionChannel | null; } export interface Scoreboard { weeks: WeeklyRow[]; channels: ChannelRow[]; channelWindowDays: number; paidAccounts: PaidAccountRow[]; atRisk: PaidAccountRow[]; currentActivePaid: number | null; currentMrrCents: number | null; currency: string; } interface WeeklyQueryRow { week: Date; name: string; subjects: number; } interface ChannelQueryRow { channel: AcquisitionChannel | null; name: string; subjects: number; } interface PaidQueryRow { user_id: string; name: string | null; email: string | null; status: string; channel: AcquisitionChannel | null; self_reported: AcquisitionChannel | null; value_events_7: number; value_events_30: number; last_value_event_at: Date | null; } function startOfWeek(date: Date): Date { const copy = new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0) ); // Postgres date_trunc('week') starts on Monday; match it so the two halves of // the table line up. const isoDayIndex = (copy.getUTCDay() + 6) % 7; copy.setUTCDate(copy.getUTCDate() - isoDayIndex); return copy; } function emptyWeek(weekStart: Date): WeeklyRow { return { weekStart, visitors: 0, ctaClicks: 0, signupStarted: 0, signups: 0, emailVerified: 0, firstVideo: 0, shareLinks: 0, externalFeedback: 0, trials: 0, newPaid: 0, canceled: 0, activePaid: 0, mrrCents: 0, }; } const WEEK_COLUMN_BY_EVENT: Record = { LANDING_VIEW: 'visitors', CTA_CLICKED: 'ctaClicks', SIGNUP_STARTED: 'signupStarted', SIGNUP_COMPLETED: 'signups', EMAIL_VERIFIED: 'emailVerified', VIDEO_ADDED: 'firstVideo', SHARE_LINK_CREATED: 'shareLinks', FIRST_GUEST_COMMENT: 'externalFeedback', TRIAL_STARTED: 'trials', SUBSCRIPTION_STARTED: 'newPaid', SUBSCRIPTION_CANCELED: 'canceled', }; export interface FunnelRates { visitorToSignup: number | null; signupToFirstVideo: number | null; firstVideoToShare: number | null; shareToFeedback: number | null; trialToPaid: number | null; } /** * Step-to-step conversion, or null when the denominator is zero. * * Null rather than 0 on purpose: "no visitors, so no rate" and "visitors, none * of whom converted" are different facts, and showing the first as 0% invents a * problem that is not there. */ export function conversionRates(row: { visitors: number; signups: number; firstVideo: number; shareLinks: number; externalFeedback: number; trials: number; newPaid: number; }): FunnelRates { const ratio = (numerator: number, denominator: number) => denominator > 0 ? numerator / denominator : null; return { visitorToSignup: ratio(row.signups, row.visitors), signupToFirstVideo: ratio(row.firstVideo, row.signups), firstVideoToShare: ratio(row.shareLinks, row.firstVideo), shareToFeedback: ratio(row.externalFeedback, row.shareLinks), trialToPaid: ratio(row.newPaid, row.trials), }; } export async function getScoreboard(options?: { weeks?: number }): Promise { const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52); const now = new Date(); const firstWeekStart = startOfWeek(now); firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() - (weeks - 1) * 7); const channelWindowStart = new Date(now); channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS); const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([ // COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing // view is deduped per visitor per day, so a visitor who came back on three // days would otherwise be three weekly visitors. Rows with no anonymous id // fall back to their own primary key and stay distinct. db.$queryRaw` SELECT date_trunc('week', occurred_at) AS week, name::text AS name, COUNT(DISTINCT COALESCE(anonymous_id, id))::int AS subjects FROM analytics_events WHERE occurred_at >= ${firstWeekStart} GROUP BY 1, 2 `, db.$queryRaw` SELECT COALESCE(ua.channel, e.channel) AS channel, e.name::text AS name, COUNT(DISTINCT COALESCE(e.anonymous_id, e.id))::int AS subjects FROM analytics_events e LEFT JOIN user_acquisitions ua ON ua.user_id = e.user_id WHERE e.occurred_at >= ${channelWindowStart} GROUP BY 1, 2 `, db.$queryRaw>` SELECT COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_STARTED')::int AS started, COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_CANCELED')::int AS canceled FROM analytics_events WHERE occurred_at < ${firstWeekStart} `, db.$queryRaw` SELECT u.id AS user_id, u.name, u.email, u."subscriptionStatus"::text AS status, ua.channel, ua.self_reported, COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '7 days')::int AS value_events_7, COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '30 days')::int AS value_events_30, MAX(e.occurred_at) AS last_value_event_at FROM users u LEFT JOIN user_acquisitions ua ON ua.user_id = u.id LEFT JOIN analytics_events e ON e.user_id = u.id AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[]) WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING') GROUP BY u.id, u.name, u.email, u."subscriptionStatus", ua.channel, ua.self_reported ORDER BY MAX(e.occurred_at) ASC NULLS FIRST `, getCachedStripeStats(), ]); const byWeek = new Map(); for (let index = 0; index < weeks; index += 1) { const weekStart = new Date(firstWeekStart); weekStart.setUTCDate(weekStart.getUTCDate() + index * 7); byWeek.set(weekStart.getTime(), emptyWeek(weekStart)); } for (const row of weekRows) { const bucket = byWeek.get(startOfWeek(row.week).getTime()); const column = WEEK_COLUMN_BY_EVENT[row.name]; if (!bucket || !column) continue; (bucket[column] as number) = row.subjects; } // One flat plan, so a per-subscription price is enough to turn a subscriber // count into MRR. Taken from Stripe rather than hardcoded, and zero when // billing is not configured at all. const unitAmountCents = stripeStats && stripeStats.activeSubscribers > 0 ? Math.round(stripeStats.mrrCents / stripeStats.activeSubscribers) : 0; let running = (priorPaid[0]?.started ?? 0) - (priorPaid[0]?.canceled ?? 0); const orderedWeeks = [...byWeek.values()].sort( (a, b) => a.weekStart.getTime() - b.weekStart.getTime() ); for (const week of orderedWeeks) { running += week.newPaid - week.canceled; week.activePaid = Math.max(running, 0); week.mrrCents = week.activePaid * unitAmountCents; } const channelBuckets = new Map(); for (const row of channelRows) { const channel = row.channel ?? 'OTHER'; const bucket = channelBuckets.get(channel) ?? { channel, visitors: 0, signups: 0, trials: 0, paid: 0, }; if (row.name === 'LANDING_VIEW') bucket.visitors += row.subjects; if (row.name === 'SIGNUP_COMPLETED') bucket.signups += row.subjects; if (row.name === 'TRIAL_STARTED') bucket.trials += row.subjects; if (row.name === 'SUBSCRIPTION_STARTED') bucket.paid += row.subjects; channelBuckets.set(channel, bucket); } const accounts: PaidAccountRow[] = paidAccounts.map((row) => ({ userId: row.user_id, name: row.name, email: row.email, status: row.status, channel: row.channel, selfReported: row.self_reported, valueEvents7: row.value_events_7, valueEvents30: row.value_events_30, lastValueEventAt: row.last_value_event_at, })); const silentBefore = new Date(now); silentBefore.setUTCDate(silentBefore.getUTCDate() - AT_RISK_SILENT_DAYS); return { weeks: orderedWeeks, channels: [...channelBuckets.values()].sort((a, b) => b.visitors - a.visitors), channelWindowDays: CHANNEL_WINDOW_DAYS, paidAccounts: accounts, atRisk: accounts.filter( (account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore ), currentActivePaid: stripeStats?.activeSubscribers ?? null, currentMrrCents: stripeStats?.mrrCents ?? null, currency: stripeStats?.currency ?? 'usd', }; }