@micha.bigler/ui-core-micha 2.12.0 → 2.14.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 +2 -0
- package/dist/i18n/notificationsTranslations.js +2 -0
- package/dist/index.js +2 -0
- package/dist/notifications/NotificationsProvider.js +74 -91
- package/dist/notifications/PopupSurface.js +84 -0
- package/dist/notifications/realtime.js +110 -0
- package/dist/onboarding/OnboardingWizard.js +3 -12
- package/dist/onboarding/WizardDialogShell.js +29 -0
- package/package.json +1 -1
- package/src/i18n/notificationsTranslations.ts +2 -0
- package/src/index.js +2 -0
- package/src/notifications/NotificationsProvider.jsx +88 -96
- package/src/notifications/PopupSurface.jsx +103 -0
- package/src/notifications/realtime.jsx +118 -0
- package/src/onboarding/OnboardingWizard.jsx +9 -23
- package/src/onboarding/WizardDialogShell.jsx +51 -0
- package/tests/NotificationsProvider.test.jsx +39 -0
- package/tests/OnboardingWizard.test.jsx +143 -0
- package/tests/PopupSurface.test.jsx +142 -0
- package/tests/notificationsExports.test.js +5 -0
- package/tests/realtime.test.jsx +192 -0
- package/work-orders/NOTIF-12.md +214 -0
- package/work-orders/NOTIF-13.md +142 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# WORK ORDER NOTIF-12 (ui-core-micha + django-core-micha) — popup channel via the wizard renderer
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** Implement through `codex exec` in the background — invoked **directly via
|
|
4
|
+
Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
5
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
6
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
7
|
+
|
|
8
|
+
Feature-level WO (Expertenchat envelope). Seams verified against ucm 2.12.0 / dcm 2.32.0.
|
|
9
|
+
|
|
10
|
+
## HARD GATE — SATISFIED 2026-07-30
|
|
11
|
+
NOTIF-13 landed and published: ucm **2.13.0** (`7a83ee9`), dcm **2.33.0** (`de77335`), both verified
|
|
12
|
+
live on npm/PyPI. This WO was re-verified against the landed code on 2026-07-30 and the context
|
|
13
|
+
package below describes the **actual shipped API**, not the pre-NOTIF-13 state. Cleared to start.
|
|
14
|
+
|
|
15
|
+
## TIER
|
|
16
|
+
Tier 2 — this extracts a renderer out of `OnboardingWizard`, which **cockpit and jg run in
|
|
17
|
+
production**. The roadmap calls the popup channel "uncritical"; that refers to business urgency, not
|
|
18
|
+
blast radius. Independent `reviewer` mandatory. `sec_reviewer` only on explicit operator request.
|
|
19
|
+
|
|
20
|
+
## SCAFFOLDING NOTICE — READ FIRST
|
|
21
|
+
**This channel ships with zero producers, deliberately.** Verified 2026-07-29 across dcm, jg-ferien,
|
|
22
|
+
cockpit, hram, spesix and survey_app: **no notification type declares `popup` in `eligible_channels`**,
|
|
23
|
+
so `resolve_channels()` will never route anything to it until some app opts a type in. The operator
|
|
24
|
+
decided to build the scaffolding anyway (2026-07-29), having been shown this.
|
|
25
|
+
|
|
26
|
+
Two obligations follow, and they are part of the acceptance criteria:
|
|
27
|
+
1. **Do not overclaim.** The CHANGELOG entry and any docstring must state plainly that the popup
|
|
28
|
+
channel is wired but inactive until a notification type declares `popup`. Precedent: NOTIF-4's
|
|
29
|
+
reviewer (R1) had to soften a CHANGELOG that promised retry behaviour no shipped dispatcher could
|
|
30
|
+
reach. Do not repeat that.
|
|
31
|
+
2. **Scaffolding must be tested scaffolding.** Register a test-local notification type that declares
|
|
32
|
+
`popup` and prove the whole path end-to-end. Untested dead code is not an acceptable outcome.
|
|
33
|
+
|
|
34
|
+
## GOAL
|
|
35
|
+
Make `popup` a real delivery channel: a notification whose type opts into `popup` is rendered to the
|
|
36
|
+
recipient in a modal that reuses the onboarding **wizard renderer**, with its seen/dismissed status
|
|
37
|
+
recorded on `NotificationRecipient` — **not** in the onboarding progress store.
|
|
38
|
+
|
|
39
|
+
## EXPECTED OUTCOME
|
|
40
|
+
- dcm's `PopupDispatcher` stops being a logging stub and delivers for real.
|
|
41
|
+
- ucm exports a popup surface that renders popup-eligible notifications and marks them via the
|
|
42
|
+
existing `feed/mark/` path.
|
|
43
|
+
- Onboarding behaviour is bit-for-bit unchanged for existing consumers.
|
|
44
|
+
- With no producer registered, nothing visibly changes in any app.
|
|
45
|
+
|
|
46
|
+
## CONTEXT PACKAGE — verified current state
|
|
47
|
+
|
|
48
|
+
**dcm `notifications/dispatch.py`:**
|
|
49
|
+
- `PopupDispatcher` (`:117-121`) is a stub: logs "queued for unimplemented popup channel" and returns
|
|
50
|
+
`DeliveryResult(ok=None, error="pending")`. It is registered in `_DISPATCHERS` (`:126-133`).
|
|
51
|
+
- The model to copy is `ChipDispatcher` (`dispatch.py:64-75`, post-NOTIF-13): it calls
|
|
52
|
+
`push_to_users([recipient.user], notification_envelope({"type": notification.notification_type, "content": ..., "notification_id": ...}))`
|
|
53
|
+
and returns `DeliveryResult(ok=True)`.
|
|
54
|
+
|
|
55
|
+
**How NOTIF-13 actually shipped — read this before writing scope D.** The envelope is a
|
|
56
|
+
**domain-level** discriminator, not a per-channel one:
|
|
57
|
+
- `delivery.py:25` `NOTIFICATION_ENVELOPE = "notification"`; `delivery.py:28-35`
|
|
58
|
+
`notification_envelope(payload)` merges `{"envelope": "notification"}` into any payload. **Additive**
|
|
59
|
+
— the pre-existing fields, including the overloaded `type`, are unchanged.
|
|
60
|
+
- ucm `src/notifications/realtime.jsx:68` routes purely on `data.envelope ?? DEFAULT_ENVELOPE`, then
|
|
61
|
+
fans to every handler subscribed to that envelope.
|
|
62
|
+
- **Inside** the notification envelope, `NotificationsProvider` keeps the original two-way split:
|
|
63
|
+
`data.type === 'notification.status'` patches status (`:98`), and **everything else is appended to
|
|
64
|
+
the feed with `unreadCount + 1`** (`:120`).
|
|
65
|
+
|
|
66
|
+
**Consequence — the trap this WO must avoid:** if `PopupDispatcher` simply calls
|
|
67
|
+
`notification_envelope({...})` like Chip does, its message is **indistinguishable from a chip
|
|
68
|
+
message**. The popup surface subscribing to the `"notification"` envelope would receive every chip
|
|
69
|
+
notification and pop up for all of them, and a notification routed to *both* chip and popup would
|
|
70
|
+
produce **two feed entries and `unreadCount + 2` for one notification**. Scope D and D2 below exist
|
|
71
|
+
to close exactly this.
|
|
72
|
+
- `_render_content(content, user)` (`:44-60`) renders `title_key`/`body_key`/`params` in the
|
|
73
|
+
recipient's language — reuse it rather than re-deriving text client-side if the payload needs text.
|
|
74
|
+
- `prefs.py:22` already treats `popup` as a channel defaulting to True; `router.py:24` narrows to
|
|
75
|
+
`ntype.eligible_channels`. **No prefs or router change is needed** — only a type opting in.
|
|
76
|
+
|
|
77
|
+
**ucm `src/onboarding/OnboardingWizard.jsx` (75 LOC)** is the renderer to reuse:
|
|
78
|
+
- MUI `Dialog open fullWidth maxWidth="sm"` (`:59`), `DialogTitle` with a step counter +
|
|
79
|
+
`LinearProgress` (`:60-67`), `DialogContent` rendering
|
|
80
|
+
`<StepComponent onComplete={...} onDismiss={...} ctx={...} />` (`:69`).
|
|
81
|
+
- Step contract: `{ id, Component, blocking, persistDismissed }`. `blocking` disables escape/backdrop
|
|
82
|
+
close (`:59`).
|
|
83
|
+
- **The parts that must NOT come along** (design decision D-F7,
|
|
84
|
+
`django-core-micha/docs/design/notifications-platform.md:70-71`): `onboarding.markStepSeen` (`:35`),
|
|
85
|
+
`dismissStep` (`:54`), and the sequential progress store behind them. Popup status lives on
|
|
86
|
+
`NotificationRecipient`.
|
|
87
|
+
- Exported at `src/index.js:61`; the four existing steps at `:62-65`.
|
|
88
|
+
|
|
89
|
+
**ucm notification status API:** `useNotifications()` already returns `markSeen` / `markDismissed` /
|
|
90
|
+
`markDone`, backed by `feed/mark/`. Use `markSeen` when a popup is shown and `markDismissed` when the
|
|
91
|
+
user closes it. **Never `markDone`** — done is a todo-channel semantic.
|
|
92
|
+
|
|
93
|
+
## SCOPE
|
|
94
|
+
|
|
95
|
+
**A. ucm — extract the dialog shell.** Lift the presentational shell out of `OnboardingWizard` into a
|
|
96
|
+
reusable renderer (dialog frame, counter/progress header, the `onComplete`/`onDismiss`/`ctx` child
|
|
97
|
+
contract). `OnboardingWizard` keeps its current behaviour by composing that shell with the onboarding
|
|
98
|
+
store; the popup surface composes the same shell with notification state.
|
|
99
|
+
|
|
100
|
+
**B. ucm — the popup surface.** A component that renders popup-eligible notifications through the
|
|
101
|
+
shell, marks `seen` on display and `dismissed` on close. For live popups it subscribes via
|
|
102
|
+
`useRealtime()` to the **`"notification"` envelope** (`DEFAULT_ENVELOPE` — there is no popup-specific
|
|
103
|
+
envelope, see D) and filters on `channel === "popup"`, ignoring `notification.status` messages and
|
|
104
|
+
channel-less legacy payloads. Export additively from `src/index.js`.
|
|
105
|
+
|
|
106
|
+
**C. Coexistence rule with onboarding — specify and test it.** Both surfaces render a MUI `Dialog`, so
|
|
107
|
+
two modals can be open at once. Required behaviour: **a blocking onboarding step wins**; the popup
|
|
108
|
+
waits until no blocking onboarding step is active. Non-blocking coexistence must not stack two dialogs.
|
|
109
|
+
|
|
110
|
+
**D. dcm — implement `PopupDispatcher.deliver`, with a channel discriminator *inside* the envelope.**
|
|
111
|
+
Emit through `notification_envelope()` (so Layer-1 routing stays domain-keyed and messaging can still
|
|
112
|
+
ride alongside) and add an explicit **`"channel": "popup"`** field to the payload. Return
|
|
113
|
+
`DeliveryResult(ok=True)`. Keep delivery-record semantics identical to `ChipDispatcher` — the
|
|
114
|
+
surrounding `dispatch()` machinery already records pending → sent/failed; do not re-implement it.
|
|
115
|
+
|
|
116
|
+
**Do NOT invent a second envelope value** (e.g. `"notification.popup"`). The envelope discriminates
|
|
117
|
+
*domains* so a future messaging stream can share the socket; overloading it with channels would
|
|
118
|
+
undo NOTIF-13's design. Channel selection belongs inside the payload.
|
|
119
|
+
|
|
120
|
+
For symmetry and so the client can tell the cases apart, `ChipDispatcher` should carry
|
|
121
|
+
`"channel": "chip"` as well. **Backward compatibility:** a payload with **no** `channel` field must
|
|
122
|
+
keep behaving exactly as today (feed entry + unread increment), because an app pinned to an older dcm
|
|
123
|
+
will still send channel-less payloads.
|
|
124
|
+
|
|
125
|
+
**D2. ucm — de-duplicate by `notification_id` in `NotificationsProvider`.** A notification routed to
|
|
126
|
+
both chip and popup now produces two WS messages. The provider must fold them into **one** feed entry
|
|
127
|
+
and **one** unread increment (de-dupe on `notification_id`, last-write-wins on content). Without this,
|
|
128
|
+
enabling popup alongside chip double-counts the badge. This is an edit to `NotificationsProvider`,
|
|
129
|
+
which is explicitly in scope here — it is not part of NOTIF-13's realtime core.
|
|
130
|
+
|
|
131
|
+
## DO NOT TOUCH
|
|
132
|
+
- **The onboarding progress store** — `markStepSeen`, `dismissStep`, `persistDismissed` persistence,
|
|
133
|
+
`OnboardingProvider`'s step-selection semantics, `stepSelection.js`.
|
|
134
|
+
- The four existing step components (`CookieConsentStep`, `CompleteNameStep`, `BrowserPushStep`,
|
|
135
|
+
`PwaInstallStep`) and their props.
|
|
136
|
+
- **NOTIF-13's realtime core** (`src/notifications/realtime.jsx`) — consume `useRealtime()` /
|
|
137
|
+
`subscribe(envelope, handler)`, do not restructure it. In particular leave the `seeded` gate, the
|
|
138
|
+
backoff/reconnect semantics and the envelope-routing alone. (`NotificationsProvider` itself IS
|
|
139
|
+
editable — scope D2 requires it.)
|
|
140
|
+
- `feed/*` REST contract, `NotificationBell`, `NotificationSettings`.
|
|
141
|
+
- `prefs.py`, `router.py`, `resolve_channels` — popup already works there; a type opting in is an
|
|
142
|
+
app-side change, not this WO's.
|
|
143
|
+
- Schema, migrations, dependencies, CI.
|
|
144
|
+
- **Do not register a `popup` producer in any app.** This WO ships the channel only.
|
|
145
|
+
|
|
146
|
+
## RISKS
|
|
147
|
+
- **Onboarding regression** is the main risk: the shell extraction touches a wizard that cockpit and jg
|
|
148
|
+
run in production. The step counter (`totalRef`/`completed`), the session-dismissed Set, and the
|
|
149
|
+
`blocking` escape-key behaviour are easy to break silently.
|
|
150
|
+
- **Stacked modals** if C is skipped — two dialogs, neither reachable.
|
|
151
|
+
- **Badge double-count** if D2 is skipped: a notification routed to both chip and popup arrives as two
|
|
152
|
+
WS messages and increments `unreadCount` twice for one notification. This is the single most likely
|
|
153
|
+
silent defect in this WO.
|
|
154
|
+
- **Popup storm** if B subscribes to the envelope without filtering on `channel` — every chip
|
|
155
|
+
notification would pop up a modal.
|
|
156
|
+
- **Regressing the NOTIF-13 R1 fix:** the `seeded` gate in `NotificationsProvider` exists because
|
|
157
|
+
decoupling the REST seed from the socket connect opened a silent-message-loss race. Do not touch it
|
|
158
|
+
while implementing D2.
|
|
159
|
+
- **Dead code rot:** with no producer, only the tests exercise the path. Hence the mandatory test-local
|
|
160
|
+
producer type.
|
|
161
|
+
- **Status semantics drift:** marking a popup `done` instead of `dismissed` would pollute todo-channel
|
|
162
|
+
semantics.
|
|
163
|
+
|
|
164
|
+
## REQUIRED TESTS / ACCEPTANCE
|
|
165
|
+
ucm (`pnpm test`, vitest):
|
|
166
|
+
- a popup-eligible notification renders in the shell; `markSeen` fires once on display;
|
|
167
|
+
- closing it fires `markDismissed`, never `markDone`;
|
|
168
|
+
- **coexistence:** with a blocking onboarding step active, no popup dialog renders; once it clears, the
|
|
169
|
+
popup appears — and never two dialogs at once;
|
|
170
|
+
- **onboarding regression guard:** existing `OnboardingWizard` behaviour unchanged — step counter,
|
|
171
|
+
progress bar, session-dismissed set, `blocking` disabling escape/backdrop close, `persistDismissed`
|
|
172
|
+
still calling `dismissStep`;
|
|
173
|
+
- with no popup-eligible notification present, nothing renders;
|
|
174
|
+
- **no popup storm:** a `channel: "chip"` message and a channel-less legacy message both leave the
|
|
175
|
+
popup surface silent;
|
|
176
|
+
- **no double-count (D2):** two WS messages for the same `notification_id` (one `chip`, one `popup`)
|
|
177
|
+
yield exactly one feed entry and `unreadCount + 1`;
|
|
178
|
+
- **NOTIF-13 R1 guard:** the `seeded` gate still holds — no socket messages are processed before the
|
|
179
|
+
REST seed completes.
|
|
180
|
+
|
|
181
|
+
dcm (`pytest` on the notifications package):
|
|
182
|
+
- register a **test-local** notification type declaring `eligible_channels=["popup"]`, call `notify()`,
|
|
183
|
+
and assert the dispatcher delivered (`ok=True`) with `envelope: "notification"` **and**
|
|
184
|
+
`channel: "popup"` on the wire — the end-to-end proof required by the scaffolding notice;
|
|
185
|
+
- a chip delivery still carries `envelope: "notification"` and now `channel: "chip"`, with every other
|
|
186
|
+
field unchanged (no regression for existing consumers);
|
|
187
|
+
- `resolve_channels` still excludes `popup` for types that do not declare it (no accidental widening);
|
|
188
|
+
- the existing `test_dispatch.py` registry assertion (`:25`) still passes.
|
|
189
|
+
|
|
190
|
+
Plus `pnpm build` (tsc) green.
|
|
191
|
+
|
|
192
|
+
## RELEASE
|
|
193
|
+
ucm minor bump + publish. dcm minor bump + publish (publish-from-main), with a **registry live-check
|
|
194
|
+
before any app pin bump**. No app pin bumps in this WO. CHANGELOG in both repos must carry the
|
|
195
|
+
inactive-until-a-producer-opts-in wording from the scaffolding notice.
|
|
196
|
+
|
|
197
|
+
## TARGET REPO / WORKING DIRECTORY
|
|
198
|
+
- Primary: `C:\Users\biglmi\Documents\webapps\ui-core-micha`
|
|
199
|
+
- Secondary (scope D): `C:\Users\biglmi\Documents\webapps\django-core-micha`
|
|
200
|
+
- Never the workspace root `…\webapps`.
|
|
201
|
+
Both are platform repos: commit directly to the trunk, no feature branches.
|
|
202
|
+
|
|
203
|
+
## PROGRESS CONTRACT
|
|
204
|
+
Emit a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
205
|
+
`PROGRESS: [<n>/<total>] <present-tense action>` **before every relevant action** (file opened, file
|
|
206
|
+
edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
|
|
207
|
+
exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
208
|
+
|
|
209
|
+
## MINI-HANDOVER (paste into a fresh Orchestrator session)
|
|
210
|
+
```
|
|
211
|
+
Orchestrator: implement work-orders/NOTIF-12.md in ui-core-micha (main; scope D also touches
|
|
212
|
+
django-core-micha). The NOTIF-13 gate is satisfied (ucm 2.13.0 / dcm 2.33.0). git pull, read the WO,
|
|
213
|
+
then follow orchestrate-codex (Codex-first, own independent review, commit on green).
|
|
214
|
+
```
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# WORK ORDER NOTIF-13 (ui-core-micha + django-core-micha) — Layer-1 transport extraction
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** Implement through `codex exec` in the background — invoked **directly via
|
|
4
|
+
Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
5
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
6
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
7
|
+
|
|
8
|
+
Feature-level WO (Expertenchat envelope). The seams below are **verified against the landed code**
|
|
9
|
+
(ucm 2.12.0, dcm 2.32.0) and are the required scope. The Orchestrator fills the exact line map.
|
|
10
|
+
|
|
11
|
+
## TIER
|
|
12
|
+
Tier 2 — restructures a provider that **cockpit consumes in production**, and changes a WebSocket
|
|
13
|
+
message contract shared across apps. Independent `reviewer` mandatory. Per AGENTS.md Tier 2,
|
|
14
|
+
`sec_reviewer` is NOT auto-included; request it only if the operator asks.
|
|
15
|
+
|
|
16
|
+
## GOAL
|
|
17
|
+
Turn ucm's notification-only socket into a generic **Layer-1 realtime primitive**: one socket,
|
|
18
|
+
subscribe-by-message-type, so a second domain (messaging) can ride it without being misread as a
|
|
19
|
+
notification. **Additive**: `NotificationsProvider` remains the sole mount point and its public
|
|
20
|
+
context value is unchanged (operator decision 2026-07-29).
|
|
21
|
+
|
|
22
|
+
## EXPECTED OUTCOME
|
|
23
|
+
- ucm exports a `useRealtime()`-style subscribe primitive alongside the unchanged `useNotifications()`.
|
|
24
|
+
- An unknown/unsubscribed message type is **ignored** instead of being appended to the notification feed.
|
|
25
|
+
- No app has to change its provider tree. cockpit and jg keep working untouched.
|
|
26
|
+
- NOTIF-15 (jg bell adoption, retiring jg's local context) becomes implementable; Phase B messaging
|
|
27
|
+
gets its substrate.
|
|
28
|
+
|
|
29
|
+
## CONTEXT PACKAGE — verified current state
|
|
30
|
+
|
|
31
|
+
**ucm `src/notifications/NotificationsProvider.jsx` (232 LOC)** owns *both* the socket and the
|
|
32
|
+
notifications domain state:
|
|
33
|
+
- socket ownership: `socketRef`, `connect()`, exponential backoff `INITIAL_BACKOFF_MS` 1s →
|
|
34
|
+
`MAX_BACKOFF_MS` 30s, auth-gated effect keyed on `[authenticated, refresh, replaceNotifications, wsUrlBase]` (`:87-179`).
|
|
35
|
+
- `handleMessage` (`:103-133`) dispatches on exactly **one** envelope type, `notification.status` (`:111`).
|
|
36
|
+
- **The blocker:** everything else falls through to `normalizeNotificationPush(data)` and is appended
|
|
37
|
+
to the feed with `unreadCount + 1` (`:130-132`). A messaging payload on this socket becomes a fake
|
|
38
|
+
notification today.
|
|
39
|
+
- **Field overload — the reason a third stream cannot simply be added:** in the status branch
|
|
40
|
+
`data.type` is the *envelope* type; in the new-notification branch `normalizeNotificationPush`
|
|
41
|
+
reads `data.type` as the *domain* notification-type key (`:25`). One field, two meanings. The
|
|
42
|
+
envelope contract must resolve this without breaking either reading.
|
|
43
|
+
- WS URL hardcoded to `/ws/notifications/` (`:36`, `:40`).
|
|
44
|
+
- exports in `src/index.js:48` (`NotificationsProvider`, `useNotifications`).
|
|
45
|
+
|
|
46
|
+
**dcm transport is ALREADY generic — correct the roadmap wording here.** The roadmap §4 calls for a
|
|
47
|
+
"dcm multi-stream consumer"; that overstates the dcm work:
|
|
48
|
+
- `notifications/delivery.py::push_to_users(users, payload)` (`:20-43`) fans an **arbitrary** payload
|
|
49
|
+
into the per-user group `notifications_user_{id}`.
|
|
50
|
+
- `notifications/consumers.py::NotificationConsumer.message()` (`:21-22`) is `send_json(event["payload"])` —
|
|
51
|
+
fully stream-agnostic. jg's messaging **already** rides this same group via `push_to_users`.
|
|
52
|
+
- The only dcm-side envelope producer is `notifications/views.py:235` (`"type": "notification.status"`);
|
|
53
|
+
the contract is documented at `views.py:3`.
|
|
54
|
+
- **So the dcm work is the envelope contract + a thin authoring helper + doc — NOT a consumer rewrite.**
|
|
55
|
+
|
|
56
|
+
**Reference implementation to promote:** jg's `frontend/src/context/NotificationsContext.jsx` (91 LOC,
|
|
57
|
+
docstring "WebSocket owner only") already does exactly this multiplexing with
|
|
58
|
+
`registerMessageCallback` / `registerChatCallback`. Read it for the shape. **Do not edit jg here.**
|
|
59
|
+
|
|
60
|
+
## SCOPE
|
|
61
|
+
|
|
62
|
+
**A. ucm — extract the socket into an internal realtime core.** Single owner; preserve the existing
|
|
63
|
+
backoff, reconnect, auth-gating and cleanup semantics **verbatim**. Expose
|
|
64
|
+
`subscribe(type, handler) -> unsubscribe`.
|
|
65
|
+
|
|
66
|
+
**B. ucm — `NotificationsProvider` becomes the first subscriber.** It registers handlers for the
|
|
67
|
+
notification envelope types. Its returned context value stays **exactly**
|
|
68
|
+
`{ notifications, unreadCount, markSeen, markDismissed, markDone, refresh }`.
|
|
69
|
+
|
|
70
|
+
**C. Envelope contract.** Introduce an explicit envelope discriminator that does not collide with the
|
|
71
|
+
domain `type` field (see the field-overload note above). Unknown / unsubscribed envelope types are
|
|
72
|
+
**ignored** — the catch-all at `:130-132` dies. This is a deliberate behaviour change and must be
|
|
73
|
+
called out in the CHANGELOG.
|
|
74
|
+
|
|
75
|
+
**D. Backward compatibility in BOTH directions** — apps pin dcm and ucm independently, so mismatched
|
|
76
|
+
versions are normal:
|
|
77
|
+
- *new ucm + old dcm* (payload has no envelope field): must still be recognised as a new notification,
|
|
78
|
+
exactly as today. Without this the feed silently goes dead against an un-bumped backend.
|
|
79
|
+
- *old ucm + new dcm*: dcm must keep emitting the legacy fields so a ucm 2.12.0 client is unaffected
|
|
80
|
+
(**cockpit runs 2.12.0 in staging/prod**).
|
|
81
|
+
|
|
82
|
+
**E. dcm — formalize the contract.** Document the envelope next to `views.py:3` and add a thin
|
|
83
|
+
authoring helper so producers stop hand-rolling the dict. `push_to_users`'s signature stays unchanged.
|
|
84
|
+
|
|
85
|
+
## DO NOT TOUCH
|
|
86
|
+
- **jg-ferien** (`NotificationsContext.jsx`, `MessagingContext.jsx`) — that is NOTIF-15.
|
|
87
|
+
- The messaging domain itself — Phase B (`MSG-*`).
|
|
88
|
+
- `NotificationBell.jsx`, `NotificationSettings.jsx`, `feedApi.js`, the `feed/*` REST contract.
|
|
89
|
+
- `BaseSecureConsumer` / S112 permission model, `IsAuthenticatedWs`, the group name `notifications_user_{id}`.
|
|
90
|
+
- **No standalone `RealtimeProvider` export that apps must mount.** Operator decision 2026-07-29:
|
|
91
|
+
additive only. A standalone provider is a later, separate WO if a second consumer ever needs it.
|
|
92
|
+
- Reconnect/backoff semantics and the single-socket-owner invariant — preserve, do not "improve".
|
|
93
|
+
- Schema, migrations, dependencies, CI.
|
|
94
|
+
- **No app pin bumps** in this WO (cockpit/jg stay where they are).
|
|
95
|
+
|
|
96
|
+
## RISKS
|
|
97
|
+
- **Silent feed death** if C lands without D: an envelope-less payload from an older dcm would be
|
|
98
|
+
ignored instead of shown. D is not optional.
|
|
99
|
+
- **cockpit is a live consumer** at ucm 2.12.0 — any change to the returned context shape breaks it.
|
|
100
|
+
- **Subscription leaks / double delivery:** `subscribe` must return a stable unsubscribe, and handlers
|
|
101
|
+
must **survive reconnects** without the caller re-registering them.
|
|
102
|
+
- **Reconnect loop:** the socket effect currently depends on `refresh` / `replaceNotifications`;
|
|
103
|
+
moving the socket out must not make the socket re-create on every feed update.
|
|
104
|
+
|
|
105
|
+
## REQUIRED TESTS / ACCEPTANCE
|
|
106
|
+
ucm runs `vitest` (`pnpm test`) and has **no tests under `src/notifications/` today** — this WO adds
|
|
107
|
+
the first ones:
|
|
108
|
+
- an unknown envelope type is ignored: feed and `unreadCount` both untouched (the regression that
|
|
109
|
+
protects messaging from being misread as a notification);
|
|
110
|
+
- two subscribers on distinct types each receive only their own messages, from **one** socket —
|
|
111
|
+
assert exactly one `WebSocket` was constructed;
|
|
112
|
+
- `unsubscribe` stops delivery for that subscriber and does not affect the others;
|
|
113
|
+
- handlers keep receiving after a close → backoff → reopen cycle, with no re-registration by the caller;
|
|
114
|
+
- a legacy envelope-less payload still lands in the feed (backward-compat D);
|
|
115
|
+
- `notification.status` handling and the `unreadCount` delta are unchanged — this guards the NOTIF-6
|
|
116
|
+
R1 fix (badge went stale after a cross-device mark);
|
|
117
|
+
- `NotificationsProvider`'s public context value is unchanged.
|
|
118
|
+
|
|
119
|
+
Plus `pnpm build` (tsc) green. If E lands in dcm, run the dcm notifications `pytest` package.
|
|
120
|
+
|
|
121
|
+
## RELEASE
|
|
122
|
+
ucm minor bump + publish (publish-from-main). dcm only if E lands there — then publish-from-main with
|
|
123
|
+
a **registry live-check before any app pin bump**. No app pin bumps in this WO.
|
|
124
|
+
|
|
125
|
+
## TARGET REPO / WORKING DIRECTORY
|
|
126
|
+
- Primary: `C:\Users\biglmi\Documents\webapps\ui-core-micha`
|
|
127
|
+
- Secondary (scope E only): `C:\Users\biglmi\Documents\webapps\django-core-micha`
|
|
128
|
+
- Never the workspace root `…\webapps`.
|
|
129
|
+
Both are platform repos: commit directly to the trunk, no feature branches.
|
|
130
|
+
|
|
131
|
+
## PROGRESS CONTRACT
|
|
132
|
+
Emit a `PLAN: <step1> | <step2> | …` line up front, then a single-line
|
|
133
|
+
`PROGRESS: [<n>/<total>] <present-tense action>` **before every relevant action** (file opened, file
|
|
134
|
+
edited, command/test run) and `PROGRESS: [<n>/<total>] done` on step completion, spaced so no gap
|
|
135
|
+
exceeds ~2 min. stdout unbuffered. Exactly one final `RESULT: DONE|BLOCKED <reason>`.
|
|
136
|
+
|
|
137
|
+
## MINI-HANDOVER (paste into a fresh Orchestrator session)
|
|
138
|
+
```
|
|
139
|
+
Orchestrator: implement work-orders/NOTIF-13.md in ui-core-micha (main; scope E also touches
|
|
140
|
+
django-core-micha). git pull first, read the WO, then follow orchestrate-codex (Codex-first, own
|
|
141
|
+
independent review, commit on green).
|
|
142
|
+
```
|