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; +}