Skip to content

Commit edc22e2

Browse files
committed
feat(notifications): add preferences heartbeat
1 parent f5542a3 commit edc22e2

6 files changed

Lines changed: 358 additions & 12 deletions

File tree

docs/NOTIFICATION_SYSTEM_REFACTORING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,15 @@ Enhanced React hook with:
115115
- Improved preference validation
116116
- Better multi-channel delivery support
117117
- Enhanced analytics integration
118+
- Notification preference heartbeat state for liveness monitoring
119+
120+
The notification preferences heartbeat runs from `useNotifications` after preferences load. It
121+
writes `notification_preferences_heartbeat_v1` to `localStorage` with the current `userId`,
122+
`lastBeatAt`, `intervalMs`, and `staleAfterMs`. Consumers can read
123+
`preferencesHeartbeat.status` (`online`, `stale`, or `offline`) and call
124+
`refreshPreferencesHeartbeat()` to re-check the stored heartbeat without waiting for the next
125+
interval. The preferences UI surfaces this as a compact sync status so users are not left guessing
126+
when preference persistence is delayed or unavailable.
118127

119128
## Migration Guide
120129

src/app/components/notifications/UserPreferences.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
RotateCcw,
1313
Check,
1414
AlertCircle,
15+
Activity,
1516
} from 'lucide-react';
1617
import { useNotifications } from '@/app/hooks/useNotifications';
1718
import RecommendationPanel from './RecommendationPanel';
@@ -65,10 +66,24 @@ const channelIcons: Record<NotificationChannel, React.ReactNode> = {
6566
sms: <MessageSquare size={16} />,
6667
};
6768

69+
const heartbeatStatusStyles = {
70+
online: 'bg-green-50 text-green-700 border-green-200',
71+
stale: 'bg-amber-50 text-amber-700 border-amber-200',
72+
offline: 'bg-red-50 text-red-700 border-red-200',
73+
};
74+
75+
const heartbeatStatusLabels = {
76+
online: 'Live',
77+
stale: 'Sync delayed',
78+
offline: 'Offline',
79+
};
80+
6881
export default function UserPreferences({ userId, onSave }: UserPreferencesProps) {
6982
const {
7083
preferences,
84+
preferencesHeartbeat,
7185
updatePreferences,
86+
refreshPreferencesHeartbeat,
7287
isLoading,
7388
recommendations,
7489
applyRecommendation,
@@ -187,9 +202,22 @@ export default function UserPreferences({ userId, onSave }: UserPreferencesProps
187202
<div className="bg-white border rounded-lg shadow-lg overflow-hidden">
188203
{/* Header */}
189204
<div className="p-4 border-b bg-gray-50">
190-
<div className="flex items-center gap-2">
191-
<Settings size={20} className="text-gray-700" />
192-
<h2 className="text-lg font-semibold text-gray-900">Notification Preferences</h2>
205+
<div className="flex flex-wrap items-center gap-3">
206+
<div className="flex items-center gap-2">
207+
<Settings size={20} className="text-gray-700" />
208+
<h2 className="text-lg font-semibold text-gray-900">Notification Preferences</h2>
209+
</div>
210+
<button
211+
type="button"
212+
onClick={refreshPreferencesHeartbeat}
213+
aria-label="Refresh notification preference sync status"
214+
className={`ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium ${
215+
heartbeatStatusStyles[preferencesHeartbeat.status]
216+
}`}
217+
>
218+
<Activity size={14} aria-hidden="true" />
219+
<span aria-live="polite">{heartbeatStatusLabels[preferencesHeartbeat.status]}</span>
220+
</button>
193221
</div>
194222
<p className="text-sm text-gray-500 mt-1">
195223
Customize how and when you receive notifications

src/app/hooks/__tests__/useNotifications.test.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* and basic hook behaviour.
66
*/
77
import { renderHook, act } from '@testing-library/react';
8-
import { describe, it, expect, beforeEach, vi } from 'vitest';
8+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
99
import { useNotifications } from '../useNotifications';
1010
import { useNotificationStore } from '@/app/store/notificationStore';
1111
import { AppNotification } from '@/lib/notifications/types';
@@ -37,10 +37,20 @@ function resetStore() {
3737
useNotificationStore.setState({ notifications: [] });
3838
}
3939

40+
async function flushHookEffects() {
41+
await act(async () => {});
42+
await act(async () => {});
43+
}
44+
4045
// ─── Tests ────────────────────────────────────────────────────────────────────
4146

4247
describe('useNotifications', () => {
4348
beforeEach(resetStore);
49+
afterEach(() => {
50+
vi.useRealTimers();
51+
vi.restoreAllMocks();
52+
resetStore();
53+
});
4454

4555
// ── clearNotification ──────────────────────────────────────────────────────
4656

@@ -133,4 +143,122 @@ describe('useNotifications', () => {
133143

134144
expect(result.current.unreadCount).toBe(0);
135145
});
146+
147+
// ── preferences heartbeat ─────────────────────────────────────────────────
148+
149+
it('starts a preferences heartbeat and persists the liveness payload', async () => {
150+
vi.useFakeTimers();
151+
vi.setSystemTime(new Date('2026-06-24T12:00:00.000Z'));
152+
153+
const { result } = renderHook(() =>
154+
useNotifications({
155+
userId: 'heartbeat-user',
156+
preferencesHeartbeatIntervalMs: 1000,
157+
preferencesHeartbeatStaleAfterMs: 3000,
158+
}),
159+
);
160+
161+
await flushHookEffects();
162+
163+
expect(result.current.isLoading).toBe(false);
164+
expect(result.current.preferencesHeartbeat.status).toBe('online');
165+
expect(result.current.preferencesHeartbeat.lastBeatAt).toBe('2026-06-24T12:00:00.000Z');
166+
167+
const stored = JSON.parse(
168+
localStorageMock.getItem('notification_preferences_heartbeat_v1') ?? 'null',
169+
);
170+
expect(stored).toMatchObject({
171+
userId: 'heartbeat-user',
172+
lastBeatAt: '2026-06-24T12:00:00.000Z',
173+
intervalMs: 1000,
174+
staleAfterMs: 3000,
175+
});
176+
177+
await act(async () => {
178+
vi.advanceTimersByTime(1000);
179+
});
180+
181+
expect(result.current.preferencesHeartbeat.lastBeatAt).toBe('2026-06-24T12:00:01.000Z');
182+
});
183+
184+
it('marks an old preferences heartbeat as stale when refreshed', () => {
185+
vi.useFakeTimers();
186+
vi.setSystemTime(new Date('2026-06-24T12:00:10.000Z'));
187+
188+
localStorageMock.setItem(
189+
'notification_preferences_heartbeat_v1',
190+
JSON.stringify({
191+
userId: 'heartbeat-user',
192+
lastBeatAt: '2026-06-24T12:00:00.000Z',
193+
intervalMs: 1000,
194+
staleAfterMs: 3000,
195+
}),
196+
);
197+
198+
const { result } = renderHook(() =>
199+
useNotifications({
200+
userId: 'heartbeat-user',
201+
enablePreferencesHeartbeat: false,
202+
preferencesHeartbeatIntervalMs: 1000,
203+
preferencesHeartbeatStaleAfterMs: 3000,
204+
}),
205+
);
206+
207+
act(() => {
208+
result.current.refreshPreferencesHeartbeat();
209+
});
210+
211+
expect(result.current.preferencesHeartbeat.status).toBe('stale');
212+
expect(result.current.preferencesHeartbeat.lastBeatAt).toBe('2026-06-24T12:00:00.000Z');
213+
});
214+
215+
it('stops the preferences heartbeat interval on unmount', async () => {
216+
vi.useFakeTimers();
217+
vi.setSystemTime(new Date('2026-06-24T12:00:00.000Z'));
218+
219+
const { result, unmount } = renderHook(() =>
220+
useNotifications({
221+
userId: 'heartbeat-user',
222+
preferencesHeartbeatIntervalMs: 1000,
223+
preferencesHeartbeatStaleAfterMs: 3000,
224+
}),
225+
);
226+
227+
await flushHookEffects();
228+
229+
expect(result.current.preferencesHeartbeat.status).toBe('online');
230+
231+
unmount();
232+
233+
await act(async () => {
234+
vi.advanceTimersByTime(5000);
235+
});
236+
237+
const stored = JSON.parse(
238+
localStorageMock.getItem('notification_preferences_heartbeat_v1') ?? 'null',
239+
);
240+
expect(stored.lastBeatAt).toBe('2026-06-24T12:00:00.000Z');
241+
});
242+
243+
it('reports the preferences heartbeat as offline when storage writes fail', async () => {
244+
vi.useFakeTimers();
245+
vi.setSystemTime(new Date('2026-06-24T12:00:00.000Z'));
246+
vi.spyOn(localStorageMock, 'setItem').mockImplementation(() => {
247+
throw new Error('storage unavailable');
248+
});
249+
250+
const { result } = renderHook(() =>
251+
useNotifications({
252+
userId: 'heartbeat-user',
253+
preferencesHeartbeatIntervalMs: 1000,
254+
preferencesHeartbeatStaleAfterMs: 3000,
255+
}),
256+
);
257+
258+
await flushHookEffects();
259+
260+
expect(result.current.preferencesHeartbeat.status).toBe('offline');
261+
expect(result.current.preferencesHeartbeat.storageAvailable).toBe(false);
262+
expect(result.current.preferencesHeartbeat.failureCount).toBe(1);
263+
});
136264
});

0 commit comments

Comments
 (0)