== Background ==
`/impact` currently shows two cards: **Key Highlights** (wikis using the kit, contributors onboarded, tasks completed) and **Users onboarded**. "Tasks completed" is a single aggregate — it says how many task runs finished, but not //what got made//. The per-task outputs the kit actually produces (articles created, policy pages translated, infoboxes imported, main pages customised…) are not surfaced anywhere.
== Proposal ==
Add a third card at the **bottom** of `/impact`:
> **Essential work done through the kit**
> The detailed outputs behind the highlights, across all wikis.
(The subtitle originally promised a per-wiki filter; that is split into its own task — see the per-wiki filter section.)
Ten stat tiles in two rows of five, each with a coloured left accent, a large value and a caption — visually the same tile idiom as `KeyHighlights.vue`.
{F96406915 size=full}
== Kit-side sources (seed data + kit-action tiles) ==
Each tile mapped to what the kit itself records. How to read this table under the decided reach scope:
- For the **kit-action tiles (4, 6, 8, 9)** — tiles that count something the kit itself did — this **is** the displayed metric; reach and direct coincide there.
- For the **reach tiles (1, 2, 3, 5, 7)** these are the **seed sets and anchors** their reach aggregation is computed from (twn_links, install events, ledger anchors, imported-template titles) — the displayed numbers are defined in the metric-scope section.
- These same sources, read directly as the tile values, are the **rejected direct-completions alternative** (see that subsection) — so this table doubles as the record of what that alternative would have shown, and its Status column tracks which MR/task unblocks each source.
The values in the mockup are placeholders, **not** targets — see the metric-scope section.
| # | Tile | Proposed source | Status
| --- | --- | --- | ---
| 1 | essential articles added | `task_verifications` rows for `create-essential-article` (distinct `created_title`) | Available **after !152**
| 2 | interface messages translated | T433428's `twn_links` (`last_count − baseline`) and/or its milestone ledger rows | **Unblocked** by T433428 — see below
| 3 | policy pages added | on-wiki: the user STARTED the translation from the kit (a `translate_policy_page` cta_click of **either `open-translation` or `open-cx`** — both, see its section) and the policy page's langlink target was later created by that same user — NOT `.complete` clicks | **T433617 implemented (MR !156)** — once merged this is a plain `task_verifications` read like tiles 1 and 5, no aggregation needed; that task's verifier IS this predicate
| 4 | HotCat gadget installs | `install_gadget.cta_click`, `cta=install-success` (fires only after the `common.js` write succeeds) | Available — an install need not lead to an edit, so this will exceed tile 5
| 5 | articles categorized with HotCat | `task_verifications` rows for `install-gadget` | Available — **but = 3 in prod today**
| 6 | infoboxes imported | `import_infobox.cta_click`, `cta=import-template-success` | Available
| 7 | articles given missing infoboxes | MW API transclusion counts of kit-imported infobox templates | **Defined** — needs the aggregation subsystem, see its section
| 8 | main page customizations | `wizard.publish` with `outcome=success` | Available
| 9 | recruit shares initiated | `community.recruit_share` | Available
| 10 | accounts created via the kit | recruit-link account creations — no accessible attribution channel | **Blocked** — see its section
== Metric scope — the card shows kit-seeded reach (DECIDED) ==
**Decided (see discussion below): the card reports kit-seeded reach.** The /impact page answers "what difference has the kit made on wiki", and reach — work causally downstream of the kit — is that difference. Direct task completions remain valuable as *operational* metrics (completion rates, drop-off, task improvement) and belong to the task-level analytics of {T429291}, not to this card. Every caption must still say what its number is ("seeded by the kit", "created by users the kit activated") — the scope is decided, honest labeling is not optional.
**Kit-seeded reach, defined:** work causally downstream of the kit even when the kit did not observe it happen, in two flavors —**infrastructure reach** (activity using something the kit installed: categories added with a kit-installed HotCat, articles transcluding kit-imported infobox templates) and **user reach** (activity by people the kit activated: categories added by users whose first HotCat use came via the kit, articles created by first-time creators the kit onboarded). Note the boundary: reach still counts only kit-connected people and kit-installed infrastructure — never a wiki's unrelated activity.
Per-tile reach definitions and their feasibility (the kit-side-sources table above keeps the direct sources: they are the seed sets and anchors the reach metrics are computed FROM, and tiles that count kit actions themselves — installs, imports, publishes — are identical under either scope):
| # | Tile | Reach metric | Feasibility
| --- | --- | --- | ---
| 1 | essential articles | mainspace articles created by kit users who were **first-time creators** (no creation before their kit anchor), plus CX-published articles by **first-time translators** (no `contenttranslation`-tagged contribution before the anchor) | good — the was-first-time predicate is one bounded `usercontribs` call per user (`ucshow=new` / `uctag=contenttranslation`, `ucend=anchor`, limit 1) and is a historical fact, cacheable forever; the ongoing count reuses `getUserNewPages` / the same tag query
| 2 | messages translated | translation edits made on translatewiki.net by kit-linked accounts **after linking** (all message groups, `usercontribs` on translatewiki.net bounded at `linked_at`) | good — one paged query per linked account, cacheable. The direct metric (`last_count − baseline`) is already activation-anchored, so "same as direct" is a fine fallback; this variant additionally escapes the direct metric's core-group-only scope, its 500-result API cap, and last-translator churn (edit counts are monotonic — someone re-editing a message later doesn't erase the work)
| 3 | policy pages | policy page created by any kit user, even without starting it from the kit (no open-event anchor) | good — same langlink + creator lookup as the direct metric, minus the anchor; see the tile 3 section for both definitions
| 5 | HotCat categorizations | (a) all HotCat edits on wikis where the kit installed it, or (b) HotCat edits by users who first used HotCat via the kit | (b) good — per-user contrib scans for the distinctive HotCat edit summary (the kai audit used exactly this), cacheable; (a) heavy — per-wiki RC scans, no edit tag to filter by
| 7 | infobox articles | articles transcluding kit-imported templates | **already defined this way** — tile 7 IS the reach pattern's template
| 8 | main page | e.g. pageviews of kit-published main pages | possible (public pageviews API) but a different unit — needs discussion
Common properties of every reach metric: external-API aggregation on a schedule with caching (never per dashboard load — same subsystem tile 7 already needs), and a seed set the kit records anyway (install events, twn_links, ledger users, imported-template titles). **Design rule for the aggregation cache: store item rows (wiki, title, timestamp), not counts** — every tile count is derived as COUNT(*) over its items, which is what makes the per-tile outputs drill-down (see the discussion) free and guarantees the headline always equals its list.
**Activation-anchored reach (the unifying rule):** the strong versions of these metrics don't count all work by kit users — they count work by users the kit *activated*: no prior work of that kind before their kit anchor. TWN's baseline already implements this at the account level (pre-link translations never credit); HotCat's user-reach variant is "first used HotCat via the kit"; tile 1's is "first article created / first translation published after meeting the kit". Suggested defaults (each a judgment call to confirm before implementation):
- **Anchor** = the user's first contact with the relevant task (first `.open` event / metadata / ledger row for that task on that wiki), falling back to `contributors.first_seen_at`. Task-contact is the stronger causal claim than mere login.
- **Per wiki**, consistent with contributors, baselines and everything else — a prolific creator on eswiki can still be an activated first-time creator on gnwiki.
- **The predicate is immutable once computed** (a historical fact), so it's evaluated once per (user, wiki) and cached forever; only the ongoing count refreshes on the aggregation schedule.
- Known blind spot: deleted contributions are invisible to `usercontribs`, so a user whose only pre-kit creation was deleted reads as first-time. Rare, biases slightly upward, accepted.
=== The rejected alternative — direct task completions ===
For the record, the dilemma this section used to pose: every tile can also be measured at the **direct** scope — only work the kit itself observed (verification-ledger runs, TWN baseline deltas, kit events). Its appeal was real: every source is a local query (no external-API aggregation subsystem, the card could ship immediately), and the numbers grow with genuine usage from a small base — tile 5 stood at **3** in production (the ledger's verified runs as of 2026-07-29) against a mockup value of 9,956; the mockup's three largest values (34,917 / 9,956 / 5,370) are only reachable at the reach scope.
**Rejected for this card** because it answers the wrong question here: "how many task runs did the kit observe" is an operational measure of the tasks, not of the kit's on-wiki impact — a wiki whose community adopted a kit-installed HotCat, or whose articles transclude kit-imported infoboxes, delivers value the direct scope is structurally blind to. The direct metrics are not discarded: they move to the task analytics of {T429291} (completion rates, drop-off, per-task improvement), and inside this card they survive as the machinery — the ledger predicates, baselines and events are the seed sets and activation anchors every reach metric is computed from.
== Per-wiki filter — not part of this card ({T433618}) ==
**This card does not implement per-wiki filtering.** The mockup subtitle's "filter by language code in the header" is delivered by **{T433618}**, which adds a page-wide wiki filter to the whole `/impact` dashboard; when it lands, this card inherits the filter like every other card on the page (its endpoint accepts the same `wiki` parameter as the rest — worth keeping in mind when writing this card's queries, but nothing here blocks on it).
The card therefore ships with the subtitle:
> The detailed outputs behind the highlights, across all wikis.
== Tile 2 — core-message translations, per-edit (windowed and exact) — DECIDED ==
**Decided source — settled in this task's comments (the windowed preference and the closing tile-2 summary, {T433621#12175151}): a per-edit scan of the linked accounts' translatewiki.net contributions, filtered to the MediaWiki core message group.** This satisfies both constraints from that thread at once — the tile follows the card's `days` selector like every other tile (**windowed**; the ≈10-step granularity caveat that originally made windowing a design question no longer exists on this source), and it counts exactly the kind of work the task asks for (core messages only) — while staying message-exact: each item is an individual core-message translation with its own timestamp, so the window selects single translations. Remaining design surface is presentational only (tile caption, drill-down layout).
Two API ingredients, composed by the aggregation:
1. **The per-account edit scan:** `list=usercontribs` on translatewiki.net per linked account, `ucnamespace=8` (every translation lives at `MediaWiki:<message-key>/<language-code>`), bounded at the account's `linked_at` — pre-link work is excluded by the bound itself. Incremental per scheduled run (continue from the last scanned timestamp).
2. **The core-group membership set:** `list=messagecollection&mcgroup=core`, **paginated via `mcoffset`** — the complete core key list (a few thousand messages, a handful of requests), shared across all users, cached and refreshed on the aggregation schedule. Note this is the same API the task's own counter uses, minus its single-request `mclimit=500` simplification — so the tile escapes the 500-message cap the in-flow read accepts.
An edit counts when its title parses as `MediaWiki:<key>/<lang>` with `<key>` in the cached core set. Items are stored as rows (the card-wide design rule), so the tile count is COUNT(*) in the window, the outputs drill-down lists the messages (each linking to its page/diff on translatewiki.net), and the window filters on the **edit's own timestamp** — when the work happened, the same convention the verification ledger uses.
Definition pins:
- **Language scope — decided: any language.** Every core-message translation by a linked account counts, as long as it happened after linking — the kit activated the translator, and all their core work is reach. Attribution follows the translation, not the link: a hausa translation by a kcg-linked user belongs to hausa. Concretely, each item row stores the **target language code** parsed from the title (`MediaWiki:<key>/<lang>` → `<lang>`), and the per-wiki filter (T433618, when it lands) matches items by the selected wiki's language code — not by the `twn_links` row's `wiki_url`, which remains only the seed that says whose contributions to scan.
- **Unit — decided: distinct messages.** One item per (message key, language) per account — the user's later edits to their own translation don't create new items. The item's timestamp is the **first** post-link edit of that message, so a message counts in exactly one window, windows are additive, and the all-time total equals the sum of the periods.
The seed and anchor come from T433428's `twn_links` (linked account + `linked_at`); its two direct derivations remain as supporting reads, not the tile:
- **Fallback while an account's scan hasn't run yet** (or translatewiki is unreachable): `SELECT COUNT(*) * 10 FROM task_verifications WHERE task_key='translate-interface-messages' AND verified_at >= datetime('now', ?) AND <EXCLUSIONS_SQL>` — windowed but decade-granular (≈10 per credited milestone); label with ≈ if ever shown.
- **Cross-check (all-time only):** `SELECT SUM(MAX(0, last_count - baseline)) FROM twn_links WHERE baseline IS NOT NULL AND last_count IS NOT NULL` (+ exclusions) — message-exact for the core group but a current-state snapshot with no time dimension.
Do NOT use the `mark-done` analytics event's `translations` property — it is frozen at the click and superseded by all of the above.
Scope caveats by source: the scan is core-only **by design** and monotonic (an edit is a historical fact — someone re-editing the message later doesn't erase it); the **last-translator** semantics and the **500-message cap** apply only to the fallback/cross-check derivations above.
== Tile 3 — count published policy pages on-wiki, not Done clicks ==
`.complete` events and `task_completions` rows only exist when the user clicks "Mark as done" — a translator who publishes the policy page via Content Translation and never returns is invisible to them. That is the same false negative the verified tasks eliminated, so the tile must read the wiki, not the click. Both scopes are defined on-wiki:
- **Direct:** the user **started the translation from the kit** — an `open-cx` cta_click for `translate_policy_page` anchors the run — and the policy page's langlink target was created **by that same user, at or after the open** (`resolveLanglinkTarget('Wikipedia:Five pillars', <code>)` then `getPageCreator`; same-user and created-at-or-after-open, i.e. exactly the create-essential-article verifier predicate pointed at a fixed title). No return visit needed; the Done click is irrelevant to the count.
- **Reach (weaker):** the policy page was created by **any kit user**, whether or not they started it from the kit — creator matched against the wiki's contributors, no open-event anchor.
- The task currently covers a single policy page (`Wikipedia:Five pillars`, `tasks.js:79`), so both counts are per-wiki ("wikis whose Five pillars a kit user created…"). If the task grows more policy pages, the same lookups apply per page.
- One langlink + one creator lookup per kit wiki — external API, so it rides the same scheduled cached aggregation as tile 7's transclusion counts. Tiny volume; the open-event anchor is a local query.
- Systematic fix, filed as **T433617**: register `translate-policy-page` in `VERIFIABLE_TASKS` — the direct predicate above IS the verifier that task specifies, so completion itself becomes on-wiki verified and ledger rows feed this tile for free. The tile does not depend on it — the aggregation works against today's schema — but if T433617 lands first, this tile simplifies to a ledger read like tiles 1 and 5.
== Tile 7 — articles transcluding kit-imported infoboxes (DEFINED) ==
**Definition (decided 2026-07-30):** distinct mainspace articles, across all wikis, that transclude an infobox template the kit imported. This is what "articles given missing infoboxes" honestly means — not template imports (tile 6), but the articles that ended up using them.
Derivation:
1. **Which templates count:** the local template pages the import-infobox tool created, recoverable per wiki from the `import-template-success` cta_click events (`{ type, title }` — `title` is the local template page). De-duplicate per (wiki, title).
2. **Count per template:** MediaWiki API `list=embeddedin` (`einamespace=0`) on each wiki, counting distinct pages; union the page sets per wiki before summing so an article with two kit templates counts once.
3. **This is the card's only external-API tile**, and transclusion counts change as wikis edit — so the number must be computed on a schedule and cached (a table or in-memory snapshot refreshed by the existing in-process sweep cadence), never per dashboard load. Politeness-delay the API calls.
Because of the aggregation+caching subsystem this can ship as a fast-follow to the card rather than blocking it: the card renders the tile once the aggregate exists (the acceptance criteria's omit-until-real rule covers the interim).
Scope note: this counts transclusions of kit-imported templates regardless of who added the infobox to the article — it is an infrastructure-reach metric (see the metric-scope section), and the template for how the card's other reach metrics should be built.
== Tile 10 — recruit-link account creations are not attributable (BLOCKED) ==
**Goal:** count accounts created on the wiki through the link the RecruitDialog shares.
**Why it is blocked (analysis 2026-07-30):** the shared link is `<wiki>/wiki/Special:CreateAccount?utm_source=starterkit&utm_medium=<platform>` (`RecruitDialog.vue:130-135`). MediaWiki ignores `utm_*` parameters — they are recorded nowhere a Toolforge tool can read. The mechanism that DOES attribute account creations to a link is the `campaign=` parameter on Special:CreateAccount, which lands in the ServerSideAccountCreation EventLogging stream — **WMF-internal analytics (Data Lake), not exposed by any public API**. The public `list=logevents&letype=newusers` shows account creations with no referrer/campaign data. So there is no automatable way for the tool to know which creations came from the kit's link.
Two paths, recorded for whenever this is picked up:
1. **Internal-analytics route (accurate, not automatable):** add `campaign=starterkit-recruit` to the recruit URL (one-line change, worth doing regardless — it starts accumulating attributable data from the moment it ships), and query ServerSideAccountCreation internally for periodic numbers. Those numbers cannot feed a live tile (the card's no-hardcoded-values rule), but they answer the question for reports.
2. **Kit-redirect proxy (automatable, weaker):** route the shared link through a kit endpoint that logs an analytics event and 302s to Special:CreateAccount. That yields **recruit-link arrivals** (clicks by recruits, a real funnel stage beyond tile 9's shares-initiated) but still not completed account creations.
Until one of these is chosen, the tile is omitted (per the acceptance criteria).
== Acceptance criteria ==
[] New card renders at the bottom of `/impact`, titled "Essential work done through the kit" with the agreed subtitle, matching the mockup's two-row five-tile layout and per-tile accent colours
[] Each shipped tile is backed by a real query — **no hardcoded or mocked values**
[] Every tile honours the existing `days` window and binds `EXCLUSIONS_SQL` + `...ADMIN_USERS`, so test wikis and analytics admins are excluded in production exactly as the other cards do
[] Tiles whose definition is unresolved are **omitted**, not shipped with placeholder numbers — the precedent is already in `KeyHighlights.vue`, which ships three tiles and documents the deferred two in a comment
[] Values use the dashboard's existing number formatting — the mockup's `34.917` dot separators are a locale-formatting question, not something to reproduce literally from the image
[] Card is visible to all `/impact` viewers; no tile exposes usernames (no maintainer-only gating needed unless drill-downs are added)
[] i18n: `en.json` + `qqq.json` only (translated files are bot-synced)
[] Responsive: tiles wrap on narrow viewports rather than overflowing
== Implementation notes ==
- Tile 2 additionally depends on **T433428** (`feat/T433428-twn-repeatable`, stacked on !153) merging — its `twn_links` table and milestone ledger rows are the tile's data source.
- Tile 3 is best sequenced **after T433617** (policy-page verification): its ledger rows make the tile a local read and the bespoke langlink/creator lookup unnecessary. Not a hard dependency — see the tile 3 section.
- **Branch off `feat/T433369-phase5-dashboard` (!152)**, not `main`. !152 already edits `server/db/impact.js` (`getTaskCompletionsDetail`) and `server/db/journey.js`, and tiles 1 and 5 read the `task_verifications` ledger that !152 teaches the dashboards to use. Branching off `main` means resolving that conflict twice.
- Server: one new aggregate in `server/db/impact.js` beside `getTaskCompletionsDetail`, returning all tiles in a single object; one new route in `server/routes/impact.js` following the existing pattern (`clampDays`, `sinceParam`).
- Client: new `client/src/components/impact/EssentialWorkCard.vue` mirroring `KeyHighlights.vue` (accent span + value + caption, `formatValue`, `days` prop, fetch on mount and on `days` change); mounted last in `ImpactDashboard.vue`.
- One endpoint for all ten tiles rather than ten calls.
- Event-derived tiles should reuse the shared definitions in `server/db/journey.js` (`completedTaskForEvent`, `CTA_COMPLETION`) where they apply, so the card cannot drift from what the rest of the dashboard calls "done".
== Open questions (blocking for the affected tiles only) ==
1. ~~**Metric scope**~~ — **RESOLVED: kit-seeded reach** (see the metric-scope section, which also
records the rejected direct-completions alternative and why).
2. **Tile 10 unblocking** — pick a path from its section (internal-analytics via `campaign=`, or the kit-redirect arrivals proxy), or drop the tile. Adding `campaign=starterkit-recruit` to the recruit URL is worth doing immediately either way, so attributable data starts accumulating.