[Notifications] RTK Query API and Redux slice
>>> [!note] Migrated issue
<!-- Drupal.org comment -->
<!-- Migrated from issue #3580214. -->
Reported by: [justafish](https://www.drupal.org/user/161058)
Related to !838
>>>
<p>Create the frontend data layer for the notification system: an RTK Query API service for fetching notifications and marking them as read, a Redux slice for UI-only state, adaptive polling logic, a notification sorting utility, and a timestamp formatting utility. This ticket provides the data foundation that the Activity Center (ticket 5) and toast system (ticket 6) build on.</p>
<h2>Dependencies</h2>
<p>Ticket 2 (backend API endpoints) must be complete.</p>
<h2>Acceptance criteria</h2>
<ul>
<li><code>notificationsApi</code> RTK Query service is created with <code>getNotifications</code> and <code>markNotificationsRead</code> endpoints.</li>
<li><code>getNotifications</code> polls at a client-determined interval: 2000ms when processing notifications are present in the cache, 10000ms otherwise.</li>
<li>Polling slows down when the browser tab is not focused (300000ms / 5 min instead of pausing entirely).</li>
<li><code>markNotificationsRead</code> sends <code>POST /canvas/api/v0/notifications/read</code> and optimistically updates the cached <code>hasRead</code> state.</li>
<li><code>notificationsSlice</code> manages <code>pageOpenedAt</code> and <code>activityCenterOpen</code> state.</li>
<li><code>pageOpenedAt</code> is initialized to the current timestamp when the store is created.</li>
<li>Sorting utility orders notifications per FR-3: processing first, then unread errors (newest first), then unread warnings (newest first), then everything else chronologically (newest first).</li>
<li>Badge count selector returns count of all unread notifications (FR-2).</li>
<li>An <code>AUTO_READ_TYPES</code> constant defines types (<code>success</code>, <code>info</code>) that are automatically marked as read when the Activity Center is opened or a toast is dismissed.</li>
<li>Timestamp formatting utility returns "Now", "Xm ago", "Xh ago", or "Xd ago" per FR-10.</li>
<li>Both <code>notificationsApi</code> and <code>notificationsSlice</code> are registered in <code>store.ts</code>.</li>
<li>Unit tests cover sorting, badge count, timestamp formatting, and slice reducers.</li>
</ul>
<h2>Implementation plan</h2>
<h3>RTK Query API</h3>
<p>Create <code>ui/src/services/notificationsApi.ts</code>:</p>
<pre><pre>import { createApi } from '@reduxjs/toolkit/query/react';<br>import { baseQuery } from '@/services/baseQuery';<br><br>export interface NotificationAction {<br> label: string;<br> href: string;<br>}<br><br>export interface Notification {<br> id: string;<br> type: 'processing' | 'success' | 'info' | 'warning' | 'error';<br> key: string | null;<br> title: string;<br> message: string;<br> timestamp: number;<br> hasRead: boolean;<br> actions: NotificationAction[] | null;<br>}<br><br>interface NotificationsResponse {<br> data: {<br> notifications: Notification[];<br> };<br>}<br><br>export const notificationsApi = createApi({<br> reducerPath: 'notificationsApi',<br> baseQuery,<br> endpoints: (builder) => ({<br> getNotifications: builder.query&lt;NotificationsResponse, void&gt;({<br> query: () => '/canvas/api/v0/notifications',<br> }),<br> markNotificationsRead: builder.mutation&lt;void, { ids: string[] }&gt;({<br> query: (body) => ({<br> url: '/canvas/api/v0/notifications/read',<br> method: 'POST',<br> body,<br> }),<br> async onQueryStarted({ ids }, { dispatch, queryFulfilled }) {<br> // Optimistic update: mark notifications as read in cache immediately.<br> const patch = dispatch(<br> notificationsApi.util.updateQueryData(<br> 'getNotifications',<br> undefined,<br> (draft) => {<br> for (const notification of draft.data.notifications) {<br> if (ids.includes(notification.id)) {<br> notification.hasRead = true;<br> }<br> }<br> },<br> ),<br> );<br> try {<br> await queryFulfilled;<br> } catch {<br> patch.undo();<br> }<br> },<br> }),<br> }),<br>});<br><br>export const {<br> useGetNotificationsQuery,<br> useMarkNotificationsReadMutation,<br>} = notificationsApi;</pre></pre><h3>Adaptive polling</h3>
<p>Polling is fully client-determined based on two factors: whether processing notifications are present, and whether the browser tab is focused.</p>
<pre><pre>const POLLING_FAST = 2000;<br>const POLLING_NORMAL = 10000;<br>const POLLING_BACKGROUND = 300000;<br><br>const isTabFocused = useDocumentVisibility(); // custom hook using document.visibilityState<br><br>const hasProcessingNotifications = data?.data.notifications.some(<br> (n) => n.type === 'processing',<br>) ?? false;<br><br>function getPollingInterval(): number {<br> if (!isTabFocused) return POLLING_BACKGROUND;<br> if (hasProcessingNotifications) return POLLING_FAST;<br> return POLLING_NORMAL;<br>}<br><br>const { data } = useGetNotificationsQuery(undefined, {<br> pollingInterval: getPollingInterval(),<br>});</pre></pre><table>
<thead>
<tr>
<th>Tab state</th>
<th>Processing present</th>
<th>Interval</th>
</tr>
</thead>
<tbody>
<tr>
<td>Focused</td>
<td>Yes</td>
<td>2000ms</td>
</tr>
<tr>
<td>Focused</td>
<td>No</td>
<td>10000ms</td>
</tr>
<tr>
<td>Unfocused</td>
<td>Either</td>
<td>300000ms (5 min)</td>
</tr>
</tbody>
</table>
<p>A small <code>useDocumentVisibility</code> hook wraps <code>document.visibilityState</code> with an event listener on <code>visibilitychange</code>, returning a boolean.</p>
<h3>Redux slice</h3>
<p>Create <code>ui/src/features/notifications/notificationsSlice.ts</code>:</p>
<pre><pre>import { createSlice } from '@reduxjs/toolkit';<br>import type { PayloadAction } from '@reduxjs/toolkit';<br><br>interface NotificationsState {<br> pageOpenedAt: number;<br> activityCenterOpen: boolean;<br>}<br><br>const initialState: NotificationsState = {<br> pageOpenedAt: Date.now(),<br> activityCenterOpen: false,<br>};<br><br>export const notificationsSlice = createSlice({<br> name: 'notifications',<br> initialState,<br> reducers: {<br> setActivityCenterOpen(state, action: PayloadAction&lt;boolean&gt;) {<br> state.activityCenterOpen = action.payload;<br> },<br> },<br> selectors: {<br> selectActivityCenterOpen: (state) => state.activityCenterOpen,<br> selectPageOpenedAt: (state) => state.pageOpenedAt,<br> },<br>});<br><br>export const { setActivityCenterOpen } = notificationsSlice.actions;<br>export const { selectActivityCenterOpen, selectPageOpenedAt } =<br> notificationsSlice.selectors;</pre></pre><h3>Sorting utility</h3>
<p>Create <code>ui/src/features/notifications/sortNotifications.ts</code>:</p>
<pre><pre>import type { Notification } from '@/services/notificationsApi';<br><br>const TYPE_PRIORITY: Record&lt;string, number&gt; = {<br> processing: 0,<br> error: 1,<br> warning: 2,<br>};<br><br>export function sortNotifications(<br> notifications: Notification[],<br>): Notification[] {<br> return [...notifications].sort((a, b) => {<br> const aPriority = getEffectivePriority(a);<br> const bPriority = getEffectivePriority(b);<br> if (aPriority !== bPriority) return aPriority - bPriority;<br> return b.timestamp - a.timestamp;<br> });<br>}<br><br>function getEffectivePriority(n: Notification): number {<br> if (n.type === 'processing') return 0;<br> if (!n.hasRead &amp;&amp; n.type === 'error') return 1;<br> if (!n.hasRead &amp;&amp; n.type === 'warning') return 2;<br> return 3;<br>}</pre></pre><h3>Badge count selector</h3>
<p>Create <code>ui/src/features/notifications/selectBadgeCount.ts</code>:</p>
<pre><pre>import type { Notification } from '@/services/notificationsApi';<br><br>export function computeBadgeCount(notifications: Notification[]): number {<br> return notifications.filter((n) => !n.hasRead).length;<br>}</pre></pre><p>Only notifications of type <code>info</code>, <code>warning</code>, and <code>error</code> contribute to the badge count. <code>success</code> and <code>info</code> notifications are auto-marked as read when the Activity Center is opened or a toast is dismissed (see tickets 5 and 6).</p>
<h3>Auto-read types constant</h3>
<p>Create <code>ui/src/features/notifications/constants.ts</code>:</p>
<pre>export const AUTO_READ_TYPES = new Set(['success', 'info']);</pre><p>This constant is shared by the Activity Center (ticket 5) and the toast manager (ticket 6) to determine which notification types are automatically marked as read on open/dismiss.</p>
<h3>Timestamp formatting</h3>
<p>Create <code>ui/src/features/notifications/formatTimestamp.ts</code>:</p>
<pre><pre>export function formatTimestamp(timestamp: number): string {<br> const now = Date.now();<br> const diffMs = now - timestamp;<br> const diffMinutes = Math.floor(diffMs / 60000);<br> const diffHours = Math.floor(diffMs / 3600000);<br> const diffDays = Math.floor(diffMs / 86400000);<br><br> if (diffMinutes &lt; 1) return 'Now';<br> if (diffMinutes &lt; 60) return `${diffMinutes}m ago`;<br> if (diffHours &lt; 24) return `${diffHours}h ago`;<br> return `${diffDays}d ago`;<br>}</pre></pre><h3>Store registration</h3>
<p>In <code>ui/src/app/store.ts</code>:</p>
<ul>
<li>Import <code>notificationsApi</code> and <code>notificationsSlice</code>.</li>
<li>Add both to <code>combineSlices()</code>.</li>
<li>Add <code>notificationsApi.middleware</code> to the middleware chain.</li>
</ul>
<h2>Tests required</h2>
<h3><code>ui/src/features/notifications/sortNotifications.test.ts</code></h3>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td>Processing notifications sort first</td>
<td>FR-3.1</td>
</tr>
<tr>
<td>Unread errors sort before unread warnings</td>
<td>FR-3.2, FR-3.3</td>
</tr>
<tr>
<td>Unread errors sort newest first</td>
<td>FR-3.2</td>
</tr>
<tr>
<td>Read errors drop to chronological group</td>
<td>FR-3 rule</td>
</tr>
<tr>
<td>Mixed types sort correctly</td>
<td>Full priority chain</td>
</tr>
</tbody>
</table>
<h3><code>ui/src/features/notifications/selectBadgeCount.test.ts</code></h3>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td>Counts all unread notifications</td>
<td>FR-2.1</td>
</tr>
<tr>
<td>Includes unread info and success</td>
<td>FR-2.2</td>
</tr>
<tr>
<td>Includes unread processing</td>
<td>FR-2.3</td>
</tr>
<tr>
<td>Excludes read notifications</td>
<td>FR-2.5</td>
</tr>
<tr>
<td>Returns 0 when all read</td>
<td>FR-2.4</td>
</tr>
</tbody>
</table>
<h3><code>ui/src/features/notifications/formatTimestamp.test.ts</code></h3>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td>Returns "Now" for < 1 minute</td>
<td>FR-10.1</td>
</tr>
<tr>
<td>Returns "Xm ago" for < 60 minutes</td>
<td>FR-10.2</td>
</tr>
<tr>
<td>Returns "Xh ago" for < 24 hours</td>
<td>FR-10.3</td>
</tr>
<tr>
<td>Returns "Xd ago" for >= 24 hours</td>
<td>FR-10.4</td>
</tr>
</tbody>
</table>
<h3><code>ui/src/features/notifications/notificationsSlice.test.ts</code></h3>
<table>
<thead>
<tr>
<th>Test case</th>
<th>Validates</th>
</tr>
</thead>
<tbody>
<tr>
<td>Initial state has <code>pageOpenedAt</code> set</td>
<td>Initialization</td>
</tr>
<tr>
<td><code>setActivityCenterOpen</code> toggles state</td>
<td>Reducer</td>
</tr>
</tbody>
</table>
<h2>Manual testing steps</h2>
<p>No UI is built in this ticket. Verify by:</p>
<ol>
<li>Running <code>npm run type-check</code> in the <code>ui/</code> directory to confirm no type errors.</li>
<li>Running <code>npm run test</code> to confirm all unit tests pass.</li>
<li>Optionally, use Redux DevTools in the browser to inspect the <code>notificationsApi</code> and <code>notifications</code> slices after the store is registered.</li>
</ol>
issue