@quazardous/qdadm 2.27.0 → 2.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.27.0",
3
+ "version": "2.29.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -36,7 +36,8 @@
36
36
  * - #footer: Custom footer (replaces ShowActions if provided)
37
37
  * - #error: Custom error display
38
38
  * - #loading: Custom display for the first load. A reload of a record already
39
- * on screen keeps it there, marked busy (#2435).
39
+ * on screen keeps it there, marked busy (#2435); the notification badge shows
40
+ * the activity (#2677).
40
41
  */
41
42
  import { computed, type PropType } from 'vue'
42
43
  import PageHeader from '../layout/PageHeader.vue'
@@ -227,7 +228,6 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
227
228
 
228
229
  <!-- Content -->
229
230
  <template v-else>
230
- <div v-if="loading" class="show-refreshing" role="progressbar" aria-label="Refreshing" />
231
231
  <!-- Single content body; Card wrapper is conditional (#1193) -->
232
232
  <CardShell :card="cardWrapper">
233
233
  <!-- Grid layout with optional media zone -->
@@ -310,28 +310,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
310
310
  padding: 3rem;
311
311
  }
312
312
 
313
- /* A record being refreshed stays on screen: a thin bar says so (#2435). */
314
- .show-refreshing {
315
- height: 3px;
316
- margin-bottom: -1rem; /* inside the page gap: the content does not move */
317
- background: linear-gradient(90deg, transparent, var(--p-primary-color, #3b82f6), transparent);
318
- background-size: 40% 100%;
319
- background-repeat: no-repeat;
320
- animation: show-refreshing 1.1s ease-in-out infinite;
321
- }
322
-
323
- @keyframes show-refreshing {
324
- from { background-position: -40% 0; }
325
- to { background-position: 140% 0; }
326
- }
327
313
 
328
- @media (prefers-reduced-motion: reduce) {
329
- .show-refreshing {
330
- animation: none;
331
- background: var(--p-primary-color, #3b82f6);
332
- opacity: 0.4;
333
- }
334
- }
335
314
 
336
315
  .show-error-message {
337
316
  margin: 1rem 0;
@@ -37,6 +37,7 @@ import {
37
37
  } from './useEntityItemPage'
38
38
  import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
39
39
  import { registerPageState } from './usePageState'
40
+ import { useNotifications } from '../notifications/NotificationStore'
40
41
  import { deepClone } from '../utils/transformers'
41
42
  import { getSiblingRoutes } from '../module/moduleRegistry'
42
43
  import {
@@ -130,6 +131,8 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
130
131
  const route = useRoute()
131
132
  const confirm = useConfirm() as ConfirmService
132
133
  const debug = inject<boolean>('qdadmDebug', false)
134
+ // Loads show as activity on the notification badge (#2677); a no-op without notifications.
135
+ const notifications = useNotifications()
133
136
 
134
137
  // Use useEntityItemPage for common infrastructure
135
138
  const itemPage = useEntityItemPage({
@@ -291,7 +294,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
291
294
  try {
292
295
  // The item itself, never a cached list row: a list row may be a summary,
293
296
  // and whatever this form shows empty it saves back empty (#2484).
294
- const responseData = await manager.get(entityId.value!, undefined, { listCache: false })
297
+ const responseData = await notifications.track(manager.get(entityId.value!, undefined, { listCache: false }))
295
298
  if (!isLatest()) return
296
299
  const transformed = transformLoad(responseData)
297
300
  if (debug) reportMissingFields(transformed as Record<string, unknown>)
@@ -15,6 +15,7 @@
15
15
  import { ref, computed, onMounted, provide, type Ref, type ComputedRef } from 'vue'
16
16
  import { useRoute } from 'vue-router'
17
17
  import { useStackHydrator, type StackHydratorReturn } from '../chain/useStackHydrator'
18
+ import { useNotifications } from '../notifications/NotificationStore'
18
19
  import type { EntityManagerCrud } from '../entity/EntityManager.interface'
19
20
  import type { OrchestratorLike } from '../entity/EntityManager.interface'
20
21
  import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
@@ -161,6 +162,8 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
161
162
 
162
163
  // Stack hydrator for setting entity data on navigation context
163
164
  const hydrator = useStackHydrator()
165
+ // Loads show as activity on the notification badge (#2677); a no-op without notifications.
166
+ const notifications = useNotifications()
164
167
 
165
168
  // ============ STATE ============
166
169
 
@@ -333,7 +336,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
333
336
  error.value = null
334
337
 
335
338
  try {
336
- const responseData = await manager.get(id)
339
+ const responseData = await notifications.track(manager.get(id))
337
340
  if (!isLatest()) return null
338
341
 
339
342
  if (!responseData) {
@@ -35,7 +35,8 @@
35
35
  */
36
36
  import { ref, computed, onUnmounted, type Ref, type ComputedRef } from 'vue'
37
37
  import { registerPageState } from './usePageState'
38
- import { useRouter, type Router } from 'vue-router'
38
+ import { useRouter, useRoute, type Router } from 'vue-router'
39
+ import { useNotifications } from '../notifications/NotificationStore'
39
40
  import { useConfirm } from 'primevue/useconfirm'
40
41
  import { requireDeleteConfirmation } from './confirmDelete'
41
42
  import {
@@ -325,9 +326,26 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
325
326
  // Reload when THIS record changed outside the session (#1888 lot D).
326
327
  // Scoped by id: an event about another record must not reload every open
327
328
  // detail page. An event carrying no id concerns the whole entity and applies.
329
+ const notifications = useNotifications()
330
+ const route = useRoute()
328
331
  useLiveEntity(entity, manager, async () => {
329
- await base.reload()
330
- }, { id: () => entityId.value })
332
+ const record = await base.reload()
333
+ // A change made elsewhere to the record on screen leaves a line in the
334
+ // history (#2677): short-lived, linked back to it, never a pop-up.
335
+ if (!record) return
336
+ const name = entityLabel.value ? `"${entityLabel.value}"` : `#${entityId.value}`
337
+ notifications.addNotification({
338
+ severity: 'info',
339
+ summary: `${manager.label ?? entity} ${name} updated elsewhere`,
340
+ keep: 'short',
341
+ ...(route?.name ? { to: { name: String(route.name), params: { ...route.params } } } : {}),
342
+ })
343
+ }, {
344
+ id: () => entityId.value,
345
+ // The update has arrived: say so on the badge at once — before the reload is
346
+ // coalesced, let alone finished (#2679).
347
+ onEvent: () => notifications.flash(),
348
+ })
331
349
  // Cast orchestrator to include toast methods
332
350
  const orchestrator = baseOrchestrator as Orchestrator
333
351
 
@@ -38,6 +38,7 @@ import {
38
38
  import { useConfirm } from 'primevue/useconfirm'
39
39
  import { requireDeleteConfirmation } from './confirmDelete'
40
40
  import { registerPageState, activeFilters } from './usePageState'
41
+ import { useNotifications } from '../notifications/NotificationStore'
41
42
  import { useHooks } from './useHooks.js'
42
43
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
43
44
  import { useActiveStack } from '../chain/useActiveStack.js'
@@ -142,6 +143,8 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
142
143
 
143
144
  const router = useRouter()
144
145
  const route = useRoute()
146
+ // Loads show as activity on the notification badge (#2677); a no-op without notifications.
147
+ const notifications = useNotifications()
145
148
  const confirm = useConfirm() as ConfirmService
146
149
 
147
150
  // Mobile detection (viewport width < 768px)
@@ -841,9 +844,12 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
841
844
  params = onBeforeLoad(params) || params
842
845
  }
843
846
 
844
- const response = manager.query
845
- ? await manager.query(params, { routingContext: entityContext.value })
846
- : await manager.list(params, entityContext.value)
847
+ // The request shows as activity on the notification badge (#2677).
848
+ const response = await notifications.track(
849
+ manager.query
850
+ ? manager.query(params, { routingContext: entityContext.value })
851
+ : manager.list(params, entityContext.value)
852
+ )
847
853
 
848
854
  fromCache.value = response.fromCache || false
849
855
 
@@ -27,6 +27,12 @@ export interface UseLiveEntityOptions {
27
27
  * own id: without it, changing one record reloads every open detail page.
28
28
  */
29
29
  id?: () => string | number | null | undefined
30
+ /**
31
+ * Called at once for every event that concerns this screen, before the
32
+ * reload is coalesced (#2679) — for a cue that must not wait, like the
33
+ * notification badge's flash.
34
+ */
35
+ onEvent?: () => void
30
36
  }
31
37
 
32
38
  interface LivePolicy {
@@ -129,6 +135,8 @@ export function useLiveEntity(
129
135
  }
130
136
  }
131
137
 
138
+ options.onEvent?.()
139
+
132
140
  // Coalesce: a backend replaying fifty rows must cost one reload, not fifty.
133
141
  if (coalesceMs <= 0) {
134
142
  fire()
@@ -199,6 +199,10 @@ export interface DebugBarConfig {
199
199
  export interface NotificationsConfig {
200
200
  enabled?: boolean
201
201
  maxNotifications?: number
202
+ /** Keep level per severity (#2677). Default: success and info `short`, warn and error `long`. */
203
+ keep?: Partial<Record<'success' | 'info' | 'warn' | 'error', 'none' | 'short' | 'long'>>
204
+ /** How long a `short` entry stays in the list, in ms. Default 5 minutes. */
205
+ shortKeepMs?: number
202
206
  }
203
207
 
204
208
  /**
@@ -359,6 +359,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
359
359
  if (this.options.notifications?.enabled) {
360
360
  this.notificationStore = createNotificationStore({
361
361
  maxNotifications: this.options.notifications.maxNotifications,
362
+ keep: this.options.notifications.keep,
363
+ shortKeepMs: this.options.notifications.shortKeepMs,
362
364
  })
363
365
  app.provide(NOTIFICATION_KEY, this.notificationStore)
364
366
  }
@@ -2,23 +2,37 @@
2
2
  /**
3
3
  * NotificationBadge - Clickable overlay for the sidebar footer logo
4
4
  *
5
- * Always rendered as a transparent click zone over the logo.
6
- * When there are alerts, the parent layout applies an opacity blink
7
- * on the logo wrapper via :has(.notification-badge-zone--alert).
5
+ * Always rendered as a transparent click zone over the logo. It says three
6
+ * things (#2677):
7
+ * - something needs attention: the parent layout blinks the logo via
8
+ * :has(.notification-badge-zone--alert);
9
+ * - how many entries are unread: a count;
10
+ * - work is in progress: a ring, once tracked work has lasted `activityDelayMs`;
11
+ * - something just arrived: a short flash, immediately (#2679).
8
12
  *
9
13
  * Click toggles the notification panel open/close.
10
14
  */
15
+ import { computed } from 'vue'
11
16
  import { useNotifications } from './NotificationStore'
12
17
 
13
18
  const store = useNotifications()
19
+
20
+ const unread = computed(() => store.unreadCount.value)
21
+ const unreadLabel = computed(() => (unread.value > 99 ? '99+' : String(unread.value)))
14
22
  </script>
15
23
 
16
24
  <template>
17
25
  <div
18
26
  class="notification-badge-zone"
19
- :class="{ 'notification-badge-zone--alert': store.hasAlert.value || store.unreadCount.value > 0 }"
27
+ :class="{ 'notification-badge-zone--alert': store.hasAlert.value || unread > 0 }"
28
+ role="button"
29
+ :aria-label="unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'"
20
30
  @click.stop.prevent="store.toggle()"
21
- />
31
+ >
32
+ <span v-if="store.isBusy.value" class="notification-badge-ring" role="progressbar" aria-label="Loading" />
33
+ <span v-if="store.isFlashing.value" class="notification-badge-flash" aria-hidden="true" />
34
+ <span v-if="unread > 0" class="notification-badge-count">{{ unreadLabel }}</span>
35
+ </div>
22
36
  </template>
23
37
 
24
38
  <style scoped>
@@ -32,4 +46,64 @@ const store = useNotifications()
32
46
  cursor: pointer;
33
47
  z-index: 1;
34
48
  }
49
+
50
+ .notification-badge-count {
51
+ position: absolute;
52
+ top: -0.35rem;
53
+ right: -0.35rem;
54
+ min-width: 1.1rem;
55
+ height: 1.1rem;
56
+ padding: 0 0.3rem;
57
+ border-radius: 999px;
58
+ background: var(--p-primary-color, #3b82f6);
59
+ color: var(--p-primary-contrast-color, #fff);
60
+ font-size: 0.65rem;
61
+ font-weight: 600;
62
+ line-height: 1.1rem;
63
+ text-align: center;
64
+ pointer-events: none;
65
+ }
66
+
67
+ /* Work in progress: a ring around the logo. */
68
+ .notification-badge-ring {
69
+ position: absolute;
70
+ inset: -0.25rem;
71
+ border-radius: 50%;
72
+ border: 2px solid transparent;
73
+ border-top-color: var(--p-primary-color, #3b82f6);
74
+ border-right-color: var(--p-primary-color, #3b82f6);
75
+ animation: notification-badge-spin 0.9s linear infinite;
76
+ pointer-events: none;
77
+ }
78
+
79
+ @keyframes notification-badge-spin {
80
+ to { transform: rotate(360deg); }
81
+ }
82
+
83
+ /* Something just arrived: a halo that grows out of the logo and fades. */
84
+ .notification-badge-flash {
85
+ position: absolute;
86
+ inset: 0;
87
+ border-radius: 50%;
88
+ pointer-events: none;
89
+ animation: notification-badge-flash 0.9s ease-out 1;
90
+ }
91
+
92
+ @keyframes notification-badge-flash {
93
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--p-primary-color, #3b82f6) 70%, transparent); }
94
+ 100% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--p-primary-color, #3b82f6) 0%, transparent); }
95
+ }
96
+
97
+ @media (prefers-reduced-motion: reduce) {
98
+ .notification-badge-ring {
99
+ animation: none;
100
+ border-color: var(--p-primary-color, #3b82f6);
101
+ opacity: 0.5;
102
+ }
103
+
104
+ .notification-badge-flash {
105
+ animation: none;
106
+ box-shadow: 0 0 0 3px var(--p-primary-color, #3b82f6);
107
+ }
108
+ }
35
109
  </style>
@@ -2,14 +2,13 @@
2
2
  * NotificationModule - Optional notification panel system
3
3
  *
4
4
  * When loaded, this module:
5
- * - Replaces the default ToastListener with NotificationListener
6
- * (captures toasts to notification store instead of ephemeral PrimeVue toasts)
7
5
  * - Registers NotificationBadge for the sidebar footer logo overlay
8
6
  * - Registers NotificationPanel zone for the panel component
9
7
  *
10
- * Without this module, classic toast behavior is unchanged (ToastBridgeModule).
11
- * With this module, toasts are captured. Use `forceToast: true` in signal data
12
- * to also show a classic PrimeVue toast.
8
+ * Toasts keep showing as pop-ups; the kernel's ToastListener also records each
9
+ * one in the store, for as long as its keep level says (#2677). This module used
10
+ * to swap the listener inside the `_app:toasts` zone, which nothing renders — so
11
+ * no toast was ever recorded. See docs/notifications.md.
13
12
  *
14
13
  * @example
15
14
  * // In kernel config
@@ -20,8 +19,6 @@
20
19
 
21
20
  import { Module } from '../kernel/Module'
22
21
  import type { KernelContext } from '../kernel/KernelContext'
23
- import { TOAST_ZONE } from '../toast/ToastBridgeModule'
24
- import NotificationListener from './NotificationListener.vue'
25
22
  import NotificationBadge from './NotificationBadge.vue'
26
23
  import NotificationPanel from './NotificationPanel.vue'
27
24
 
@@ -45,18 +42,6 @@ export class NotificationModule extends Module {
45
42
  ctx.zone(NOTIFICATION_BADGE_ZONE)
46
43
  ctx.zone(NOTIFICATION_STATUS_ZONE)
47
44
 
48
- // Replace the toast-listener block in the toast zone with our NotificationListener.
49
- // This intercepts toast signals and captures them to the notification store.
50
- // The toast zone is defined by ToastBridgeModule - we replace its listener block.
51
- ctx.zone(TOAST_ZONE) // Ensure the zone exists
52
- ctx.block(TOAST_ZONE, {
53
- id: 'toast-listener',
54
- component: NotificationListener,
55
- weight: 0,
56
- operation: 'replace',
57
- replaces: 'toast-listener',
58
- })
59
-
60
45
  // Register badge component for sidebar footer overlay
61
46
  ctx.block(NOTIFICATION_BADGE_ZONE, {
62
47
  id: 'notification-badge',
@@ -13,11 +13,21 @@
13
13
  * - Notification list (most recent first)
14
14
  * - Empty state
15
15
  */
16
- import { RouterLink } from 'vue-router'
16
+ import { RouterLink, useRouter, type RouteLocationRaw } from 'vue-router'
17
17
  import { useNotifications } from './NotificationStore'
18
- import type { NotificationSeverity } from './NotificationStore'
18
+ import type { Notification, NotificationSeverity } from './NotificationStore'
19
19
 
20
20
  const store = useNotifications()
21
+ const router = useRouter()
22
+
23
+ /** An entry is read when clicked; one with a `to` also takes the user there (#2677). */
24
+ function openNotification(notif: Notification): void {
25
+ store.markRead(notif.id)
26
+ if (!notif.to) return
27
+ store.close()
28
+ // NotificationTarget mirrors StatusItem.to: a vue-router location, typed loosely for app code.
29
+ void router.push(notif.to as RouteLocationRaw)
30
+ }
21
31
 
22
32
  const severityIcons: Record<NotificationSeverity, string> = {
23
33
  success: 'pi pi-check-circle',
@@ -113,9 +123,11 @@ function formatTime(timestamp: number): string {
113
123
  class="notification-item"
114
124
  :class="{
115
125
  'notification-item--unread': !notif.read,
126
+ 'notification-item--link': !!notif.to,
116
127
  [`notification-item--${notif.severity}`]: true,
117
128
  }"
118
- @click="store.markRead(notif.id)"
129
+ :role="notif.to ? 'link' : undefined"
130
+ @click="openNotification(notif)"
119
131
  >
120
132
  <div class="notification-item-icon">
121
133
  <i :class="severityIcons[notif.severity]" />
@@ -34,6 +34,16 @@ const NOTIFICATION_KEY: InjectionKey<NotificationStore> = Symbol('qdadm-notifica
34
34
 
35
35
  export type NotificationSeverity = 'success' | 'info' | 'warn' | 'error'
36
36
 
37
+ /**
38
+ * How long an entry stays in the list (#2677): `none` is not recorded, `short`
39
+ * is dropped after `shortKeepMs` (read or not), `long` stays until cleared or
40
+ * pushed out by the cap.
41
+ */
42
+ export type NotificationKeep = 'none' | 'short' | 'long'
43
+
44
+ /** Where an entry leads when clicked: a vue-router location. */
45
+ export type NotificationTarget = { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } | string
46
+
37
47
  export interface Notification {
38
48
  id: string
39
49
  severity: NotificationSeverity
@@ -42,6 +52,20 @@ export interface Notification {
42
52
  emitter?: string
43
53
  timestamp: number
44
54
  read: boolean
55
+ keep: Exclude<NotificationKeep, 'none'>
56
+ /** When a `short` entry leaves the list; null for `long`. */
57
+ expiresAt: number | null
58
+ to?: NotificationTarget
59
+ }
60
+
61
+ /** What `addNotification` takes: `keep` defaults by severity, see `NotificationStoreConfig.keep`. */
62
+ export interface NewNotification {
63
+ severity: NotificationSeverity
64
+ summary: string
65
+ detail?: string
66
+ emitter?: string
67
+ keep?: NotificationKeep
68
+ to?: NotificationTarget
45
69
  }
46
70
 
47
71
  export interface StatusItem {
@@ -56,6 +80,19 @@ export interface StatusItem {
56
80
 
57
81
  export interface NotificationStoreConfig {
58
82
  maxNotifications?: number
83
+ /** Keep level per severity. Default: success and info `short`, warn and error `long`. */
84
+ keep?: Partial<Record<NotificationSeverity, NotificationKeep>>
85
+ /** How long a `short` entry stays, in ms. Default 5 minutes. */
86
+ shortKeepMs?: number
87
+ /** Ongoing activity shorter than this shows nothing on the badge, in ms. Default 400. */
88
+ activityDelayMs?: number
89
+ }
90
+
91
+ const DEFAULT_KEEP: Record<NotificationSeverity, NotificationKeep> = {
92
+ success: 'short',
93
+ info: 'short',
94
+ warn: 'long',
95
+ error: 'long',
59
96
  }
60
97
 
61
98
  export interface NotificationStore {
@@ -63,7 +100,8 @@ export interface NotificationStore {
63
100
  notifications: ComputedRef<Notification[]>
64
101
  unreadCount: ComputedRef<number>
65
102
  hasAlert: ComputedRef<boolean>
66
- addNotification(n: Omit<Notification, 'id' | 'timestamp' | 'read'>): string
103
+ /** Records an entry and returns its id — `''` when its keep level is `none`. */
104
+ addNotification(n: NewNotification): string
67
105
  markRead(id: string): void
68
106
  markAllRead(): void
69
107
  removeNotification(id: string): void
@@ -75,6 +113,20 @@ export interface NotificationStore {
75
113
  updateStatus(id: string, updates: Partial<StatusItem>): void
76
114
  removeStatus(id: string): void
77
115
 
116
+ // Activity (#2677)
117
+ /** Counts `work` as in progress until it settles, and returns it unchanged. */
118
+ track<T>(work: Promise<T>): Promise<T>
119
+ /** True once tracked work has been in progress for `activityDelayMs`. */
120
+ isBusy: ComputedRef<boolean>
121
+ /**
122
+ * Flash the badge: something just arrived — an update to the record on
123
+ * screen (#2679). Immediate, never delayed; a flash already running absorbs
124
+ * further calls, so a burst flashes once.
125
+ */
126
+ flash(): void
127
+ /** True while the badge flashes. */
128
+ isFlashing: ComputedRef<boolean>
129
+
78
130
  // Panel state
79
131
  isOpen: Ref<boolean>
80
132
  open(): void
@@ -102,6 +154,9 @@ let _idCounter = 0
102
154
  */
103
155
  export function createNotificationStore(config: NotificationStoreConfig = {}): NotificationStore {
104
156
  const maxNotifications = config.maxNotifications ?? 50
157
+ const keepBySeverity = { ...DEFAULT_KEEP, ...(config.keep ?? {}) }
158
+ const shortKeepMs = config.shortKeepMs ?? 5 * 60 * 1000
159
+ const activityDelayMs = config.activityDelayMs ?? 400
105
160
 
106
161
  const state = reactive<NotificationState>({
107
162
  notifications: [],
@@ -112,13 +167,46 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
112
167
 
113
168
  // ── Notifications ──────────────────────────────────────────────────────
114
169
 
115
- function addNotification(n: Omit<Notification, 'id' | 'timestamp' | 'read'>): string {
170
+ // A `short` entry leaves when it expires; one timer, set for the soonest.
171
+ let pruneTimer: ReturnType<typeof setTimeout> | null = null
172
+
173
+ function schedulePrune(): void {
174
+ if (pruneTimer) clearTimeout(pruneTimer)
175
+ pruneTimer = null
176
+ const soonest = state.notifications.reduce<number | null>(
177
+ (min, n) => (n.expiresAt !== null && (min === null || n.expiresAt < min) ? n.expiresAt : min),
178
+ null
179
+ )
180
+ if (soonest === null) return
181
+ pruneTimer = setTimeout(prune, Math.max(0, soonest - Date.now()))
182
+ }
183
+
184
+ function prune(): void {
185
+ const now = Date.now()
186
+ for (let i = state.notifications.length - 1; i >= 0; i--) {
187
+ const expiresAt = state.notifications[i]!.expiresAt
188
+ if (expiresAt !== null && expiresAt <= now) state.notifications.splice(i, 1)
189
+ }
190
+ schedulePrune()
191
+ }
192
+
193
+ function addNotification(n: NewNotification): string {
194
+ const keep = n.keep ?? keepBySeverity[n.severity] ?? 'long'
195
+ if (keep === 'none') return ''
196
+
116
197
  const id = `notif-${++_idCounter}-${Date.now()}`
198
+ const timestamp = Date.now()
117
199
  const notification: Notification = {
118
- ...n,
200
+ severity: n.severity,
201
+ summary: n.summary,
202
+ ...(n.detail !== undefined ? { detail: n.detail } : {}),
203
+ ...(n.emitter !== undefined ? { emitter: n.emitter } : {}),
204
+ ...(n.to !== undefined ? { to: n.to } : {}),
119
205
  id,
120
- timestamp: Date.now(),
206
+ timestamp,
121
207
  read: false,
208
+ keep,
209
+ expiresAt: keep === 'short' ? timestamp + shortKeepMs : null,
122
210
  }
123
211
 
124
212
  // Add to front (most recent first)
@@ -129,6 +217,7 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
129
217
  state.notifications.length = maxNotifications
130
218
  }
131
219
 
220
+ if (notification.expiresAt !== null) schedulePrune()
132
221
  return id
133
222
  }
134
223
 
@@ -181,6 +270,46 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
181
270
  }
182
271
  }
183
272
 
273
+ // ── Activity ───────────────────────────────────────────────────────────
274
+
275
+ const inFlight = ref(0)
276
+ const busy = ref(false)
277
+ let busyTimer: ReturnType<typeof setTimeout> | null = null
278
+
279
+ function track<T>(work: Promise<T>): Promise<T> {
280
+ inFlight.value++
281
+ if (inFlight.value === 1) {
282
+ // Only work that lasts shows: a fast reload leaves the badge alone.
283
+ busyTimer = setTimeout(() => {
284
+ busyTimer = null
285
+ if (inFlight.value > 0) busy.value = true
286
+ }, activityDelayMs)
287
+ }
288
+ const settle = (): void => {
289
+ inFlight.value = Math.max(0, inFlight.value - 1)
290
+ if (inFlight.value > 0) return
291
+ if (busyTimer) clearTimeout(busyTimer)
292
+ busyTimer = null
293
+ busy.value = false
294
+ }
295
+ work.then(settle, settle)
296
+ return work
297
+ }
298
+
299
+ // ── Flash ──────────────────────────────────────────────────────────────
300
+
301
+ const FLASH_MS = 900
302
+ const flashing = ref(false)
303
+
304
+ function flash(): void {
305
+ // A burst of updates flashes once: the running flash already says it.
306
+ if (flashing.value) return
307
+ flashing.value = true
308
+ setTimeout(() => {
309
+ flashing.value = false
310
+ }, FLASH_MS)
311
+ }
312
+
184
313
  // ── Panel state ────────────────────────────────────────────────────────
185
314
 
186
315
  function open(): void {
@@ -229,6 +358,10 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
229
358
  registerStatus,
230
359
  updateStatus,
231
360
  removeStatus,
361
+ track,
362
+ isBusy: computed(() => busy.value),
363
+ flash,
364
+ isFlashing: computed(() => flashing.value),
232
365
  isOpen,
233
366
  open,
234
367
  close,
@@ -272,6 +405,10 @@ export function useNotifications(): NotificationStore {
272
405
  registerStatus: () => {},
273
406
  updateStatus: () => {},
274
407
  removeStatus: () => {},
408
+ track: (work) => work,
409
+ isBusy: computed(() => false),
410
+ flash: () => {},
411
+ isFlashing: computed(() => false),
275
412
  isOpen: ref(false),
276
413
  open: () => {},
277
414
  close: () => {},
@@ -32,6 +32,9 @@ export {
32
32
  NOTIFICATION_KEY,
33
33
  type Notification,
34
34
  type NotificationSeverity,
35
+ type NotificationKeep,
36
+ type NotificationTarget,
37
+ type NewNotification,
35
38
  type StatusItem,
36
39
  type NotificationStore,
37
40
  type NotificationStoreConfig,
@@ -59,6 +59,10 @@ export type EntityFactory = (
59
59
  export interface ToastOptions {
60
60
  life?: number
61
61
  emitter?: string
62
+ /** How long the notification history keeps it (#2677); default by severity. */
63
+ keep?: 'none' | 'short' | 'long'
64
+ /** Where its history entry leads: a vue-router location. */
65
+ to?: { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } | string
62
66
  }
63
67
 
64
68
  /**
@@ -196,6 +200,8 @@ export class Orchestrator {
196
200
  detail,
197
201
  life: opts.life ?? defaults[severity],
198
202
  emitter: opts.emitter,
203
+ ...(opts.keep !== undefined ? { keep: opts.keep } : {}),
204
+ ...(opts.to !== undefined ? { to: opts.to } : {}),
199
205
  })
200
206
  }
201
207
  }
@@ -11,15 +11,26 @@ import { onMounted, onUnmounted, inject } from 'vue'
11
11
  import { useToast } from 'primevue/usetoast'
12
12
  import type { SignalBus } from '../kernel/SignalBus'
13
13
  import { explainMissingToast } from '../kernel/vitePluginCheck'
14
+ import {
15
+ NOTIFICATION_KEY,
16
+ type NotificationKeep,
17
+ type NotificationSeverity,
18
+ type NotificationTarget,
19
+ } from '../notifications/NotificationStore'
14
20
 
15
21
  interface ToastEventData {
16
22
  summary?: string
17
23
  detail?: string
18
24
  life?: number
25
+ emitter?: string
26
+ keep?: NotificationKeep
27
+ to?: NotificationTarget
19
28
  }
20
29
 
21
30
  const toast = explainMissingToast(useToast)
22
31
  const signals = inject<SignalBus | null>('qdadmSignals', null)
32
+ // Provided when `notifications.enabled`: every toast also leaves a trace (#2677).
33
+ const notifications = inject(NOTIFICATION_KEY, null)
23
34
 
24
35
  let unsubscribe: (() => void) | null = null
25
36
 
@@ -39,6 +50,15 @@ onMounted(() => {
39
50
  detail: data?.detail,
40
51
  life: data?.life ?? 3000
41
52
  })
53
+ // The pop-up is unchanged; the history keeps it, for as long as its keep level says.
54
+ notifications?.addNotification({
55
+ severity: severity as NotificationSeverity,
56
+ summary: data?.summary ?? '',
57
+ ...(data?.detail !== undefined ? { detail: data.detail } : {}),
58
+ ...(data?.emitter !== undefined ? { emitter: data.emitter } : {}),
59
+ ...(data?.keep !== undefined ? { keep: data.keep } : {}),
60
+ ...(data?.to !== undefined ? { to: data.to } : {}),
61
+ })
42
62
  })
43
63
  })
44
64
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { inject, getCurrentInstance } from 'vue'
21
21
  import type { SignalBus } from '../kernel/SignalBus'
22
+ import type { NotificationKeep, NotificationTarget } from '../notifications/NotificationStore'
22
23
 
23
24
  /**
24
25
  * Toast severity types
@@ -33,16 +34,24 @@ export interface ToastOptions {
33
34
  summary: string
34
35
  detail?: string
35
36
  life?: number
36
- /** Force classic PrimeVue toast even when NotificationModule is active */
37
+ /** @deprecated Toasts always pop up now (#2677); kept for compatibility, it does nothing. */
37
38
  forceToast?: boolean
39
+ /** How long the notification history keeps it (#2677); default by severity. */
40
+ keep?: NotificationKeep
41
+ /** Where its history entry leads: a vue-router location. */
42
+ to?: NotificationTarget
38
43
  }
39
44
 
40
45
  /**
41
46
  * Toast method options
42
47
  */
43
48
  export interface ToastMethodOptions {
44
- /** Force classic PrimeVue toast even when NotificationModule is active */
49
+ /** @deprecated Toasts always pop up now (#2677); kept for compatibility, it does nothing. */
45
50
  forceToast?: boolean
51
+ /** How long the notification history keeps it (#2677); default by severity. */
52
+ keep?: NotificationKeep
53
+ /** Where its history entry leads: a vue-router location. */
54
+ to?: NotificationTarget
46
55
  }
47
56
 
48
57
  /**
@@ -90,10 +99,18 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
90
99
  * @param summary - Toast title
91
100
  * @param detail - Toast detail message
92
101
  * @param life - Duration in ms (0 for sticky)
93
- * @param forceToast - Force classic PrimeVue toast when NotificationModule is active
102
+ * @param options - keep and to, for the notification history (#2677)
94
103
  */
95
- function addToast(severity: ToastSeverity, summary: string, detail?: string, life: number = 3000, forceToast?: boolean): void {
96
- signals.emit(`toast:${severity}`, { summary, detail, life, emitter: resolvedEmitter, forceToast })
104
+ function addToast(severity: ToastSeverity, summary: string, detail?: string, life: number = 3000, options?: ToastMethodOptions): void {
105
+ signals.emit(`toast:${severity}`, {
106
+ summary,
107
+ detail,
108
+ life,
109
+ emitter: resolvedEmitter,
110
+ ...(options?.forceToast !== undefined ? { forceToast: options.forceToast } : {}),
111
+ ...(options?.keep !== undefined ? { keep: options.keep } : {}),
112
+ ...(options?.to !== undefined ? { to: options.to } : {}),
113
+ })
97
114
  }
98
115
 
99
116
  return {
@@ -105,7 +122,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
105
122
  * @param options - Additional options
106
123
  */
107
124
  success(summary: string, detail?: string, life: number = 3000, options?: ToastMethodOptions): void {
108
- addToast('success', summary, detail, life, options?.forceToast)
125
+ addToast('success', summary, detail, life, options)
109
126
  },
110
127
 
111
128
  /**
@@ -116,7 +133,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
116
133
  * @param options - Additional options
117
134
  */
118
135
  error(summary: string, detail?: string, life: number = 5000, options?: ToastMethodOptions): void {
119
- addToast('error', summary, detail, life, options?.forceToast)
136
+ addToast('error', summary, detail, life, options)
120
137
  },
121
138
 
122
139
  /**
@@ -127,7 +144,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
127
144
  * @param options - Additional options
128
145
  */
129
146
  info(summary: string, detail?: string, life: number = 3000, options?: ToastMethodOptions): void {
130
- addToast('info', summary, detail, life, options?.forceToast)
147
+ addToast('info', summary, detail, life, options)
131
148
  },
132
149
 
133
150
  /**
@@ -138,7 +155,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
138
155
  * @param options - Additional options
139
156
  */
140
157
  warn(summary: string, detail?: string, life: number = 4000, options?: ToastMethodOptions): void {
141
- addToast('warn', summary, detail, life, options?.forceToast)
158
+ addToast('warn', summary, detail, life, options)
142
159
  },
143
160
 
144
161
  /**
@@ -146,7 +163,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
146
163
  * @param options - Toast options
147
164
  */
148
165
  add(options: ToastOptions): void {
149
- addToast(options.severity || 'info', options.summary, options.detail, options.life, options.forceToast)
166
+ addToast(options.severity || 'info', options.summary, options.detail, options.life, options)
150
167
  },
151
168
  }
152
169
  }