@micha.bigler/ui-core-micha 2.21.3 → 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +3 -0
- package/dist/i18n/messagingTranslations.js +3 -3
- package/dist/messaging/ConversationList.js +8 -1
- package/dist/messaging/MessagingProvider.js +63 -5
- package/dist/messaging/PollCard.js +83 -23
- package/dist/messaging/ReadTicks.js +52 -10
- package/dist/messaging/Thread.js +29 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +3 -3
- package/src/messaging/ConversationList.jsx +8 -1
- package/src/messaging/MessagingProvider.jsx +60 -5
- package/src/messaging/PollCard.jsx +103 -19
- package/src/messaging/ReadTicks.jsx +55 -8
- package/src/messaging/Thread.jsx +24 -1
- package/tests/ConversationList.test.jsx +52 -0
- package/tests/Thread.test.jsx +9 -8
- package/tests/messagingChunk6.test.jsx +16 -9
- package/tests/messagingCompactBubble.test.jsx +3 -3
- package/tests/messagingInteractions.test.jsx +10 -3
- package/tests/messagingMsg6.test.jsx +24 -9
- package/tests/messagingMsg6f.test.jsx +155 -0
- package/tests/messagingMsg6g.test.jsx +166 -0
- package/tests/messagingPollRendering.test.jsx +24 -14
- package/tests/messagingThreadReceipts.test.jsx +10 -2
- package/tests/messagingThreadingQuoting.test.jsx +1 -1
- package/work-orders/MSG-6f.md +214 -0
- package/work-orders/MSG-6g.md +244 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# WORK ORDER MSG-6f (ui-core-micha) — the host title resolver is never called for broadcasts, and the read ticks claim a delivery that does not exist
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
|
|
4
|
+
addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
|
|
5
|
+
out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
|
|
6
|
+
via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
7
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
8
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
9
|
+
|
|
10
|
+
Feature-level WO (Expertenchat envelope). Both defects were found by **verifying the landed MSG-6e /
|
|
11
|
+
MSG-7 / MSG-8 / MSG-10 work in a running app** (jg-ferien local, flag on, ucm 2.21.3, dcm 2.39.5,
|
|
12
|
+
2026-08-03). Both are cases of "the other side shipped its half; ucm still renders the old behaviour".
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core, consumed by every app. Independent `reviewer` mandatory. `ui_reviewer` for
|
|
16
|
+
scope B (it changes what a user believes about message delivery).
|
|
17
|
+
|
|
18
|
+
## SCOPE
|
|
19
|
+
|
|
20
|
+
**A. `titleOf` never calls the host resolver for a broadcast — jg's fix is dead code.**
|
|
21
|
+
|
|
22
|
+
`ConversationList.jsx` `titleOf()` gates the host callback on kind:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
if (conversation.kind === 'managed' && resolveManagedLabel) {
|
|
26
|
+
const label = resolveManagedLabel(conversation);
|
|
27
|
+
if (label) return label;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
jg-ferien MSG-10 correctly taught its `resolveManagedConversationLabel` to handle
|
|
32
|
+
`kind === 'broadcast'` — but ucm never calls it for that kind, so the branch is unreachable from the
|
|
33
|
+
list and the row still falls through to `t('MessagingList.UNTITLED')`.
|
|
34
|
+
|
|
35
|
+
**Verified live after MSG-10 landed:** the same conversation (`kind: "broadcast"`,
|
|
36
|
+
`external_key: "broadcast"`, `title: null`) renders **"Unbenannte Unterhaltung"** in the list and
|
|
37
|
+
**"Event-Ankündigungen"** in the open thread header — the thread header works only because jg's own
|
|
38
|
+
`resolveConversationTitle` calls the resolver directly, with no kind gate.
|
|
39
|
+
|
|
40
|
+
**The fix: stop gating on kind.** Call `resolveManagedLabel` for **every** conversation and let the
|
|
41
|
+
host decide — a `null` return already means "I have no label", which is exactly the delegation contract,
|
|
42
|
+
and the comment directly above `titleOf` already describes the label as host-supplied. Gating on kind
|
|
43
|
+
inside ucm duplicates a decision that belongs to the host and will break again for the next kind.
|
|
44
|
+
|
|
45
|
+
Check `resolveDirectUserName`'s gate (`kind === 'direct' && other_user_id != null`) in the same pass and
|
|
46
|
+
state whether it has the same problem. Do not change it blindly — a direct conversation is the one kind
|
|
47
|
+
where the id-based lookup is genuinely required.
|
|
48
|
+
|
|
49
|
+
**B. The read ticks assert a delivery that the platform does not track.**
|
|
50
|
+
|
|
51
|
+
Operator report, 2026-08-03: *"die Häkchen geben das Gefühl, dass jede Nachricht sofort zugestellt ist,
|
|
52
|
+
auch wenn das Browserfenster geschlossen ist."* Correct, and it is now strictly worse than when it was
|
|
53
|
+
written:
|
|
54
|
+
|
|
55
|
+
- dcm MSG-7 scope C **removed `delivered_count`** from the `read-status` response — verified live, the
|
|
56
|
+
response now carries only `all_read` and `recipient_detail`.
|
|
57
|
+
- `ReadTicks.jsx` still renders
|
|
58
|
+
`t('MessagingReadTicks.DELIVERED', { count: renderedStatus.delivered_count || 0 })`. With the field
|
|
59
|
+
gone, `undefined || 0` produces the **identical** label as before. Verified live: every own message
|
|
60
|
+
shows `aria-label="Zugestellt an 0"`.
|
|
61
|
+
|
|
62
|
+
So the UI states "Zugestellt an 0" — simultaneously claiming a delivery and reporting that it reached
|
|
63
|
+
nobody — on a single outlined check that reads as WhatsApp's delivered-tick. There is no delivery
|
|
64
|
+
signal behind it at all.
|
|
65
|
+
|
|
66
|
+
**Resolution (operator-directed, 2026-08-03): show a read PROPORTION in group and broadcast, keep a
|
|
67
|
+
two-state tick in DMs.**
|
|
68
|
+
|
|
69
|
+
The operational need in a camp is *"did a relevant share of the group read this?"* — a leader posting
|
|
70
|
+
"bus leaves at 14:00" to forty participants needs a number, not a checkmark. `all_read` is the wrong
|
|
71
|
+
measurement, not a useless feature: in any group it is essentially never true, so a boolean pins the
|
|
72
|
+
indicator to its first state precisely where the information matters most.
|
|
73
|
+
|
|
74
|
+
Operator decision, 2026-08-03:
|
|
75
|
+
|
|
76
|
+
| Conversation kind | Viewer | Display |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `direct` | sender | two-state tick — `DoneOutlinedIcon` **light grey** "Gesendet" → `DoneAllOutlinedIcon` **dark blue** "Gelesen" |
|
|
79
|
+
| `group`, `broadcast`, `managed` | team member | a **ratio**: "18/40" (read / members). Numeric, no icon ladder. |
|
|
80
|
+
| `group`, `broadcast`, `managed` | ordinary participant | **nothing** — render no indicator at all |
|
|
81
|
+
| per-recipient detail | team member | unchanged — already correctly gated (see below) |
|
|
82
|
+
|
|
83
|
+
**The contrast between the two DM states must be clearly stronger than today.** Currently
|
|
84
|
+
`color={status.all_read ? 'primary' : 'inherit'}`, and `inherit` inside the meta `Stack` resolves to
|
|
85
|
+
`text.disabled` — the two states are barely distinguishable. Use an explicit light grey and an explicit
|
|
86
|
+
dark blue rather than `inherit`/`primary`, and verify the difference is obvious at the rendered
|
|
87
|
+
`1rem` icon size in both light and dark theme. The icon shape already differs (`Done` vs `DoneAll`), so
|
|
88
|
+
the distinction does not rest on colour alone — keep it that way.
|
|
89
|
+
|
|
90
|
+
**"nothing" for an ordinary participant in a group is deliberate, not an oversight.** A permanently
|
|
91
|
+
grey tick — which is what `all_read` would produce in any real group — is the "looks stuck" failure this
|
|
92
|
+
scope exists to remove. No indicator is better than a misleading one.
|
|
93
|
+
|
|
94
|
+
**No client-side permission logic.** dcm MSG-9 returns the counts only to viewers holding
|
|
95
|
+
`read_receipt_detail`, so the rule is purely shape-driven: *counts present → ratio; else `direct` →
|
|
96
|
+
tick; else → render nothing*. Do not branch on roles or capabilities in ucm.
|
|
97
|
+
|
|
98
|
+
**The server already has the number and throws it away.** `read_status` (`services.py:344-347`) computes
|
|
99
|
+
`all_read` from exactly the queryset that yields the count:
|
|
100
|
+
`participants.filter(last_read_at__gte=message.created_at).count()` — the same shape as the
|
|
101
|
+
`delivered_count` that had no writer. **A `read_count` + `recipient_count` pair is the counter
|
|
102
|
+
`delivered_count` should have been.**
|
|
103
|
+
|
|
104
|
+
Adding them is a `django-core-micha` change, not ucm's: **this scope is blocked on `django-core-micha`
|
|
105
|
+
MSG-9**, which adds `read_count` and `recipient_count` inside the existing `read_receipt_detail`-gated
|
|
106
|
+
block. Keep `all_read` — the DM branch still uses it.
|
|
107
|
+
|
|
108
|
+
Do not derive the ratio client-side by counting `recipient_detail` yourself. It would happen to work
|
|
109
|
+
(both are gated on the same right), but it re-implements a server aggregate in the client and would
|
|
110
|
+
silently produce a wrong denominator the moment dcm's participant filtering changes. Read the counts
|
|
111
|
+
dcm sends, or render nothing.
|
|
112
|
+
|
|
113
|
+
Retire `MessagingReadTicks.DELIVERED` and its `{{count}}` regardless — the new ratio string is a
|
|
114
|
+
different key with different semantics, and reusing "Zugestellt" would carry the false claim forward.
|
|
115
|
+
Also stop reading the now-always-`null` `last_delivered_at` out of `recipient_detail`.
|
|
116
|
+
|
|
117
|
+
**Per-recipient detail stays as it is.** `services.py:340` already gates it on
|
|
118
|
+
`kind != DIRECT and "read_receipt_detail" in rights` (in jg: `MANAGER_RIGHTS`), and DMs never expose it
|
|
119
|
+
even to moderators — a documented design invariant. This WO does not touch that; the popover keeps
|
|
120
|
+
working for managers.
|
|
121
|
+
|
|
122
|
+
**Why not implement delivery tracking as well** (wiring `mark_delivered` from a client ack, technically
|
|
123
|
+
possible since dcm MSG-8 made WS frames arrive): in a browser app "delivered" can only mean "a tab had
|
|
124
|
+
the socket open at that moment". For a recipient who is simply not online it would sit at the first
|
|
125
|
+
state indefinitely — replacing "looks always delivered" with "looks permanently stuck", plus a write per
|
|
126
|
+
received message. It only becomes meaningful with guaranteed device-level push. A read ratio answers the
|
|
127
|
+
real question without that ambiguity. **If the operator wants delivery too, it is a feature WO.**
|
|
128
|
+
|
|
129
|
+
**C. The read signal misses the most attentive reader — fix this or the ratio understates.**
|
|
130
|
+
|
|
131
|
+
What `read_count` actually measures must be stated plainly, because it is weaker than "read":
|
|
132
|
+
`mark_read` (`dcm services.py:288-294`) sets `ConversationParticipant.last_read_at` to *now*, and after
|
|
133
|
+
MSG-6e scope D there is exactly **one** caller — `Thread.jsx:58`, a `useEffect` keyed on
|
|
134
|
+
`[conversationId, markConversationRead]`. So the recorded fact is **"opened this conversation"**, and
|
|
135
|
+
`read_count` counts participants who opened it at some point after the message was posted.
|
|
136
|
+
|
|
137
|
+
It is not tab-open, not socket-connected, not per-message interaction, not viewport visibility.
|
|
138
|
+
|
|
139
|
+
Two asymmetries follow. The first is acceptable, the second is a defect:
|
|
140
|
+
|
|
141
|
+
1. Opening a conversation marks its **entire** history read in one stroke. Someone who opens it and
|
|
142
|
+
leaves immediately counts as having read a 200-message backlog. This is the universal convention
|
|
143
|
+
(WhatsApp behaves the same) — accept it, do not try to fix it here.
|
|
144
|
+
2. **A participant already sitting in the open conversation when the message arrives is NOT counted.**
|
|
145
|
+
The effect is keyed on `conversationId`, so it does not re-fire when a new message lands. The person
|
|
146
|
+
who saw it in real time is recorded as not having read it, until they navigate away and back.
|
|
147
|
+
|
|
148
|
+
Asymmetry 2 is newly common and newly consequential: before dcm MSG-8 no frame was ever delivered, so
|
|
149
|
+
no thread ever updated live and this case did not arise. Now it is the *normal* case for anyone
|
|
150
|
+
actually paying attention — and it biases the ratio **downward**, which is the wrong direction for a
|
|
151
|
+
leader deciding whether to chase people about the bus time.
|
|
152
|
+
|
|
153
|
+
**Fix: re-mark read when a new message arrives in the conversation currently open.** Trigger on the
|
|
154
|
+
incoming `message` frame for the active conversation, not on a timer. Guard it: do not fire for the
|
|
155
|
+
viewer's **own** send (dcm already excludes the sender from the fan-out, so no frame arrives — verify
|
|
156
|
+
rather than assume), and do not fire when the thread is mounted but the document is hidden
|
|
157
|
+
(`document.visibilityState`), or a background tab would report reads nobody performed. `mark_read` is
|
|
158
|
+
already idempotent — it only writes when the new timestamp is later (`services.py:291`) — so a
|
|
159
|
+
redundant call is cheap, but a *wrong* one is not.
|
|
160
|
+
|
|
161
|
+
**Known cost, do not silently ignore:** `Thread` mounts `ReadTicks` per own message and each instance
|
|
162
|
+
fires its own `read-status` request — O(n) for a sender with many messages in a thread, which is exactly
|
|
163
|
+
the broadcast-heavy leader this feature serves. A batch endpoint (message ids → counts) is the natural
|
|
164
|
+
answer and belongs in the same dcm WO. Do **not** fold the counts into `serialize_message` instead:
|
|
165
|
+
`views.py:50-54` documents why viewer-specific read state must stay out of it — it would leak into the
|
|
166
|
+
`message`/`message_edited` realtime frames, the same trap `thread_last_read_at` was kept out of.
|
|
167
|
+
|
|
168
|
+
## NON-GOALS / DO NOT TOUCH
|
|
169
|
+
- Do not reintroduce `delivered_count` on either side.
|
|
170
|
+
- Do not change `all_read`'s semantics or how `read_state` frames update the cached receipt (that is
|
|
171
|
+
MSG-6e scope C, already landed — leave it working).
|
|
172
|
+
- No change to `isOwn`/`senderName`/`canShowReadTicks`; the `sender` object from dcm 2.39.4 fixed those
|
|
173
|
+
and they are **verified working** (all server-loaded messages now render `own` with a tick).
|
|
174
|
+
- Do not touch jg-ferien. Its `resolveManagedConversationLabel` is already correct and must stay as it
|
|
175
|
+
is — scope A is what makes it reachable.
|
|
176
|
+
|
|
177
|
+
## RISKS
|
|
178
|
+
- **A changes behaviour for every host app**, including any that relies on `resolveManagedLabel` being
|
|
179
|
+
called only for `managed`. A host returning a non-null label for an unexpected kind would newly see
|
|
180
|
+
it win over `conversation.title`. Check the ordering in `titleOf` after the change: a host label
|
|
181
|
+
should still lose to nothing it previously lost to, apart from the intended new case.
|
|
182
|
+
- B changes a user-facing claim. Getting it wrong in the other direction (removing the indicator
|
|
183
|
+
entirely) loses the "did my message go out" affordance — hence two states, not zero.
|
|
184
|
+
- The retired i18n key may be referenced by host apps' tests. Grep before deleting and note any hits.
|
|
185
|
+
|
|
186
|
+
## REQUIRED TESTS TO WRITE
|
|
187
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
188
|
+
|
|
189
|
+
1. **A:** a conversation with `kind: 'broadcast'`, `title: null`, and a host `resolveManagedLabel` that
|
|
190
|
+
returns a string renders **that string**, not `MessagingList.UNTITLED`. This must **fail against
|
|
191
|
+
today's code** — prove it.
|
|
192
|
+
2. **A:** a host resolver returning `null` still falls through to the existing chain unchanged
|
|
193
|
+
(regression guard for the delegation contract).
|
|
194
|
+
3. **A:** the existing `managed` / `event_all` / `event_team` behaviour is unchanged.
|
|
195
|
+
4. **B:** `direct` renders the two-state tick and the two states use visibly different explicit colours (assert the resolved colours differ, not just that a colour prop is set); a `group` WITH counts renders the ratio; a `group` WITHOUT counts renders **no** indicator. Assert no label containing "Zugestellt" is produced anywhere in `ReadTicks`.
|
|
196
|
+
that **no** label containing a `{{count}}`-derived number is produced anywhere in `ReadTicks`.
|
|
197
|
+
5. **B:** `all_read` still renders the double check in `primary` with the unchanged label.
|
|
198
|
+
|
|
199
|
+
**Non-vacuity:** tests 1 and 4 must fail on the current code. Test 1 is the one that matters — jg
|
|
200
|
+
shipped a fix for this exact symptom that did nothing, because the test asserted on the wrong side of
|
|
201
|
+
the boundary. Assert on the **rendered list row**, not on the resolver function in isolation.
|
|
202
|
+
|
|
203
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
204
|
+
`ConversationList` and `ReadTicks` test files plus the messaging i18n tests. Not the full suite.
|
|
205
|
+
|
|
206
|
+
## TARGET REPO
|
|
207
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
|
|
208
|
+
the repo's release flow. jg's pin bump is a separate step; jg needs it to close its own MSG-9
|
|
209
|
+
finding 4/12, which cannot be fixed from jg at all.
|
|
210
|
+
|
|
211
|
+
## PROGRESS CONTRACT
|
|
212
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
213
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
214
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# WORK ORDER MSG-6g (ui-core-micha) — poll UI: tap-to-vote with result bars, no separate vote button
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
|
|
4
|
+
addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
|
|
5
|
+
out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
|
|
6
|
+
via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
7
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
8
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
9
|
+
|
|
10
|
+
Feature-level WO (Expertenchat envelope). Operator request 2026-08-03: *"bei der Abstimmung wäre es
|
|
11
|
+
schön, wenn es ähnlicher wie WhatsApp mit dem Balken sowie ohne Abstimmungsbutton möglich wäre."*
|
|
12
|
+
|
|
13
|
+
This is a **UX improvement, not a defect** — the current poll works. It is scoped separately so it never
|
|
14
|
+
blocks the messaging defect WOs.
|
|
15
|
+
|
|
16
|
+
## TIER
|
|
17
|
+
Tier 2 — shared-core UI consumed by every app. Independent `reviewer` + `ui_reviewer` mandatory.
|
|
18
|
+
|
|
19
|
+
## CURRENT STATE
|
|
20
|
+
|
|
21
|
+
`PollCard.jsx` renders each option as a MUI `FormControlLabel` with a `Checkbox` (multi-select) or radio,
|
|
22
|
+
labelled `"<text> (<vote_count>)"`, plus a separate **"Abstimmen"** submit button and a
|
|
23
|
+
**"Umfrage schliessen"** action for the owner. Voting is therefore a two-step interaction — select, then
|
|
24
|
+
submit — and the result is a bare number in parentheses.
|
|
25
|
+
|
|
26
|
+
## GOAL
|
|
27
|
+
|
|
28
|
+
Voting is one tap on the option itself, and the distribution is readable at a glance from proportional
|
|
29
|
+
bars rather than parenthesised counts.
|
|
30
|
+
|
|
31
|
+
## SCOPE
|
|
32
|
+
|
|
33
|
+
**A. Tap-to-vote — remove the separate submit button.**
|
|
34
|
+
|
|
35
|
+
- **Single-choice poll** (`allow_multiple: false`): tapping an option casts the vote immediately.
|
|
36
|
+
Tapping a different option moves the vote. The "Abstimmen" button goes away entirely.
|
|
37
|
+
- **Multi-select poll** (`allow_multiple: true`): tapping an option toggles that option immediately —
|
|
38
|
+
each tap is its own vote update, not a batched submit. **Confirm dcm's vote endpoint accepts the full
|
|
39
|
+
option-id set per call** (it takes `option_ids`, so a toggle sends the new complete set, not a delta);
|
|
40
|
+
if it does not, stop and report rather than inventing a delta protocol.
|
|
41
|
+
- Every tap is an optimistic UI update reconciled against the server response, following the pattern
|
|
42
|
+
`MessagingProvider` already uses for sends. **A failed vote must visibly revert** — a silently
|
|
43
|
+
swallowed failure that leaves the bar showing a vote nobody recorded is worse than the current
|
|
44
|
+
two-step flow.
|
|
45
|
+
- Keep "Umfrage schliessen" — that is an owner action, not part of voting.
|
|
46
|
+
|
|
47
|
+
**B. Result bars.**
|
|
48
|
+
|
|
49
|
+
- Each option shows a horizontal proportional bar plus its count and percentage, in place of the
|
|
50
|
+
`(<n>)` suffix.
|
|
51
|
+
- The viewer's own choice(s) are visually marked. `voted_option_ids` already exists for this and is
|
|
52
|
+
carried in the REST vote/create/close response — note the standing constraint documented in
|
|
53
|
+
`MessagingProvider.jsx`: it is **viewer-specific and never present on a `poll_updated` frame**, so
|
|
54
|
+
frame handling must not wipe a locally-known selection. Do not regress that; it was a deliberate fix.
|
|
55
|
+
- Denominator: total votes cast, not participant count. A poll's turnout relative to group size is a
|
|
56
|
+
different question and is **not** in scope.
|
|
57
|
+
- Percentages must not mislead at zero votes — define and implement the zero-vote rendering explicitly
|
|
58
|
+
rather than letting a division produce `NaN`.
|
|
59
|
+
|
|
60
|
+
**C. Closed polls.** A closed poll shows final bars and is not interactive. Verify the closed state is
|
|
61
|
+
visually unmistakable — with the submit button gone, "closed" can no longer be inferred from a missing
|
|
62
|
+
button.
|
|
63
|
+
|
|
64
|
+
## NON-GOALS / DO NOT TOUCH
|
|
65
|
+
- No backend change. If a scope-A requirement appears to need one, stop and report — that is a dcm WO.
|
|
66
|
+
- Do not change `voted_option_ids` handling in `applyFrame` beyond what B needs to read it (see the
|
|
67
|
+
merge-trap note above).
|
|
68
|
+
- Do not add a "who voted for what" disclosure. Per-voter visibility is a privacy decision nobody has
|
|
69
|
+
taken, and the existing `voters` data in `serialize_poll` is not licence to surface it.
|
|
70
|
+
- Do not change poll creation (`Composer`'s dialog) — this WO is the display and voting surface only.
|
|
71
|
+
- Do not touch `ReadTicks`, conversation titles, or anything in the MSG-6f scope.
|
|
72
|
+
|
|
73
|
+
## RISKS
|
|
74
|
+
- **Tap-to-vote removes the "are you sure" step.** On a phone a mis-tap now votes. Mitigate by making
|
|
75
|
+
the vote reversible (tap again / tap another) and by ensuring the optimistic state is obviously
|
|
76
|
+
distinct from the confirmed state; do not add a confirmation dialog, which would reinstate the
|
|
77
|
+
two-step flow this WO removes.
|
|
78
|
+
- Rapid successive taps race. The last write must win deterministically — state how (request ordering,
|
|
79
|
+
or discard stale responses by comparing against the latest local intent).
|
|
80
|
+
- Accessibility: an option becomes an interactive control with a name, a pressed/checked state and
|
|
81
|
+
keyboard operability. Bars are decorative; the count and percentage must be in the accessible name or
|
|
82
|
+
adjacent text, not conveyed by width alone. `ui_reviewer` should check this specifically.
|
|
83
|
+
- Every host app inherits the new interaction — a visible behaviour change beyond jg-ferien.
|
|
84
|
+
|
|
85
|
+
## REQUIRED TESTS TO WRITE
|
|
86
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
87
|
+
|
|
88
|
+
1. Single-choice: tapping an option votes without any further interaction; tapping a second option
|
|
89
|
+
moves the vote (the first is no longer selected, the counts reflect it).
|
|
90
|
+
2. Multi-select: tapping toggles one option on and off, and each tap sends the complete option-id set.
|
|
91
|
+
3. A rejected vote reverts the optimistic state and surfaces an error — assert the bar returns to its
|
|
92
|
+
prior value, not merely that an error appeared.
|
|
93
|
+
4. Bars render proportionally, including the **zero-vote** case, with no `NaN` and no division error.
|
|
94
|
+
5. The viewer's own selection survives a `poll_updated` frame that does not carry `voted_option_ids`
|
|
95
|
+
(the documented merge trap — this must not regress).
|
|
96
|
+
6. A closed poll is non-interactive: tapping an option does nothing and issues no request.
|
|
97
|
+
|
|
98
|
+
**Non-vacuity:** test 5 must fail if the merge-trap guard is removed. Test 3 must assert the reverted
|
|
99
|
+
value, not the presence of an error message.
|
|
100
|
+
|
|
101
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
102
|
+
`PollCard` and its neighbours in the messaging test files. Not the full suite.
|
|
103
|
+
|
|
104
|
+
## TARGET REPO
|
|
105
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
|
|
106
|
+
the repo's release flow; the consuming pin bump is a separate step.
|
|
107
|
+
|
|
108
|
+
## MINI-HANDOVER (pastable)
|
|
109
|
+
|
|
110
|
+
> Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`, or `develop` if present).
|
|
111
|
+
> Work order: `work-orders/MSG-6g.md` — read it fully, then follow the `orchestrate-codex` skill.
|
|
112
|
+
>
|
|
113
|
+
> **UX improvement, not a defect — this blocks nothing and is blocked by nothing.** Do not let it hold
|
|
114
|
+
> up the messaging defect WOs (dcm MSG-10, ucm MSG-6f).
|
|
115
|
+
>
|
|
116
|
+
> Frontend only: no backend change is in scope. If tap-to-vote appears to need one, stop and report.
|
|
117
|
+
> Two things that are easy to break and are called out in the WO: a failed vote must visibly revert
|
|
118
|
+
> (not fail silently), and the viewer's `voted_option_ids` must survive a `poll_updated` frame that
|
|
119
|
+
> does not carry it — that merge-trap guard was a deliberate earlier fix and test 5 pins it.
|
|
120
|
+
|
|
121
|
+
## PROGRESS CONTRACT
|
|
122
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
123
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
124
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Part B — Implementation map (Orchestrator) / as-built record
|
|
129
|
+
|
|
130
|
+
**Codex was unavailable (out of credits) — implemented directly by the Orchestrator.**
|
|
131
|
+
|
|
132
|
+
### Blocker found and resolved before implementation
|
|
133
|
+
|
|
134
|
+
Line 38-40's own contingency ("confirm dcm's vote endpoint accepts the full option-id set
|
|
135
|
+
per call... if it does not, stop and report") fired for real: `vote_poll` (dcm
|
|
136
|
+
`services.py:247-259`) only cleared prior votes for single-choice polls; multi-select only
|
|
137
|
+
ever `bulk_create`d, so a smaller `option_ids` set never retracted an omitted vote —
|
|
138
|
+
untoggling was silently a no-op. Reported to the operator, who confirmed fixing it
|
|
139
|
+
directly in dcm rather than working around it client-side. Fixed and published as dcm
|
|
140
|
+
`2.40.1` — see dcm `work-orders/MSG-11.md`. `option_ids` is now authoritative (delete
|
|
141
|
+
anything no longer in the set, then re-create the requested set) for both poll kinds.
|
|
142
|
+
|
|
143
|
+
### Files changed
|
|
144
|
+
|
|
145
|
+
- `src/messaging/MessagingProvider.jsx`:
|
|
146
|
+
- New module-level `applyOptimisticVote(poll, previousSelectedIds, nextSelectedIds, userId)`
|
|
147
|
+
— adjusts only the options whose membership in the vote actually changed (count +/-1,
|
|
148
|
+
`voters` array +/- the acting user), leaves everything else untouched.
|
|
149
|
+
- New `pollVoteTokensRef` (per-poll "latest tap wins" token) alongside the existing
|
|
150
|
+
`activeIdRef`/`seenEventsRef`/`filtersRef`.
|
|
151
|
+
- `castPollVote(messageId, poll, optionIds)` rewritten: captures `previousPoll` from
|
|
152
|
+
`cache.messages[messageId].poll` (falling back to the `poll` param), patches the
|
|
153
|
+
optimistic projection into the cache immediately (same `patchMessage` shape
|
|
154
|
+
`toggleReaction` already uses), then on success reconciles with the server's poll, on
|
|
155
|
+
failure reverts to `previousPoll` — both guarded by the token so a stale response from
|
|
156
|
+
a since-superseded tap can't stomp a newer one.
|
|
157
|
+
- `src/messaging/PollCard.jsx`: full rewrite.
|
|
158
|
+
- Single-choice (`allow_multiple: false`) → `Radio` per option (native "tap a different
|
|
159
|
+
option moves the vote, tap the same one is a no-op" semantics — matches the WO's
|
|
160
|
+
stated design directly, no extra toggle-off logic needed).
|
|
161
|
+
- Multi-select → `Checkbox` per option (existing toggle semantics preserved).
|
|
162
|
+
- Whole-option-row tap target (`Box onClick`, `stopPropagation` on the control's own
|
|
163
|
+
`onClick` to avoid a double-fire) — WhatsApp-style, matches the operator's own framing.
|
|
164
|
+
- `LinearProgress` bar + `{{count}} ({{percent}}%)` (`MessagingPoll.OPTION_RESULT`, new
|
|
165
|
+
key) next to each option; `percent = totalVotes > 0 ? round(count/totalVotes*100) : 0`
|
|
166
|
+
— never `NaN`.
|
|
167
|
+
- Accessible name (`aria-label` on the control) carries option text + result text +
|
|
168
|
+
"you voted" — never width-only.
|
|
169
|
+
- Closed state: `Chip` with a lock icon + `MessagingPoll.CLOSED`, replacing the old
|
|
170
|
+
caption-only text (now the *only* closed signal, since the submit button is gone).
|
|
171
|
+
- Local `selected` state + a `voteTokenRef` mirror the provider's own token guard, since
|
|
172
|
+
`PollCard` is a "dumb" prop-driven component (documented in
|
|
173
|
+
`tests/messagingPollRendering.test.jsx`) — a rejected vote must revert visibly even
|
|
174
|
+
when the host never re-passes a fresh `message` prop from its own cache.
|
|
175
|
+
- Removed: the "Abstimmen" submit button and its `SELECT_OPTION` validation entirely.
|
|
176
|
+
- `src/i18n/messagingTranslations.ts`: removed now-orphaned `MessagingPoll.VOTE` and
|
|
177
|
+
`MessagingPoll.SELECT_OPTION`; added `MessagingPoll.OPTION_RESULT`.
|
|
178
|
+
|
|
179
|
+
### Tests
|
|
180
|
+
|
|
181
|
+
- `tests/messagingMsg6g.test.jsx` (new) — the 6 WO-required cases verbatim.
|
|
182
|
+
- `tests/messagingPollRendering.test.jsx` — updated for radio (not checkbox) on
|
|
183
|
+
single-choice, the new label/aria-label shape, and the closed-chip instead of a
|
|
184
|
+
disabled button; its `t` mock upgraded to interpolate `{{count}}`/`{{percent}}` (was
|
|
185
|
+
bare-key-only, insufficient to assert rendered numbers).
|
|
186
|
+
`tests/messagingInteractions.test.jsx`'s poll test rewritten for tap-to-vote (no submit
|
|
187
|
+
click). `tests/messagingCompactBubble.test.jsx`'s poll smoke test needed no change
|
|
188
|
+
(empty `options: []`, no control interaction).
|
|
189
|
+
|
|
190
|
+
### Verification
|
|
191
|
+
Full suite 234 → 240, `tsc` clean (pre-review).
|
|
192
|
+
|
|
193
|
+
### Independent review: `reviewer` + `ui_reviewer`, both mandatory (author = Orchestrator, not Codex)
|
|
194
|
+
|
|
195
|
+
**`reviewer` — 3 findings, all fixed:**
|
|
196
|
+
- **R1 (P1, real bug):** `castPollVote`'s optimistic count projection was built from the
|
|
197
|
+
caller-supplied `poll` argument, not from `previousPoll` (the value already freshly read
|
|
198
|
+
from `cache.messages[messageId].poll` two lines above, and correctly used as the revert
|
|
199
|
+
target on failure). For a second rapid tap (e.g. switching a single-choice vote A→B)
|
|
200
|
+
where the caller's `poll` reference hadn't yet been refreshed — exactly how `PollCard`
|
|
201
|
+
is documented to behave, a "dumb" prop-driven component — the count math computed
|
|
202
|
+
against the stale pre-first-tap baseline, landing a silently wrong transient
|
|
203
|
+
`vote_count` in the **shared cache** (not just local render state) until the server's
|
|
204
|
+
response eventually reconciled it away. Fixed: project from `previousPoll`. Verified
|
|
205
|
+
non-vacuous: reverted the fix, confirmed the new regression test below fails with the
|
|
206
|
+
exact predicted wrong number (4 instead of 5), then restored it.
|
|
207
|
+
- **R2 (P2, coverage gap):** no test asserted vote-count values across a multi-tap
|
|
208
|
+
sequence with non-zero starting counts — the zero-vote fixtures used elsewhere
|
|
209
|
+
happened to mask R1 via the `Math.max(0, ...)` clamp. Closed by a new
|
|
210
|
+
`messagingMsg6g.test.jsx` case using non-zero seed counts (5/2) and a genuinely
|
|
211
|
+
cache-connected reader alongside a *statically-propped* `PollCard` (the scenario that
|
|
212
|
+
actually reproduces the staleness, per R1's own trace — a cache-connected `PollCard`
|
|
213
|
+
re-renders with a fresh `poll` prop between taps and would not have caught this).
|
|
214
|
+
- **R3 (P3):** `applyOptimisticVote` could push a literal `undefined` into an option's
|
|
215
|
+
`voters` array when `user?.id` is momentarily unset (`AuthContext` not yet populated).
|
|
216
|
+
Fixed with a `userId != null` guard.
|
|
217
|
+
|
|
218
|
+
**`ui_reviewer` — 2 findings, both fixed:**
|
|
219
|
+
- **U1 (P2):** no visual distinction between an in-flight optimistic vote and a
|
|
220
|
+
server-confirmed one, contradicting the WO's own risk-mitigation requirement. Fixed by
|
|
221
|
+
reusing `ReactionBar.jsx`'s established `pending` Set convention — dims the tapped
|
|
222
|
+
option's bar/text while its request is in flight, but (unlike `ReactionBar`) does
|
|
223
|
+
**not** disable the control, since the WO explicitly requires rapid re-taps to remain
|
|
224
|
+
possible and race deterministically (last-write-wins via the token guard), not be
|
|
225
|
+
blocked.
|
|
226
|
+
- **U2 (P2):** single-choice rendered bare, ungrouped `Radio` controls — no `RadioGroup`,
|
|
227
|
+
no shared `name`. Diverged from this repo's own established radio idiom
|
|
228
|
+
(`AuthFactorRequirementCard.jsx`'s `RadioGroup` + `FormControlLabel`) and lost native
|
|
229
|
+
arrow-key navigation between options plus the screen-reader group announcement. Fixed
|
|
230
|
+
by wrapping single-choice options in a real `RadioGroup`; multi-select's `Checkbox`
|
|
231
|
+
options are unaffected (no equivalent ARIA grouping concept applies). Switching to
|
|
232
|
+
`FormControlLabel` for both control types also let the manual `Box onClick` +
|
|
233
|
+
`stopPropagation()` whole-row-tap hack be removed entirely — `FormControlLabel`'s own
|
|
234
|
+
`<label>` wrapping already makes the whole row natively clickable.
|
|
235
|
+
- Found and fixed while addressing U2, not itself a reviewer finding: `RadioGroup`'s own
|
|
236
|
+
`onChange` only ever hands back `event.target.value`, a DOM string, which would coerce
|
|
237
|
+
a non-string option id (dcm's are UUID strings today, but the type isn't a guaranteed
|
|
238
|
+
contract) — caught by `tests/messagingInteractions.test.jsx`'s existing numeric-id
|
|
239
|
+
fixture failing after the `RadioGroup` change. Fixed by keeping each `Radio`'s own
|
|
240
|
+
`onChange` (closure-typed, matching the `Checkbox` branch) instead of relying on the
|
|
241
|
+
group-level handler.
|
|
242
|
+
|
|
243
|
+
Re-verified after all fixes: full suite 234 → **241** (7 new: 6 WO-required cases +
|
|
244
|
+
1 R1/R2 regression), `tsc` clean.
|