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

12 KiB
Raw Permalink Blame History

Creator Studio — implementation checklist

Single source of truth for status. Everything open — build items, decisions, blockers, bugs, deferred work — is tracked here. Detail and rationale live in the reference docs (linked inline); this file is the index of what's left, not the why.

Reference: page specs (dashboard.md, models.md, analytics.md, earnings.md, licensing.md, settings.md, join.md) · pre-implementation-decisions.md (A/B/C decision log) · owner-rollup-handoff.md + cdc-koen.md (A1) · questions-*.md (answered Q&A) · feedback-justin-round-2.md (review notes).

Legend: [x] done · [ ] not started · [~] partial · 🚧 blocked on a dep · 🟢 needs a decision · ⏭ deferred / lower-priority · 🐛 flagged bug (upstream, not ours to fix).


Shipped

Shared / shell

  • App scaffold (SvelteKit spoke), auth gate, nav, layout, favicon
  • Membership resolver (Creator Program gate, B1) + moderator test simulator (cs-test-membership cookie)
  • Monetization module — setLicensingFee / bulkSetLicensingFee / apply-default-by-type (kysely); early-access write client → main-app REST endpoint POST /api/v1/model-versions/early-access
  • Axiom logging (handleError hook)
  • Redis read-through cache — @civitai/redis createRedisCacheBuilder / createSysRedisCacheBuilder → spoke cache.ts; range-scaled TTL
  • Charting primitive (C1) — Chart.js Chart wrapper in @civitai/ui (SSR-safe; chartColor() theme bridge)
  • @civitai/buzz wired (shim + BUZZ_ENDPOINT) — authoritative buzz-account reads (cash), not ClickHouse
  • Content/creator analytics reads module (analytics.ts, userId-keyed) + earnings/cash reads (earnings.ts, cash.ts)
  • Empty / unavailable / non-CP state hardening across dashboard, /earnings, /analytics

/models — Model management ⭐

  • Grouped table (models → versions, drafts included); search / fee filter / sort / pagination (URL-driven)
  • Set / adjust / clear licensing fee — single + inline, fractional (0.01), CP-gated; status Off/Active/Paused
  • Bulk fee editing (?mode=bulk) — per-version + select-all, confirm dialog; apply-default-by-type
  • Non-commercial base-model guard; ownership + CP gate re-checked server-side
  • Full early/paid-access config editor (B12, full parity) — per-version drawer → main-app endpoint
  • States: empty, filtered-empty, non-member notice, error toasts

/ — Dashboard

  • Shell: headline stat cards + section link cards + member badge
  • Content-activity row (30d reactions/followers/images/posts/profile views, getContentTotals)
  • Buzz earned (30d) + CP cash cards (ready / pending / withdrawn, authoritative via @civitai/buzz); cash cards gated to CP members

/earnings — Earnings by source (A1 Part 1)

  • By-source breakdown (comp / license / tip / access / cosmetic) — reads buzzTransactions directly (already owner-keyed); licenseFee+'27' filter, early-access prefix, cosmetic sell
  • Per-currency faithful display (B8/D1, no conversion/merge) — currency cards + source×currency table
  • Buzz-only per-currency trend chart (real buzz colors); Redis-cached; 7/30/90d + day/week controls
  • CP cash panel — authoritative ready / pending / withdrawn (buzz service + CashWithdrawal), USD (cents÷100), matches the Buzz dashboard; Buzz Dashboard link-out (C6: one cash home)

/analytics — Basic analytics

  • Content/creator section (B4b): reactions/followers/images/posts/profile views over time + top-images; Redis-cached
  • Date-range control — 7/30/90d presets + day/week granularity (URL-driven)
  • Zero-activity + unavailable empty states
  • Audience tab — follower active reach (share of followers active in the last 30/60/100 days) + follower country doughnut. getFollowerReach ($lib/server/follower-reach.ts) probes the new default.user_activity_rollup (per-user last-seen + last-known country) with the creator's follower ids from Postgres. Refreshed by the user-activity-rollup cron (*/30, src/server/jobs/user-activity-rollup.ts); DDL + backfill are hand-applied — src/server/clickhouse/migrations/2026-09-04-user-activity-rollup.sql, and they must land before the job or the page deploys. Panels are suppressed under 25 followers and countries under 5 followers are folded into "Other" (disclosure rule, enforced server-side in redactReach); an empty or >12h-stale rollup renders "unavailable" rather than a confident 0%.
  • (route moved /earnings/analytics → /analytics)

/settings — Payout & settings

  • Membership / tier status card; Payout (Tipalti) status card
  • Payout unlock (#16) — "Set up payouts" prompt unlocks once settled cash ≥ $50 (getCreatorCash)
  • Fee defaults — read-only info per B9 (becomes a link to the templates list once pricing-templates.md ships)

/join — Membership upsell

  • CP-framed upsell (B1), capability comparison, CTA; CP-member redirect to /; reusable JoinUpsell; nav aligned to CP membership

Open — needs a product decision (Justin)

  • 🟢 #23 — Early-access reframing. Pre-check answered — the backend already enforces manage-only. mergeEarlyAccessConfigUpdate (model-version.service.ts:341) throws "You cannot add early access on a model after it has been published"; on a published version with existing EA you can only loosen terms (no price↑, no timeframe↑, no donation-goal change). So enabling-on-published is impossible server-side — the studio drawer currently lets you try and eats a 400. Remaining work is UX in /models (disable "enable EA" for published-without-config versions; allow manage-only), not a product decision. (feedback #23)
  • 🟢 B13 — Publish / schedule a version in v1? Recommended default: fast-follow (not v1). (decisions B13)

Blocked — backend / main-app dependency

  • 🚧 A1 Part 2 — owner-keyed modelVersionId→ownerUserId dictionary (Koen / CDC). Unblocks per-model earnings, /analytics model section (generations/downloads/top-models), and the dashboard top-earning-models widget. Ask is written: cdc-koen.md. In-spoke fallback is viable now (Postgres version-ids → IN() over pre-aggregated daily MVs; ~820ms for a 349-version creator, capped for mega-creators) — see "Deferred" below.
  • 🚧 A4 — Sell access indefinitely. Reuse early-access uncapped; needs the main-app representation (nullable timeframe/indefinite flag). Write path already exists (the B12 endpoint). (decisions A4)
  • 🚧 licenseFee type is '27' (ingest bug, Justin owns). We filter type IN ('licenseFee','27') until the MV fix + backfill lands, then drop '27'. (owner-rollup-handoff §🔴)
  • 🚧 B4 — owner-keyed daily SummingMergeTree MVs (perf, not correctness). The content-analytics 90-day reads scan raw event tables (cached, but heavy). Backend MVs would remove the raw-scan load. Not blocking any feature. (feedback #10)

Flagged bugs — upstream (shape the numbers, not ours to fix)

  • 🐛 Access sales always credit yellow — confirmed bug; fix is forward-only, historical rows stay yellow. (owner-rollup-handoff §D1)
  • 🐛 Cosmetic creator payouts are best-effort — a failed payout leaves no row, so /earnings can under-report cosmetic revenue with no signal. (owner-rollup-handoff §payment-path bugs)
  • 🐛 ClickHouse buzzTransactions mirror gap — CH showed a pending-cash balance the buzz service didn't; flag to whoever owns the buzz→CH sync. Doesn't affect the studio (cash reads go to the buzz service).

Deferred / unblocked builds (buildable when prioritized)

  • 🚧 Pricing templates — creator-authored fee + paid-access defaults targeting model type × base model/ecosystem, pre-filling the main-app version form. Plan: pricing-templates.md. Resolves #17; needs no product decision (see the B9 note there). A per-type localStorage stopgap already ships in the main app and is retired by phase 4 of that plan. Blocked twice over: on paid-access unification (templates store a gate, and its shape is changing), and on a deferred decision about the targeting axis — Justin proposed creator-named price tags instead of model-type × base-model targeting, and that review waits on the DonationGoal keep-or-drop question. See Price tags.
  • 🟢 Paid access: discounts + a free-unlock pledge — reconcile early access and permanent paid access into one system, with a scheduled discount ladder and an irrevocable free-unlock pledge. Plan: paid-access-decay.md — structure settled 2026-09-02 (17 questions answered, nothing schema-shaped open), then reopened 2026-09-03 by a creator thread on early-access-to-paid-access switching — Q18 and Q19 were superseded 2026-09-03 by donation-goals.md, which answers the donation-goal half differently: a met goal makes a model free to everyone for 30 days, not permanently, and 30 days is now the only guaranteed free — which invalidates this doc's permanent PaidAccessGuarantee and is tracked as D1. Q20 is still open. Escrow was explored and parked. DonationGoal may be dropped entirely pending Justin's decision, which would remove three of the four rules. Blocks pricing templates until built.
  • ⏭ Model analytics via the in-spoke fallback — Postgres version-ids → IN() over daily_resource_generation_counts / daily_downloads / buzz_resource_compensation, capped by version count, Redis-cached. Ships per-model usage/earnings + top-models for small/moderate creators now; swap the read to the A1 dictionary when it lands (UI/contract unchanged). (Scoped + measured; paused by request.)
  • ⏭ #24 — Bulk "select all matching" + base-model filter (/models) — highest-value remaining bulk-fee build. (feedback #24)
  • #11 — Synchronized crosshair across charts — DONE: added a plugins prop to the @civitai/ui Chart wrapper + a createSyncedCrosshair() plugin (shared hover index across charts sharing a date axis); wired across the /analytics charts.
  • ⏭ #4 — Dashboard charts — low priority, no spec (Justin: "low priority, deferred"; dashboard.md only floats an earnings-summary sparkline, not content). A reactions chart was tried and reverted — wrong metric for an earnings/at-a-glance surface. If revisited, do a buzz-earned sparkline (via getEarningsSeries).
  • Lifetime "total comments received" stat — DONE: getAllTimeTotals surfaces all-time reactions + comments as a context line on /analytics (the one place comments appear — no fast period-scoped source). Comments come from Postgres (Thread.commentCount over the image's thread and its replies); the image_metrics_user rollup it originally read is dead and returned 0 for every creator newer than userId 9.66M. ClickHouse has no correct substitute — see the note on getAllTimeTotals before reaching for the comments table, whose entityId is a comment id, not an image id.
  • ⏭ Access-config drawer URL-addressable (?version= shallow routing) — /models polish.