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
This commit is contained in:
2026-07-29 19:27:24 +00:00
parent 7ced6f2bf2
commit 9bc571eb69
4 changed files with 207 additions and 3 deletions
@@ -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<string, unknown> | null;
workspaceId: string;
createdAt: string;
}
const actionIcons: Record<string, React.ReactNode> = {
created: <PlusCircle className="h-3.5 w-3.5 text-green-500" />,
completed: <CheckCircle2 className="h-3.5 w-3.5 text-blue-500" />,
logged: <Flame className="h-3.5 w-3.5 text-orange-500" />,
commented: <MessageSquare className="h-3.5 w-3.5 text-purple-500" />,
};
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<string | null>(null);
const [activities, setActivities] = useState<ActivityItem[]>([]);
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="h-5 w-5" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold">Activity</span>
{activities.length > 0 && (
<span className="text-xs text-muted-foreground">{activities.length} items</span>
)}
</div>
<ScrollArea className="max-h-80">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
) : activities.length === 0 ? (
<div className="flex items-center justify-center py-8">
<span className="text-sm text-muted-foreground">No recent activity</span>
</div>
) : (
<div className="divide-y">
{activities.map((item) => (
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
<div className="mt-0.5 shrink-0">
{actionIcons[item.action] || <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm">{actionLabel(item)}</p>
<p className="text-xs text-muted-foreground">{formatTimeAgo(item.createdAt)}</p>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}
@@ -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<string, boolean> = {
task_created: true,
task_completed: true,
habit_logged: true,
comment_added: true,
};
const prefLabels: Record<string, string> = {
task_created: 'Task created',
task_completed: 'Task completed',
habit_logged: 'Habit logged',
comment_added: 'Comment added',
};
export function NotificationPrefs() {
const [prefs, setPrefs] = useState<Record<string, boolean>>(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 (
<div className="space-y-3">
{Object.entries(prefLabels).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label htmlFor={`notif-${key}`} className="text-sm font-normal">
{label}
</Label>
<Switch
id={`notif-${key}`}
checked={prefs[key]}
onCheckedChange={() => toggle(key)}
aria-label={`Toggle ${label}`}
/>
</div>
))}
</div>
);
}
@@ -49,6 +49,15 @@ export function SettingsShortcuts() {
<Button variant="outline" onClick={resetShortcuts}>
Reset to defaults
</Button>
{/* Notification Preferences */}
<div className="pt-4 border-t">
<h3 className="font-semibold mb-1">Notification Preferences</h3>
<p className="text-sm text-muted-foreground mb-4">
Choose which events trigger in-app notifications.
</p>
<NotificationPrefs />
</div>
</CardContent>
</Card>
);
+1 -3
View File
@@ -42,9 +42,7 @@ export function TopBar() {
<Plus className="mr-1 h-4 w-4" aria-hidden="true" />
{label}
</Button>
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="h-5 w-5" aria-hidden="true" />
</Button>
<NotificationBell />
</div>
</header>
<CommandPalette />