From 9bc571eb69abd85111fdd6fcdf5e8b6b40c2a35e Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 29 Jul 2026 19:27:24 +0000 Subject: [PATCH] feat: add notification bell with activity feed and notification preferences - New NotificationBell component in topbar showing recent activity - Popover with last 10 activity items from /api/domains/[id]/activity - Notification preferences section in Settings > Shortcuts tab - Preferences persisted to localStorage (pe_notification_prefs) - Toggle switches for task_created, task_completed, habit_logged, comment_added --- .../notifications/notification-bell.tsx | 133 ++++++++++++++++++ .../notifications/notification-prefs.tsx | 64 +++++++++ .../settings/settings-shortcuts.tsx | 9 ++ apps/web/components/topbar.tsx | 4 +- 4 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 apps/web/components/notifications/notification-bell.tsx create mode 100644 apps/web/components/notifications/notification-prefs.tsx diff --git a/apps/web/components/notifications/notification-bell.tsx b/apps/web/components/notifications/notification-bell.tsx new file mode 100644 index 0000000..5c2da27 --- /dev/null +++ b/apps/web/components/notifications/notification-bell.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { Bell, Clock, CheckCircle2, PlusCircle, MessageSquare, Flame } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface ActivityItem { + id: string; + actor: string; + action: string; + entityType: string; + entityId: string; + changes: Record | null; + workspaceId: string; + createdAt: string; +} + +const actionIcons: Record = { + created: , + completed: , + logged: , + commented: , +}; + +function formatTimeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return `${diffH}h ago`; + const diffD = Math.floor(diffH / 24); + if (diffD < 7) return `${diffD}d ago`; + return new Date(dateStr).toLocaleDateString(); +} + +function actionLabel(item: ActivityItem): string { + const entity = item.entityType.charAt(0).toUpperCase() + item.entityType.slice(1); + switch (item.action) { + case 'created': return `${entity} created`; + case 'completed': return `${entity} completed`; + case 'logged': return `${entity} logged`; + case 'commented': return `Comment on ${entity}`; + default: return `${item.action} ${item.entityType}`; + } +} + +export function NotificationBell() { + const [domainId, setDomainId] = useState(null); + const [activities, setActivities] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const fetchedRef = useRef(false); + + // Auto-detect first domain + useEffect(() => { + fetch('/api/domains?sort=sort_order') + .then((res) => res.json()) + .then((data) => { + const items = data.items || []; + if (items.length > 0) setDomainId(items[0].id); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + if (!open || fetchedRef.current) return; + fetchActivities(); + }, [open, domainId]); + + async function fetchActivities() { + if (!domainId) return; + setLoading(true); + fetchedRef.current = true; + try { + const res = await fetch(`/api/domains/${domainId}/activity?limit=10`); + if (!res.ok) throw new Error(); + const data = await res.json(); + setActivities(data.items || []); + } catch { + // Silently fail — non-critical UI + } finally { + setLoading(false); + } + } + + return ( + + + + + +
+ Activity + {activities.length > 0 && ( + {activities.length} items + )} +
+ + {loading ? ( +
+ Loading... +
+ ) : activities.length === 0 ? ( +
+ No recent activity +
+ ) : ( +
+ {activities.map((item) => ( +
+
+ {actionIcons[item.action] || } +
+
+

{actionLabel(item)}

+

{formatTimeAgo(item.createdAt)}

+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/components/notifications/notification-prefs.tsx b/apps/web/components/notifications/notification-prefs.tsx new file mode 100644 index 0000000..637618a --- /dev/null +++ b/apps/web/components/notifications/notification-prefs.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; + +const STORAGE_KEY = 'pe_notification_prefs'; + +const defaultPrefs: Record = { + task_created: true, + task_completed: true, + habit_logged: true, + comment_added: true, +}; + +const prefLabels: Record = { + task_created: 'Task created', + task_completed: 'Task completed', + habit_logged: 'Habit logged', + comment_added: 'Comment added', +}; + +export function NotificationPrefs() { + const [prefs, setPrefs] = useState>(defaultPrefs); + + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setPrefs({ ...defaultPrefs, ...JSON.parse(stored) }); + } + } catch { + // Ignore parse errors + } + }, []); + + function toggle(key: string) { + const updated = { ...prefs, [key]: !prefs[key] }; + setPrefs(updated); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch { + // Storage may be full + } + } + + return ( +
+ {Object.entries(prefLabels).map(([key, label]) => ( +
+ + toggle(key)} + aria-label={`Toggle ${label}`} + /> +
+ ))} +
+ ); +} diff --git a/apps/web/components/settings/settings-shortcuts.tsx b/apps/web/components/settings/settings-shortcuts.tsx index 464d63d..23fd7f1 100644 --- a/apps/web/components/settings/settings-shortcuts.tsx +++ b/apps/web/components/settings/settings-shortcuts.tsx @@ -49,6 +49,15 @@ export function SettingsShortcuts() { + + {/* Notification Preferences */} +
+

Notification Preferences

+

+ Choose which events trigger in-app notifications. +

+ +
); diff --git a/apps/web/components/topbar.tsx b/apps/web/components/topbar.tsx index 1616f1d..d65a17a 100644 --- a/apps/web/components/topbar.tsx +++ b/apps/web/components/topbar.tsx @@ -42,9 +42,7 @@ export function TopBar() {