diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d6e32145..0906bf75 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2956,6 +2956,52 @@ packages:
'@types/babel__generator@7.27.0':
resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+ '@tiptap/extension-youtube@3.23.6':
+ resolution: {integrity: sha512-j+Yfx4JkG/PE8mA4oEhAm9z8k1wMgbDtIS7V7BjFzyEOusFfISgovaHDLHJt5tlm7e321pzoBB9RNg8yIAApdw==}
+ peerDependencies:
+ '@tiptap/core': 3.23.6
+
+ '@tiptap/extensions@3.23.6':
+ resolution: {integrity: sha512-X09/Db1teB+ifXzDGVVFmOeQRx7wTAayE9/280spxpsHkHZvJ5bHRvWIzUzviMIjbBz+NPDIKYPK7gMfh9iaig==}
+ peerDependencies:
+ '@tiptap/core': 3.23.6
+ '@tiptap/pm': 3.23.6
+
+ '@tiptap/pm@3.23.6':
+ resolution: {integrity: sha512-in5CaMaWlJcH2A1q6GJKFtrodE8WLS3M9tIi/f89jPmIVHJShpodC0KZDNyJkrVBQomYk0DEh86Utm6ASXzQww==}
+
+ '@tiptap/react@3.23.6':
+ resolution: {integrity: sha512-Tw9KZkYqFMk3vaJAEQKqEYIO/iq3cSJe7OUEGBul4k4GaMQeLItLf5EYhUd0GIPXci1WVVPNntKJsHfX25M37w==}
+ peerDependencies:
+ '@tiptap/core': 3.23.6
+ '@tiptap/pm': 3.23.6
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^18.3.1
+ react-dom: ^18.3.1
+
+ '@tiptap/starter-kit@3.23.6':
+ resolution: {integrity: sha512-gykwtGWrnWCmtql1hid3opac/KV8zQvOAnu3bTqIqcHrn1FusbUwKmNzavSbfGvcktHM3hFjb35W48JyVLyu/A==}
+
+ '@tootallnate/quickjs-emscripten@0.23.0':
+ resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
+ '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1':
+ resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==}
+ engines: {node: '>=12'}
+
+ '@tybys/wasm-util@0.10.2':
+ resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
'@types/babel__template@7.4.4':
resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
@@ -3695,6 +3741,37 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ axe-core@4.11.4:
+ resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
+ engines: {node: '>=4'}
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ b4a@1.8.1:
+ resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
+ babel-jest@29.7.0:
+ resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
+ engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ peerDependencies:
+ '@babel/core': ^7.8.0
+
+ babel-plugin-istanbul@6.1.1:
+ resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
+ engines: {node: '>=8'}
+
+ babel-plugin-jest-hoist@29.6.3:
+ resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==}
+ engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+
+
axe-core@4.11.4:
resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
engines: {node: '>=4'}
@@ -14267,7 +14344,7 @@ snapshots:
jsontokens@4.0.1:
dependencies:
'@noble/hashes': 1.8.0
- '@noble/secp256k1': 1.7.2
+ '@noble/secp256k1': 1.7.1
base64-js: 1.5.1
jsx-ast-utils@3.3.5:
@@ -14780,11 +14857,11 @@ snapshots:
dependencies:
'@adraffy/ens-normalize': 1.11.1
'@noble/ciphers': 1.3.0
- '@noble/curves': 1.9.7
+ '@noble/curves': 1.9.2
'@noble/hashes': 1.8.0
'@scure/bip32': 1.7.0
'@scure/bip39': 1.6.0
- abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76)
+ abitype: 1.0.8(typescript@5.9.3)(zod@3.25.76)
eventemitter3: 5.0.1
optionalDependencies:
typescript: 5.9.3
diff --git a/src/app/components/notifications/RecommendationPanel.tsx b/src/app/components/notifications/RecommendationPanel.tsx
new file mode 100644
index 00000000..eb3912d6
--- /dev/null
+++ b/src/app/components/notifications/RecommendationPanel.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import React from 'react';
+import { Lightbulb, X, Check, ChevronRight } from 'lucide-react';
+import { NotificationRecommendation } from '@/lib/notifications';
+
+interface RecommendationPanelProps {
+ recommendations: NotificationRecommendation[];
+ onApply: (id: string) => Promise;
+ onDismiss: (id: string) => void;
+}
+
+const impactConfig = {
+ high: {
+ label: 'High impact',
+ className: 'bg-red-100 text-red-700 border border-red-200',
+ },
+ medium: {
+ label: 'Medium impact',
+ className: 'bg-amber-100 text-amber-700 border border-amber-200',
+ },
+ low: {
+ label: 'Low impact',
+ className: 'bg-blue-100 text-blue-700 border border-blue-200',
+ },
+} as const;
+
+export default function RecommendationPanel({
+ recommendations,
+ onApply,
+ onDismiss,
+}: RecommendationPanelProps) {
+ const [applying, setApplying] = React.useState(null);
+
+ if (recommendations.length === 0) return null;
+
+ const handleApply = async (id: string) => {
+ setApplying(id);
+ try {
+ await onApply(id);
+ } finally {
+ setApplying(null);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Recommendations
+
+
+ {recommendations.length}
+
+
+
+ {/* Recommendation cards */}
+
+ {recommendations.map((rec) => {
+ const impact = impactConfig[rec.impact];
+ const isApplying = applying === rec.id;
+
+ return (
+
+
+ {/* Impact badge + title row */}
+
+
+
+ {impact.label}
+
+
+ {rec.title}
+
+
+ {/* Dismiss */}
+
+
+
+ {/* Description */}
+
+ {rec.description}
+
+
+ {/* Apply button */}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/components/notifications/UserPreferences.tsx b/src/app/components/notifications/UserPreferences.tsx
index ef18c2b6..122b6bcb 100644
--- a/src/app/components/notifications/UserPreferences.tsx
+++ b/src/app/components/notifications/UserPreferences.tsx
@@ -14,6 +14,7 @@ import {
AlertCircle,
} from 'lucide-react';
import { useNotifications } from '@/app/hooks/useNotifications';
+import RecommendationPanel from './RecommendationPanel';
import {
UserNotificationPreferences,
NotificationChannel,
@@ -65,7 +66,7 @@ const channelIcons: Record = {
};
export default function UserPreferences({ userId, onSave }: UserPreferencesProps) {
- const { preferences, updatePreferences, isLoading } = useNotifications({ userId });
+ const { preferences, updatePreferences, isLoading, recommendations, applyRecommendation, dismissRecommendation } = useNotifications({ userId });
const [localPreferences, setLocalPreferences] = useState(
null,
@@ -188,6 +189,13 @@ export default function UserPreferences({ userId, onSave }: UserPreferencesProps
+ {/* Recommendation Engine Panel */}
+
+
{/* Global Channel Settings */}
Global Channels
diff --git a/src/app/hooks/useNotifications.tsx b/src/app/hooks/useNotifications.tsx
index b336ab6a..85a71aeb 100644
--- a/src/app/hooks/useNotifications.tsx
+++ b/src/app/hooks/useNotifications.tsx
@@ -8,6 +8,7 @@ import {
NotificationCategory,
UserNotificationPreferences,
NotificationAnalytics,
+ NotificationRecommendation,
generateNotificationId,
shouldSendNotification,
calculateAnalytics,
@@ -16,6 +17,7 @@ import {
createDefaultPreferences,
validatePreferences,
NotificationService,
+ generateRecommendations,
} from '@/lib/notifications';
interface UseNotificationsOptions {
@@ -69,9 +71,15 @@ interface UseNotificationsReturn {
notification: AppNotification,
channels: NotificationChannel[],
) => Promise>;
+
+ // Recommendations
+ recommendations: NotificationRecommendation[];
+ applyRecommendation: (id: string) => Promise;
+ dismissRecommendation: (id: string) => void;
}
const PREFERENCES_STORAGE_KEY = 'notification_preferences_v1';
+const DISMISSED_RECOMMENDATIONS_KEY = 'dismissed_recommendations_v1';
export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
const { userId = 'default', enableAnalytics = true } = options;
@@ -88,6 +96,14 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
const [preferences, setPreferences] = useState(null);
const [analytics, setAnalytics] = useState(null);
const [isLoading, setIsLoading] = useState(true);
+ const [dismissedIds, setDismissedIds] = useState>(() => {
+ try {
+ const stored = localStorage.getItem(DISMISSED_RECOMMENDATIONS_KEY);
+ return stored ? new Set(JSON.parse(stored)) : new Set();
+ } catch {
+ return new Set();
+ }
+ });
// Load preferences from localStorage
useEffect(() => {
@@ -322,6 +338,44 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
// Computed values
const unreadCount = useMemo(() => notifications.filter((n) => !n.read).length, [notifications]);
+ // Recommendations — recompute whenever analytics or preferences change
+ const recommendations = useMemo(() => {
+ if (!analytics || !preferences) return [];
+ const all = generateRecommendations(analytics, preferences);
+ return all.filter((r) => !dismissedIds.has(r.id));
+ }, [analytics, preferences, dismissedIds]);
+
+ // Apply a recommendation: merge its patch into preferences then auto-dismiss
+ const applyRecommendation = useCallback(
+ async (id: string) => {
+ const rec = recommendations.find((r) => r.id === id);
+ if (!rec) return;
+ await updatePreferences(rec.preferencePatch);
+ // Dismiss after applying so it no longer appears
+ setDismissedIds((prev) => {
+ const next = new Set(prev);
+ next.add(id);
+ try {
+ localStorage.setItem(DISMISSED_RECOMMENDATIONS_KEY, JSON.stringify([...next]));
+ } catch { /* ignore */ }
+ return next;
+ });
+ },
+ [recommendations, updatePreferences],
+ );
+
+ // Dismiss without applying
+ const dismissRecommendation = useCallback((id: string) => {
+ setDismissedIds((prev) => {
+ const next = new Set(prev);
+ next.add(id);
+ try {
+ localStorage.setItem(DISMISSED_RECOMMENDATIONS_KEY, JSON.stringify([...next]));
+ } catch { /* ignore */ }
+ return next;
+ });
+ }, []);
+
return {
// State
notifications,
@@ -351,6 +405,11 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
// Multi-channel delivery
sendToChannel,
sendToAllChannels,
+
+ // Recommendations
+ recommendations,
+ applyRecommendation,
+ dismissRecommendation,
};
}
diff --git a/src/lib/notifications/__tests__/recommendation-engine.test.ts b/src/lib/notifications/__tests__/recommendation-engine.test.ts
new file mode 100644
index 00000000..1600d363
--- /dev/null
+++ b/src/lib/notifications/__tests__/recommendation-engine.test.ts
@@ -0,0 +1,465 @@
+/**
+ * Recommendation Engine — unit tests
+ *
+ * Pure function tests: no DOM, no mocks, no async.
+ */
+import { describe, it, expect } from 'vitest';
+import { generateRecommendations } from '../recommendation-engine';
+import {
+ NotificationAnalytics,
+ NotificationCategory,
+ NotificationChannel,
+ UserNotificationPreferences,
+} from '../types';
+
+// ─── Helpers ───────────────────────────────────────────────────────────────────
+
+function makeAnalytics(
+ overrides: Partial<{
+ totalSent: number;
+ totalRead: number;
+ totalClicked: number;
+ byChannel: Partial;
+ byCategory: Partial;
+ }> = {},
+): NotificationAnalytics {
+ const defaultChannel = { sent: 0, read: 0, clicked: 0 };
+ const defaultCategory = { sent: 0, read: 0, clicked: 0 };
+
+ return {
+ totalSent: overrides.totalSent ?? 0,
+ totalRead: overrides.totalRead ?? 0,
+ totalClicked: overrides.totalClicked ?? 0,
+ readRate: overrides.totalSent
+ ? ((overrides.totalRead ?? 0) / overrides.totalSent) * 100
+ : 0,
+ clickRate: overrides.totalSent
+ ? ((overrides.totalClicked ?? 0) / overrides.totalSent) * 100
+ : 0,
+ byChannel: {
+ push: defaultChannel,
+ email: defaultChannel,
+ sms: defaultChannel,
+ 'in-app': defaultChannel,
+ ...overrides.byChannel,
+ },
+ byCategory: {
+ course_update: defaultCategory,
+ message: defaultCategory,
+ achievement: defaultCategory,
+ reminder: defaultCategory,
+ system: defaultCategory,
+ social: defaultCategory,
+ payment: defaultCategory,
+ ...overrides.byCategory,
+ },
+ };
+}
+
+function makePreferences(
+ overrides: Partial = {},
+): UserNotificationPreferences {
+ return {
+ userId: 'test-user',
+ channels: {
+ push: true,
+ email: true,
+ sms: false,
+ inApp: true,
+ },
+ categories: {
+ course_update: { enabled: true, channels: ['in-app', 'email'] },
+ message: { enabled: true, channels: ['in-app', 'push'] },
+ achievement: { enabled: true, channels: ['in-app', 'push', 'email'] },
+ reminder: { enabled: true, channels: ['in-app', 'push'] },
+ system: { enabled: true, channels: ['in-app'] },
+ social: { enabled: true, channels: ['in-app'] },
+ payment: { enabled: true, channels: ['in-app', 'email'] },
+ },
+ quietHours: {
+ enabled: false,
+ start: '22:00',
+ end: '08:00',
+ timezone: 'UTC',
+ },
+ frequency: {
+ digest: 'realtime',
+ maxPerDay: 50,
+ },
+ ...overrides,
+ };
+}
+
+// ─── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('generateRecommendations', () => {
+ // ── Empty data ──────────────────────────────────────────────────────────────
+
+ describe('empty analytics', () => {
+ it('returns an empty array when totalSent is 0', () => {
+ const result = generateRecommendations(makeAnalytics(), makePreferences());
+ expect(result).toEqual([]);
+ });
+ });
+
+ // ── Rule 1: disable_category ────────────────────────────────────────────────
+
+ describe('Rule 1 — disable_category', () => {
+ it('recommends disabling a category with read-rate < 20 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 1,
+ byCategory: {
+ system: { sent: 10, read: 1, clicked: 0 }, // 10 % read-rate
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'disable_category' && r.category === 'system');
+ expect(rec).toBeDefined();
+ expect(rec!.preferencePatch.categories!.system.enabled).toBe(false);
+ });
+
+ it('does NOT recommend disabling a category with fewer than 5 sent', () => {
+ const analytics = makeAnalytics({
+ totalSent: 3,
+ totalRead: 0,
+ byCategory: {
+ system: { sent: 3, read: 0, clicked: 0 },
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'disable_category');
+ expect(rec).toBeUndefined();
+ });
+
+ it('does NOT recommend disabling an already-disabled category', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 0,
+ byCategory: {
+ system: { sent: 10, read: 0, clicked: 0 },
+ },
+ });
+
+ const prefs = makePreferences({
+ categories: {
+ ...makePreferences().categories,
+ system: { enabled: false, channels: ['in-app'] },
+ },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'disable_category' && r.category === 'system');
+ expect(rec).toBeUndefined();
+ });
+
+ it('assigns high impact when read-rate < 10 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 0,
+ byCategory: {
+ system: { sent: 10, read: 0, clicked: 0 }, // 0 % read-rate
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'disable_category');
+ expect(rec!.impact).toBe('high');
+ });
+ });
+
+ // ── Rule 2: add_channel ─────────────────────────────────────────────────────
+
+ describe('Rule 2 — add_channel (push)', () => {
+ it('recommends adding push when click-rate > 60 % and push not in category channels', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 5,
+ totalClicked: 7,
+ byCategory: {
+ system: { sent: 10, read: 5, clicked: 7 }, // 70 % click-rate
+ },
+ });
+
+ // system category doesn't have push
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'add_channel' && r.category === 'system');
+ expect(rec).toBeDefined();
+ expect(rec!.channel).toBe('push');
+ expect(rec!.preferencePatch.categories!.system.channels).toContain('push');
+ });
+
+ it('does NOT recommend push when it is already in the category channels', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 5,
+ totalClicked: 7,
+ byCategory: {
+ message: { sent: 10, read: 5, clicked: 7 }, // message already has push
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'add_channel' && r.category === 'message');
+ expect(rec).toBeUndefined();
+ });
+
+ it('does NOT recommend push when push is globally disabled', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 5,
+ totalClicked: 7,
+ byCategory: {
+ system: { sent: 10, read: 5, clicked: 7 },
+ },
+ });
+
+ const prefs = makePreferences({
+ channels: { push: false, email: true, sms: false, inApp: true },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'add_channel');
+ expect(rec).toBeUndefined();
+ });
+ });
+
+ // ── Rule 3: switch_digest ───────────────────────────────────────────────────
+
+ describe('Rule 3 — switch_digest', () => {
+ it('recommends daily digest when email read-rate < 10 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 0,
+ byChannel: {
+ email: { sent: 10, read: 0, clicked: 0 }, // 0 % read-rate
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'switch_digest');
+ expect(rec).toBeDefined();
+ expect(rec!.preferencePatch.frequency!.digest).toBe('daily');
+ });
+
+ it('does NOT recommend digest when already on a digest mode', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 0,
+ byChannel: {
+ email: { sent: 10, read: 0, clicked: 0 },
+ },
+ });
+
+ const prefs = makePreferences({
+ frequency: { digest: 'daily', maxPerDay: 50 },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'switch_digest');
+ expect(rec).toBeUndefined();
+ });
+
+ it('does NOT recommend digest when email is globally disabled', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 0,
+ byChannel: {
+ email: { sent: 10, read: 0, clicked: 0 },
+ },
+ });
+
+ const prefs = makePreferences({
+ channels: { push: true, email: false, sms: false, inApp: true },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'switch_digest');
+ expect(rec).toBeUndefined();
+ });
+ });
+
+ // ── Rule 4: enable_quiet_hours ──────────────────────────────────────────────
+
+ describe('Rule 4 — enable_quiet_hours', () => {
+ it('recommends quiet hours when not set and overall read-rate < 40 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 3, // 30 % read-rate
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'enable_quiet_hours');
+ expect(rec).toBeDefined();
+ expect(rec!.preferencePatch.quietHours!.enabled).toBe(true);
+ });
+
+ it('does NOT recommend quiet hours when already enabled', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 3,
+ });
+
+ const prefs = makePreferences({
+ quietHours: { enabled: true, start: '22:00', end: '08:00', timezone: 'UTC' },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'enable_quiet_hours');
+ expect(rec).toBeUndefined();
+ });
+
+ it('does NOT recommend quiet hours when read-rate >= 40 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 5, // 50 % read-rate
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'enable_quiet_hours');
+ expect(rec).toBeUndefined();
+ });
+ });
+
+ // ── Rule 5: reduce_frequency ────────────────────────────────────────────────
+
+ describe('Rule 5 — reduce_frequency', () => {
+ it('recommends reducing maxPerDay when >= 50 and read-rate < 30 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 2, // 20 % read-rate
+ });
+
+ const result = generateRecommendations(analytics, makePreferences()); // maxPerDay = 50
+ const rec = result.find((r) => r.type === 'reduce_frequency');
+ expect(rec).toBeDefined();
+ expect(rec!.preferencePatch.frequency!.maxPerDay).toBe(20);
+ expect(rec!.impact).toBe('high');
+ });
+
+ it('does NOT recommend reducing frequency when maxPerDay < 50', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 2,
+ });
+
+ const prefs = makePreferences({
+ frequency: { digest: 'realtime', maxPerDay: 20 },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'reduce_frequency');
+ expect(rec).toBeUndefined();
+ });
+
+ it('does NOT recommend reducing frequency when read-rate >= 30 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 4, // 40 % read-rate
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'reduce_frequency');
+ expect(rec).toBeUndefined();
+ });
+ });
+
+ // ── Rule 6: enable_sms ─────────────────────────────────────────────────────
+
+ describe('Rule 6 — enable_sms', () => {
+ it('recommends enabling SMS when push read-rate > 70 % and SMS is disabled', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 8,
+ byChannel: {
+ push: { sent: 10, read: 8, clicked: 3 }, // 80 % read-rate
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences()); // sms: false
+ const rec = result.find((r) => r.type === 'enable_sms');
+ expect(rec).toBeDefined();
+ expect(rec!.preferencePatch.channels!.sms).toBe(true);
+ });
+
+ it('does NOT recommend enabling SMS when already enabled', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 8,
+ byChannel: {
+ push: { sent: 10, read: 8, clicked: 3 },
+ },
+ });
+
+ const prefs = makePreferences({
+ channels: { push: true, email: true, sms: true, inApp: true },
+ });
+
+ const result = generateRecommendations(analytics, prefs);
+ const rec = result.find((r) => r.type === 'enable_sms');
+ expect(rec).toBeUndefined();
+ });
+
+ it('does NOT recommend SMS when push read-rate <= 70 %', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 7,
+ byChannel: {
+ push: { sent: 10, read: 6, clicked: 2 }, // 60 % read-rate
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+ const rec = result.find((r) => r.type === 'enable_sms');
+ expect(rec).toBeUndefined();
+ });
+ });
+
+ // ── Deterministic IDs & deduplication ──────────────────────────────────────
+
+ describe('deterministic IDs', () => {
+ it('generates the same ID for the same signal on repeated calls', () => {
+ const analytics = makeAnalytics({
+ totalSent: 10,
+ totalRead: 2,
+ });
+
+ const result1 = generateRecommendations(analytics, makePreferences());
+ const result2 = generateRecommendations(analytics, makePreferences());
+
+ const ids1 = result1.map((r) => r.id);
+ const ids2 = result2.map((r) => r.id);
+
+ expect(ids1).toEqual(ids2);
+ });
+ });
+
+ // ── Sort order ─────────────────────────────────────────────────────────────
+
+ describe('sort order', () => {
+ it('returns recommendations sorted by impact: high → medium → low', () => {
+ // Craft analytics to trigger all three impact levels:
+ // - reduce_frequency → high
+ // - disable_category medium → medium (read 15 %)
+ // - enable_quiet_hours → low
+ const analytics = makeAnalytics({
+ totalSent: 20,
+ totalRead: 4, // 20 % overall — triggers quiet_hours (< 40%) and reduce_frequency (< 30%)
+ byCategory: {
+ system: { sent: 20, read: 3, clicked: 0 }, // 15 % — medium disable
+ },
+ });
+
+ const result = generateRecommendations(analytics, makePreferences());
+
+ const impactValues = result.map((r) => r.impact);
+ const impactOrder = { high: 0, medium: 1, low: 2 };
+ const isSorted = impactValues.every((val, i) =>
+ i === 0 ? true : impactOrder[val] >= impactOrder[impactValues[i - 1]],
+ );
+ expect(isSorted).toBe(true);
+ });
+ });
+});
diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts
index e2d19f54..37145dc4 100644
--- a/src/lib/notifications/index.ts
+++ b/src/lib/notifications/index.ts
@@ -5,6 +5,7 @@
export * from './types';
export * from './service';
+export { generateRecommendations } from './recommendation-engine';
// Re-export utility functions from notificationUtils
export {
@@ -22,3 +23,4 @@ export {
createDefaultPreferences,
validatePreferences,
} from '@/utils/notificationUtils';
+
diff --git a/src/lib/notifications/recommendation-engine.ts b/src/lib/notifications/recommendation-engine.ts
new file mode 100644
index 00000000..193faa54
--- /dev/null
+++ b/src/lib/notifications/recommendation-engine.ts
@@ -0,0 +1,260 @@
+/**
+ * Notification Preference Recommendation Engine
+ *
+ * A pure, stateless function that analyses NotificationAnalytics and the
+ * user's current UserNotificationPreferences and returns a prioritised list
+ * of NotificationRecommendation objects, each carrying a preferencePatch
+ * that can be applied directly via updatePreferences().
+ *
+ * Rules:
+ * 1. disable_category — category read-rate < 20 %
+ * 2. add_channel — category click-rate > 60 % but push not enabled
+ * 3. switch_digest — email channel read-rate < 10 %
+ * 4. enable_quiet_hours— quiet hours not set + overall read-rate < 40 %
+ * 5. reduce_frequency — maxPerDay >= 50 and overall read-rate < 30 %
+ * 6. enable_sms — push read-rate > 70 % and SMS globally disabled
+ */
+
+import {
+ NotificationAnalytics,
+ NotificationCategory,
+ NotificationRecommendation,
+ UserNotificationPreferences,
+} from './types';
+
+// ─── Thresholds ────────────────────────────────────────────────────────────────
+
+const LOW_READ_RATE = 20; // % — below this → suggest disabling category
+const HIGH_CLICK_RATE = 60; // % — above this → suggest adding push
+const LOW_EMAIL_READ_RATE = 10; // % — below this → suggest digest mode
+const QUIET_READ_RATE = 40; // % — below this (overall) → suggest quiet hours
+const HIGH_FREQ_READ_RATE = 30; // % — below this at maxPerDay ≥ 50 → suggest reducing
+const HIGH_PUSH_READ_RATE = 70; // % — above this → suggest SMS
+
+// ─── Helpers ───────────────────────────────────────────────────────────────────
+
+function pct(numerator: number, denominator: number): number {
+ if (denominator === 0) return 0;
+ return (numerator / denominator) * 100;
+}
+
+/**
+ * Build a deterministic ID so the same signal always produces the same ID.
+ * This lets the UI persist dismissed IDs across renders.
+ */
+function makeId(...parts: string[]): string {
+ return `rec_${parts.join('_')}`;
+}
+
+// ─── Engine ────────────────────────────────────────────────────────────────────
+
+/**
+ * Generate notification preference recommendations based on analytics data.
+ *
+ * @param analytics - NotificationAnalytics produced by calculateAnalytics()
+ * @param preferences - The user's current UserNotificationPreferences
+ * @returns Array of NotificationRecommendation objects, ordered by impact (high → low)
+ */
+export function generateRecommendations(
+ analytics: NotificationAnalytics,
+ preferences: UserNotificationPreferences,
+): NotificationRecommendation[] {
+ // No data → no recommendations
+ if (analytics.totalSent === 0) return [];
+
+ const recommendations: NotificationRecommendation[] = [];
+
+ const overallReadRate = pct(analytics.totalRead, analytics.totalSent);
+
+ // ── Rule 1: disable_category ─────────────────────────────────────────────────
+ // For each category that has been sent ≥ 5 notifications but has a read-rate
+ // below LOW_READ_RATE, recommend disabling it.
+
+ const categoryKeys = Object.keys(analytics.byCategory) as NotificationCategory[];
+
+ for (const category of categoryKeys) {
+ const catStats = analytics.byCategory[category];
+ const catPrefs = preferences.categories[category];
+
+ if (!catPrefs?.enabled) continue; // already disabled
+ if (catStats.sent < 5) continue; // not enough data
+
+ const readRate = pct(catStats.read, catStats.sent);
+
+ if (readRate < LOW_READ_RATE) {
+ recommendations.push({
+ id: makeId('disable_category', category),
+ type: 'disable_category',
+ title: `Disable "${formatCategory(category)}" notifications`,
+ description: `Only ${readRate.toFixed(0)}% of "${formatCategory(category)}" notifications are read. Disabling this category will reduce noise.`,
+ impact: readRate < 10 ? 'high' : 'medium',
+ category,
+ preferencePatch: {
+ categories: {
+ ...preferences.categories,
+ [category]: {
+ ...catPrefs,
+ enabled: false,
+ },
+ },
+ },
+ });
+ }
+ }
+
+ // ── Rule 2: add_channel (push) ────────────────────────────────────────────────
+ // For each category with click-rate > HIGH_CLICK_RATE but push not in its channels,
+ // recommend adding the push channel.
+
+ for (const category of categoryKeys) {
+ const catStats = analytics.byCategory[category];
+ const catPrefs = preferences.categories[category];
+
+ if (!catPrefs?.enabled) continue;
+ if (catStats.sent < 5) continue;
+ if (!preferences.channels.push) continue; // push globally disabled
+ if (catPrefs.channels.includes('push')) continue; // already has push
+
+ const clickRate = pct(catStats.clicked, catStats.sent);
+
+ if (clickRate > HIGH_CLICK_RATE) {
+ recommendations.push({
+ id: makeId('add_channel', category, 'push'),
+ type: 'add_channel',
+ title: `Enable push for "${formatCategory(category)}"`,
+ description: `${clickRate.toFixed(0)}% of "${formatCategory(category)}" notifications are clicked — you'd benefit from instant push delivery.`,
+ impact: 'medium',
+ category,
+ channel: 'push',
+ preferencePatch: {
+ categories: {
+ ...preferences.categories,
+ [category]: {
+ ...catPrefs,
+ channels: [...catPrefs.channels, 'push'],
+ },
+ },
+ },
+ });
+ }
+ }
+
+ // ── Rule 3: switch_digest ─────────────────────────────────────────────────────
+ // Email channel read-rate below LOW_EMAIL_READ_RATE → suggest digest mode
+ // (only when currently on realtime).
+
+ if (preferences.channels.email && preferences.frequency.digest === 'realtime') {
+ const emailStats = analytics.byChannel['email'];
+ if (emailStats && emailStats.sent >= 5) {
+ const emailReadRate = pct(emailStats.read, emailStats.sent);
+
+ if (emailReadRate < LOW_EMAIL_READ_RATE) {
+ recommendations.push({
+ id: makeId('switch_digest', 'daily'),
+ type: 'switch_digest',
+ title: 'Switch email to daily digest',
+ description: `Only ${emailReadRate.toFixed(0)}% of email notifications are read immediately. A daily digest reduces inbox clutter while keeping you informed.`,
+ impact: 'medium',
+ channel: 'email',
+ preferencePatch: {
+ frequency: {
+ ...preferences.frequency,
+ digest: 'daily',
+ },
+ },
+ });
+ }
+ }
+ }
+
+ // ── Rule 4: enable_quiet_hours ───────────────────────────────────────────────
+ // If quiet hours are not enabled and overall read-rate is below QUIET_READ_RATE,
+ // suggest enabling them with sensible defaults.
+
+ if (!preferences.quietHours.enabled && overallReadRate < QUIET_READ_RATE) {
+ recommendations.push({
+ id: makeId('enable_quiet_hours'),
+ type: 'enable_quiet_hours',
+ title: 'Enable quiet hours',
+ description: `Your overall read rate is ${overallReadRate.toFixed(0)}%. Enabling quiet hours (e.g. 22:00–08:00) can reduce interruptions during low-engagement periods.`,
+ impact: 'low',
+ preferencePatch: {
+ quietHours: {
+ ...preferences.quietHours,
+ enabled: true,
+ start: preferences.quietHours.start || '22:00',
+ end: preferences.quietHours.end || '08:00',
+ },
+ },
+ });
+ }
+
+ // ── Rule 5: reduce_frequency ──────────────────────────────────────────────────
+ // maxPerDay >= 50 and overall read-rate < HIGH_FREQ_READ_RATE → reduce to 20.
+
+ if (
+ preferences.frequency.maxPerDay >= 50 &&
+ overallReadRate < HIGH_FREQ_READ_RATE
+ ) {
+ recommendations.push({
+ id: makeId('reduce_frequency'),
+ type: 'reduce_frequency',
+ title: 'Reduce daily notification limit',
+ description: `You receive up to ${preferences.frequency.maxPerDay} notifications/day but only read ${overallReadRate.toFixed(0)}% of them. Lowering the limit to 20 will surface the most important ones.`,
+ impact: 'high',
+ preferencePatch: {
+ frequency: {
+ ...preferences.frequency,
+ maxPerDay: 20,
+ },
+ },
+ });
+ }
+
+ // ── Rule 6: enable_sms ────────────────────────────────────────────────────────
+ // Push read-rate > HIGH_PUSH_READ_RATE but SMS is globally disabled →
+ // suggest enabling SMS for high-priority categories.
+
+ if (!preferences.channels.sms) {
+ const pushStats = analytics.byChannel['push'];
+ if (pushStats && pushStats.sent >= 5) {
+ const pushReadRate = pct(pushStats.read, pushStats.sent);
+
+ if (pushReadRate > HIGH_PUSH_READ_RATE) {
+ recommendations.push({
+ id: makeId('enable_sms'),
+ type: 'enable_sms',
+ title: 'Consider enabling SMS for critical alerts',
+ description: `You read ${pushReadRate.toFixed(0)}% of push notifications. Adding SMS ensures you never miss urgent or payment notifications even when your device is silenced.`,
+ impact: 'low',
+ channel: 'sms',
+ preferencePatch: {
+ channels: {
+ ...preferences.channels,
+ sms: true,
+ },
+ },
+ });
+ }
+ }
+ }
+
+ // ── Sort by impact: high → medium → low ──────────────────────────────────────
+
+ const impactOrder: Record<'high' | 'medium' | 'low', number> = {
+ high: 0,
+ medium: 1,
+ low: 2,
+ };
+
+ return recommendations.sort((a, b) => impactOrder[a.impact] - impactOrder[b.impact]);
+}
+
+// ─── Utilities ────────────────────────────────────────────────────────────────
+
+function formatCategory(category: NotificationCategory): string {
+ return category
+ .split('_')
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ');
+}
diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts
index e7d9b695..dbd5b8c1 100644
--- a/src/lib/notifications/types.ts
+++ b/src/lib/notifications/types.ts
@@ -94,3 +94,24 @@ export interface NotificationDeliveryResult {
error?: string;
timestamp: Date;
}
+
+export type RecommendationType =
+ | 'disable_category'
+ | 'add_channel'
+ | 'switch_digest'
+ | 'enable_quiet_hours'
+ | 'reduce_frequency'
+ | 'enable_sms';
+
+export interface NotificationRecommendation {
+ /** Stable deterministic ID — same inputs always produce the same ID. */
+ id: string;
+ type: RecommendationType;
+ title: string;
+ description: string;
+ impact: 'low' | 'medium' | 'high';
+ /** Ready-to-apply diff — pass directly to updatePreferences(). */
+ preferencePatch: Partial;
+ category?: NotificationCategory;
+ channel?: NotificationChannel;
+}