@almadar/std 16.216.0 → 16.217.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,526 @@
1
+ app std-notification-preferences "1.0.0"
2
+ "std-notification-preferences — per-person notification preference store plus a consultable gate. Owns NotificationPreference rows (one per person + event key: channel override in_app/email/push/slack, mode instant/digest, quiet-hours window 'HH:MM-HH:MM' such as 22:00-07:00). The PreferencesManage trait is standard CRUD over the store (list / create / update / delete, persisted, success/failure cascade arms): a data-grid browse plus ModalRecordModal create/edit forms whose SAVE carries the full row payload (std-list daisy-chain idiom). The PreferenceGate trait is the consult half other notify traits call: emit CHECK_PREFERENCE { personId, eventKey } and the gate fetches the matching row (a row whose eventKey is blank applies to every event) and answers PREFERENCE_RESOLVED { personId, eventKey, channel, mode, suppressed } — suppressed is true only when the row's quietHours window covers the current hour; mode 'digest' rides on the verdict for the caller to defer. Default policy: no matching row — or a failed lookup — resolves fail-open to { channel: in_app, mode: instant, suppressed: false }; the absence of a preference never silences a notification."
3
+ orbital NotificationPreferencesOrbital {
4
+ uses Modal from "std/behaviors/std-modal"
5
+
6
+ type CHECK_PREFERENCE = Event { personId : string, eventKey : string } "Consult the gate: which channel/mode applies to this person for this event, and is delivery suppressed right now? Blank eventKey falls back to the gate's configured eventKey."
7
+
8
+ type PREFERENCE_RESOLVED = Event { personId : string, eventKey : string, channel : string, mode : string, suppressed : boolean } "The gate's verdict. Fail-open defaults: no matching row or a failed lookup resolves { channel: in_app, mode: instant, suppressed: false } — absence of a preference never silences a notification."
9
+
10
+ entity NotificationPreference [persistent: notification_preferences] {
11
+ id : string!
12
+ personId : string! @description "The person this preference row belongs to." @synonyms "user, recipient, owner, account"
13
+ eventKey : string! @description "Event key this preference applies to (matches a notify listener's eventKey); blank = all events." @synonyms "event, trigger, topic, subscription"
14
+ channel : "in_app" | "email" | "push" | "slack" @description "Delivery channel override for this person." @synonyms "preferred channel, delivery method" = in_app
15
+ mode : "instant" | "digest" @description "Delivery mode: instant (default) or digest (deferred to a batch cycle)." @synonyms "delivery mode, frequency, cadence, defer" = instant
16
+ quietHours : string @description "Quiet-hours window as \"HH:MM-HH:MM\" (e.g. 22:00-07:00); blank or equal start/end = disabled." @synonyms "do not disturb, quiet window, night mode" = ""
17
+ createdAt : number @description "Timestamp indicating when this preference row was created." @synonyms "creation time, created" = 0
18
+ updatedAt : number @description "Timestamp indicating when this preference row was last updated." @synonyms "modified, last updated" = 0
19
+ }
20
+
21
+ entity GateView [runtime] {
22
+ id : string!
23
+ personId : string @description "Scratch: personId of the consult currently in flight." @synonyms "current person" = ""
24
+ eventKey : string @description "Scratch: effective event key of the consult currently in flight (payload eventKey, or the configured eventKey when the payload is blank)." @synonyms "current event key" = ""
25
+ channel : string @description "Scratch: resolved channel for the current consult." @synonyms "resolved channel" = ""
26
+ mode : string @description "Scratch: resolved delivery mode for the current consult." @synonyms "resolved mode" = ""
27
+ quietHours : string @description "Scratch: quiet-hours window of the matched row for the current consult; blank = none." @synonyms "resolved quiet hours" = ""
28
+ }
29
+
30
+ ;; Browse + coordinator in one trait (std-notification-center's
31
+ ;; PreferenceEditor shape): the grid owns list/delete, the modal trait refs
32
+ ;; own the create/edit forms, and the DO_CREATE/DO_UPDATE arms own the
33
+ ;; persist side-effects triggered by the modals' payload-carrying SAVE
34
+ ;; (renamed PREFERENCE_CREATED/PREFERENCE_UPDATED so create and edit stay
35
+ ;; distinguishable without source-scoped listens — the std-list idiom).
36
+ trait PreferencesManage -> NotificationPreference [interaction, collection] {
37
+ initial: loading
38
+
39
+ state loading {
40
+ INIT -> loading
41
+ (fetch NotificationPreference {
42
+ emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
43
+ })
44
+ (render-ui main {
45
+ align: center
46
+ children: [{ type: spinner }, {
47
+ color: muted
48
+ content: "Loading notification preferences…"
49
+ type: typography
50
+ variant: caption
51
+ }]
52
+ className: py-12
53
+ direction: vertical
54
+ gap: md
55
+ type: stack
56
+ })
57
+ PreferencesLoaded -> browsing
58
+ (render-ui main {
59
+ children: [{
60
+ children: [{
61
+ children: [{ name: bell, size: lg, type: icon }, {
62
+ content: @config.title
63
+ type: typography
64
+ variant: h3
65
+ }]
66
+ direction: horizontal
67
+ gap: sm
68
+ type: stack
69
+ }, {
70
+ action: CREATE
71
+ icon: plus
72
+ label: "New preference"
73
+ type: button
74
+ variant: primary
75
+ }]
76
+ direction: horizontal
77
+ gap: sm
78
+ justify: between
79
+ type: stack
80
+ }, { type: divider }, {
81
+ cols: 1
82
+ entity: ?data
83
+ fields: [{
84
+ label: Person
85
+ name: personId
86
+ variant: h4
87
+ }, {
88
+ label: Event
89
+ name: eventKey
90
+ variant: caption
91
+ }, {
92
+ label: Channel
93
+ name: channel
94
+ variant: badge
95
+ }, {
96
+ label: Mode
97
+ name: mode
98
+ variant: badge
99
+ }, {
100
+ label: "Quiet hours"
101
+ name: quietHours
102
+ variant: caption
103
+ }]
104
+ gap: sm
105
+ itemActions: [{
106
+ event: EDIT
107
+ icon: pencil
108
+ label: Edit
109
+ variant: secondary
110
+ }, {
111
+ event: DELETE
112
+ icon: trash-2
113
+ label: Delete
114
+ variant: danger
115
+ }]
116
+ look: @config.tableLook
117
+ type: data-grid
118
+ }]
119
+ direction: vertical
120
+ gap: md
121
+ type: stack
122
+ })
123
+ PreferencesLoadFailed -> error
124
+ (render-ui main {
125
+ align: center
126
+ children: [{
127
+ message: ?error
128
+ type: alert
129
+ variant: error
130
+ }, {
131
+ action: RETRY
132
+ icon: rotate-ccw
133
+ label: Retry
134
+ type: button
135
+ variant: primary
136
+ }]
137
+ className: py-12
138
+ direction: vertical
139
+ gap: md
140
+ type: stack
141
+ })
142
+ }
143
+
144
+ state browsing {
145
+ INIT -> loading
146
+ (fetch NotificationPreference {
147
+ emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
148
+ })
149
+ (render-ui main { size: sm, type: spinner })
150
+ ;; CREATE/EDIT open the modal trait refs (modal slot) — the grid stays
151
+ ;; mounted behind them, so no arm here.
152
+ DELETE -> browsing
153
+ (persist delete NotificationPreference ?id {
154
+ emit: { failure: PreferenceSaveFailed, success: PreferenceDeleted }
155
+ })
156
+ ;; The modal SAVE payload carries the whole form row (std-list
157
+ ;; persistor shape): the bare ?data pointer is what lets a walk bind the
158
+ ;; target row from this step's own payload — a literal field map with a
159
+ ;; nested (object/get ?data id) is deliberately not that shape.
160
+ ;; createdAt/updatedAt ride the payload when the edit prefill carries
161
+ ;; them; the modal idiom cannot stamp @now at submit time.
162
+ DO_CREATE -> browsing
163
+ (persist create NotificationPreference ?data {
164
+ emit: { failure: PreferenceSaveFailed, success: PreferenceSaved }
165
+ })
166
+ DO_UPDATE -> browsing
167
+ (persist update NotificationPreference ?data {
168
+ emit: { failure: PreferenceSaveFailed, success: PreferenceSaved }
169
+ })
170
+ PreferenceSaved -> loading
171
+ (notify success "Notification preference saved")
172
+ (fetch NotificationPreference {
173
+ emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
174
+ })
175
+ PreferenceDeleted -> loading
176
+ (notify info "Notification preference removed")
177
+ (fetch NotificationPreference {
178
+ emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
179
+ })
180
+ PreferenceSaveFailed -> browsing
181
+ (notify warning "Could not save the notification preference")
182
+ ;; A load failure lands on the error state (std-saved-search shape), not
183
+ ;; a stay-in-browsing toast: `error` must be reachable from the stable
184
+ ;; browsing state — `loading` is transient (the fetch cascade leaves it
185
+ ;; immediately), so no walk can park the runtime there to dispatch the
186
+ ;; failure event from.
187
+ PreferencesLoadFailed -> error
188
+ (render-ui main {
189
+ align: center
190
+ children: [{
191
+ message: ?error
192
+ type: alert
193
+ variant: error
194
+ }, {
195
+ action: RETRY
196
+ icon: rotate-ccw
197
+ label: Retry
198
+ type: button
199
+ variant: primary
200
+ }]
201
+ className: py-12
202
+ direction: vertical
203
+ gap: md
204
+ type: stack
205
+ })
206
+ }
207
+
208
+ state error {
209
+ INIT -> loading
210
+ (fetch NotificationPreference {
211
+ emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
212
+ })
213
+ (render-ui main { size: sm, type: spinner })
214
+ ;; the error screen's Retry button: INIT is lifecycle-only and the bus
215
+ ;; never delivers it, so the affordance must target a first-class event
216
+ RETRY -> loading
217
+ (fetch NotificationPreference {
218
+ emit: { failure: PreferencesLoadFailed, success: PreferencesLoaded }
219
+ })
220
+ (render-ui main { size: sm, type: spinner })
221
+ }
222
+
223
+ emits {
224
+ CREATE
225
+ @description "Opens the blank preference modal."
226
+ @synonyms "add, new, insert"
227
+ @tier "domain"
228
+ EDIT {
229
+ id : string!
230
+ row : NotificationPreference
231
+ }
232
+ @description "Opens a preference row in the edit modal."
233
+ @synonyms "modify, update, change"
234
+ @tier "domain"
235
+ DELETE {
236
+ id : string!
237
+ row : NotificationPreference
238
+ }
239
+ @description "Removes a preference row."
240
+ @synonyms "remove, discard, clear"
241
+ @tier "domain"
242
+ PreferencesLoaded {
243
+ data : [NotificationPreference]
244
+ }
245
+ @description "The preference collection finished loading."
246
+ @synonyms "loaded, fetched, retrieved"
247
+ @tier "domain"
248
+ PreferencesLoadFailed {
249
+ error : string
250
+ code : string
251
+ }
252
+ @description "The preference collection failed to load."
253
+ @synonyms "error, failure, load error"
254
+ @tier "internal"
255
+ PreferenceSaved {
256
+ id : string!
257
+ }
258
+ @description "A preference row persisted (create or update)."
259
+ @synonyms "created, updated, persisted"
260
+ @tier "domain"
261
+ PreferenceSaveFailed {
262
+ error : string
263
+ code : string
264
+ }
265
+ @description "A preference persist failed."
266
+ @synonyms "failed, error, unsuccessful"
267
+ @tier "internal"
268
+ PreferenceDeleted {
269
+ id : string!
270
+ }
271
+ @description "A preference row was deleted."
272
+ @synonyms "removed, purged, discarded"
273
+ @tier "domain"
274
+ }
275
+
276
+ listens {
277
+ DO_CREATE {
278
+ data : NotificationPreference!
279
+ }
280
+ @description "Create persist requested; the modal's SAVE payload carries the full form row."
281
+ @synonyms "add, new, insert"
282
+ @tier "internal"
283
+ DO_UPDATE {
284
+ data : NotificationPreference!
285
+ }
286
+ @description "Update persist requested; the modal's SAVE payload carries the full form row."
287
+ @synonyms "modified, changed, refreshed"
288
+ @tier "internal"
289
+ PreferenceCreate.PREFERENCE_CREATED -> DO_CREATE
290
+ PreferenceEdit.PREFERENCE_UPDATED -> DO_UPDATE
291
+ DELETE {
292
+ id : string!
293
+ row : NotificationPreference
294
+ }
295
+ @description "Removes a preference row."
296
+ @synonyms "remove, discard, clear"
297
+ @tier "domain"
298
+ RETRY
299
+ @description "Retries loading the preference collection after a failure."
300
+ @synonyms "try again, reload"
301
+ @tier "presentation"
302
+ PreferencesLoaded {
303
+ data : [NotificationPreference]
304
+ }
305
+ @description "The preference collection finished loading."
306
+ @synonyms "loaded, fetched, retrieved"
307
+ @tier "domain"
308
+ PreferencesLoadFailed {
309
+ error : string
310
+ code : string
311
+ }
312
+ @description "The preference collection failed to load."
313
+ @synonyms "error, failure, load error"
314
+ @tier "internal"
315
+ PreferenceSaved {
316
+ id : string!
317
+ }
318
+ @description "A preference row persisted (create or update)."
319
+ @synonyms "created, updated, persisted"
320
+ @tier "domain"
321
+ PreferenceSaveFailed {
322
+ error : string
323
+ code : string
324
+ }
325
+ @description "A preference persist failed."
326
+ @synonyms "failed, error, unsuccessful"
327
+ @tier "internal"
328
+ PreferenceDeleted {
329
+ id : string!
330
+ }
331
+ @description "A preference row was deleted."
332
+ @synonyms "removed, purged, discarded"
333
+ @tier "domain"
334
+ }
335
+
336
+ config {
337
+ title : string = "Notification preferences"
338
+ @label "Section title"
339
+ @description "Heading shown above the preference list"
340
+ @tier "presentation"
341
+ tableLook : "dense" | "spacious" | "striped" | "borderless" | "card-rows" = dense
342
+ @label "Table look"
343
+ @description "Layer 2 visual treatment for the preference data table rendered by this atom."
344
+ @tier "presentation"
345
+ }
346
+ }
347
+
348
+ ;; Create modal: configured via `config`, no effects overrides. SAVE is
349
+ ;; renamed to PREFERENCE_CREATED so PreferencesManage distinguishes
350
+ ;; Create-SAVE from Edit-SAVE without source-scoped listens. Form field
351
+ ;; types/options/required-ness are entity-schema-driven (channel/mode render
352
+ ;; as selects from the entity enums).
353
+ trait PreferenceCreate = Modal.traits.ModalRecordModal -> NotificationPreference {
354
+ events {
355
+ OPEN: CREATE
356
+ SAVE: PREFERENCE_CREATED
357
+ }
358
+
359
+ config {
360
+ icon: plus-circle
361
+ title: "New notification preference"
362
+ fields: (personId eventKey channel mode quietHours)
363
+ mode: create
364
+ }
365
+
366
+ listens {
367
+ PreferencesManage.CREATE -> CREATE
368
+ CREATE {
369
+ id : string
370
+ row : NotificationPreference
371
+ }
372
+ @description "Opens the blank preference modal."
373
+ @synonyms "add, new, insert"
374
+ @tier "domain"
375
+ SAVE {
376
+ data : @entity!
377
+ }
378
+ @description "The preference form was submitted; the payload carries the full row."
379
+ @synonyms "persist, store, commit"
380
+ @tier "domain"
381
+ ModalRecordLoadFailed {
382
+ error : string
383
+ code : string
384
+ }
385
+ @description "Indicates failure to load a record for a modal."
386
+ @synonyms "error, failure, problem, unsuccessful"
387
+ @tier "internal"
388
+ }
389
+ }
390
+
391
+ ;; Edit modal: same composition, mode edit — OPEN pre-fills from the grid
392
+ ;; row payload and the row id rides into the SAVE payload so the update
393
+ ;; persist finds the right row.
394
+ trait PreferenceEdit = Modal.traits.ModalRecordModal -> NotificationPreference {
395
+ events {
396
+ OPEN: EDIT
397
+ SAVE: PREFERENCE_UPDATED
398
+ }
399
+
400
+ config {
401
+ icon: pencil
402
+ title: "Edit notification preference"
403
+ fields: (personId eventKey channel mode quietHours)
404
+ mode: edit
405
+ }
406
+
407
+ listens {
408
+ PreferencesManage.EDIT -> EDIT
409
+ EDIT {
410
+ id : string
411
+ row : NotificationPreference
412
+ }
413
+ @description "Opens a preference row in the edit modal."
414
+ @synonyms "modify, update, change"
415
+ @tier "domain"
416
+ SAVE {
417
+ data : @entity!
418
+ }
419
+ @description "The preference form was submitted; the payload carries the full row."
420
+ @synonyms "persist, store, commit, update"
421
+ @tier "domain"
422
+ ModalRecordLoadFailed {
423
+ error : string
424
+ code : string
425
+ }
426
+ @description "Indicates failure to load a record for a modal."
427
+ @synonyms "error, failure, problem, unsuccessful"
428
+ @tier "internal"
429
+ }
430
+ }
431
+
432
+ trait PreferenceGate -> GateView [lifecycle, instance] {
433
+ initial: idle
434
+
435
+ state idle {
436
+ CHECK_PREFERENCE -> idle
437
+ (set @entity.personId ?personId)
438
+ (set @entity.eventKey (if ["=", ?eventKey, ""] @config.eventKey ?eventKey))
439
+ (fetch NotificationPreference {
440
+ emit: { failure: PREFERENCE_CHECK_FAILED, success: PREFERENCE_ROWS_LOADED }
441
+ })
442
+ PREFERENCE_ROWS_LOADED -> idle
443
+ ;; Fail-open default policy: no matching row resolves channel in_app /
444
+ ;; mode instant / suppressed false — absence of a preference never
445
+ ;; silences a notification.
446
+ (set @entity.channel (if ["=", (array/len (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))), 0] in_app (object/get (array/first (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))) channel)))
447
+ (set @entity.mode (if ["=", (array/len (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))), 0] instant (object/get (array/first (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))) mode)))
448
+ (set @entity.quietHours (if ["=", (array/len (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))), 0] "" (object/get (array/first (array/filter ?data (fn p (and ["=", (object/get @p personId), @entity.personId] (or ["=", (object/get @p eventKey), ""] ["=", (object/get @p eventKey), @entity.eventKey]))))) quietHours)))
449
+ (emit PREFERENCE_RESOLVED {
450
+ channel: @entity.channel
451
+ eventKey: @entity.eventKey
452
+ mode: @entity.mode
453
+ personId: @entity.personId
454
+ suppressed: (and (str/includes @entity.quietHours "-") ["!=", (str/slice (array/first (str/split @entity.quietHours "-")) 0 2), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)] (if ["<", (str/slice (array/first (str/split @entity.quietHours "-")) 0 2), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)] (and [">=", (time/hour @now), (str/slice (array/first (str/split @entity.quietHours "-")) 0 2)] ["<", (time/hour @now), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)]) (or [">=", (time/hour @now), (str/slice (array/first (str/split @entity.quietHours "-")) 0 2)] ["<", (time/hour @now), (str/slice (array/nth (str/split @entity.quietHours "-") 1) 0 2)])))
455
+ })
456
+ ;; Lookup failure is fail-open too: a broken preference store must not
457
+ ;; mute notifications.
458
+ PREFERENCE_CHECK_FAILED -> idle
459
+ (emit PREFERENCE_RESOLVED {
460
+ channel: in_app
461
+ eventKey: @entity.eventKey
462
+ mode: instant
463
+ personId: @entity.personId
464
+ suppressed: false
465
+ })
466
+ (render-ui toast {
467
+ message: ?error
468
+ type: alert
469
+ variant: error
470
+ })
471
+ }
472
+
473
+ emits {
474
+ PREFERENCE_RESOLVED -> external {
475
+ personId : string
476
+ eventKey : string
477
+ channel : string
478
+ mode : string
479
+ suppressed : boolean
480
+ }
481
+ @description "The gate's verdict for a CHECK_PREFERENCE consult: resolved channel and mode, and whether quiet hours suppress delivery right now. Fail-open: no row or failed lookup = { channel: in_app, mode: instant, suppressed: false }."
482
+ @synonyms "verdict, preference answer, gate result, resolved preference"
483
+ @tier "domain"
484
+ PREFERENCE_ROWS_LOADED {
485
+ data : [NotificationPreference]
486
+ }
487
+ @description "NotificationPreference rows loaded; the arm filters to the (personId, eventKey) match and resolves the verdict"
488
+ @tier "internal"
489
+ PREFERENCE_CHECK_FAILED {
490
+ error : string!
491
+ }
492
+ @description "The preference fetch failed — the arm emits the fail-open verdict"
493
+ @tier "internal"
494
+ }
495
+
496
+ listens {
497
+ CHECK_PREFERENCE {
498
+ personId : string
499
+ eventKey : string
500
+ }
501
+ @description "Consult the gate for a person's effective notification preference."
502
+ @synonyms "check preference, resolve preference, should notify, gate notification"
503
+ @tier "domain"
504
+ PREFERENCE_ROWS_LOADED {
505
+ data : [NotificationPreference]
506
+ }
507
+ @description "NotificationPreference rows loaded; the arm resolves the verdict"
508
+ @tier "internal"
509
+ PREFERENCE_CHECK_FAILED {
510
+ error : string!
511
+ }
512
+ @description "The preference fetch failed"
513
+ @tier "internal"
514
+ }
515
+
516
+ config {
517
+ eventKey : string = ""
518
+ @label "Event key"
519
+ @description "Stable key naming the event this gate checks preferences for (e.g. 'BudgetThreshold' — the std-finance-tracker NotifyOnEventListener convention). A blank CHECK_PREFERENCE payload eventKey falls back to this value. Preference rows whose eventKey is blank apply to every event."
520
+ @synonyms "preference key, event name, subscription key, notification topic"
521
+ @tier "policy"
522
+ }
523
+ }
524
+
525
+ page "/notification-preferences" as NotificationPreferencesPage -> PreferencesManage, PreferenceCreate, PreferenceEdit, PreferenceGate
526
+ }