Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions docs/live-visitors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,46 @@ function. Admin queries refresh every three seconds while the page is visible.
The dashboard opens with a company grid. Individual sessions are hidden until
a company is expanded, and each session has its own expandable tabs and activity.
Unmatched traffic stays in one collapsed row at the bottom. Columns support
sorting, and search matches companies, industries, libraries, and page paths.
multi-column sorting with Shift-click, column visibility, row selection,
pagination, compact density, and CSV export. Search matches companies, industries,
libraries, and page paths. Library, industry, size, and funnel-stage filters can
be combined with minimum website views and recent actions. Values within a
filter match with OR, while different filters match with AND.

The overview counts identified companies once per domain. Library breakdowns
count a company once per library, so one company can appear in several bars.
Industry breakdowns exclude unmatched traffic. The company trend records real
polling snapshots in the open dashboard tab, up to five minutes, and resets on
reload. Grid action totals count named interactions in the last five minutes,
excluding page views.
Industry and size breakdowns exclude unmatched traffic. Clicking a breakdown
applies its filter. Size buckets use the lower bound of Scarf's employee range.
The status widget and sortable Status column classify a company as Active when
at least one online tab is visible, Background when all online tabs are hidden,
or Left when no tabs remain online. Each company occupies one status, even when
it has both visible and hidden tabs. Active describes visibility, not confirmed
attention or interaction. Status filters combine with the other company filters.
Grid action totals count named interactions in the last five minutes, excluding
page views. Pause freezes polling, including expanded sessions and history.

## Scarf company history

The server uses the existing `ANALYTICS_SCARF_API_TOKEN` bearer token for Scarf's
`/v3/insights/TanStack/aggregations/export` endpoint. Reporting is separate from
the lookup key and requires Scarf reporting access. Exports cover the selected
7, 30, 90, or 365 UTC calendar days, including today. Growth compares website
pixel views with the preceding equal-length window, so today's totals are
incomplete. These are window totals, not lifetime totals.

Website views include only the site's registered Scarf pixels. Site events
come from the TanStack Site Telemetry package, including browser interactions
and server requests. Events are not added to views. Sources and endpoints are
Scarf's distinct network counts, not people. Company reports join live company
domains exactly, without guessing parent-company relationships. Missing rows or
failed reports show N/A rather than zero. No company data is automatically
unlocked and no Scarf credits are spent on enrichment.

Successful aggregate exports are cached at the edge for 15 minutes and partial
or unavailable responses for one minute. Live presence still refreshes every
three seconds. Sorting and filtering do not request new exports. Only admins
can request reporting data, and source identifiers and credentials are never
returned to the browser.

## Enable the experiment

Expand Down Expand Up @@ -46,9 +78,11 @@ contact, not proof that a person is looking at the page. `pageshow` handles
restoration from the browser's back/forward cache. Sequence numbers prevent a
late heartbeat or departure from overwriting newer tab state.

Each tab retains up to 40 named actions. The admin includes departures from the
last five minutes when requested and returns at most 500 recently seen tabs,
with a visible truncation notice. Sessions disappear from reads at 24 hours and
Each tab retains up to 40 named actions. The company overview aggregates all
eligible tabs without a 500-tab cap. Individual company sessions are fetched
only when expanded, with at most 500 eligible recently seen tabs per company
and a visible truncation notice. The admin includes departures from the last
five minutes when requested. Sessions disappear from reads at 24 hours and
are deleted, with their tabs, on the next minute cleanup.

## Company lookup
Expand Down
9 changes: 2 additions & 7 deletions src/components/ProductScarf.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import { useRouterState } from '@tanstack/react-router'
import { Scarf } from './Scarf'

const products = [
{ path: '/application-starter', id: 'ed471ec3-aa08-430f-8481-258b3a1f94dc' },
{ path: '/builder', id: 'aa4dcee9-86a2-49b4-9981-40c97cc1f55b' },
{ path: '/stats/npm', id: 'e729f879-e127-48e1-be19-c68f6786c9e8' },
{ path: '/intent/registry', id: '811c34af-38bd-4f06-9762-3f58dea0650a' },
]
import { productScarfPixels } from '~/utils/analytics/scarf-pixels'

export function ProductScarf() {
const product = useRouterState({
select: (state) => {
const pathname = state.resolvedLocation?.pathname
return products.find(
return productScarfPixels.find(
({ path }) => pathname === path || pathname?.startsWith(`${path}/`),
)
},
Expand Down
64 changes: 64 additions & 0 deletions src/components/admin/CompanyFacetFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { CaretDownIcon } from '@phosphor-icons/react'

export function CompanyFacetFilter({
label,
items,
selected,
onToggle,
}: {
label: string
items: { name: string; value: number }[]
selected: string[]
onToggle: (values: string[]) => void
}) {
const [search, setSearch] = useState('')
const visible = items.filter((item) =>
item.name.toLowerCase().includes(search.toLowerCase()),
)
return (
<details className="group relative">
<summary
className={`flex h-7 cursor-pointer list-none items-center gap-2 rounded border px-2 text-[11px] [&::-webkit-details-marker]:hidden ${selected.length ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400' : 'border-gray-500/20'}`}
>
{label}
{selected.length > 0 && (
<span className="font-mono">{selected.length}</span>
)}
<CaretDownIcon aria-hidden="true" className="text-gray-500" />
</summary>
<fieldset className="absolute left-0 top-8 z-40 max-h-72 min-w-56 max-w-80 space-y-0.5 overflow-auto rounded-md border border-gray-500/25 bg-white p-1.5 shadow-lg dark:bg-gray-950">
<legend className="sr-only">{label}</legend>
<input
aria-label={`Search ${label.toLowerCase()} values`}
placeholder="Find value..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="mb-1 w-full rounded border border-gray-500/20 bg-transparent px-2 py-1.5 text-xs"
/>
{visible.map((item) => (
<label
key={item.name}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-gray-500/10"
>
<input
type="checkbox"
checked={selected.includes(item.name)}
onChange={() => onToggle([item.name])}
className="accent-emerald-600"
/>
<span className="min-w-0 flex-1 truncate" title={item.name}>
{item.name}
</span>
<span className="font-mono text-[10px] text-gray-500">
{item.value}
</span>
</label>
))}
{!visible.length && (
<p className="p-2 text-xs text-gray-500">No matching values.</p>
)}
</fieldset>
</details>
)
}
182 changes: 44 additions & 138 deletions src/components/admin/LiveCompanyCharts.tsx
Original file line number Diff line number Diff line change
@@ -1,158 +1,64 @@
import { useMemo } from 'react'
import { areaY, defineChart, lineY } from '@tanstack/charts'
import { Chart } from '@tanstack/charts/react'
import { scaleLinear, scaleUtc } from 'd3'

export type CompanySample = { time: number; companies: number }

export function LiveCompanyTrend({
samples,
count,
}: {
samples: CompanySample[]
count: number
}) {
const definition = useMemo(() => {
const first = samples[0]?.time ?? Date.now()
const last = Math.max(first + 1_000, samples.at(-1)?.time ?? first)
const points = samples.map((sample) => ({
...sample,
date: new Date(sample.time),
}))
return defineChart({
marks: [
areaY(points, {
x: 'date',
y1: 0,
y2: 'companies',
fill: '#10b981',
fillOpacity: 0.12,
}),
lineY(points, {
x: 'date',
y: 'companies',
stroke: '#10b981',
strokeWidth: 2,
}),
],
scales: {
x: {
scale: scaleUtc().domain([new Date(first), new Date(last)]),
axis: {
ticks: {
count: 3,
format: (value) =>
new Date(value).toLocaleTimeString([], {
minute: '2-digit',
second: '2-digit',
}),
},
},
},
y: {
scale: scaleLinear()
.domain([
0,
Math.max(3, ...samples.map((sample) => sample.companies)),
])
.nice(),
grid: true,
axis: {
ticks: {
count: 3,
format: (value) => (Number.isInteger(value) ? String(value) : ''),
},
},
},
},
margin: { top: 10, right: 10, bottom: 24, left: 25 },
theme: {
background: 'transparent',
foreground: 'currentColor',
muted: '#6b7280',
grid: 'rgba(107, 114, 128, 0.15)',
},
})
}, [samples])
return (
<section className="min-w-0 p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-medium text-gray-600 dark:text-gray-400">
Identified companies online
</h2>
<span className="text-[11px] text-gray-500">
Since opened · up to 5m
</span>
</div>
<p className="mt-2 text-4xl font-semibold tracking-tight tabular-nums">
{count}
</p>
<div className="mt-3 h-36">
{samples.length < 2 ? (
<p className="flex h-full items-center text-xs text-gray-500">
Trend starts with the next live update.
</p>
) : (
<Chart
definition={definition}
height={144}
initialWidth={400}
ariaLabel="Identified companies online since this dashboard opened, up to five minutes"
/>
)}
</div>
</section>
)
}

export function CompanyBreakdownChart({
title,
items,
color,
selected,
onSelect,
note,
}: {
title: string
items: { name: string; value: number }[]
items: { name: string; value: number; values: string[] }[]
color: string
selected: string[]
onSelect: (values: string[]) => void
note?: string
}) {
const maximum = Math.max(1, ...items.map((item) => item.value))
return (
<section className="min-w-0 p-5 sm:p-6">
<div className="mb-5 flex items-baseline justify-between gap-3">
<h2
className="text-sm font-medium text-gray-600 dark:text-gray-400"
title={note}
>
<section className="min-w-0 px-4 py-2">
<div className="mb-1 flex items-baseline justify-between gap-2">
<h2 className="text-xs font-semibold" title={note}>
{title}
</h2>
<span className="text-[11px] text-gray-500">Companies</span>
<span className="text-[10px] text-gray-500">Companies</span>
</div>
{items.length === 0 ? (
<p className="flex h-40 items-center text-xs text-gray-500">
No identified companies online yet.
{!items.length ? (
<p className="flex h-28 items-center text-xs text-gray-500">
No companies match.
</p>
) : (
<ul className="space-y-3.5" aria-label={title}>
{items.map((item) => (
<li key={item.name}>
<div className="mb-1.5 flex items-baseline justify-between gap-3 text-xs">
<span className="truncate" title={item.name}>
{item.name}
</span>
<span className="font-mono tabular-nums">{item.value}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-gray-500/10">
<div
className="h-full rounded-full"
style={{
width: `${(item.value / maximum) * 100}%`,
backgroundColor: color,
}}
/>
</div>
</li>
))}
<ul className="space-y-0" aria-label={title}>
{items.map((item) => {
const active = item.values.every((value) =>
selected.includes(value),
)
return (
<li key={item.name}>
<button
type="button"
aria-pressed={active}
onClick={() => onSelect(item.values)}
className={`relative flex h-5 w-full items-center gap-2 overflow-hidden rounded-sm px-2 text-left text-[11px] hover:bg-gray-500/10 focus-visible:outline focus-visible:outline-emerald-500 ${active ? 'ring-1 ring-inset ring-current' : ''}`}
title={`${item.name}: ${item.value} companies. Click to ${active ? 'remove' : 'apply'} filter.`}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 opacity-15"
style={{
width: `${(item.value / maximum) * 100}%`,
backgroundColor: color,
}}
/>
<span className="relative min-w-0 flex-1 truncate">
{item.name}
</span>
<span className="relative font-mono tabular-nums">
{item.value}
</span>
</button>
</li>
)
})}
</ul>
)}
</section>
Expand Down
Loading
Loading