A refused assignee pool is said, not hidden - #95
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/searchon 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 with401, 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
401onusers/searchand200on v1projectusers; a new token with the permission gets200. The server offersusers_searchunderprojectsat/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 ofWriteRefusal: the message starts with a token the surfaces translate.TokenRefusedcarries 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.401on the pool becomestoken-refused: users search (projects)(v1:projectusers (projects)), mapped toForbidden. Any other failure, including the version probe, is returned as the error it is. Both log awarn!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.assigneePoolErrorMessagenames the missing permission, or says the read failed with what the host said.eventWriteError.tsnow exports its two error-reading helpers (codedError,errorMessageText) for it.TaskDialog: keeps where the read stands. A failure shows its sentence, tied byaria-describedbyto a Try again button; loading and "nobody on this list" areFocusableNotes.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 wraptaskListMembersJsonineventCoded, so the code and message reach JS instead of a native description.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-vikunjaalone clean;cargo xtask ts-types --checkcurrent (newReadRefusal.ts).tsc,eslintclean; vitest 2164 passed, locally and underTZ=UTC.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).Review round
One adversarial round (2 lenses, every finding verified): 13 confirmed, none refuted.
→ 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.
errorMessageTextnow keeps what follows the last cause marker, soReadRefusaland the existingWriteRefusalboth work on the phone (tests with both shapes).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.not_found) instead of an empty pool.forbidden,conflictandnetworkcarry their code to JS; the otherStoreErrorvariants 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-todoistandcal-ffialone clean; desktop and mobiletsc,eslintclean; vitest 2166 locally and underTZ=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.
activeElementis the body). Regression test added; red with the old check.AssigneePickertakes alabelRef, and focus goes to the picker's label.Checks: desktop and mobile
tsc,eslintclean; vitest 2167 locally and underTZ=UTC.Not in this PR
🤖 Generated with Claude Code