Files
briant e81e79e5f2 feat(creator-studio): follower active reach + country breakdown (868m15npe)
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>
2026-09-04 16:11:27 -06:00

6.9 KiB
Raw Permalink Blame History

/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 Chart wrapper 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.
  • badge for deltas vs. prior period; skeleton while loading; popover for metric definitions.
  • Date-range control — ⚠️ @civitai/ui has 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 by modelVersionId (the userId columns that exist are the generator/downloader, not the creator). Scoping to "my models" needs a modelVersion → ownerUserId mapping / the owner-keyed rollup (plan §7.6 gap #1). Until that MV/dictionary lands, v1 must fall back to resolving the creator's modelVersionIds (from @civitai/db) and querying WHERE 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 — skeleton stat 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/clickhouse is 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/ui has 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.