Skip to content

A refused assignee pool is said, not hidden - #95

Merged
Timtam merged 3 commits into
mainfrom
fix/vikunja-members-error
Sep 22, 2026
Merged

Timtam merged 3 commits into
mainfrom
fix/vikunja-members-error

Conversation

@Timtam

@Timtam Timtam commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Decision 130, from Toni's report that the "Assigned to" field was gone from every Vikunja task.

The cause

The assignee pool comes from GET /api/v2/projects/{id}/users/search on a v2 server (since 1d9c49a). That route exists only in v2 and carries a token permission of its own, users search under Projects. Vikunja never adds a permission to an existing token, so a token made before the server ran 2.4 cannot use it, even if everything was ticked back then. Vikunja answers with 401, code 11 ("missing, malformed, expired or otherwise invalid token"). Every other route Aperio calls shares a permission with v1, so everything else worked.

The adapter turned any failure of that read into an empty list, and the editors hide the field for an empty list. So the field disappeared without a word.

Measured against Toni's server (2.5.0) with a probe that prints only status codes and permission names: the old token gets 401 on users/search and 200 on v1 projectusers; a new token with the permission gets 200. The server offers users_search under projects at /routes. Research on 2.5.0 and 2.6.0 (Vikunja's own permission check run in a Go test) found no Vikunja bug and nothing changed in 2.6.0. A new token fixed it for Toni; this PR makes Aperio say it next time.

The change

  • cal_core::ReadRefusal, the reading twin of WriteRefusal: the message starts with a token the surfaces translate. TokenRefused carries the permission the read needs as its detail. It cannot tell a missing permission from an expired token, because Vikunja answers both alike, so the sentence says both.
  • Vikunja adapter: a 401 on the pool becomes token-refused: users search (projects) (v1: projectusers (projects)), mapped to Forbidden. Any other failure, including the version probe, is returned as the error it is. Both log a warn! with the path.
  • shared/assigneePool.ts: assigneeField(mode, pool) decides once what the field shows. It is hidden only where the list cannot hold assignees; otherwise it is loading, failed, empty, or the picker. assigneePoolErrorMessage names the missing permission, or says the read failed with what the host said. eventWriteError.ts now exports its two error-reading helpers (codedError, errorMessageText) for it.
  • Desktop TaskDialog: keeps where the read stands. A failure shows its sentence, tied by aria-describedby to a Try again button; loading and "nobody on this list" are FocusableNotes.
  • Phone TaskEditorModal: the same states and retry. It now reads the pool and "me" apart, as the desktop does; "me" failing used to take the pool down with it. The iOS and Android modules wrap taskListMembersJson in eventCoded, so the code and message reach JS instead of a native description.
  • Docs: the tutorial (en and de) says to give a Vikunja token full access and why; the adapter docs describe the per-route permissions and the refusal; DESIGN.md names the v2 route. The phone editor's header comment no longer calls assignees desktop-only.

Both hosts already passed the adapter's error on; only the adapter and the two editors swallowed it.

Checks

  • cargo test --workspace --all-features: 2827 passed; fmt, clippy -D warnings, adapter-vikunja alone clean; cargo xtask ts-types --check current (new ReadRefusal.ts).
  • Desktop and mobile tsc, eslint clean; vitest 2164 passed, locally and under TZ=UTC.
  • New tests: the adapter (a 401 names the permission; a 500 is an error, not an empty pool; the refusal maps to Forbidden), ReadRefusal (token equals the serialized name), the shared rule and sentences, and the desktop editor (the refusal's own sentence tied to the retry button; a retry reads again and shows the picker; another failure is said; nobody to assign is said; no field on a local list).
  • Red proofs, 7 of 7 red, tree restored byte for byte: the adapter swallowing every failure; a 401 not named as a refusal; the refusal mapped to a protocol error; the editor turning a failure into an empty pool; a failed read hiding the field; the token not read (this one first stayed green, because the general sentence also contains the permission — the test now pins the refusal's own sentence); the retry button not carrying the failure.
  • Not covered by a test: the phone editor and the native modules (no runner).

Review round

One adversarial round (2 lenses, every finding verified): 13 confirmed, none refuted.

  • High: the phone never found the refusal. Expo wraps every error a native module throws ("Call to function 'CalFfi.…' has been rejected.
    → Caused by: …" on Android; "Calling the '…' function has failed
    → Caused by: …" on iOS, where the code is dropped too), and the refusal token was looked for at the start. errorMessageText now keeps what follows the last cause marker, so ReadRefusal and the existing WriteRefusal both work on the phone (tests with both shapes).
  • Focus: "Try again" unmounted when pressed, and the loading note when the people arrived; focus fell to the page. Desktop: while focus is in the field, a layout effect puts it on the new state's element; the field is role="group" labelled "Assigned to", and the picker's selects carry that label (labelledBy). Phone: focus goes to the field's label, which stays while loading, and the outcome is announced.
  • "Nobody on this list" must be true: Todoist turned every collaborator failure into an empty list; now only its 403 (a project that cannot be shared) is empty, anything else an error. Both hosts report an unroutable list (not_found) instead of an empty pool.
  • Wording: the refusal sentence no longer states Vikunja's token model as fact (the core only knows "lacks the permission or no longer valid") and says "API token" like the account form; the phone's label is a plain label, like the picker's.
  • Deferred (TODO.md, B9): on the phone only forbidden, conflict and network carry their code to JS; the other StoreError variants reach the fallback sentence as the native exception's name. That needs the Swift and Kotlin modules, which only a phone build can check.

Checks for the round: 2828 Rust tests, fmt, clippy, adapter-todoist and cal-ffi alone clean; desktop and mobile tsc, eslint clean; vitest 2166 locally and under TZ=UTC. 6 more red proofs, all red: Expo's wrapper left in front (assignee and write tests); the fallback detail keeping it; Todoist swallowing every failure; focus not put back; the picker's select without its field's name.

Second review (of the review round)

Two lenses: 5 confirmed, 1 refuted, all about the new focus handling, fixed here.

  • Medium: the desktop repark took focus from a live element. A blur to nowhere (a click on the dialog's text) left the flag set, and the next list change pulled focus off the list select, where arrow keys then assigned a person. It now only recovers focus that fell out (activeElement is the body). Regression test added; red with the old check.
  • Medium, phone: a retry that brought the people left the cursor on an unmounted label. AssigneePicker takes a labelRef, and focus goes to the picker's label.
  • Low: the repark prefers the labelled select over a chip's Remove button; the group is named only while the picker is absent (NVDA said the label twice); on the phone the label is focused before the loading sentence and both sentences are queued.

Checks: desktop and mobile tsc, eslint clean; vitest 2167 locally and under TZ=UTC.

Not in this PR

  • Vikunja lists the caller's own bot users in the pool; filtering them is a separate choice.
  • Toni wants to move Aperio fully to v2 and drop v1 (servers before 2.4): a separate decision.

🤖 Generated with Claude Code

Timtam and others added 3 commits September 22, 2026 11:48
Toni's "Assigned to" field vanished from every Vikunja task. The pool is
read from GET /api/v2/projects/{id}/users/search, a v2-only route with a
token permission of its own ("users search" under Projects). Vikunja
never adds a permission to an existing token, so his token, made before
his server ran 2.4, got a 401 there, and the adapter turned every
failure of that read into an empty list; an empty list hid the field.
A probe against his server showed it: the old token 401, a new one 200.

Decision 130:
- cal_core::ReadRefusal, the reading twin of WriteRefusal: a message
  that starts with a token the surfaces translate. TokenRefused carries
  the permission the read needs, since Vikunja answers a missing
  permission and an expired token with the same 401.
- The Vikunja adapter reports a 401 on the pool as that refusal and any
  other failure as the error it is, with a warn! naming the path.
- shared/assigneePool.ts: assigneeField(mode, pool) decides once what
  the field shows (hidden only where the list cannot hold assignees;
  otherwise loading, failed, empty or the picker), and
  assigneePoolErrorMessage names the missing permission.
- Both editors keep where the read stands and offer a retry; the phone
  now reads the pool and "me" apart, as the desktop does, and its native
  modules keep the error's code and message for this call.
- The tutorial and the adapter docs say which token permission Aperio
  needs; DESIGN.md names the v2 route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The phone never found the refusal: Expo puts its own sentence in front
  of every error a native module throws ("Call to function ... has been
  rejected. -> Caused by: ..."), and on iOS drops the code. The shared
  errorMessageText now keeps only what follows the last cause marker,
  so ReadRefusal and WriteRefusal both work on the phone.
- "Try again" unmounted itself when pressed, and the loading note did
  when the people arrived; focus fell to the page. On the desktop the
  field now puts focus back on its new state's element while focus is
  in it; the field is a group labelled "Assigned to", and the picker's
  selects carry that label. On the phone focus goes to the field's
  label and the outcome is announced.
- "Nobody on this list can be assigned" is a claim: Todoist now answers
  an empty pool only for its 403 (a project that cannot be shared) and
  reports every other failure; both hosts report a list no account
  routes instead of answering nobody.
- The refusal sentence says only what the core knows and uses the
  account form's word, "API token"; the phone's field label is a plain
  label like every other.

Recorded as open in TODO.md: on the phone only forbidden, conflict and
network carry their code to JS; the rest needs the native modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The desktop's repark took focus from a live element: a blur to nowhere
  (a click on the dialog's text) left the field's flag set, and the next
  list change pulled focus off the list select, where arrow keys then
  assigned a person. It now only recovers focus that fell out.
- It prefers the picker's labelled select over a chip's "Remove".
- The group names itself only while the picker is not there; NVDA said
  "Assigned to" twice on entering the select.
- Phone: when a retry brings the people, focus goes to the picker's own
  label (AssigneePicker labelRef); the label is focused before the
  loading sentence, and both sentences are queued, so neither cuts the
  other off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Timtam
Timtam merged commit 15278aa into main Sep 22, 2026
13 checks passed
@Timtam
Timtam deleted the fix/vikunja-members-error branch September 22, 2026 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant