Skip to contentVibraUI

Event stream

What accounts changed in the product, newest first and grouped by day; reads eventStream().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-usage-event-stream

Needs the @vibra registry in your components.json — set it up once.

Source

app/saas/usage/components/event-stream.tsx
import { ActivityFeed } from "@/components/ui/activity-feed"
import { Widget } from "@/components/ui/widget"

import { eventStream, NOW } from "../data"

export function EventStream() {
  return (
    <Widget
      data-widget="widget-saas-usage-event-stream"
      title="Event stream"
      description="What accounts changed in the product, newest first"
      className="h-full"
      footer="Every event is written to the audit trail and kept for a year."
    >
      <ActivityFeed items={eventStream()} groupByDay now={NOW} />
    </Widget>
  )
}
app/saas/usage/data.ts
/**
 * What this page reads. The accounts are `db.customers` rows and the event
 * stream is the `db.auditEvents` trail, so both change the moment a repository
 * is swapped for a real store.
 *
 * Product telemetry has no entity of its own, so three things are derived and
 * none of them is a literal. An account's monthly active users is a *rule* over
 * the row db does record — its seats, times how deeply its plan tends to be
 * used, times a jitter fixed by `seeded("dashboard-product-usage")`, capped at
 * the seats it pays for. The daily active series is that population shaped day
 * by day from the same generator, so it can never drift away from the accounts
 * on the page. Retention and feature adoption are curves off the same seed.
 * "Now" is `REFERENCE_DATE`; nothing here reads a clock.
 */
import { formatNumber, formatPercent, getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  seeded,
  type Customer,
  type Member,
} from "@/lib/sample-data"

const DAY_MS = 86_400_000

/** How much of a plan's seats log in during a month, before the per-account jitter. */
const PLAN_ENGAGEMENT: Record<Customer["plan"], number> = {
  enterprise: 0.82,
  team: 0.71,
  starter: 0.58,
  free: 0.36,
}

// One series for the whole block: each live account's engagement, the daily
// readings, adoption and retention draw from it in this order, so every number
// on the page is the same on every render.
const SEED = "dashboard-product-usage"

// An account that has churned or been suspended logs nobody in.
const isLive = (customer: Customer) => customer.status === "active" || customer.status === "trial"

type Draws = {
  /** How far each account runs from its plan's engagement, by id. */
  engagement: Map<string, number>
  /** Each day's noise on the daily and on the weekly reading. */
  days: { daily: number; weekly: number }[]
  adoption: number[][]
  retention: number[]
}

let drawn: Draws | undefined

/**
 * The figures no row carries, drawn once and in the block's fixed order — when
 * a page first asks, never when the module loads. The accounts themselves are
 * read per request: one that churns since leaves the page, and one that goes
 * live draws its engagement from its own id.
 */
function draws(): Draws {
  if (drawn) return drawn
  const rand = seeded(SEED)
  const engagement = new Map(
    db.customers
      .all()
      .filter(isLive)
      .map((customer) => [customer.id, 0.82 + rand() * 0.36])
  )
  const days = Array.from({ length: SERIES_DAYS }, () => {
    const daily = 0.95 + rand() * 0.1
    return { daily, weekly: 0.97 + rand() * 0.06 }
  })
  const adoption = FEATURES.map(() => Array.from({ length: ADOPTION_WEEKS }, () => 0.94 + rand() * 0.12))
  const retention = Array.from({ length: 8 }, (_, week) => (week === 0 ? 1 : 0.97 + rand() * 0.06))
  drawn = { engagement, days, adoption, retention }
  return drawn
}

export type Account = { id: string; company: string; plan: Customer["plan"]; monthlyActive: number }

/** Every live account, the most people in the product first. */
function accounts(): Account[] {
  const { engagement } = draws()
  return db.customers
    .all()
    .filter(isLive)
    .map((customer) => ({
      id: customer.id,
      company: customer.company,
      plan: customer.plan,
      monthlyActive: Math.max(
        1,
        Math.min(
          customer.seats,
          Math.round(
            customer.seats *
              PLAN_ENGAGEMENT[customer.plan] *
              (engagement.get(customer.id) ?? 0.82 + seeded(`${SEED}:${customer.id}`)() * 0.36)
          )
        )
      ),
    }))
    .sort((a, b) => b.monthlyActive - a.monthlyActive)
}

/** The accounts with the most people in the product this month. */
export function topAccounts(limit = 6): Account[] {
  return accounts().slice(0, limit)
}

/** How many live accounts the product is measured across. */
export function accountCount(): number {
  return accounts().length
}

// How a monthly population splits down: a little under two thirds come back in
// a given week, a third on a given day. Weekends run at 0.62 of a weekday.
const WEEKLY_SHARE = 0.61
const DAILY_SHARE = 0.34
const WEEKEND_FACTOR = 0.62

const SERIES_DAYS = 90

/** Midnight UTC on the last complete day before "now". */
const LAST_DAY =
  Date.UTC(
    REFERENCE_DATE.getUTCFullYear(),
    REFERENCE_DATE.getUTCMonth(),
    REFERENCE_DATE.getUTCDate()
  ) - DAY_MS

export type ActivePoint = { date: string; dau: number; wau: number; mau: number }

/**
 * Ninety days of active users. The monthly line is the population itself — the
 * accounts above, added up — so it is flat by construction and neither the
 * chart nor the stat card can drift away from the list beside them. Only the
 * daily and weekly readings move: a weekday runs well ahead of a weekend.
 */
function activeSeries(population: Account[]): ActivePoint[] {
  // The population the whole page is measured against: everyone who logged in
  // this month, across every live account.
  const mau = population.reduce((total, account) => total + account.monthlyActive, 0)
  return draws().days.map((noise, index) => {
    const at = new Date(LAST_DAY - (SERIES_DAYS - 1 - index) * DAY_MS)
    const weekend = at.getUTCDay() === 0 || at.getUTCDay() === 6
    return {
      date: at.toISOString().slice(0, 10),
      dau: Math.round(mau * DAILY_SHARE * (weekend ? WEEKEND_FACTOR : 1) * noise.daily),
      wau: Math.round(mau * WEEKLY_SHARE * noise.weekly),
      mau,
    }
  })
}

/** The daily, weekly and monthly active users over the window, oldest first. */
export function activeUsers(): ActivePoint[] {
  return activeSeries(accounts())
}

/** How many days the active-users chart covers. */
export const SERIES_WINDOW_DAYS = SERIES_DAYS

export type UsageStat = {
  key: string
  label: string
  value: string
  /** Left off where there is nothing to compare against — the population itself. */
  delta?: number
  description: string
}

const ratio = (current: number, previous: number): number =>
  previous === 0 ? 0 : current / previous - 1

/** The four headline numbers, each against the same day a week earlier. */
export function usageStats(): UsageStat[] {
  const population = accounts()
  const series = activeSeries(population)
  const latest = series[series.length - 1]
  const weekAgo = series[series.length - 8]
  const against = "vs the same day last week"
  const stickiness = latest.dau / latest.mau
  const wasStickiness = weekAgo.dau / weekAgo.mau

  return [
    {
      // The population every other number on the page is a share of, so there
      // is no week-on-week change to report: it is the accounts themselves.
      key: "mau",
      label: "Monthly active",
      value: formatNumber(latest.mau, { maximumFractionDigits: 0 }),
      description: `across ${population.length} live accounts`,
    },
    {
      key: "wau",
      label: "Weekly active",
      value: formatNumber(latest.wau, { maximumFractionDigits: 0 }),
      delta: ratio(latest.wau, weekAgo.wau),
      description: against,
    },
    {
      key: "dau",
      label: "Daily active",
      value: formatNumber(latest.dau, { maximumFractionDigits: 0 }),
      delta: ratio(latest.dau, weekAgo.dau),
      description: against,
    },
    {
      key: "stickiness",
      label: "Stickiness",
      value: formatPercent(stickiness, { maximumFractionDigits: 1 }),
      delta: ratio(stickiness, wasStickiness),
      description: "DAU over MAU",
    },
  ]
}

/** The eight surfaces adoption is measured across, in the order they shipped. */
export const FEATURES = [
  "Dashboards",
  "Alerts",
  "Reports",
  "API keys",
  "Integrations",
  "Audit log",
  "SSO",
  "Exports",
]

const ADOPTION_WEEKS = 12

/** The week labels the adoption grid is drawn against, oldest first. */
export const ADOPTION_COLUMNS = Array.from(
  { length: ADOPTION_WEEKS },
  (_, index) => `W${index + 1}`
)

/**
 * The share of live accounts that touched each surface in each week, as a
 * percentage. An older surface starts high and creeps up; a newer one starts
 * low and climbs faster.
 */
function adoption(): number[][] {
  return draws().adoption.map((weeks, row) => {
    const start = 74 - row * 8
    const climb = 4 + row * 1.4
    return weeks.map((noise, week) => {
      const trend = start + (week / (ADOPTION_WEEKS - 1)) * climb
      return Math.round(Math.max(2, Math.min(96, trend * noise)))
    })
  })
}

/** Feature adoption, one row per surface and one column per week. */
export function featureAdoption(): number[][] {
  return adoption()
}

/** The adoption grid whole: the surfaces down the side, the weeks along the top, the shares between. */
export type AdoptionGrid = { features: string[]; weeks: string[]; values: number[][] }

/** Everything the adoption heatmap draws, handed to it as one prop. */
export function adoptionGrid(): AdoptionGrid {
  return { features: FEATURES, weeks: ADOPTION_COLUMNS, values: adoption() }
}

export type RetentionPoint = { week: string; retained: number }

/**
 * A cohort's retention over its first eight weeks: everyone in week 0, a steep
 * drop through the first fortnight, then a floor the product settles on.
 */
/** The retention curve, week 0 first. */
export function retention(): RetentionPoint[] {
  return draws().retention.map((noise, week) => {
    if (week === 0) return { week: "Week 0", retained: 100 }
    const floor = 41
    const decay = floor + (100 - floor) * Math.exp(-week / 2.1)
    return { week: `Week ${week}`, retained: Math.round(decay * noise) }
  })
}

/** The newest product events: who did what to which record, newest first. */
export function eventStream(limit = 8) {
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return db.auditEvents
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, limit)
    .map((event) => ({
      id: event.id,
      actor: {
        name: members.get(event.actor)?.name ?? "A teammate",
        src: members.get(event.actor)?.avatarUrl,
      },
      action: event.action,
      target: `${event.resource.replace(/_/g, " ")} ${event.resourceId}`,
      time: event.at,
    }))
}

/** The moment the feed measures "Today" against. */
export const NOW = REFERENCE_DATE

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** When the numbers on this page were last collected. */
export function lastUpdated(): string {
  return `Last updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Product usage dashboard page