npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@seifer-webapp-factory/analytics

v0.6.0

Published

analytics — negende capability-module: een volledig anoniem, event-first analytics-platform. Consent-gated meetlaag over de frontend analytics-kit, een validerende ontvangstlaag met code-gedeclareerd meetwoordenboek, gescheiden opslag voor ruwe gebeurteni

Readme

@seifer-webapp-factory/analytics

The analytics capability module — a first-party, event-first, privacy-by-design analytics platform. Ninth capability module, and the first to prove that a module can be fully anonymous end-to-end and still own data: it validates incoming events against a code-declared measurement dictionary, sessionises them, rolls them up, enforces a three-store retention split, and serves gated dashboards — all without a session, an account or a login anywhere in the pipeline.

  • Design doc / decisions: ../analytics.md
  • Tier model: ../README.md
  • Manifest spec: @seifer-webapp-factory/capability-spec (this module's manifest.ts is a facade over it)

What it is

| Part | Where | Ownership | |---|---|---| | Contract (endpoints, event catalog, error taxonomy, events, config schema) | contract/ | dependency (semver) | | Ingest/validation, normalisation, sessionisation, rollup, retention, report services | backend/src/ | dependency | | useMeasurement() (thin layer over the frontend analytics kit), useAnalyticsReport() | frontend/src/ | dependency | | Neutral default-stylesheet | frontend/src/analytics.css | dependency | | Host-adapter controllers/DTOs, migrations, seed dictionary, ReportAccess reference adapters | backend/templates/ | project-owned after materialization | | Six dashboard pages (one tabbed screen), tab bar + trend chart + table components, measurement wiring plugin, consent bindings, nav, i18n | frontend/templates/ | project-owned | | manifest.ts | manifest.ts | provides / requires (no modules) / config / migrations / contract / templateVersion | | scaffolder/ | scaffolder/ | version-aware materializer (presence-check is a no-op — see below) |

The anonymity guarantee

This is the module's central design constraint, not an incidental property:

  • No session. Every endpoint descriptor in the contract carries auth: false. There is nothing to authenticate against — the ingest path (POST /analytics/events) is public and unauthenticated by design, because most measurable traffic is logged-out traffic.
  • No account. The module has no concept of a user, a profile or a login. It cannot offer one.
  • No identity linking. visitor_id is a pseudonymous, salted hash rotated every visitorIdRotationHours (default 24h). The salt is never persisted alongside the hash, it is never derived from an account id, e-mail address or any stable identifier, and no column anywhere in the six owned tables references a user, account or subject id. The optional account-linking path the source spec describes (§9.4) is deliberately not implemented — a host that needs it builds the join above the module, where the legal basis for it also has to live.
  • Cookieless. There is no persistent cookie identifier. A rotating salted hash is the opposite design choice from a persistent cookie id, which is what would turn every host into a consent-required tracker. Full IP addresses are never stored — the ingest layer may read the request IP transiently to derive a coarse region and the rotating hash, and must discard it within the request.

Read access is a separate concern from all of the above: reports, session timelines and raw-event reads are still gated — by an injected ReportAccess port, fail-closed (see below), not by identity.

Install

npm install @seifer-webapp-factory/analytics

Materialize the surface with the scaffolder (materializeAnalyticsModule), point it at its own analytics database — never the application database (see "Deployment topologies" below) — and bind a JobScheduler and a ReportAccess adapter. Without the latter the module installs and runs, but every dashboard stays closed until you bind one.

At a linked module: dedupe the kits

Working against a file: link to this module (local development, before publication)? Set in your Nuxt config:

build: { transpile: ['@seifer-webapp-factory/kits', '@seifer-webapp-factory/analytics'] },
vite: { resolve: { dedupe: ['vue', '@seifer-webapp-factory/kits'] } },

Without this, the linked module resolves its kit import to the monorepo's node_modules while your own components resolve theirs from the project — two copies of a kit mean two different injection symbols, and composables that expect a single provided instance fail. Use dedupe, not an alias on the bare package name — an alias bypasses the exports map and breaks subpath imports. Not needed once the module is published.

No module dependencies

analytics declares no requires.modules — it is kit-only. This is a deliberate, load-bearing choice, not an oversight: the module must install on a product that has no accounts, no sessions and no login at all — a marketing site, a one-pager, a public information site. Depending on authentication would make the single most common analytics host the one host that cannot install it.

The scaffolder's checkRequiredModules / assertAssemblable presence-check therefore has nothing to check for this module — it is a no-op here. It is still exported from scaffolder/ for parity with the siblings (account, authorization, organizations, …) that do have module dependencies.

Requires-ports (the host must provide)

| Port | Default the module ships | Overridable? | |---|---|---| | EventStore | postgresEventStore (reference adapter) | yes (required in practice) | | RollupStore | postgresRollupStore (reference adapter) | yes (required in practice) | | ReportAccess | denyAll — fail-closed. Every read endpoint answers 403 and no dashboard renders until a host adapter is bound | yes (required in practice) | | Clock | system clock | yes | | RateLimiter | the rate-limit kit's default limiter, configured per site | yes | | JobScheduler | the jobs kit's scheduler; rollup + retention sweeps register on install | yes | | ConsentStore | the privacy kit's consent registry (pgConsentStore) | yes | | AuditLog | the audit kit's sink | yes | | DesignSystem | class hooks + --analytics-* token contract + neutral default stylesheet | yes |

ReportAccess deserves the emphasis. It is a two-argument port — can(scope, request, websiteId?): boolean | Promise<boolean> over the scopes report · raw · behaviour · session · dictionary · retention. Its default is deny-all, and that default is not a placeholder to be forgotten about: a dashboard that fails closed is the intended first experience. Three reference adapters ship as surface (materialized, host-owned, freely editable): denyAll (the default), namedTokenAccess (per-human scoped bearer tokens — the no-identity-system path, see "How an operator sees the numbers" below), and allowList (source-IP / private-network only). A host running authentication + authorization writes its own adapter delegating to its permission check; the module neither knows nor cares which it got.

Config

Every knob has a safe default (divergence level 1 — see ../analytics.md for the full rationale behind each one). Two things have no working default on purpose: sites (an empty list would make the module an open collector) and access.adapter (defaults to 'deny' — a dashboard that is accidentally public is worse than one that does not work).

See contract/config.ts (analyticsConfigSchema) for the authoritative shape. Summary, grouped as in the build plan:

  • Sites — sites: [{ id, origins[], label? }], required, non-empty. Doubles as the ingest CORS allow-list and backs the unknown_site → 404 check.
  • Access — access.adapter ('deny' | 'named-tokens' | 'allow-list' | 'custom', default 'deny'), access.tokens (required, non-empty when adapter: 'named-tokens'), access.allowedSources (CIDR list, required when adapter: 'allow-list').
  • Sessions — sessions.inactivityMinutes (30), sessions.maxHours (24), sessions.multiTabPolicy ('merge').
  • Visitors — visitors.idRotationHours (24), visitors.enabled (true; disabling degrades unique visitors to session counts rather than failing).
  • Retention — retention.rawEventDays (90), retention.behaviourDays (14), retention.rollupMonths (25), retention.rejectionDays (30), retention.sessionTimelineDays (14). behaviourDays may never exceed rawEventDays — enforced at config load.
  • Consent — consent.defaultState ('unknown'), consent.bufferUntilDecision (true).
  • Exclusions — exclusions.excludedPaths ([]), exclusions.sensitivePaths (defaults to /login*, /register*, /account*, /settings*, /checkout*, /payment*, /messages*, /reset-password*), exclusions.allowedQueryParams (the five utm_* params).
  • Thresholds — thresholds.scrollDepths ([10, 25, 50, 75, 90, 100]), thresholds.sectionViewedMs (1000), thresholds.sectionVisibleRatio (0.5), thresholds.engagedSessionSeconds (10), thresholds.engagedSessionInteractions (2).
  • Conversion — conversion.attributionWindowDays (30), conversion.attributionModel ('last_non_direct'), conversion.multiplePerSession (false).
  • Ingest — ingest.maxBatchSize (50), ingest.maxEventBytes (8 KB), ingest.perSourceRatePerMinute (600), ingest.samplingRate (1.0), ingest.clockSkewToleranceMinutes (10).
  • Freshness — freshness.rollupIntervalMinutes (5), freshness.rollupFullRecomputeCron ('0 3 * * *'), freshness.dashboardStaleAfterMinutes (15, must exceed rollupIntervalMinutes — enforced at config load), freshness.liveWindowMinutes (30).
  • Other — botPolicy ('flag' | 'drop', default 'flag'), locale (default 'nl').

Required host env / secrets

When access.adapter is 'named-tokens', every declared token's value is supplied as a host secret, not in config — ANALYTICS_TOKEN_<NAME> (uppercased token name, e.g. a token named owner reads ANALYTICS_TOKEN_OWNER).

access: {
  adapter: 'named-tokens',
  tokens: [
    { name: 'owner',   scopes: ['report', 'raw', 'session', 'dictionary', 'retention'] },
    { name: 'analyst', scopes: ['report'] },
    { name: 'auditor', scopes: ['report', 'quality'] },
  ],
}

requires ANALYTICS_TOKEN_OWNER, ANALYTICS_TOKEN_ANALYST and ANALYTICS_TOKEN_AUDITOR all present in the deployment environment.

The deployer must forward every one of these variables. A missed variable does not degrade gracefully: it arrives as an empty string, the config kit's fail-fast schema validation rejects it at startup, and the process crash-loops before the migration gate even runs. If a token in access.tokens has no working credential, treat it as a deploy-blocking configuration error, not a runtime concern to catch later — there is no code path in this module that tolerates a missing token value silently.

The storage ports — and what a non-relational adapter must support

The source spec requires the platform to prescribe no database. That only holds if the module never emits SQL of its own, so storage is two narrow ports, not one Database:

| Port | Operations | What a store must support | |---|---|---| | EventStore | append(events) · scanSession(sessionId) · scanRange(filter) · deleteOlderThan(ts) · deleteByVisitor(id) | append-only writes, an ordered read of one session's events, a range scan by time + site, bulk delete | | RollupStore | upsert(rollups) · query(metric, period, dimensions) · deleteOlderThan(ts) | keyed upsert and a flat filtered read — no joins, no window functions |

The genuinely hard analytics logic — sessionisation, funnels, routes, engagement — is sequential logic over an ordered event list, and it lives in the module's own code (sessionise, rollup), operating on batches the EventStore hands back. The store is never asked for a window function. Reports then read RollupStore only, which essentially any store can serve.

Optional pushdown: an adapter may declare capabilities: { funnelPushdown, sequenceMatch, columnarScan }. When present the module delegates the heavy sequence work to the store (ClickHouse's windowFunnel / sequenceMatch, Postgres window functions); when absent it computes in-module. Correctness is identical either way — only the scale ceiling differs.

In: PostgreSQL (the shipped reference adapter), ClickHouse (the natural second adapter for this workload), TimescaleDB, DuckDB, MongoDB, and any document store with a filtered aggregation read. Out: pure key-value stores with no range scan, and anything that cannot return one session's events in timestamp order. The constraint is ordered range reads, not SQL.

Deployment topologies

Because storage is a port, the deployment shape is a host decision, not a module one. Both use the identical schema — the module only requires that website_id is present on every row and enforced on every read.

  1. Own database per product — the default, and the chosen shape for the webapp-factory fleet. Each product runs its own EventStore / RollupStore (typically its own Postgres database), declares one sites entry, and its dashboards render under that product's own /admin/analytics. Isolation by construction: no site can read another's data even through a bug, because the other site's data is not in the store. A retention mistake, a schema change or a restore stays inside one product.

    The cost, stated plainly: a static marketing site that today runs with no database at all gains a Postgres container. On a small shared host that is a real sizing change, not a rounding error — check host capacity before the second such install.

  2. One shared store per context, website_id-scoped. A single store serves several sites; each declares its own sites entry and its own origin allow-list. Every read path filters on website_id and passes it to ReportAccess, so one site's operator cannot see another site's reports even though the rows live in the same tables. Dashboards can land either embedded in each site's own app or in one standalone analytics app (e.g. stats.<domain>) reading the shared store — same pages, same contract, only the host app differs.

How an operator sees the numbers

The module has no concept of a user, so it cannot offer a login. Instead, when access.adapter is 'named-tokens', each human gets a distinct scoped credential without an identity system: the operator opens the dashboard, is prompted once for a token, and it is held in sessionStorage and sent as Authorization: Bearer … on every report call. Comparison is constant-time; a wrong token yields 403 and the dashboard's empty state, never a partial one.

This buys least privilege (an analyst token cannot reach session timelines or raw events), revocation (drop one entry from access.tokens, redeploy — the others keep working), and attribution (the audit log records the token name, so "who read the session data" has an answer). It does not buy real identity: a named token is shared-secret authentication, it can be handed to a colleague, and it does not expire on its own — the audit trail proves which token was used, not which person used it. A host that needs a full role model binds a custom adapter over a real identity system instead; that is precisely what the port exists for.

Data freshness. Ingest is immediate; reports read rollups, so a dashboard is only as fresh as the last rollup run.

| Layer | Freshness | Why | |---|---|---| | Raw events | immediate | written in the ingest request | | "Right now" panel | immediate | reads raw over liveWindowMinutes (30), bypassing rollups | | Today's figures | ≤ rollupIntervalMinutes (5) | incremental rollup of the affected period | | Historical figures | nightly | full recompute — the correctness pass, rollupFullRecomputeCron |

Every dashboard shows its computed_at and visibly marks itself stale past dashboardStaleAfterMinutes (15), so a silently dead rollup job reads as stale rather than as a quiet drop in traffic.

Divergence & eject

  1. Configure — every knob in "Config" above.
  2. Extend — add an event definition to the host's analyticsEventCatalog extension (own name, allowed properties, retention class), typed on both stacks; bind a custom ReportAccess adapter delegating to an existing identity system.
  3. Materialize & edit — eject any frontend/templates dashboard page (e.g. /admin/analytics/conversion to show a bespoke attribution table) or a backend/templates migration to add a dimension; the mechanism keeps upgrading via semver, template upgrades become a three-way merge.
  4. Fork — replace the sessionisation service with a bespoke identity-stitching implementation (no path back — last resort).

Herkomst van bezoeken — de GeoLookup-poort (module 0.4.0)

Standaard bepaalt de module niets over waar een bezoeker vandaan komt. Het bron-IP wordt precies één keer gebruikt (voor het roterende pseudoniem) en daarna losgelaten; er is geen geo-lookup, en het bereik-dashboard toont daarom unknown bij Regio's.

Wil je dat wel, dan bind je een adapter en zet je de precisie expliciet:

// config
geo: { precision: 'municipality', minimumGroupSize: 5 }

// wiring — de meegeleverde referentie-adapter leest de herkomst uit CDN-headers,
// zodat je geen geo-database in je container hoeft te zetten:
import { createCdnHeaderGeo } from './analytics/geo/cdn-header-geo';
const geo = createCdnHeaderGeo({
  municipalityByCity: { amsterdam: 'NL-GM0363', zaanstad: 'NL-GM0479' },
});
createAnalyticsBackend({ /* … */ geo: geo.forRequest(request.headers) });

Vier dingen om te weten voordat je dit aanzet:

  • precisie is een ladder (none → country → region → municipality) en wordt bij de ingest afgeknipt. Een adapter mag meer teruggeven dan je configureert; wat je niet vraagt, wordt niet opgeslagen;
  • er wordt een CODE opgeslagen, nooit een naam (NL-GM0363). Het contract laat structureel niets anders toe. Namen komen via labelFor op de poort en alleen bij het tonen;
  • kleine gemeenten worden samengevoegd tot overig zodra ze onder minimumGroupSize bezoekers blijven (standaard 5, minimaal 2 op gemeenteniveau). Dat is geen kosmetiek: bij lage aantallen is gemeente + apparaat + bezochte pagina's + tijdstip een aanwijsbaar persoon. De optelling blijft kloppen, want er wordt samengevoegd en niet weggelaten;
  • nauwkeurigheid: geo-IP haalt op gemeenteniveau 50–80% op vast internet en fors minder op mobiel (carrier-NAT) en achter VPN's. Richtinggevend, geen bewijs.

Zonder gebonden adapter verandert er niets: geen herkomst, geen fout, en de sectie "Gemeenten" blijft onzichtbaar in plaats van leeg.

Upgrading a host to module 0.6.0, package only — the numbers arrive with the token

The defect. An operator opened a dashboard, typed the token, submitted — and the gate opened onto a red "token required" message. Reloading the page showed the numbers that had been available all along. Two causes, neither visible in isolation, both unavoidable in the combination every dashboard page makes:

  • useReportToken() bootstrapped its own ref per call from sessionStorage. The gate and useAnalyticsReport() both call it during the same setup(), so both started empty — and typing the token only ever reached the gate's copy. sessionStorage was the shared state, but only at start-up;
  • useAnalyticsReport() never tried again after its first, token-less refresh() bounced off forbidden. That first attempt is the normal case: onMounted fetches before anyone has typed anything.

What changed. One shared ref per storage key per tab, and the report composable refetches by itself the moment a token arrives. Clearing a token (log out, or a 403 the gate reacts to) does not trigger a fetch — the empty state the gate already shows is the right answer there.

What a host has to know:

  • package bump only. No surface change, no new token, no migration, no API change. templateVersion stays where it is for this half of 0.6.0 (the SegmentFilter half below does need materialising);
  • the token is now shared between every useReportToken() call with the same storageKey in a tab. Host code that called it expecting an isolated copy now sees one token. Two dashboards that must stay separate still can — give them distinct storageKeys, which remain fully isolated from each other;
  • server-side nothing is shared. The shared refs live at module level and would outlive a request, so during server render every call gets a fresh, unshared ref — one visitor's token can never surface in the next visitor's render. Guarded by a test in tests/frontend/ssr-smoke.test.ts, the only suite that runs without a DOM.

Upgrading a host to templateVersion 0.8.0 (module 0.6.0) — the collapse button actually collapses

The defect. In 0.6.0 the filter became a disclosure on narrow screens. The component set only the class analytics-segment-filter--collapsed; hiding the panel and removing the button above the breakpoint lived in frontend/src/analytics.css. That stylesheet is part (c) of the style contract, and part (c) is optional: DESIGN-SYSTEM-PORT.md explicitly lets a host style the class hooks (part a) with its own design system instead. Both integrating hosts did exactly that — and both got a button that is visible, announces aria-expanded, and does nothing when clicked. A dead control that also lies to a screen reader.

The general lesson, and the reason this is a module defect rather than an integration mistake: the behaviour of a control must never live in the optional half of the style contract. Appearance may.

What changed. components/SegmentFilter.vue now owns the state: matchMedia decides wide or narrow, v-if renders the button only below the breakpoint, and v-show hides the panel with an inline display: none that no host stylesheet can accidentally override. The disclosure works with zero CSS.

What a host has to know:

  • the breakpoint is now a token: --analytics-filter-breakpoint, default 48rem, documented in frontend/TOKENS.md. Bind it on a scope that covers the dashboards (:root is safest). Moving the boundary is still a CSS change and still not an eject — it just travels the same road as every other --analytics-* token. An unusable value is ignored and the default applies: browsers do not normalise an unparseable media query (Chromium hands (min-width: oeps) straight back, matching nothing), so one typo would otherwise collapse every dashboard on every monitor;
  • the server render is now open, where 0.6.0–0.7.0 rendered it collapsed. There is no viewport to measure on the server, and the initial state has to be the one that breaks nothing if the JavaScript never arrives. A phone gets a wide first paint and collapses right after hydration;
  • bump and materialise together. A host that bumps the package but keeps the 0.7.0 surface gets a filter that no longer collapses at all — the old component expects the stylesheet to do it, and the stylesheet no longer does. The reverse (new surface, old package) is harmless: the removed CSS rules were only ever redundant with the component;
  • a host with its own stylesheet has nothing to do. --collapsed survives as a class hook for styling the closed state, but it carries no behaviour any more.

Upgrading a host to templateVersion 0.7.0 (module 0.5.0) — pages get names

Two reports — "best bekeken pagina's" in reach and the page-to-page flow in routes — were unreadable for every host using the reference sink, for the same reason: page_id. It is the only page dimension the reporting side knows, and the sink filled it with a random id per path, with no hook to put anything else in. Both reports therefore showed a list of UUIDs with one view each: technically correct, worthless to a reader.

What changed. createAnalyticsClient gained a pageId?: () => string option and now exports normalisePageId(). The default did not change — it is still a random id. Making it path-like silently would mix old random rows and new path rows in the same rollup dimension, and that is the host's call, not a package bump's. The plugin template does set it, so the improvement arrives with the surface upgrade.

What the template now does. plugins/analytics.client.ts passes a pageId thunk that prefers the route pattern (/product/:id()) over the path, falling back to window.location.pathname. The pattern matters once you have dynamic routes: with the raw path, /product/123 and /product/456 are two pages, and a thousand products give you a thousand rows of one view each. With only static routes the two coincide and you will not notice the difference. The route is read from router.currentRoute inside the thunk rather than tracked from a router hook, so it does not depend on the order in which hooks were registered.

Your existing rows. Rows written before the upgrade keep their random ids and will sit alongside the new path-like ones in the same dimension. Two honest options:

  • Leave it. Raw events age out after retention.rawEventDays (default 90), but rollups do not age out with them (erasureResponseSchema.rollups_unaffected: true), so the UUID rows stay in the reach report until you recompute.
  • Start clean. Delete the raw events you no longer want and run rollup.run({ mode: 'full' }). That is the only route that actually clears the dimension.

One more behaviour change, and it is not cosmetic. The sink now sends page_url (the path only — no origin, no query string). Without it, pathOf(event.page_url) was always undefined server-side, which means exclusions.excludedPaths and exclusions.sensitivePaths never fired. Those are a privacy guarantee (spec §6.3), not decoration — they simply did not exist while the client withheld the path. If you have either list configured, expect events that arrive today to start being rejected with path_excluded. That is intended, but it explains a step down in the dashboard.

The companion fix lives in kits 0.1.3: createRouteViewTracker attached the route name as props.name, and name is on the measurement contract's forbidden list. The ingest did not drop the property — it rejected the whole event with forbidden_property (422). A host on an older kit loses every automatic page view, with a transport, consent gate and rollup that all look healthy. The route name is now opt-in as route_name via includeRouteName.

Upgrading a host to templateVersion 0.6.0 (module 0.4.0) — the filter is rebuilt

components/SegmentFilter.vue changed in six ways. No migration and no endpoint path changed, and the reporting query is byte-for-byte what it was; the config and the dictionary response each gained one optional field (campaigns), and one mechanism bug was fixed along the way.

1. It collapses on narrow screens. A disclosure below 48rem: six fields in two boxed groups took most of a phone screen before any number came into view. Collapsed, one button row remains, carrying the applied date range and how many segment filters are on. Above the breakpoint the button is display: none and the panel is always open — identical to what a desktop host sees today.

2. The period selector (day/week/month) is gone. A filter picks which data counts; the period does not — it sets how finely the same data is summed. The date range is a filter and stays. The period value still travels in SegmentFilterValue and through the URL (?period=), so deep links and the tab bar are unaffected — there is simply no on-screen control for it any more. A host that wants one places it where granularity belongs (next to the trend chart), or keeps its own copy of this component.

3. Source and device are select boxes. Both are derived to a closed set at ingest, so a free-text field turned any other spelling ("organic", "mobiel") into a silently empty report. The options come from ANALYTICS_SOURCE_GROUPS / ANALYTICS_DEVICE_CATEGORIES, new exports of the contract and now the single source the backend's deriveChannelGroup/deriveDeviceCategory classify with. campaign_id stays free text — that is a host-owned identifier whose value set the module cannot know. The query schema was deliberately not tightened to an enum: an unknown value must yield an empty report, not a validation_error.

4. There is no Apply button — every field change filters. A button that only repeats what the form already knows is a step to be learned and then ignored, and it allowed a state where the fields said one thing and the numbers below them another. It listens to change, not input, so a <select> and a date field report once a value exists and a text field on blur/Enter — at input every keystroke in the campaign field would become a report call. The one change that does not leave is a half-filled or reversed date range (unavoidable while moving a range); those fields carry aria-invalid until it makes sense again. The panel no longer auto-collapses either: with live filtering there is no "done" moment to react to.

This also required a fix in the mechanism: useAnalyticsReport().refresh() used to drop a call while one was in flight. That was defensible behind an Apply button; with live filtering it is the normal case, and it left the operator looking at numbers that did not match the fields. The latest request now always wins, and superseded responses are ignored rather than written over newer ones.

5. Campaigns are picked by name. A campaign_id is an opaque host identifier nobody knows by heart, and a typo produced an empty report indistinguishable from "this campaign had no visitors". The host now declares campaigns: [{ id, label }] in its analytics config; those names travel with GET /analytics/dictionary (a new field on the dictionary response) exactly as funnels already do, and the filter shows a text field with a native suggestion list (list + <datalist>). The name is displayed, the id is sent. Declare nothing and it stays what it was: a free-text field on the id — and a non-declared id remains typeable either way, which is why this is not a <select>. Duplicate ids or duplicate names are rejected by config validation, because a chosen name has to resolve to exactly one id.

6. Dates are localised. New provideLocale() / useLocale() in runtime.ts (default nl-NL, wired by plugins/analytics-dashboard.client.ts) and formatIsoDate(), used for the range on the collapsed button. The input fields stay <input type="date"> — their value is and stays ISO — and carry lang, but how a browser renders such a field is implementation-defined: Firefox follows lang, Chrome and Safari follow the browser/system setting. There is no way for a page to force dd-mm-jjjj on a native date input.

What a diverged host has to carry over:

  • class hooks: new __toggle, __toggle-label, __summary, __panel and the modifier analytics-segment-filter--collapsed; __group--period is renamed to __group--dates, and __group--segment starts on its own row with extra space above it. See frontend/CLASS-HOOKS.md;
  • the breakpoint lives in exactly one place — the media query in frontend/src/analytics.css. Superseded by templateVersion 0.8.0: that arrangement is exactly what left a dead button in every host that styles the hooks itself. The boundary is now the token --analytics-filter-breakpoint and the behaviour is in the component; see the 0.8.0 section above;
  • i18n: new analytics.filter.show, analytics.filter.hide, analytics.filter.activeSegments (interpolates {count}), one label per source group and device category (analytics.filter.sourceGroup.*, analytics.filter.deviceCategory.*, including .any). analytics.filter.legend now reads "Datumbereik" / "Date range" and analytics.filter.campaignId is "Campagne" / "Campaign" (it is no longer an id the operator types). The four analytics.filter.period* keys and analytics.filter.apply are removed. Missing keys render as the key name — visible, not silent, but worth carrying over into a diverged dictionary;
  • Apply and Reset now collapse the panel and return focus to the button, so keyboard focus does not fall to <body> with the panel. Above the breakpoint that focus() is a no-op.

Upgrading a host to templateVersion 0.3.0 (module 0.2.0) — requires chart.js

This one has a manual install step; skipping it fails the host's build, not a test:

npm install chart.js   # new peer dependency, ^4.4.0
  • components/TrendChart.vue draws with Chart.js on a <canvas> instead of hand-written SVG. This is a deliberate flip of the module's zero-runtime-dependency stance — the reasoning, and what a canvas costs, are recorded in ../analytics.md under "Lock flip";
  • the drawing's class hooks no longer exist (__line, __dot, __tick, __grid, __hit, __crosshair, __value-label). A host stylesheet targeting them now styles nothing. __plot is now the height-bearing container with __canvas inside it;
  • chart colours are read in JS, not applied by CSS — a canvas inherits nothing. Bind --analytics-chart-line, --analytics-surface, --analytics-text, --analytics-text-muted and --analytics-border on a scope that actually covers the dashboards;
  • the table equivalent, the text readout and the ←/→ keyboard handling are unchanged and are now the only accessible representation of the data — do not remove them when diverging this component.

Upgrading a host to templateVersion 0.2.0 (module 0.1.3)

The surface was restructured, so this upgrade is a real three-way merge on the dashboard pages, not a version bump:

  • the six dashboards became one screen with a tab bar (components/AnalyticsTabs.vue). They are still six routes with the same paths — only nav/tab in routes/analytics-routes.ts changed, so the host's admin navigation shows one entry instead of six;
  • the reporting period now travels between tabs through the URL (?period=&from=&to=); segment filters stay local to the dashboard where they were set (SHARED_FILTER_KEYS in runtime.ts is the one place that draws the line);
  • new components/TrendChart.vue: visitors per day as a line chart on the reach dashboard, carrying the trend table as its table equivalent;
  • MetricTable no longer draws in-cell bars — breakdowns arrive sorted high → low instead, and --analytics-bar-track / --analytics-bar-fill are gone from the token bridge (--analytics-chart-line replaces them). An already-materialized bridge keeps the old names harmlessly; drop them when convenient;
  • bugfix, applies to diverged pages too: the trend tables read dimension date, while the report service emits period_start. Dimension keys match exactly, so this produced no error — just a permanently empty trend table on reach, engagement, conversion and quality. A host that has already ejected those pages must make the same correction by hand; the merge will not do it for a rewritten file.

Phase map

| Version | Phases | Scope | |---|---|---| | v0.1.0 | 0–5 | contract-first · backend slice (ingest/validation/sessionisation/rollup/retention) · frontend slice (six dashboards) · manifest + scaffolder · vertical e2e · divergence proof | | v0.2.0 | 6 | heatmaps — click/scroll/element/visibility capture, page-version separation, sensitive-path refusal | | v0.3.0 | 7 | session analysis — chronological timelines, frustration signals, strict access control |

Phases 6 and 7 are in the build plan, not deferred out of it — they ship as later minor versions because their own acceptance criteria are unverifiable until the base (phases 0–5) is demonstrably reliable. See ../analytics.md for the full phase table and gates.

Tests

npm test            # contract, manifest, backend unit, frontend, scaffolder — no Docker
npm run test:e2e     # backend testcontainer e2e — REQUIRES Docker
npx playwright test  # vertical e2e, standalone with no other capability module and no auth — REQUIRES a running sample app + browser