Team activity
What the team changed, newest first and grouped by day, from the workspace's audit trail; reads recentActivity().
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { ActivityFeed } from "@/components/ui/activity-feed"
import { Widget } from "@/components/ui/widget"
import { recentActivity } from "../data"
import { ActivityScroller } from "./activity-scroller"
/** A server component: the feed reads a fixed "now", so nothing here needs state but its scroll box. */
export function RecentActivity() {
return (
<Widget
data-widget="widget-saas-overview-recent-activity"
title="Activity"
description="What the team changed"
className="h-full"
>
<ActivityScroller label="Activity feed">
<ActivityFeed items={recentActivity()} groupByDay now={REFERENCE_DATE} />
</ActivityScroller>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-saas-overview-recent-activityNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { ActivityFeed } from "@/components/ui/activity-feed"
import { Widget } from "@/components/ui/widget"
import { recentActivity } from "../data"
import { ActivityScroller } from "./activity-scroller"
/** A server component: the feed reads a fixed "now", so nothing here needs state but its scroll box. */
export function RecentActivity() {
return (
<Widget
data-widget="widget-saas-overview-recent-activity"
title="Activity"
description="What the team changed"
className="h-full"
>
<ActivityScroller label="Activity feed">
<ActivityFeed items={recentActivity()} groupByDay now={REFERENCE_DATE} />
</ActivityScroller>
</Widget>
)
}/**
* What this page reads. Rows come from `db`, so swapping a repository for a
* real store is the whole migration; the daily series behind the charts have
* no entity of their own, so they are generated once from `seeded("dashboard-01")`
* — deterministic, measured against `REFERENCE_DATE`, never a clock.
*/
import { getInitials } from "@/lib/format"
import {
previousPeriod,
traced,
trailingPeriod,
type Metric,
type Provenance,
} from "@/lib/metric"
import {
db,
REFERENCE_DATE,
seeded,
type Customer,
type Member,
} from "@/lib/sample-data"
export type { Customer }
export type RangeKey = "7d" | "30d" | "90d"
const RANGE_DAYS: Record<RangeKey, number> = { "7d": 7, "30d": 30, "90d": 90 }
/** How many days a range covers, for copy like "vs previous 30 days". */
export function rangeDays(range: RangeKey): number {
return RANGE_DAYS[range]
}
const DAY_MS = 86_400_000
// Twice the longest range, so every range can be compared with the one before it.
const SERIES_DAYS = 180
type Day = { date: string; visitors: number; signups: number; sessionSeconds: number }
/** 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
const SERIES_START = LAST_DAY - (SERIES_DAYS - 1) * DAY_MS
// Two days near the end of the window when a launch post ran, so the chart has
// something to explain rather than only a trend.
const SPIKE_FROM = SERIES_DAYS - 26
/**
* Half a year of daily traffic: weekends run a little over half a weekday, the
* whole window climbs, and the launch post shows up as a two-day spike.
*/
function generateDays(): Day[] {
const rand = seeded("dashboard-01")
return Array.from({ length: SERIES_DAYS }, (_, index) => {
const at = new Date(SERIES_START + index * DAY_MS)
const weekend = at.getUTCDay() === 0 || at.getUTCDay() === 6
const growth = 1 + (index / SERIES_DAYS) * 0.7
const spike = index >= SPIKE_FROM && index < SPIKE_FROM + 2 ? 1.42 : 1
const base = (weekend ? 520 : 1_020) * growth * spike
const visitors = Math.round(base * (0.88 + rand() * 0.24))
// Signups track visitors at a rate that drifts a little day to day.
const rate = 0.032 + rand() * 0.009
const sessionSeconds = Math.round((196 + (index / SERIES_DAYS) * 62) * (0.94 + rand() * 0.12))
return {
date: at.toISOString().slice(0, 10),
visitors,
signups: Math.max(1, Math.round(visitors * rate)),
sessionSeconds,
}
})
}
const DAYS = generateDays()
/** The `days` days ending `offset` spans back: `span(30, 1)` is the previous 30 days. */
function span(days: number, offset = 0): Day[] {
const end = DAYS.length - offset * days
return DAYS.slice(Math.max(0, end - days), end)
}
const sum = (rows: readonly Day[], key: "visitors" | "signups" | "sessionSeconds"): number =>
rows.reduce((total, row) => total + row[key], 0)
// The middle session, not the average one: a handful of very long sessions drags
// a mean somewhere no reader ever sat. Even-length windows take the midpoint of
// the two middles, which is what "median" means for an even count.
const median = (rows: readonly Day[], key: "sessionSeconds"): number => {
if (rows.length === 0) return 0
const sorted = rows.map((row) => row[key]).sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
}
/** The four measures the overview plots, each keyed as its stat card is. */
export type MetricKey = "visitors" | "signups" | "rate" | "session"
/**
* One day, in both periods: the four measures as they were, and the same four
* over the window immediately before — aligned by position, so the compare
* ghost lines up day for day with the series it sits behind.
*/
export type TrafficPoint = Record<MetricKey | `${MetricKey}Before`, number> & { date: string }
const measures = (day: Day) => ({
visitors: day.visitors,
signups: day.signups,
// A fraction, not a percentage: the chart's own formatter prints the sign.
rate: day.signups / day.visitors,
session: day.sessionSeconds,
})
/** The daily measures inside a range, oldest first, each beside the period before it. */
export function trafficPoints(range: RangeKey): TrafficPoint[] {
const days = RANGE_DAYS[range]
const now = span(days)
const before = span(days, 1)
return now.map((day, index) => {
const previous = before[index] ?? day
const was = measures(previous)
return {
date: day.date,
...measures(day),
visitorsBefore: was.visitors,
signupsBefore: was.signups,
rateBefore: was.rate,
sessionBefore: was.session,
}
})
}
/**
* The day the launch post ran, or undefined when it falls outside the range —
* the peak of the two-day spike the generator writes in, read back off the rows
* rather than written out a second time as a literal.
*/
export function launchDay(range: RangeKey): string | undefined {
const window = span(RANGE_DAYS[range])
const spike = DAYS.slice(SPIKE_FROM, SPIKE_FROM + 2)
const peak = spike.reduce((most, day) => (day.visitors > most.visitors ? day : most), spike[0])
return window.some((day) => day.date === peak.date) ? peak.date : undefined
}
export type OverviewStat = {
key: string
label: string
/** The number itself, carrying the kind it is read in. */
metric: Metric
/** The same measure over the range immediately before this one. */
previous: number
/** What the change is measured against, taken from the period itself. */
compareLabel: string
/** Where the number came from — the sampled rows and the formula, never the whole window. */
provenance: Provenance
spark: number[]
sparkType: "area" | "bar" | "line"
}
/**
* The four headline numbers for a range, each traced back to the days it was
* computed from. `traced` runs the sum over every day in the window and keeps
* only the first few rows, so a card can show its work without the page
* handing a quarter of daily traffic to the browser. The rows go in newest
* first, which is the end of the window a reader checks.
*/
export function overviewStats(range: RangeKey): OverviewStat[] {
const days = RANGE_DAYS[range]
const now = span(days)
const before = span(days, 1)
const recent = [...now].reverse()
// REFERENCE_DATE is this page's "now"; the metric lib keeps none of its own.
const compareLabel = `vs ${previousPeriod(trailingPeriod(days, REFERENCE_DATE)).label.toLowerCase()}`
const visitors = traced("daily traffic", recent, "sum(visitors)", (rows) => sum(rows, "visitors"), {
columns: ["date", "visitors"],
})
const signups = traced("daily traffic", recent, "sum(signups)", (rows) => sum(rows, "signups"), {
columns: ["date", "signups"],
})
const rate = traced(
"daily traffic",
recent,
"sum(signups) ÷ sum(visitors)",
(rows) => sum(rows, "signups") / sum(rows, "visitors"),
{ columns: ["date", "visitors", "signups"] }
)
const session = traced(
"daily sessions",
recent,
"median(sessionSeconds)",
(rows) => median(rows, "sessionSeconds"),
{ columns: ["date", "sessionSeconds"] }
)
const wasVisitors = sum(before, "visitors")
const wasSignups = sum(before, "signups")
return [
{
key: "visitors",
label: "Visitors",
metric: { kind: "count", value: visitors.value },
previous: wasVisitors,
compareLabel,
provenance: visitors.provenance,
spark: now.map((day) => day.visitors),
sparkType: "area",
},
{
key: "signups",
label: "Signups",
metric: { kind: "count", value: signups.value },
previous: wasSignups,
compareLabel,
provenance: signups.provenance,
spark: now.map((day) => day.signups),
sparkType: "bar",
},
{
key: "rate",
label: "Signup rate",
metric: { kind: "percent", value: rate.value, precision: 2 },
previous: wasSignups / wasVisitors,
compareLabel,
provenance: rate.provenance,
spark: now.map((day) => Math.round((day.signups / day.visitors) * 1000) / 10),
sparkType: "line",
},
{
key: "session",
label: "Median session",
metric: { kind: "duration", value: session.value, unit: "s" },
previous: median(before, "sessionSeconds"),
compareLabel,
provenance: session.provenance,
spark: now.map((day) => day.sessionSeconds),
sparkType: "line",
},
]
}
/** One reading for each range the toolbar offers. */
export type ByRange<T> = Record<RangeKey, T>
const byRange = <T,>(read: (range: RangeKey) => T): ByRange<T> => ({
"7d": read("7d"),
"30d": read("30d"),
"90d": read("90d"),
})
/**
* Every range the toolbar offers, computed here rather than in the island that
* renders them: the range is client state, the numbers are not.
*/
export function overviewStatsByRange(): ByRange<OverviewStat[]> {
return byRange(overviewStats)
}
/** What the traffic chart draws for one range: its days, and the launch post when it falls inside. */
export type TrafficView = { days: number; points: TrafficPoint[]; launch?: string }
/**
* The chart's rows for every range, handed to the island as a prop — the same
* reason as the tiles': an island that imported this module would take `db`,
* and every row behind it, into the browser.
*/
export function trafficByRange(): ByRange<TrafficView> {
return byRange((range) => ({ days: rangeDays(range), points: trafficPoints(range), launch: launchDay(range) }))
}
/** Where one range's visitors came from. */
export type SourcesView = { days: number; sources: { name: string; value: number }[] }
export function trafficSourcesByRange(): ByRange<SourcesView> {
return byRange((range) => ({ days: rangeDays(range), sources: trafficSources(range) }))
}
/** One range's most-read pages, and how many pages saw any traffic in it. */
export type PagesView = { days: number; pages: { label: string; value: number }[]; withTraffic: number }
export function topPagesByRange(): ByRange<PagesView> {
return byRange((range) => ({ days: rangeDays(range), pages: topPages(range), withTraffic: pagesWithTraffic(range) }))
}
// Where visitors arrive from, largest first. The names are the block's own
// vocabulary; the split comes off the same generator as the traffic.
const SOURCE_NAMES = ["Organic search", "Direct", "Referral", "Paid social", "Email"]
const SOURCE_WEIGHTS = shares("dashboard-01-sources", SOURCE_NAMES.length, 0.42)
/** `count` shares of 1, each one `decay` times the share before it, plus a little noise. */
function shares(name: string, count: number, decay: number): number[] {
const rand = seeded(name)
const raw = Array.from({ length: count }, (_, index) => decay ** index * (0.9 + rand() * 0.2))
const total = raw.reduce((sum, value) => sum + value, 0)
return raw.map((value) => value / total)
}
/** Where a range's visitors came from, largest share first. */
export function trafficSources(range: RangeKey): { name: string; value: number }[] {
const visitors = sum(span(RANGE_DAYS[range]), "visitors")
return SOURCE_NAMES.map((name, index) => ({
name,
value: Math.round(visitors * SOURCE_WEIGHTS[index]),
}))
}
const PAGE_PATHS = [
"/",
"/pricing",
"/docs/quickstart",
"/changelog",
"/blog/observability-budgets",
"/docs/api/events",
]
// Each page in the list takes this share of the one above it, and the tail
// below the list keeps following the same curve.
const PAGE_DECAY = 0.62
const PAGE_WEIGHTS = shares("dashboard-01-pages", PAGE_PATHS.length, PAGE_DECAY)
/**
* How many pages saw any traffic at all. `topPages` is the head of a curve that
* falls by `PAGE_DECAY` a step, so the count is the step at which it finally
* drops below one view — which is why a shorter range reaches fewer pages.
*/
export function pagesWithTraffic(range: RangeKey): number {
const [busiest] = topPages(range)
const steps = Math.floor(Math.log(busiest.value) / Math.log(1 / PAGE_DECAY)) + 1
return Math.max(PAGE_PATHS.length, steps)
}
/** The six most-read pages in a range, by views. */
export function topPages(range: RangeKey): { label: string; value: number }[] {
// A visitor reads about 2.4 pages, so views run ahead of visitors.
const views = sum(span(RANGE_DAYS[range]), "visitors") * 2.4
return PAGE_PATHS.map((label, index) => ({
label,
value: Math.round(views * PAGE_WEIGHTS[index]),
}))
}
/** What the team changed, newest first — the workspace's own audit trail. */
export function recentActivity() {
// Read per call, never held at module scope: a teammate renamed since the
// server started is the name on the next render.
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, 8)
.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,
meta: event.diff?.[0]
? `${event.diff[0].field}: ${event.diff[0].from} → ${event.diff[0].to}`
: undefined,
}))
}
// The plans as they are written for a reader, keyed by the value a customer row
// carries: "team" → "Team".
const PLAN_NAMES = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))
/** A customer's plan, as the pricing page writes it. */
export function planName(plan: Customer["plan"]): string {
return PLAN_NAMES.get(plan) ?? plan
}
const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]
/** Every plan a customer row can carry, as the pricing page writes it — for an island to look up. */
export function planNames(): Record<Customer["plan"], string> {
return Object.fromEntries(PLANS.map((plan) => [plan, planName(plan)])) as Record<Customer["plan"], string>
}
/** The newest workspaces, across every plan. */
export function recentSignups(): Customer[] {
return db.customers
.all()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, 12)
}
/** 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`
}"use client"
import * as React from "react"
/**
* The activity feed's scroll box. A box that scrolls is one the keyboard has
* to reach — the arrow keys scroll only what has the focus, and nothing in the
* feed takes it (axe scrollable-region-focusable) — so, by Table's rule, while
* the entries overflow it the box is a tab stop and a region named `label`,
* and while they fit it is neither. The name stands apart from the card's, so
* the page never holds two regions of one name.
*/
export function ActivityScroller({ label, children }: { label: string; children: React.ReactNode }) {
const box = React.useRef<HTMLDivElement>(null)
const [scrolling, setScrolling] = React.useState(false)
React.useEffect(() => {
const node = box.current
if (!node) return
const measure = () => setScrolling(node.scrollHeight > node.clientHeight)
measure()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(measure)
observer.observe(node)
if (node.firstElementChild) observer.observe(node.firstElementChild)
return () => observer.disconnect()
}, [])
return (
<div
ref={box}
data-slot="activity-scroller"
{...(scrolling ? { tabIndex: 0, role: "region", "aria-label": label } : null)}
className="relative max-h-72 overflow-y-auto rounded-sm focus-ring-inset"
>
{children}
</div>
)
}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 Analytics overview page