@micha.bigler/ui-core-micha 2.21.2 → 2.22.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.
@@ -0,0 +1,155 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
5
+ import { ThemeProvider, createTheme } from '@mui/material/styles';
6
+
7
+ // Thread.jsx (scope C) subscribes to the messaging envelope independently of
8
+ // MessagingProvider's own subscription -- a Set of handlers per envelope, not
9
+ // one, so a dispatched frame reaches every current subscriber.
10
+ let frameHandlers;
11
+ function dispatchFrame(frame) { frameHandlers.forEach((handler) => handler(frame)); }
12
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key, i18n: { language: 'en' } }) }));
13
+ vi.mock('../src/notifications/realtime', () => ({
14
+ useRealtime: () => ({
15
+ subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); },
16
+ onReconnect: () => () => {},
17
+ }),
18
+ }));
19
+
20
+ import { AuthContext } from '../src/auth/AuthContext';
21
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
22
+ import { ReadTicks } from '../src/messaging/ReadTicks';
23
+ import { Thread } from '../src/messaging/Thread';
24
+
25
+ const theme = createTheme();
26
+ function withTheme(children) { return <ThemeProvider theme={theme}>{children}</ThemeProvider>; }
27
+
28
+ function makeApi(overrides = {}) {
29
+ return {
30
+ listConversations: vi.fn().mockResolvedValue({ results: [] }),
31
+ listMessages: vi.fn().mockResolvedValue({ results: [] }),
32
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
33
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
34
+ markConversationRead: vi.fn().mockResolvedValue({}),
35
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ beforeEach(() => { frameHandlers = new Set(); });
41
+ afterEach(cleanup);
42
+
43
+ describe('MSG-6f scope B: read ratio vs two-state tick', () => {
44
+ it('4a: a direct conversation renders a two-state tick, and the two states resolve to visibly different colours', async () => {
45
+ const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
46
+ const { rerender } = render(withTheme(
47
+ <MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>
48
+ ));
49
+ const sentIcon = await screen.findByLabelText('MessagingReadTicks.SENT');
50
+ const sentColor = getComputedStyle(sentIcon).color;
51
+
52
+ api.getReadStatus.mockResolvedValue({ all_read: true });
53
+ cleanup();
54
+ render(withTheme(
55
+ <MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>
56
+ ));
57
+ const readIcon = await screen.findByLabelText('MessagingReadTicks.ALL_READ');
58
+ const readColor = getComputedStyle(readIcon).color;
59
+
60
+ expect(sentColor).toBeTruthy();
61
+ expect(readColor).toBeTruthy();
62
+ expect(sentColor).not.toBe(readColor);
63
+ });
64
+
65
+ it('4b: a group WITH counts renders dcm\'s own read_count/recipient_count verbatim, never recomputed from recipientDetail (must fail if recomputed -- recipientDetail is not thresholded against this message\'s created_at, only dcm\'s own counts are)', async () => {
66
+ // Deliberately inconsistent with read_count/recipient_count: every
67
+ // participant has SOME last_read_at (e.g. from reading the conversation
68
+ // long before this message existed), which is exactly the shape that
69
+ // would make a naive "count non-null last_read_at" recomputation report
70
+ // 40/40 -- the bug caught in review. dcm's own read_count (18) is the
71
+ // only correct source; recipientDetail must not be trusted for the ratio.
72
+ const allEverRead = Array.from({ length: 40 }, (_, i) => ({ user_id: `u-${i}`, last_read_at: '2020-01-01T00:00:00Z' }));
73
+ const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 18, recipient_count: 40, recipient_detail: allEverRead }) });
74
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
75
+ const ratio = await screen.findByLabelText(/READ_RATIO/);
76
+ expect(ratio.textContent).toBe('18/40');
77
+ expect(document.body.querySelector('[data-testid="DoneOutlinedIcon"]')).toBeNull();
78
+ expect(document.body.querySelector('[data-testid="DoneAllOutlinedIcon"]')).toBeNull();
79
+ expect(document.body.textContent).not.toMatch(/Zugestellt/);
80
+ });
81
+
82
+ it('4c: a group WITHOUT counts (an ordinary participant) renders no indicator at all', async () => {
83
+ const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
84
+ const { container } = render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
85
+ await waitFor(() => expect(api.getReadStatus).toHaveBeenCalled());
86
+ expect(container.textContent).toBe('');
87
+ expect(container.querySelector('svg')).toBeNull();
88
+ });
89
+
90
+ it('5: all_read still renders the double check with its own unchanged label', async () => {
91
+ const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: true }) });
92
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>);
93
+ const icon = await screen.findByLabelText('MessagingReadTicks.ALL_READ');
94
+ expect(icon.getAttribute('data-testid')).toBe('DoneAllOutlinedIcon');
95
+ });
96
+ });
97
+
98
+ describe('MSG-6f scope C: re-mark read on a live message frame for the open conversation', () => {
99
+ function renderThread(api, { userId = 1 } = {}) {
100
+ return render(
101
+ <AuthContext.Provider value={{ user: { id: userId } }}>
102
+ <MessagingProvider api={api}><Thread conversationId="c1" /></MessagingProvider>
103
+ </AuthContext.Provider>
104
+ );
105
+ }
106
+
107
+ it('marks read again when a message frame arrives for the currently-open conversation', async () => {
108
+ const api = makeApi();
109
+ renderThread(api);
110
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
111
+ api.markConversationRead.mockClear();
112
+
113
+ act(() => dispatchFrame({ type: 'message', conversation_id: 'c1', message: { id: 'm2', conversation_id: 'c1', sender: { id: 2 } } }));
114
+
115
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalled());
116
+ expect(api.markConversationRead.mock.calls[0][0]).toBe('c1');
117
+ });
118
+
119
+ it('does not re-mark for a message frame belonging to a different conversation', async () => {
120
+ const api = makeApi();
121
+ renderThread(api);
122
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
123
+ api.markConversationRead.mockClear();
124
+
125
+ act(() => dispatchFrame({ type: 'message', conversation_id: 'other-conversation', message: { id: 'm2' } }));
126
+
127
+ await new Promise((resolve) => setTimeout(resolve, 50));
128
+ expect(api.markConversationRead).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('does not re-mark while the document is hidden', async () => {
132
+ const api = makeApi();
133
+ renderThread(api);
134
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
135
+ api.markConversationRead.mockClear();
136
+
137
+ const visibilitySpy = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
138
+ act(() => dispatchFrame({ type: 'message', conversation_id: 'c1', message: { id: 'm2' } }));
139
+ await new Promise((resolve) => setTimeout(resolve, 50));
140
+ expect(api.markConversationRead).not.toHaveBeenCalled();
141
+ visibilitySpy.mockRestore();
142
+ });
143
+
144
+ it('does not re-mark for a non-message frame type (e.g. reaction) for the same conversation', async () => {
145
+ const api = makeApi();
146
+ renderThread(api);
147
+ await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
148
+ api.markConversationRead.mockClear();
149
+
150
+ act(() => dispatchFrame({ type: 'reaction', conversation_id: 'c1', message_id: 'm2' }));
151
+
152
+ await new Promise((resolve) => setTimeout(resolve, 50));
153
+ expect(api.markConversationRead).not.toHaveBeenCalled();
154
+ });
155
+ });
@@ -7,14 +7,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
7
7
 
8
8
  vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count !== undefined ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
9
9
 
10
+ // Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
11
+ // independently of MessagingProvider's own subscription -- a Set of handlers
12
+ // per envelope, not one handler per envelope, so a dispatched frame still
13
+ // reaches every current subscriber, matching the real registry's multi-handler support.
10
14
  const realtimeSubscribers = new Map();
11
15
  vi.mock('../src/notifications/realtime', () => ({
12
16
  useRealtime: () => ({
13
- subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
17
+ subscribe: (envelope, handler) => {
18
+ if (!realtimeSubscribers.has(envelope)) realtimeSubscribers.set(envelope, new Set());
19
+ realtimeSubscribers.get(envelope).add(handler);
20
+ return () => realtimeSubscribers.get(envelope)?.delete(handler);
21
+ },
14
22
  onReconnect: () => () => {},
15
23
  }),
16
24
  }));
17
- function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
25
+ function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.forEach((handler) => handler(frame)); }
18
26
 
19
27
  import { ConversationList } from '../src/messaging/ConversationList';
20
28
  import { Thread } from '../src/messaging/Thread';
@@ -19,7 +19,7 @@ function makeApi(messages = []) {
19
19
  getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
20
20
  markConversationRead: vi.fn().mockResolvedValue({}),
21
21
  markThreadRead: vi.fn().mockResolvedValue({}),
22
- getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 0 }),
22
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
23
23
  };
24
24
  }
25
25
  function renderBubble(ui) {
@@ -23,9 +23,21 @@ function makeApi(overrides = {}) {
23
23
  afterEach(cleanup);
24
24
 
25
25
  describe('messaging unread lifecycle', () => {
26
- it('marks the selected conversation read', async () => {
26
+ it('marks the selected conversation read once its thread mounts', async () => {
27
+ // MSG-6e made `Thread`'s mount effect the sole owner of marking a
28
+ // conversation read (`ConversationList`'s own onClick no longer does it,
29
+ // to stop the double `POST .../read/` this test used to mask) — so the
30
+ // realistic host wiring (onOpen mounts Thread) is what's under test here,
31
+ // not a click on ConversationList in isolation.
27
32
  const api = makeApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 12, title: 'Support' }] }) });
28
- render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
33
+ function Surface() {
34
+ const [conversationId, setConversationId] = React.useState(null);
35
+ return <>
36
+ <ConversationList onOpen={(conversation) => setConversationId(conversation.id)} />
37
+ {conversationId != null && <Thread conversationId={conversationId} />}
38
+ </>;
39
+ }
40
+ render(<MessagingProvider api={api}><Surface /></MessagingProvider>);
29
41
  fireEvent.click(await screen.findByRole('button', { name: /Support/ }));
30
42
  await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledWith(12, undefined));
31
43
  });
@@ -0,0 +1,308 @@
1
+ # WORK ORDER MSG-6e (ui-core-micha) — messaging surface defects found by click-through
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). Origin: jg-ferien `work-orders/MSG-9.md` findings 3, 7, 9, 10,
11
+ 13b and 14 — all reproduced in a running app on 2026-08-03. Read that file's "Live verification"
12
+ section; do not re-derive the evidence.
13
+
14
+ ## TIER
15
+ Tier 2 — shared-core, consumed by every app. Independent `reviewer` mandatory. `ui_reviewer` requested
16
+ for scope A (visible layout change).
17
+
18
+ ## SCOPE — six independent defects, one release
19
+
20
+ **A. Timestamps: `HH:MM` plus a day separator, and pass the active locale (findings 3 + 9).**
21
+ `MessageBubble.jsx:133` calls `new Date(message.created_at).toLocaleString()` **with no locale
22
+ argument**, so it renders in the runtime's default locale — observed as `8/3/2026, 10:41:29 AM` in a
23
+ German UI. Two changes, and they must land together:
24
+
25
+ 1. Show `HH:MM` on the bubble. The **date** appears once as a separator row when the day changes,
26
+ inserted by `Thread` (it needs the neighbouring message to compare against), **not** by
27
+ `MessageBubble`.
28
+ 2. Pass i18next's active language to every date formatter. `ConversationList` already threads
29
+ `i18n?.language` into `relativeTime` — follow that existing pattern rather than inventing another.
30
+
31
+ Not a one-off: `SupportRecoveryRequestsTab.jsx:368` and `:410` have the same bare `toLocaleString()`.
32
+ Fix them in the same pass; leaving them is how this recurs.
33
+
34
+ **B. A freshly created poll is cached under the wrong id (finding 10).**
35
+ `createConversationPoll` (`MessagingProvider.jsx:401-406`):
36
+
37
+ ```js
38
+ const message = poll?.message || poll; // <- dcm never sends `message`
39
+ if (message?.id != null) patchMessage({ ...message, ..., poll: ... });
40
+ ```
41
+
42
+ dcm's `_poll_response` (`messaging/views.py:32-36`) returns `serialize_poll(poll)` + `voted_option_ids`.
43
+ Keys: `id`, `question`, `allow_multiple`, `closed_at`, `created_by_id`, `options`, `voted_option_ids`.
44
+ **No `message`, no `message_id`.** So `message.id` is the **poll's** id and `patchMessage` writes a
45
+ phantom timeline entry keyed by it, with no `created_at`, no `sender_id`, no `kind`.
46
+
47
+ Observed: a newly created poll renders with **no timestamp**, sender "Unbekannte Person", and sorts to
48
+ the **top** of the timeline (`chronological()` on `new Date(undefined)` → `NaN`), above messages from
49
+ two days earlier. Correct after reload.
50
+
51
+ **The `|| poll` fallback is the actual defect** — it converts a wrong assumption into silent data
52
+ corruption instead of an error. Remove the guess: resolve the message id explicitly from a field dcm
53
+ really sends, or re-fetch the message. **If dcm's response genuinely carries no way to identify the
54
+ message, do not invent one — stop and report**; that would be a dcm contract gap needing its own WO.
55
+ Apply the same scrutiny to `castPollVote`/`closePoll` (`:407+`), which use the same response shape.
56
+
57
+ **C. Handle the read-state frames dcm already sends (finding 7).**
58
+ `applyFrame` (`MessagingProvider.jsx:123ff`) handles only `message`, `message_edited`, `reaction` and
59
+ `poll_updated`. dcm publishes `read_state` (`services.py:293`), `delivered` (`:302`) and
60
+ `thread_read_state` (`:312`) — all silently discarded. Combined with `ReadTicks` fetching its status
61
+ once on mount (`ReadTicks.jsx:15-19`) and never re-fetching, a receipt can **never** update within a
62
+ session.
63
+
64
+ **Ordering, updated 2026-08-03 — the earlier caveat here is resolved.** jg MSG-9 finding 1 is now
65
+ diagnosed as `django-core-micha` MSG-8: `push_to_users` feeds raw `datetime` objects into a msgpack
66
+ channel layer, `group_send` raises, the exception is swallowed as a warning, and the frame is dropped
67
+ (measured 16/16 failures). **C is therefore worth doing and no longer speculative.**
68
+
69
+ Two concrete implications:
70
+
71
+ - `read_state`, `delivered` and `thread_read_state` build their payloads with an explicit
72
+ `.isoformat()`, so **those three frames arrive today** — C is verifiable right now, before MSG-8
73
+ lands.
74
+ - `message` / `message_edited` / `conversation_upsert` frames start arriving only once MSG-8 ships. Do
75
+ not conclude from "no message frame appears" that C is broken; check MSG-8's status first.
76
+
77
+ **D. Stop double-firing `markConversationRead` (finding 13, ucm half).**
78
+ Both `Thread.jsx:55` (a `useEffect` on `conversationId`) and `ConversationList.jsx:77` (the row's
79
+ `onClick`) call it for the same conversation. Measured: `POST /conversations/<id>/read/` fires **twice**
80
+ on every open. Keep exactly one owner. `Thread` is the better one — it fires on every way a
81
+ conversation becomes active, including deep links and programmatic switches, whereas the list click
82
+ covers only one path. Removing the `Thread` effect instead would silently break those paths.
83
+
84
+ **E. Plural forms for the reply counter (finding 14).**
85
+ `MessagingThread.SHOW_REPLIES` is a single string `'{{count}} Antworten anzeigen'`
86
+ (`i18n/messagingTranslations.ts:32`). All three languages are wrong at count 1: "1 Antworten anzeigen",
87
+ "Show 1 replies", "1 réponses". Use i18next plural keys (`_one`/`_other`). **Audit the rest of
88
+ `messagingTranslations.ts` for other `{{count}}` strings without plural forms** and fix them in the same
89
+ pass — including `MessagingReadTicks.DELIVERED` if it survives dcm MSG-7 scope C.
90
+
91
+ ## DEPENDENCY — do not fix findings 2, 5 and 11 here
92
+ Own/incoming alignment, missing read ticks and "Unbekannte Person" all trace to dcm never serializing a
93
+ `sender` object. **ucm's code is already correct for all three** — `MessageBubble.jsx:100/108/109`
94
+ implements the alignment in full, it simply never sees `isOwn === true`. That fix is
95
+ `django-core-micha` MSG-7. **Changing `MessageBubble`'s ownership logic here would be wrong and would
96
+ mask the real defect.** If a change in this WO appears to need it, stop and report.
97
+
98
+ ## NON-GOALS / DO NOT TOUCH
99
+ - No change to `isOwn`, `canShowReadTicks`, `senderName`, or any ownership derivation (see above).
100
+ - No change to `reconcileMessage`'s merge semantics — the optimistic-`sender` survival it produces is
101
+ currently the only thing making own messages render at all; changing it before MSG-7 lands would make
102
+ the surface strictly worse.
103
+ - No change to `useRealtimeCore`. The missing heartbeat is real (jg MSG-9 finding 1) but is its own
104
+ Tier 0 diagnosis with cross-app scope.
105
+ - No new dependencies.
106
+
107
+ ## RISKS
108
+ - **B is the one that can corrupt state.** A wrong id key writes a phantom row into every consumer's
109
+ timeline. Get its test right before the fix.
110
+ - A changes visible layout in every host app — hence `ui_reviewer`.
111
+ - D: removing the wrong caller silently breaks read-marking on deep links. State which one you kept and
112
+ why.
113
+ - E: i18next plural resolution depends on the configured language; a key that works in `de` can still be
114
+ wrong in `fr`. Assert all three.
115
+
116
+ ## REQUIRED TESTS TO WRITE
117
+ Narrow and behavioural. Do NOT run the full suite.
118
+
119
+ 1. **B, the important one:** creating a poll puts exactly **one** new entry in the message cache, keyed
120
+ by the **message** id, carrying `created_at`, and it sorts **last** in a timeline whose other
121
+ messages are older. Assert the poll id is **not** used as a message key.
122
+ 2. A bubble renders `HH:MM` only; a day separator appears exactly once between two messages on different
123
+ days and **not** between two on the same day.
124
+ 3. Date formatting uses the active i18next language — assert a `de` render differs from an `en` render
125
+ for the same timestamp (this fails today, where both produce the runtime default).
126
+ 4. D: opening a conversation issues exactly **one** `markConversationRead` call, via both entry paths
127
+ (list click and direct mount).
128
+ 5. E: `SHOW_REPLIES` at `count: 1` renders the singular in **de, en and fr**.
129
+ 6. C: a `read_state` frame and a `thread_read_state` frame each update the cached receipt `ReadTicks`
130
+ actually renders, without a REST round-trip. No test asserts a `delivered` frame does anything — it
131
+ should not (dcm MSG-7 retires that frame's only publisher in this same run).
132
+
133
+ **Non-vacuity:** each test must fail with its fix reverted. Test 1 in particular must fail against
134
+ today's `|| poll` fallback — if it passes on the unmodified code, it is not testing the defect.
135
+
136
+ ## TEST SCOPE FOR THE GATE (orchestrator)
137
+ The messaging test files plus whatever covers `SupportRecoveryRequestsTab`. Not the full suite.
138
+
139
+ ## OPERATOR DECISIONS (this run)
140
+ - **Scope C — superseded below.** The envelope above was updated after the original defer decision was
141
+ made (finding 1 is now diagnosed as `django-core-micha` MSG-8, not still-open); the instruction that
142
+ follows replaces the earlier "defer all of C" call.
143
+ - **Implement C narrowly: `read_state` and `thread_read_state` only. Do NOT add a `delivered` frame
144
+ handler.** The envelope's update confirms these three frames already carry an explicit `.isoformat()`
145
+ and arrive today, independent of MSG-8 (which only gates `message`/`message_edited`/`conversation_upsert`).
146
+ But `delivered` is the one exception: dcm MSG-7 (this same run, scope C there) **deletes `mark_delivered`**
147
+ — the only production code that ever published a `delivered` frame — as part of retiring delivery
148
+ tracking down to an honest two-state receipt (`all_read` only). Adding a `delivered` handler in
149
+ `applyFrame` here would be live-testable today (against the pre-MSG-7 backend) but dead on arrival the
150
+ moment MSG-7 ships, and contradicts the product decision behind it. In `applyFrame`
151
+ (`MessagingProvider.jsx:123ff`), add `read_state`/`thread_read_state` branches that merge the received
152
+ `last_read_at` (and for `thread_read_state`, the `root_id`) into the relevant cached receipt/message so
153
+ `ReadTicks`/thread-unread state updates without a REST round-trip — do not add a third branch for
154
+ `delivered`. Required test 6 covers `read_state`; add the equivalent for `thread_read_state`, do not add
155
+ one for `delivered`.
156
+ - **Scope B's root cause is now confirmed and fixed at the source, in parallel, by `django-core-micha`
157
+ MSG-7 scope F (operator-approved extension, running concurrently in this session):** dcm's poll REST
158
+ response (`_poll_response` / `serialize_poll`) previously had no `message_id` at all — verified by
159
+ reading dcm's `views.py`/`serializers.py`/`models.py` directly, not assumed. It now returns
160
+ `message_id` (a string, dcm's UUID-as-string convention) on create/vote/close. **Do not re-derive or
161
+ re-verify dcm's fix — build against the assumption that `poll.message_id` is present** on the response
162
+ object `createConversationPoll`/`castPollVote`/`closeConversationPoll` receive. If your local dcm
163
+ checkout does not yet have `message_id` on that response when you test, that is a sequencing problem to
164
+ report, not a signal to invent a fallback in ucm.
165
+
166
+ ## IMPLEMENTATION MAP (Orchestrator)
167
+
168
+ ### Context package
169
+
170
+ **A — timestamps (`src/messaging/MessageBubble.jsx`, `src/components/SupportRecoveryRequestsTab.jsx`)**
171
+ - `MessageBubble.jsx:133` — `{new Date(message.created_at).toLocaleString()}` inside the `hasMeta` block
172
+ (`:132-136`). Change to render `HH:MM` only, with the active i18next locale, e.g.
173
+ `new Date(message.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })`
174
+ — `useTranslation()` is already destructured as `{ t }` at `:33`; add `i18n` to that destructure.
175
+ - The day-separator is `Thread`'s job (it has the neighbouring message), not `MessageBubble`'s. In
176
+ `Thread.jsx`, the render loop over `roots` (`:139-153`, `roots.map((message) => { ... })`) and the
177
+ nested `replies.map` (`:151`) only ever see one message at a time inside the callback — you need the
178
+ previous rendered root's date to compare against. Track it across iterations (e.g. reduce over `roots`
179
+ building an array of `{ message, showDateSeparator }` before rendering, rather than mutating a ref
180
+ inside `.map`, which fires render-order-dependent and is fragile under React StrictMode's double-invoke).
181
+ Insert a separator row (a `Divider`-adjacent `Typography` with the formatted date, locale-aware, e.g. via
182
+ `toLocaleDateString(i18n.language)`) when the day changes. `chronological()` (`Thread.jsx:10`) already
183
+ guarantees ascending order, so a simple previous-vs-current day comparison suffices — no need to
184
+ re-sort.
185
+ - `ConversationList.jsx`'s `relativeTime(value, locale)` (`:31-36`) is the existing pattern for threading
186
+ `i18n?.language` through a date formatter (called at `:89` with `i18n?.language`) — follow that shape,
187
+ don't invent a second convention.
188
+ - `SupportRecoveryRequestsTab.jsx:368` and `:410` (both bare `new Date(...).toLocaleString()`) — same fix,
189
+ same locale-threading. Check whether that component already has `i18n` from `useTranslation()`; if not,
190
+ destructure it there too.
191
+
192
+ **B — poll cache key (`src/messaging/MessagingProvider.jsx`)**
193
+ - `createConversationPoll` (`:401-406`): replace `const message = poll?.message || poll;` — dcm never
194
+ sends a `message` object, only the flat poll projection now carrying `message_id` (see Operator
195
+ Decisions above). Resolve the message id from `poll.message_id` explicitly; if absent, treat it as a
196
+ real error (don't silently fall back to `poll.id`). Something like:
197
+ ```js
198
+ const messageId = poll?.message_id;
199
+ if (messageId != null) patchMessage({ id: messageId, conversation_id: poll.conversation_id ?? conversationId, created_at: poll.created_at ?? new Date().toISOString(), poll });
200
+ ```
201
+ — note dcm's poll object itself carries no `created_at` either (check `serialize_poll`); you likely need
202
+ to accept that a freshly created poll message has no server `created_at` until the next fetch/frame, OR
203
+ confirm whether the realtime `poll_updated` frame (which does carry a full conversation/message context
204
+ via `_poll_updated_payload`) arrives fast enough in practice to backfill it. Investigate `serialize_poll`'s
205
+ actual returned keys yourself (do not assume) and choose the least-guessy option; state your reasoning.
206
+ - `castPollVote` (`:407-412`) / `closeConversationPoll` (`:413-418`) use `cache.messages[messageId]` where `messageId` is already
207
+ passed in by the caller (not derived from the poll response) — these two are likely already correct
208
+ today (the bug is specific to *creation*, where the id doesn't exist in cache yet). Verify, don't assume;
209
+ the WO's instruction to apply "the same scrutiny" to these means check-first, not change-by-default.
210
+
211
+ **D — double `markConversationRead` (`src/messaging/Thread.jsx`, `src/messaging/ConversationList.jsx`)**
212
+ - `Thread.jsx:56` — `useEffect(() => { if (conversationId != null) markConversationRead(conversationId)...`
213
+ - `ConversationList.jsx:85` — the `ListItemButton`'s `onClick` also calls `markConversationRead(...)`.
214
+ - Keep `Thread.jsx:56`; remove the call from `ConversationList.jsx:85`'s `onClick` (keep `onOpen?.(conversation)`
215
+ itself — only drop the `markConversationRead` call and its `.catch`).
216
+
217
+ **C — wiring gap you must close, not just `applyFrame` (`src/messaging/MessagingProvider.jsx`, `src/messaging/ReadTicks.jsx`)**
218
+ - `EMPTY_CACHE` (`:35`) already declares a `receipts: {}` slot — unused today, no reducer case writes to
219
+ it and nothing reads from it. This is almost certainly the intended home for what `read_state`/
220
+ `thread_read_state` frames should update. Add reducer cases in `applyFrame` (`:123ff`, alongside the
221
+ existing `message`/`reaction`/`poll_updated`/etc. branches) for `frame.type === 'read_state'` and
222
+ `'thread_read_state'` that merge into `state.receipts`, keyed so `ReadTicks` can look itself up (e.g. by
223
+ `conversation_id` + `user_id` for `read_state`, by `root_id` + `user_id` for `thread_read_state` — the
224
+ payload shapes are `{"user_id", "last_read_at"}` and `{"user_id", "root_id", "last_read_at"}`
225
+ respectively, per dcm `services.py:293`/`:312`).
226
+ - **Writing to `cache.receipts` alone does not satisfy required test 6** ("updates the cached receipt
227
+ without a REST round-trip") unless something actually reads it. `ReadTicks.jsx` (`:10-19`) today only
228
+ ever calls `getMessageReadStatus(messageId)` once on mount via `useEffect`, into its own local `status`
229
+ state — it has no path back to `cache` at all. You need `ReadTicks` to react to the relevant slice of
230
+ `cache.receipts` (via `useMessaging()`) so a frame arriving after the initial REST fetch actually updates
231
+ what's rendered — e.g. merge a fresher `cache.receipts` entry over the REST-derived `status` state, or
232
+ derive `all_read` from the participant set + `cache.receipts` entries you have instead of holding a
233
+ second independent read of the same fact. Use your judgment on the exact merge (a full redesign of
234
+ `ReadTicks`' data source is not required — the REST fetch stays as the initial/fallback source of truth,
235
+ e.g. for `recipient_detail`, which no frame carries), but the frame update must be observable in what
236
+ `ReadTicks` renders, not just in an otherwise-unread part of `cache`.
237
+
238
+ **E — plural forms (`src/i18n/messagingTranslations.ts`)**
239
+ - `MessagingThread.SHOW_REPLIES` (`:32`): convert to i18next `_one`/`_other` keys (de/en/fr), all three —
240
+ `Thread.jsx:144` already calls `t('MessagingThread.SHOW_REPLIES', { count: ... })`, i18next resolves the
241
+ plural key automatically once `_one`/`_other` exist; no call-site change needed.
242
+ - Audit found two more `{{count}}` strings with no plural variants: `MessagingComposer.FILES_SELECTED`
243
+ (`:64`) and `MessagingReactions.TOGGLE` (`:72`) — fix both the same way. `MessagingPoll.OPTION` (`:85`,
244
+ `"Option {{count}}"`) is an ordinal label, not a plural noun phrase — it reads correctly at every count
245
+ in all three languages as-is; leave it alone.
246
+ - `MessagingReadTicks.DELIVERED` (`:57`) — **do not add plural forms for it.** Per the WO's own conditional
247
+ ("if it survives dcm MSG-7 scope C"): it does not survive — dcm MSG-7 removes `delivered_count` from
248
+ `read_status` this same run (operator-approved), so `ReadTicks.jsx:21`'s
249
+ `status.delivered_count || 0` will render `0` from now on for a different reason (`undefined` instead of
250
+ an always-NULL field) but with **no change in rendered output** — it already always showed "0" before
251
+ this run. This is a pre-existing, unrelated-to-this-WO UX rough edge (a two-state receipt showing a
252
+ meaningless count); flag it as a follow-up in your final report, do not fix `ReadTicks.jsx` here — that
253
+ is out of this WO's scope A-E.
254
+
255
+ ### Do-not-touch reminders (from the envelope, restated)
256
+ No change to `isOwn`/`canShowReadTicks`/`senderName`/ownership derivation (that's dcm MSG-7's job) or to
257
+ `reconcileMessage`'s merge semantics or `useRealtimeCore`.
258
+
259
+ ### Target repo working directory
260
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`
261
+
262
+ ## TARGET REPO
263
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
264
+ the repo's release flow; **jg's pin bump is a separate step and NOT part of this WO** — jg also needs
265
+ django-core-micha MSG-7, and bumping one without the other leaves the surface half-fixed.
266
+
267
+ ## PROGRESS CONTRACT
268
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
269
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
270
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
271
+
272
+ ## FIX ROUND 1 (Orchestrator, post-review — read this if you are resuming this WO)
273
+
274
+ Your prior chunk (`RESULT: DONE`) is already in the working tree, uncommitted: the orchestrator
275
+ independently confirmed 221/221 tests pass (41 files, up from a 215/40 baseline) and `tsc` is clean. Do
276
+ NOT redo or re-verify what already works. The orchestrator also fixed one pre-existing test itself
277
+ (`tests/messagingUnreadLifecycle.test.jsx`'s "marks the selected conversation read" — it rendered
278
+ `ConversationList` alone with no `Thread`, which your scope D change legitimately broke since `Thread`'s
279
+ mount effect is now sole owner; already corrected and re-verified green, no action needed from you on
280
+ that file). Fix only the following, then re-run your test scope and report `RESULT: DONE` again.
281
+
282
+ **[P1, blocking — confirmed by the independent `reviewer`] Scope C's fix does not actually work for
283
+ direct (1:1) conversations — the single most common case.** `ReadTicks.jsx`'s merge of `cache.receipts`
284
+ over the REST-fetched `status` only takes effect through `recipientDetail` (derived from
285
+ `status.recipient_detail`). But per this repo's own established server contract
286
+ (`work-orders/MSG-3f.md:267`: `recipient_detail` is only present for non-direct conversations — dcm's
287
+ `read_status` never returns it for a `kind: 'direct'` conversation, by design, per the DM-privacy
288
+ carve-out). So for a DM: `applyFrame` correctly writes the incoming `read_state` frame into
289
+ `cache.receipts`, but `ReadTicks` never looks at `cache.receipts` for that case — `recipientDetail` stays
290
+ `undefined`, and `allRead` falls back to the stale `status.all_read` from the one-time REST fetch. The
291
+ defect this scope exists to fix ("a receipt can never update within a session", WO finding 7) is therefore
292
+ still present for every 1:1 conversation. Your own new test (`tests/messagingMsg6.test.jsx`, the
293
+ `read_state` test) only exercises `conversation: { kind: 'group' }` — write a DM-scoped equivalent that
294
+ would have caught this.
295
+
296
+ Fix: for a direct conversation, derive the receipt from `cache.receipts` keyed by the conversation's
297
+ counterpart, not from `recipient_detail` (which will never be populated for DMs — do not wait for it).
298
+ `conversation.other_user_id` (dcm's bare-id field for a direct conversation's counterpart, added in a
299
+ prior WO) is what you need: look up `cache.receipts[\`${conversation.id}:${conversation.other_user_id}\`]`
300
+ directly when `conversation?.kind === 'direct'`, and treat its `last_read_at` presence as `all_read` for
301
+ that case (a DM has exactly one other participant, so "all read" and "counterpart has read" are the same
302
+ fact — no `.every()` needed, unlike the group/managed case). Keep the existing `recipient_detail`-based
303
+ path for non-direct conversations unchanged. Add a test mirroring the existing group-conversation
304
+ `read_state` test but with `conversation: { id: 'c1', kind: 'direct', other_user_id: 'u2' }`, asserting the
305
+ label flips from `DELIVERED` to `ALL_READ` after the frame arrives, with no additional REST call.
306
+
307
+ Re-run your test scope (the messaging test files plus whatever covers `SupportRecoveryRequestsTab`) after
308
+ this change. Do not touch anything outside `ReadTicks.jsx` and the one new/extended test.