Adds two panels to the Audience analytics tab: what share of a creator's followers were active in the last 30/60/100 days, and where those followers are, as a doughnut. Requested in #civitai-testers — total follower counts say little when accounts go dormant. Both answers are a per-user lookup over the creator's follower ids. Reading them from the source tables is not affordable: pageViews is 4.58B rows sorted by (time, pageId, userId), so a follower-id filter cannot skip granules — measured 5.59 GiB / 2.19 s per 10,000-id chunk over 100 days, and a 53,304-id follower set needs six chunks. So this adds default.user_activity_rollup, a per-user last-seen + last-known-country table keyed by userId, against which the same probe is a primary-key seek. The rollup merges on max/argMax, both idempotent, so the refresh job re-scans an overlapping window instead of tracking a watermark and a catch-up after a missed run cannot double anything. That is a property of the engine choice, not of rollups generally — the migration says so next to a warning against adding a count-shaped column. DDL AND BACKFILL ARE HAND-APPLIED, and must land before this deploys: src/server/clickhouse/migrations/2026-09-04-user-activity-rollup.sql. Disclosure rules are enforced server-side, in redactReach and presentReach, not in the component: SvelteKit serialises the whole load return into __sveltekit_data and __data.json, so a template-side filter still publishes "one follower in NO" to View Source. Panels are withheld under 25 followers; countries under 5 followers fold into "Other". An empty or stale rollup is treated as an outage, not as data. Every follower without a rollup row counts dormant, so an unbackfilled table would otherwise render a confident "0.0% active" — assertRollupUsable refuses a table that is empty or more than 12 hours behind, and the page says "unavailable" instead. Also: - register Chart.js DoughnutController + ArcElement in the shared @civitai/ui Chart, which carried line and bar only, and export CHART_PALETTE_SIZE so the slice cap cannot drift out of step with the palette - correct analytics.ts's claim that UserEngagement has no usable index for "everyone who follows X"; prod carries UserEngagement_type_targetUserId_idx INCLUDE (userId), and this change depends on it - docs: six lines across three creator-studio page specs said @civitai/ui has no chart primitive, contradicting the checklist's own C1 entry; the svelte-app-standard rule sending ClickHouse roll-ups behind /api/* did not match any analytics tab, all of which read them in load Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.9 KiB
/analytics — Basic analytics ⭐
v1 priority. The "what's driving my earnings" view: the creator's own model usage — generations per resource, downloads, engagement over time — read-only from ClickHouse daily aggregates. Keep it basic for v1; richer analytics is post-v1. Umbrella: plan §3, analytics source plan §5.1 / §7.6.
User story
As a creator, I open /analytics and see how my models are being used — generations per resource and
downloads trending over time, plus a top-models breakdown. It answers "what's driving my fees?" so I can tie usage to
the money I see on earnings.md. I can change the date range and skim which versions carry my usage.
Layout & components
@civitai/ui (shadcn-svelte) primitives — don't hand-build:
card— headline stat tiles (total generations, total downloads, engagement) for the selected range.- charts — generations-over-time + downloads-over-time line/area, per-resource, via the
Chartwrapper in@civitai/ui(Chart.js, SSR-safe; line, bar and doughnut controllers registered) — decision C1. table— top models / versions breakdown (generations · downloads · engagement, sortable). Depends on the owner-keyed rollup (see Data) — may be deferred if that rollup slips.tabs(optional) — switch metric views (usage vs. downloads vs. engagement) to keep each view basic.badgefor deltas vs. prior period;skeletonwhile loading;popoverfor metric definitions.- Date-range control — ⚠️
@civitai/uihas no calendar primitive; a preset range control (7d/30d/90d) or a chosen date-picker is needed (see open questions).
Data (reads) — +page.server.ts
Read-only. ALL reads = ClickHouse via @civitai/clickhouse (daily aggregates / materialized views), never the
buzz service (too slow) and never individual rows
(plan §5.1,
§7.6). No monetization module, no writes.
Existing daily aggregates to read (plan §7.6),
all keyed by modelVersionId, date:
- Generations —
default.daily_resource_generation_counts(generations / version / day). - Downloads —
default.daily_downloads,daily_downloads_unique,modelVersionUniqueDownloads. - Engagement / metrics —
default.entityMetric*suite. - (Earnings-by-source lives on earnings.md via
orchestration.resourceCompensations— not re-charted here; see the boundary in open questions.)
⚠️ Scoping gap — the load-bearing constraint. None of these aggregates are keyed by the creator's
userId— they are keyed bymodelVersionId(theuserIdcolumns that exist are the generator/downloader, not the creator). Scoping to "my models" needs amodelVersion → ownerUserIdmapping / the owner-keyed rollup (plan §7.6 gap #1). Until that MV/dictionary lands, v1 must fall back to resolving the creator'smodelVersionIds (from@civitai/db) and queryingWHERE modelVersionId IN (…)— which balloons for prolific creators. This is a cross-team dependency on Koen/backend (plan §7.4).
Actions (writes)
None. This page is read-only — no form actions, no mutations. (Monetization writes live on models.md / licensing.md.)
States
- Loading —
skeletonstat tiles + chart/table placeholders. - Empty — creator has no models, or no usage in range → friendly empty state + link to models.md / upload on the main app.
- No-usage-in-range — models exist but zero generations/downloads for the window → show zeros, not an error.
- Data-lag — daily aggregates trail real time; note "as of last daily rollup" so partial-today isn't read as a drop.
- Rollup-missing fallback — if the owner-keyed rollup isn't live, the per-model table may be hidden/capped; headline
usage still renders via the
IN (…)fallback. - Error — ClickHouse query failure → inline error on the affected panel, other panels still render.
Gating
Any logged-in user can access — this is a read, so gating is light
(plan §1). No member-tier action gate here (that's a per-action
concern, and this page has no actions). Member vs non-member is a data-visibility nuance: usage is only interesting
where a fee is active (member) — a non-member's usage doesn't drive fees, so copy should frame usage as potential and
point to join.md. Nav item is not memberOnly.
Shared / cross-refs
- Earnings by money/source → earnings.md — analytics = usage that drives fees; earnings = money by source. Draw the boundary cleanly (see open questions).
- Dashboard headline stats → dashboard.md pulls a subset of these tiles.
- Model management → models.md (which versions have an active fee → what usage is monetized).
- ClickHouse client
@civitai/clickhouseis shared; the creator-usage query logic is new here (plan §7.6).
Open questions
What counts as "basic" for v1?Decided 2026-07-09 (B4): two sections — a Model section (the proposed metrics + weekly granularity, generations split by buzz color, earnings WoW, per-model last 1–2 wk, cost-to-generate reference) and a NEW Content/Creator section (reactions, followers, images/posts published, profile views, top-content table, all-time stat tiles). See pre-implementation-decisions.md B4. Richer analytics (cohorts, funnels) is post-v1.- Per-model breakdown table depends on the owner-keyed rollup (plan §7.6 gap #1) — is it v1 (needs the MV from backend) or fast-follow (v1 ships headline usage only)?
- Date-range picker —
@civitai/uihas no calendar; ship presets (7d/30d/90d) for v1 or adopt a date-picker? - Default range + granularity — proposed last 30 days, daily (matches the daily aggregates); confirm.
- Earnings-over-time boundary — does the money time-series live here or on earnings.md? Proposed: usage here, money there, cross-linked — confirm so we don't double-build the same chart.