Phase 6: Activity log and notifications
Add activity logging to all write API routes, notification system with real-time delivery via Socket.IO, @mention parsing in comments, activity feed sidebar on board page, per-card activity in card modal, and notification bell with unread badge in the navbar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
|
||||
|
||||
type Props = {
|
||||
boardId: string;
|
||||
cardId?: string;
|
||||
columnId?: string;
|
||||
};
|
||||
|
||||
let { boardId, cardId, columnId }: Props = $props();
|
||||
|
||||
let activities = $state<any[]>([]);
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
function formatAction(a: any): string {
|
||||
const meta = a.metadata || {};
|
||||
switch (a.action) {
|
||||
case 'board.updated': return 'updated the board';
|
||||
case 'board.deleted': return 'deleted the board';
|
||||
case 'column.created': return `created column "${meta.title}"`;
|
||||
case 'column.updated': return `renamed column to "${meta.title}"`;
|
||||
case 'column.deleted': return `deleted column "${meta.title}"`;
|
||||
case 'card.created': return `created card "${meta.title}"`;
|
||||
case 'card.moved': return `moved card from "${meta.from}" to "${meta.to}"`;
|
||||
case 'card.updated': return 'updated card';
|
||||
case 'card.deleted': return `deleted card "${meta.title}"`;
|
||||
case 'card.archived': return `archived card "${meta.title}"`;
|
||||
case 'comment.added': return 'added a comment';
|
||||
case 'comment.deleted': return 'deleted a comment';
|
||||
case 'member.added': return `added ${meta.member} to the board`;
|
||||
case 'member.removed': return `removed ${meta.member} from the board`;
|
||||
case 'member.updated': return `changed ${meta.member}'s role to ${meta.to}`;
|
||||
case 'assignee.added': return `assigned ${meta.assignee}`;
|
||||
case 'assignee.removed': return `unassigned ${meta.assignee}`;
|
||||
case 'label.added': return `added label "${meta.tag}"`;
|
||||
case 'label.removed': return `removed label "${meta.tag}"`;
|
||||
case 'checklist.created': return `created checklist "${meta.title}"`;
|
||||
case 'checklist.deleted': return `deleted checklist "${meta.title}"`;
|
||||
case 'checklist_item.added': return `added "${meta.title}" to checklist`;
|
||||
case 'checklist_item.toggled': return meta.completed ? `completed "${meta.title}"` : `unchecked "${meta.title}"`;
|
||||
case 'attachment.added': return `attached "${meta.filename}"`;
|
||||
case 'attachment.deleted': return `removed attachment "${meta.filename}"`;
|
||||
default: return a.action;
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function buildUrl(): string {
|
||||
if (cardId && columnId) {
|
||||
return `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/activity`;
|
||||
}
|
||||
return `/api/boards/${boardId}/activity`;
|
||||
}
|
||||
|
||||
async function fetchActivities(cursor?: string | null) {
|
||||
const url = new URL(buildUrl(), window.location.origin);
|
||||
url.searchParams.set('limit', '20');
|
||||
if (cursor) url.searchParams.set('cursor', cursor);
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) return;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
loadingMore = true;
|
||||
const data = await fetchActivities(nextCursor);
|
||||
if (data) {
|
||||
activities = [...activities, ...data.activities];
|
||||
nextCursor = data.nextCursor;
|
||||
}
|
||||
loadingMore = false;
|
||||
}
|
||||
|
||||
function onActivityNew(d: any) {
|
||||
// If we're showing card-specific activity and this is for a different card, ignore
|
||||
if (cardId && d.cardId && d.cardId !== cardId) return;
|
||||
|
||||
// Re-fetch latest to get the new entry
|
||||
fetchActivities().then((data) => {
|
||||
if (data) {
|
||||
// Merge: prepend new ones that aren't already in our list
|
||||
const existingIds = new Set(activities.map((a) => a.id));
|
||||
const newOnes = data.activities.filter((a: any) => !existingIds.has(a.id));
|
||||
activities = [...newOnes, ...activities];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const data = await fetchActivities();
|
||||
if (data) {
|
||||
activities = data.activities;
|
||||
nextCursor = data.nextCursor;
|
||||
}
|
||||
loading = false;
|
||||
|
||||
onBoardEvent('activity:new', onActivityNew);
|
||||
return () => offBoardEvent('activity:new', onActivityNew);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if loading}
|
||||
<div class="animate-pulse space-y-2">
|
||||
<div class="h-3 bg-gray-200 rounded w-3/4"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
{:else if activities.length === 0}
|
||||
<p class="text-sm text-gray-400 text-center py-4">No activity yet</p>
|
||||
{:else}
|
||||
{#each activities as activity (activity.id)}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center flex-shrink-0 mt-0.5"
|
||||
title={activity.user.name}
|
||||
>
|
||||
{activity.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-gray-700 leading-snug">
|
||||
<span class="font-medium">{activity.user.name}</span>
|
||||
{' '}{formatAction(activity)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{timeAgo(activity.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if nextCursor}
|
||||
<button
|
||||
onclick={loadMore}
|
||||
disabled={loadingMore}
|
||||
class="w-full text-center text-sm text-gray-500 hover:text-gray-700 py-2 transition"
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
import ChecklistSection from './ChecklistSection.svelte';
|
||||
import CommentList from './CommentList.svelte';
|
||||
import AttachmentList from './AttachmentList.svelte';
|
||||
import ActivityFeed from './ActivityFeed.svelte';
|
||||
|
||||
type Props = {
|
||||
card: any;
|
||||
@@ -209,6 +210,12 @@
|
||||
{currentUserId}
|
||||
{canEdit}
|
||||
/>
|
||||
|
||||
<!-- Activity Log -->
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Activity</h3>
|
||||
<ActivityFeed {boardId} cardId={card.id} {columnId} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getSocket } from '$lib/stores/socket.js';
|
||||
|
||||
let notifications = $state<any[]>([]);
|
||||
let unreadCount = $state(0);
|
||||
let open = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
const res = await fetch('/api/notifications?limit=10');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
notifications = data.notifications;
|
||||
unreadCount = data.unreadCount;
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||
unreadCount = 0;
|
||||
notifications = notifications.map((n) => ({ ...n, read: true }));
|
||||
}
|
||||
|
||||
async function markRead(id: string) {
|
||||
await fetch(`/api/notifications/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ read: true })
|
||||
});
|
||||
const n = notifications.find((x) => x.id === id);
|
||||
if (n && !n.read) {
|
||||
n.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onNewNotification(notification: any) {
|
||||
notifications = [notification, ...notifications.slice(0, 9)];
|
||||
unreadCount++;
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement;
|
||||
if (!el.closest('.notification-bell')) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchNotifications();
|
||||
loading = false;
|
||||
|
||||
const socket = getSocket();
|
||||
socket.on('notification:new', onNewNotification);
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
socket.off('notification:new', onNewNotification);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative notification-bell">
|
||||
<button
|
||||
onclick={() => { open = !open; }}
|
||||
class="relative rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
|
||||
title="Notifications"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
{#if unreadCount > 0}
|
||||
<span class="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="absolute right-0 top-full mt-1 w-80 bg-white rounded-lg shadow-xl border border-gray-200 z-50 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100">
|
||||
<span class="text-sm font-semibold text-gray-700">Notifications</span>
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
onclick={markAllRead}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#if loading}
|
||||
<div class="p-4 text-center text-sm text-gray-400">Loading...</div>
|
||||
{:else if notifications.length === 0}
|
||||
<div class="p-4 text-center text-sm text-gray-400">No notifications</div>
|
||||
{:else}
|
||||
{#each notifications as n (n.id)}
|
||||
<a
|
||||
href={n.link || '#'}
|
||||
onclick={() => { if (!n.read) markRead(n.id); open = false; }}
|
||||
class="block px-3 py-2.5 hover:bg-gray-50 transition border-b border-gray-50 last:border-0 {n.read ? '' : 'bg-blue-50/50'}"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
{#if n.type === 'assigned'}
|
||||
<svg class="w-4 h-4 text-[var(--color-primary)]" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" />
|
||||
</svg>
|
||||
{:else if n.type === 'mentioned'}
|
||||
<svg class="w-4 h-4 text-amber-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M14.243 5.757a6 6 0 10-.986 9.284 1 1 0 111.087 1.678A8 8 0 1118 10a3 3 0 01-4.8 2.401A4 4 0 1114 10a1 1 0 102 0c0-1.537-.586-3.07-1.757-4.243zM12 10a2 2 0 10-4 0 2 2 0 004 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-gray-700 leading-snug">{n.title}</p>
|
||||
{#if n.body}
|
||||
<p class="text-xs text-gray-500 mt-0.5 truncate">{n.body}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-400 mt-0.5">{timeAgo(n.createdAt)}</p>
|
||||
</div>
|
||||
{#if !n.read}
|
||||
<div class="w-2 h-2 rounded-full bg-[var(--color-primary)] flex-shrink-0 mt-1.5"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user