Page MenuHomePhabricator

Make the "Translate interface messages using Translatewiki" task repeatable
Closed, ResolvedPublic4 Estimated Story Points

Description

Background

translate-interface-messages is currently a one-shot essential task:

  • The completion target is a hardcoded TARGET = 10 in client/src/components/translatewiki/TwnStepTranslate.vue:74.
  • Progress is the user's count of core-group messages in the wiki's language where they are the last translator, read live from the Translatewiki.net API (list=messagecollection, mcgroup=core, mclanguage=<wiki code>, mcfilter=last-translator:<twn user id>) — a client-side call from step 2 only.
  • The Translatewiki account is linked by OAuth (TwnStepConnect.vue popup → /api/twn/connect → identify), but the resulting identity is stored only in the session as req.session.twnUser (server/routes/twn.js:69). It disappears when the session does, and nothing outside step 2 can see it.
  • "Mark as done" is enabled once translationCount >= 10 and writes a single completion via POST /tasks/complete.
  • The registry entry (client/src/registry/essential-tasks.js:104) has neither repeatable nor completionScope, and the task key is absent from PER_USER_TASKS (server/routes/tasks.js:25).

Two consequences: a user who already had ≥10 translations before ever opening StarterKit is credited for work StarterKit had nothing to do with, and a user who finishes has no way to take the task on again.

This is planned as the last of the essential tasks to be made repeatable, after install-gadget (merged, f8aa9ff) and create-essential-article (on feat/essential-tasks-repeatable).

Proposed approach

When the user links their Translatewiki account we store two things: the account (name + id) and their baseline — the number of core messages they have already translated in the wiki's language at that moment. Everything else is derived from the live count.

baseline = core-message count for this language at link time (stored once)
count    = live core-message count for this language

// Which milestones the count has crossed for this link — the candidates for
// crediting. Equivalently: floor(count/10) - floor(baseline/10) of them.
crossed  = every multiple of 10  m  where  baseline < m <= count

// What the UI shows never comes from the live count directly:
runs       = task_completions.run_count      // ledger-backed, monotonic, all accounts
done       = runs >= 1
nextTarget = (max(floor(count / 10), highestCredited / 10) + 1) * 10
             // highestCredited = highest milestone in the ledger for the
             // CURRENTLY linked account (0 if it has none yet)

Targets are absolute counts rounded up to the next decade, and the baseline is what stops StarterKit from claiming pre-existing work.

The split matters: the live count decides which milestones become eligible for credit, but displayed done-ness and run count come from the ledger-backed run_count, which only ever goes up. A count that dips does not un-complete anything (see Crediting runs), and nextTarget is the next uncredited decade — never one the user has already been paid for.

Baseline at linkCurrent countNew translationsRuns creditedNext target
000010
01010120
16160020
16204130
45450050
45505160
457227380

The original worked example holds: a user who links at 16 translations is not done, and their first target is 20 — four new translations. When they reach 20 they have one run; the next target is 30.

IMPORTANT: The baseline is written once, at link time, and never refreshed while the link stands. Recomputing it from the current count on any later read would silently reset the user's progress to zero every time.

First run is a partial run

Because targets are absolute decades, the first run after linking costs however far it is to the next decade — between 1 and 10 new translations. A user linking at 16 needs 4; at 45 they need 5; at 40 or 0 they need the full 10. Every run after the first costs a full 10.

This is a direct consequence of the "round up to the next decade" rule and matches the original example (16 → 20). The alternative — targets at baseline + 10 × n, so every run costs exactly 10 — gives fairer runs but non-round targets (55, 65, 75). Decade rounding is kept: round numbers are the point, and the discount applies only once per link.

Progress display, and what a dropping count costs

Because targets are absolute counts while runs are relative amounts of work, the progress bar must be drawn relative to where the current run started, not as count / nextTarget:

runStart = min(count, highestCredited > 0 ? highestCredited : baseline)
label    = "<count - runStart> of <nextTarget - runStart> messages translated"
// so the bar is empty at the start of every run, and its denominator is the
// real cost of this run: 10 normally, less for a first partial run, more if
// the count has dropped since the last credited milestone.

Drawing it absolutely instead would start every run with a nearly-full bar: a user linking at 45 with a target of 50 would see "45 of 50", 90% complete, before translating anything.

WARNING: A dropping count makes a run cost more than 10. Baseline 0, the user translates 10 and is credited run 1 at milestone 10. Someone else then re-edits 3 of those messages, so the count falls to 7. Run 1 stays credited and the card stays done, but the next target is still 20 — so run 2 now costs 13 new translations, not 10. The runStart = min(...) clamp is what keeps the bar honest here: it reads "0 of 13", not "0 of 10". A relative bar against a hardcoded 10 would show 0/10 while silently requiring 13, and a user could translate 10 messages into a full bar and earn nothing.

This churn tax is inherent to counting current last-translator status rather than translation work done, and it is accepted: someone else overwriting a translation is uncommon, the amount is small, and the runStart clamp means the user always sees the true number rather than a misleading flat 10. Step 2 should add a line of explanation when the count has fallen below where the run started, so the larger target is not a mystery.

Two alternatives were considered and not taken:

  • Targets that follow the count down — reference + 10, with the reference tracking the current count, so every run costs exactly 10. Rejected because targets stop being round decades (17, 27 …), abandoning the rule the whole design is built on.
  • A monotonic source — counting the user's translation edits rather than their current last-translator status would remove the dip entirely, and with it the tax and the un-completion risk. It is available: list=usercontribs&ucuser=<name>&ucnamespace=8 on translatewiki.net returns translation edits, since core messages live at MediaWiki:<key>/<langcode>. Not taken because it changes what the task counts: usercontribs knows nothing about message groups, so mcgroup=core scoping would be lost unless the core group's key list is fetched separately and intersected, and the API returns one row per edit, so repeated edits to the same message need dedup with paging. Worth revisiting as a follow-up if churn turns out to matter in practice.

Persisting the Translatewiki link

The dashboard card has to derive done-ness and run count too, so the session-only twnUser becomes a persisted record holding the account name, the account id, and the baseline.

  • Keyed per user, and per wiki to match task_completions — the count is language-scoped (mclanguage is the wiki's code), so the same Translatewiki account legitimately yields different counts, and therefore different baselines, on different wikis.
  • The link is OAuth-verified, not self-asserted: it is written from the existing /api/twn/callback → identify result, so it cannot be spoofed by typing someone else's username.
  • The baseline is captured in that same callback where possible — the earliest moment the count can be fetched, and the moment the user joins the task. It needs the wiki's language code and the SUL username from the session (wikiUrl is set at wiki-OAuth initiation, auth.js:51; user at its callback, auth.js:120). Both are normally present, since the task is only reachable from an authenticated dashboard, but this app has a known session-desync failure mode — the same one behind null-identity analytics events. So the implementation needs a fallback: if either is missing at the TWN callback, capture the baseline at the first count read in step 2 instead.
  • No link ⇒ the task is not done, 0 runs. This is a rule, not an inference — with no account there is no count and nothing can be derived.
  • If the user links a different Translatewiki account, the stored record is overwritten: new account, new baseline captured at that moment, and the milestone series starts fresh (see below). Runs already credited are kept — work done is work done — so relinking never reduces the run count.

Decision: two StarterKit users may link the same Translatewiki account. There is no uniqueness constraint on the account side of the link. Baselines are captured per user, so neither is credited for work the other did before linking; the cost is that new translations made on a shared account credit both users, mildly inflating completion counts. That case — a shared or handed-over account — is rare enough not to justify blocking it, and a uniqueness constraint can be added later without touching the model.

Crediting runs

Runs are credited through the T433014 machinery: creditVerifiedRun (server/db/verifications.js:148) inserts a ledger row and bumps run_count in one idempotent transaction, tagged with a source that already includes 'status-read' — i.e. "external state proves a run happened, credit it without a click." Here the ledger row is one decade milestone per credited run, keyed (wiki_url, task_key, username, twn_account_id, milestone) where milestone is the absolute count crossed (50, 60, 70 …).

Three reasons this beats deriving runs live on every read:

  1. task_completions.run_count feeds the card's run counter and the per-wiki milestone tiles on /impact (server/db/impact.js:185). A user crossing a decade without clicking anything needs a DB row.
  2. The impact dashboard's "Tasks completed" headline is computed from analytics events, not from task_completions (server/db/impact.js:157-163). No click means no mark-done event, so a purely derived run is invisible to that metric and the card and dashboard disagree.
  3. The count can go down. The last-translator filter means that when someone else edits a message the user translated, the user loses credit for it. Pure derivation would withdraw a run and flip a completed card back to not-done; a ledger row, once written, is never withdrawn.

The milestone key also makes count churn harmless in the other direction: if a user crosses 50, drops to 47, and climbs back to 50, INSERT OR IGNORE on milestone 50 means no second credit.

Relinking: credits are kept

Including the Translatewiki account id in the ledger key is what makes relinking safe — milestones are absolute counts, so they are only meaningful relative to the account that produced them.

Previously credited runs are never lost. run_count lives on task_completions, keyed (wiki_url, task_key, username) with no account id, so it accumulates across every account the user has ever linked and is only ever incremented. A user who earned one run on account A and then links account B still shows done with 1 run; account B simply starts a fresh milestone series from its own baseline. The rationale: the translations were really made, and which account made them is StarterKit's bookkeeping problem, not the user's.

What relinking does reset is the run in progress. Partial progress toward the next target is not carried over, because it was progress against a different account's count — a user 7 messages into a 10-message run on account A who relinks to B starts B's first run from B's baseline. Worth saying in the UI so it does not read as data loss.

Two edges the account-scoped key handles for free:

  • Relinking back to a previously used account. Old ledger rows for that account are still there, so already-earned milestones cannot be re-credited. If the account's count has meanwhile fallen below its highest credited milestone (say A is credited at 50 but now counts 48), the new baseline is 48 while highestCredited is 50, and nextTarget correctly asks for 60 rather than re-offering 50.
  • No credit-farming by account switching. Each account's baseline excludes its own pre-existing translations, so hopping between accounts never converts old work into new runs.

Note for reviewers: watermarks were rejected for T433014 while the title-keyed ledger was accepted. The milestone ledger is the analogue of what was accepted, not of what was rejected.

Caveat to resolve in implementation: the existing task_verifications table is article-shaped (article_title TEXT NOT NULL, UNIQUE(wiki_url, task_key, username, article_title)), so this needs either its own ledger table or a generalised credit-key column rather than a milestone stuffed into article_title.

Worked example: user links with 45 translations already done

  • At link. Baseline 45 stored against the account. floor(45/10) = 4, so runs credited is 0 — the task reads as not started. The ledger is empty; milestones 10, 20, 30 and 40 are never inserted, because credit is only ever written for milestones above the baseline's decade. The 45 pre-existing translations are visible in the UI as context but earn nothing.
  • First target 50 — five new translations. On crossing, ledger row milestone = 50, run_count → 1, card flips to done with 1 run.
  • Then 60, 70, 80 … — ten new translations each, one ledger row and one run apiece.
  • If the count dips to 47 because someone else re-edited a message, run 1 stays credited and the card stays done — displayed runs come from the ledger, not the live count. The next target stays 60, not 50: 50 is already paid for, and offering it again would let the user translate 3 messages into a full progress bar and earn nothing.
  • If they relink to another account at, say, 12 translations, that account's baseline is 12, its first target is 20, and milestone 50 from the old account is untouched. Their run count stays at whatever they had earned.

Scope

Registry and server plumbing:

  • Add repeatable: true and completionScope: 'user' to the translate-interface-messages entry.
  • Add the key to PER_USER_TASKS (server/routes/tasks.js:25). Forgetting this fails silently: the Repeat button renders, "done" reflects other users' completions, and run counts are never populated.

Link and baseline storage:

  • Persist account name, account id and baseline from /api/twn/callback, replacing session-only twnUser as the source of truth (the session copy can stay as a cache). This adds a server-side Translatewiki count call to the callback.
  • Read/expose the record wherever it is needed: /completions for the card, step 1 to show the current link, step 2 for progress.
  • Handle relinking as an overwrite with a fresh baseline.

Completion derivation and crediting:

  • Server-side Translatewiki count fetch in /completions, gated on the user having a link — no link, no external call.
  • Derive runs per the formula and credit newly crossed milestones through the ledger.
  • Crediting is triggered by a /completions read for a linked user, matching T433014's 'status-read' source. A user who translates and never comes back to the dashboard is credited on their next visit, not before. If that lag matters, a sweep can be added later — TWN already emits an open-translate CTA event that could seed one.
  • Register the task in VERIFIABLE_TASKS (server/db/verifications.js:145) so POST /tasks/complete rejects click-based completion for it (the guard at server/routes/tasks.js:221-225 already exists for exactly this reason). Its verify is count-based rather than article-based, so the article-shaped getPendingOpens query does not apply to it as written.
WARNING: Without that registration, a milestone is credited twice: once by the ledger path (creditVerifiedRun → completeTask, verifications.js:130) and again by the client's existing api.completeTask call (TwnStepTranslate.vue:149), which lands on the same completeTask and does run_count = run_count + 1. The invariant to preserve is the one recorded at verifications.js:128 — completeTask only ever fires alongside a new ledger row.

Dashboard card:

EssentialCardSubCard.vue already renders the repeatable affordances — completed styling, a "Repeat task" button that navigates with ?repeat=1, and a ↺ N runs counter — but reads both from server state: isTaskCompleted(props.task.key) (line 89) and getTaskRunCount(props.task.key) (line 92), fed by GET /api/tasks/completions. Nothing on the dashboard can call the Translatewiki API usefully on its own, hence the server-side fetch above. The card should additionally show the linked account name and the translation count behind the runs, so the number is explainable.

This adds one external call per linked user per /completions read. The create-essential-article review raised the same latency concern about the MediaWiki calls on that route; the same mitigation applies.

Repeat session handling in the view:

  • client/src/views/TranslateInterfacePage.vue:49 holds completion in a local const done = ref(false) with no server rehydration — unlike HotCatInstaller.vue:73, which derives it from isTaskCompleted(TASK_KEY). Today a returning completed user is dropped back into step 1. This must be wired to the store before repeat mode means anything, and is the largest client-side piece.
  • Add the ?repeat=1 handling. Since this task is scheduled last, the shared useRepeatSession({ done }) composable from finding 8 of the create-essential-article review should already exist — use it rather than writing a third copy, and make sure it clears ?repeat=1 on settle (finding 6).

Step 2 UI (TwnStepTranslate.vue):

  • One source for the numbers. Step 2 must read count, runStart, nextTarget and runs from our server, not from its own client-side call to translatewiki.net (TwnStepTranslate.vue:92-118). If both sources exist they disagree — the server credits at count N while the client renders N+2 — and the bar can read complete before any run has been credited. The visibilitychange refetch that fires on return from Translatewiki (line 120) should hit our endpoint too.
  • Replace the hardcoded TARGET = 10 with the derived nextTarget.
  • Fix the pre-load default translationCount = ref(10) (line 78), which currently equals the target and therefore leaves "Mark as done" enabled while the count is still loading and when no account is linked.
  • Progress bar and label are drawn relative to runStart, not count / nextTarget — see Progress display above; show the linked account name, the total count, and the baseline so the user can see what is being counted for this run.
  • "Mark as done" becomes UI-only: it advances to the completion screen and writes nothing. The api.completeTask('translate-interface-messages') call at line 149 must be removed — with the task registered in VERIFIABLE_TASKS that endpoint returns 400 and the user would see an error banner on an otherwise successful run.

i18n (hand-edit en.json and qqq.json only — the rest is bot-synced from translatewiki.net):

  • starterkit-twn-step2-progress-label is "$1 of 10 messages translated". The 10 is baked into the English string and into every already-translated version, so an in-place edit would render a wrong number in every other language. Needs a new message key taking the target as $2.
  • New keys for the linked account name and baseline context on the card and in steps 1–2, and for the relink affordance.
  • starterkit-task-twn-intro-description mentions "the top 20" — worth a look for consistency once the target is dynamic.

Migration of existing state

Because the link has only ever lived in sessions, effectively no user has one today, and no baselines exist. Existing task_completions rows for this task should be left alone: the ledger never withdraws credit, so past completers keep their run_count, and the impact dashboard's history is unaffected. Their baseline is captured the next time they link, so their future runs are measured from that point.

One inconsistency is accepted: a past completer who has not relinked shows as not done on the card (no link ⇒ not done) while their historical completion still counts on /impact. The row is not deleted, just not surfaced — relinking restores their run count on the card.

Accepted consequences and known limits

  • The first run after linking is a partial run (1–10 new translations), by design — see above.
  • A run can cost more than 10 when someone else overwrites the user's translations and the count drops. The progress bar shows the true remaining number rather than a flat 10, so the user is never misled, but the extra work is real.
  • Pre-existing translations earn nothing, which is the point of the baseline. They can still be shown as context.
  • mclimit: 500 caps the API read, so the count saturates at 500 and the number of available runs is 50 − floor(baseline/10). A user linking at 480 translations can earn at most 2.
  • Only the core message group is counted; translations in other groups do not move the bar.
  • The count is per wiki language, so baselines and runs are per-wiki — a user active on two wikis has independent progress on each.
  • Each credited run counts as another completed task in the impact dashboard, consistent with how HotCat repeats are already counted (the metric's subtitle already says "re-runs count"). Any change to that belongs to the wider metric discussion on T433014, not here.

Definition of done

  • With no Translatewiki account linked, the task reads as not started with 0 runs, whatever else is in the database.
  • Linking stores the account and the current core-message count as the baseline, and the task still reads as not started however many translations the user already had.
  • A user who links at 45 translations gets a first target of 50, is credited one run on reaching it, and is never credited for the original 45.
  • A user who links at 16 gets a first target of 20 and one run on reaching it; the next target is 30.
  • The card shows done state, run count, the linked account name, and the translation count behind it.
  • "Start again" leads into the step flow with the next target, and previous runs are still shown.
  • Every run starts with an empty progress bar, and the bar's target is the true number of new translations needed — including when the count has dropped below where the run started, and when the first run is a partial one.
  • A credited run is never withdrawn when the translation count drops, re-crossing the same milestone does not credit twice, and the next target after a dip is the next uncredited decade.
  • A crossed milestone increments run_count exactly once, whichever path observes it first, and the "Mark as done" click never writes a run.
  • The count shown in step 2 and the count the server credits against are the same number — the progress bar cannot read complete while no run has been credited.
  • Relinking to a different account stores a new baseline and restarts the milestone series, while previously credited runs remain — the run count never drops.
  • Relinking back to a previously used account cannot re-credit milestones it already earned, and the next target skips them.
  • One user's link, baseline and runs are never visible to, or overwritten by, another user on the same wiki.
  • For a linked user, the run count on the card matches the run_count recorded in task_completions, so the card and /impact cannot drift.

Out of scope

  • Counting message groups other than core.
  • Changing the decade granularity or making the target configurable.
  • Backfilling baselines for historical completions.

Details

Related Changes in GitLab:
TitleReferenceAuthorSource BranchDest Branch
Fix: Key the TWN message scan state per account so relinks cannot leakrepos/lpl/starterkit-v1!171ngkountasfix/twn-scan-state-account-keymain
Feat: Log a server-emitted link_succeeded event with the TWN accountrepos/lpl/starterkit-v1!168ngkountasfeat/twn-link-succeeded-eventmain
Customize query in GitLab

Event Timeline

ngkountas set the point value for this task to 4.

eamedina merged https://gitlab.wikimedia.org/repos/lpl/starterkit-v1/-/merge_requests/154

Feat: Make the Translatewiki task repeatable via baseline + decade milestones (T433428)

ngkountas merged https://gitlab.wikimedia.org/repos/lpl/starterkit-v1/-/merge_requests/168

Feat: Log a server-emitted link_succeeded event with the TWN account

ngkountas opened https://gitlab.wikimedia.org/repos/lpl/starterkit-v1/-/merge_requests/171

Fix: Key the TWN message scan state per account so relinks cannot leak

ngkountas merged https://gitlab.wikimedia.org/repos/lpl/starterkit-v1/-/merge_requests/171

Fix: Key the TWN message scan state per account so relinks cannot leak