Analytics
Everything a prototype needs to look and behave like a product-analytics or dashboard app, on simulated data (ADR-021):
| Layer | Package | What |
|---|---|---|
| Engine | `@protoyard/analytics` | Date ranges and comparison, bucketing, aggregates and deltas, filters and segments, funnels, retention, dashboard layouts. Plain TypeScript + zod. |
| Generators | @protoyard/analytics/generators | Deterministic time series, product events, funnel journeys, cohorts, live-counter timelines and scenario fragments (launch spike, outage, seasonal, empty, massive) |
| Views | `@protoyard/analytics-ui` | KPI tiles and live counters, date range picker, filter builder and segments, dashboard grid, drill-down, cohort table, funnel analysis (Svelte 5, one subpath per view) |
| Charts | `@protoyard/charts-ui` | Every chart the views draw. See CHARTS. |
| Starter | `templates/analytics` | pnpm protoyard new my-idea --template analytics |
| Flagship | `prototypes/pebblestat-analytics-demo` | Projects, reports, alerts, annotations, sampling at enterprise scale |
Everything is simulated: events come from scenarios and the timeline, "now" is the simulated clock, and roles are a UX simulation, not security.
An optional blueprint archetype block can declare the analytics experience, its event and metric entity slots, dashboard route, scenario defaults, and the public @protoyard/analytics plus lazy analytics UI entry points. It is validated data and does not load Svelte components by itself. See the `@protoyard/blueprint` archetype contract.
A quick starter, `blueprints/quick/analytics.json`, declares that archetype for a fictional product: weighted signup, activation and purchase events with a channel and integer-cent revenue, a dashboard (three KPIs and a weekly bar chart by type), an event explorer on /explore, and a metrics list, plus two validation tasks. At its dense default it generates 160 events and 16 metrics (a 37 KB world, 7.5 KB gzip), byte-identical for the same seed. The blueprint integration test checks analytics' public engine, grid and date-range export targets, plus the callback contracts used to save layout and range changes.
The data flow
scenario (presets + analyticsFragments)
→ generator (generateProductEvents, …; ctx.rng forks, ctx.ids, reference time)
→ Dexie tables (events indexed on [type+at] and at)
→ liveQuery(querier, { initial, deps: () => [range, filter, …] })
→ pure read model (bucketize, compileFilter, computeFunnel, computeRetention)
→ analytics-ui views and charts-ui charts (lazy routes)
Mutations (save a layout, a segment, a report) go through services: UI → service → session.assert → zod (DashboardLayoutSchema, filterSchemaFor(catalog)) → environment.run → db.transaction. The views never write: they call a callback that returns the service promise, so Buttons and the grid show pending feedback.
Engine
@protoyard/analytics, all in UTC with "now" passed in:
- Ranges:
resolveDateRange(input, now)for nine presets or custom
days and instants (half-open [start, end)), plus comparisonRange(range, 'previous-period' | 'previous-year').
- Buckets:
bucketize(rows, { range, granularity, at, aggregate, value })
fills every bucket, empty ones included. Empty buckets are 0 for counts and null for averages. More than 10,000 buckets throws.
- Aggregates:
count,sum,unique,avg,min,max,p50,
p90, p95. delta() is null for a previous value of 0, never ∞.
- Filters: data-only AND/OR trees up to 4 levels with 14 operators.
filterSchemaFor(catalog)turns unknown properties and wrong operators into field errors.compileFilterbuilds the predicate.- Paths reject
__proto__,prototypeandconstructor. - Funnels:
computeFunnel(events, steps, { window, breakdown, range })
returns counts, conversions, drop-off and the median time to convert, per step and per breakdown value.
- Retention:
computeRetention(events, { cohortEvent, returnEvent, interval, periods, now, mode }). modeisbounded(active in period) orunbounded(not yet churned).- Unfinished periods are marked
incomplete. - Layouts:
DashboardLayoutSchema(12 columns),compactLayout,
moveWidget, resizeWidget.
Exact semantics: analytics README.
Generators
@protoyard/analytics/generators uses only the scenario context, so the same seed always gives the same world. The table and an example are in SCENARIOS → Analytics data.
generateProductEventsgives a power-law number of sessions per user,
weighted event types, quiet weekends when the scenario is weekly, and user properties on every event.
liveCounterEventsbuilds timeline events that do not depend on the
window: advancing a week at once yields the same events, with the same ids, as advancing seven times by a day.
- Spikes and outages shape
generateTimeSeries. Event streams do not apply
them by themselves: the template and the flagship add a launch wave and thin events inside an outage (see their generators.ts).
Views (@protoyard/analytics-ui)
Each view is its own subpath, so a screen pays only for what it imports.
| Import | Components | Key props |
|---|---|---|
@protoyard/analytics-ui | KpiTile, KpiRow, LiveCounter; formatKpi, kpiDelta | KpiTile: label, value, unit (number, compact, percent, cents), previous, positive, trend, trendPeriod, href, loading, empty. LiveCounter: label, query (a liveQuery), duration, announceEvery |
…/date-range | DateRangePicker, DateRangePanel, MonthGrid | value: { range, comparison, granularity }, now, onchange(value, change); toDateRangeQuery / fromDateRangeQuery for the hash |
…/filters | FilterBuilder, FilterGroupEditor, SegmentPicker, BreakdownSelect | properties: { path, label, type, options? }[], value / filter, onchange, onsave (returns the service promise; field errors render inline) |
…/grid | DashboardGrid, WidgetCard | layout, label, editing, onchange(layout) (returns the save promise; reverts on rejection), widget snippet, collapseBelow. WidgetCard: title, description, menu, states, expandable |
…/drill | DrillDown; drillStep, pushDrill, truncateDrill, drillFilter, toDrillQuery, fromDrillQuery | rows, columns, getRowId, path (data, round-trips the hash), onchange, chart snippet (drill(step) from onselect), base, pageSize |
…/cohort | CohortTable | title, result (from computeRetention), display (percent, count), palette, description |
…/funnel | FunnelAnalysis | title, result (from computeFunnel), selected (breakdown, bindable), breakdownLabel, orientation, onstepselect |
Accessibility:
- KPI tiles are articles named by their label; the delta reads its direction first.
- The grid can be moved and resized by keyboard, with live announcements.
- The drill breadcrumb is a
navwitharia-current. - The cohort table has scoped headers, and its cell text is shown only where it reaches 4.5:1.
- Toggles use
aria-pressed.
Build an analytics prototype
pnpm protoyard new my-analytics --template analytics
pnpm protoyard dev my-analytics
pnpm vitest run prototypes/my-analytics
pnpm protoyard scenario bench my-analytics --check
pnpm protoyard budget my-analytics
The template ships six screens (Overview, Trends, Funnels, Retention, Live, Segments) and six scenarios (empty, active, launch-spike, outage, massive, viewer). To add a dashboard to a prototype step by step, use the `add-dashboard` skill.
The flagship: Pebblestat Analytics
prototypes/pebblestat-analytics-demo is fictional Mixpanel-style product analytics built from the template:
- two projects (Web, Mobile);
- Insights with a comparison period and totals per breakdown;
- a user explorer with a hash-restorable drill-down;
- saved reports and segments;
- daily metric alerts that fire as simulated time advances;
- release, incident and campaign annotations;
- owner, analyst and viewer personas.
Its specs (AGENTS.md, docs/) describe every flow. Review comments are on ("build": { "review": { "enabled": true, "adapter": "local" } }), so reviewers can pin notes to its dashboards (REVIEW_COMMENTS.md).
Enterprise scale is sampled, and says so.
- 100,000 raw events, or even the plan's 20,000-event sample, cannot be
generated inside the 50 ms scenario budget.
- So
enterprise-scalestores daily rollups (about 100k events) plus a 5 %
user sample (about 5,000 raw events).
- Event totals come from the rollups, and user counts are estimates labelled
"(est.)".
Measured numbers
On the Apple M1 Max, Node 26.11.1, headless Chromium 156, 2026-10-19, under heavy shared load (load average 60–140): upper bounds.
| What | Measured | Budget |
|---|---|---|
| Generators: 90 days × 10 metrics + ~19k events from 2,000 users (Node, median) | 26–33 ms | < 50 ms |
| Funnel over 100k events from 10k users, with a breakdown (Node, median) | 27–45 ms | < 50 ms |
| Weekly retention over the same events | 30–32 ms | — |
Template scenarios (scenario bench, slowest: massive, ~10k rows) | 36–38 ms | < 50 ms |
Flagship scenarios (slowest: launch-day, 4.5k rows; enterprise-scale 16 ms) | 19–23 ms | < 50 ms |
| Template initial JS (gzip) | 191.8 KB | ≤ 210 KB (plan), 250 KB |
| Flagship initial JS (gzip) | 207.4 KB; 208.9 KB with review comments on (budget, 2026-10-20) | ≤ 210 KB (plan), 250 KB |
Flagship enterprise-scale dashboard, first visit (generate + render) | ~1.0 s, 0 long tasks | 0 long tasks > 200 ms |
View sizes (esbuild, gzip, with svelte, @protoyard/ui and the engine treated as external):
| View | Gzip |
|---|---|
| KpiTile (with its Sparkline) | 5.1 KB |
| LiveCounter | 1.3 KB |
| FilterBuilder | 4.6 KB |
| SegmentPicker | 2.0 KB |
| Date range picker | 4.3 KB |
| DashboardGrid + WidgetCard | 3.6 KB |
| DrillDown | 1.3 KB |
| CohortTable | 2.7 KB |
| FunnelAnalysis | 2.0 KB |
Limits (honest)
- No ingestion: no SDK, API or warehouse. Events exist only in scenarios and
the timeline.
- Aggregation runs in the browser over IndexedDB rows. That is fine up to
tens of thousands of events per screen, which is why enterprise scale is sampled.
- No alert delivery (email, Slack), no scheduled reports, no sharing.
- Annotations are a strip under the charts, not marks on the plot (charts-ui
has no annotation layer yet).
- The date range is shared per tab in memory. The drill path is in the hash;
the range is not.
Tests
pnpm vitest run packages/analytics packages/analytics-ui
PROTOYARD_BENCH=1 pnpm vitest run packages/analytics/tests/generators.test.ts # opt-in generation budget
pnpm vitest run templates/analytics prototypes/pebblestat-analytics-demo
PROTOYARD_E2E_PROTOTYPES=pebblestat-analytics-demo,analytics pnpm test:e2e tests/e2e/pebblestat-analytics-demo.spec.ts tests/e2e/layout-fit.spec.ts
For agents
The distributable Protoyard skill routes kit choices through its kits sub-skill (`skills/protoyard/kits/SKILL.md`) and its catalog (`skills/protoyard/references/kits.md`), which link back to this guide. Keep the catalog's entry for this kit in step when its entry points or limits change.