/** * Activity feed: unified event type and builders for historical + real-time events. * * Data sources: * 1. Issue JSONL timestamps (created_at, closed_at, updated_at) * 2. Dependency timestamps (link created_at) * 3. ATProto comments, claims, likes (from useBeadsComments) * 4. Real-time SSE diffs (BeadsDiff from diffBeadsData) */ import type { GraphNode, GraphLink } from "./types"; import type { BeadsDiff, NodeChange } from "./diff-beads"; import type { BeadsComment } from "@/lib/comments"; // ============================================================================ // Types // ============================================================================ export type ActivityEventType = | "node-created" | "node-closed" | "node-status-changed" | "node-priority-changed" | "node-title-changed" | "node-owner-changed" | "node-assignee-changed" | "link-added" | "link-removed" | "comment-added" | "reply-added" | "task-claimed" | "task-unclaimed" | "like-added"; export interface ActivityActor { handle: string; avatar?: string; did?: string; } export interface ActivityEvent { /** Unique key for React rendering and deduplication: `${type}:${nodeId}:${time}` */ id: string; type: ActivityEventType; /** Unix milliseconds, for sorting */ time: number; /** Which issue this event relates to */ nodeId: string; /** Issue title for display (may be undefined for deleted nodes) */ nodeTitle?: string; /** Who performed the action (for comments, claims, likes) */ actor?: ActivityActor; /** Human-readable detail: e.g. "open -> in_progress", comment text preview, link target */ detail?: string; /** Extra structured context */ meta?: Record; } /** Filter category for the UI */ export type ActivityFilterCategory = | "issues" | "deps" | "comments" | "claims" | "likes"; /** Map event types to filter categories */ export function getEventCategory(type: ActivityEventType): ActivityFilterCategory { switch (type) { case "node-created": case "node-closed": case "node-status-changed": case "node-priority-changed": case "node-title-changed": case "node-owner-changed": case "node-assignee-changed": return "issues"; case "link-added": case "link-removed": return "deps"; case "comment-added": case "reply-added": return "comments"; case "task-claimed": case "task-unclaimed": return "claims"; case "like-added": return "likes"; } } // ============================================================================ // Historical feed builder // ============================================================================ /** * Build the full historical activity feed from existing data. * Called once on load and when allComments changes. */ export function buildHistoricalFeed( nodes: GraphNode[], links: GraphLink[], allComments: BeadsComment[] | null ): ActivityEvent[] { const events: ActivityEvent[] = []; const seen = new Set(); function add(event: ActivityEvent) { if (seen.has(event.id)) return; seen.add(event.id); events.push(event); } // --- Issue lifecycle events --- for (const node of nodes) { // Created if (node.createdAt) { const time = new Date(node.createdAt).getTime(); if (!isNaN(time)) { add({ id: `node-created:${node.id}:${time}`, type: "node-created", time, nodeId: node.id, nodeTitle: node.title, actor: node.createdBy ? { handle: node.createdBy } : node.owner ? { handle: node.owner } : node.assignee ? { handle: node.assignee } : undefined, detail: node.issueType, meta: { issueType: node.issueType, prefix: node.prefix }, }); } } // Closed if (node.closedAt) { const time = new Date(node.closedAt).getTime(); if (!isNaN(time)) { add({ id: `node-closed:${node.id}:${time}`, type: "node-closed", time, nodeId: node.id, nodeTitle: node.title, actor: node.owner ? { handle: node.owner } : node.createdBy ? { handle: node.createdBy } : node.assignee ? { handle: node.assignee } : undefined, detail: node.closeReason || "Closed", meta: { prefix: node.prefix }, }); } } } // --- Dependency events --- for (const link of links) { if (link.createdAt) { const time = new Date(link.createdAt).getTime(); if (!isNaN(time)) { const src = typeof link.source === "object" ? (link.source as { id: string }).id : link.source; const tgt = typeof link.target === "object" ? (link.target as { id: string }).id : link.target; const sourceNode = nodes.find((n) => n.id === src); add({ id: `link-added:${src}->${tgt}:${time}`, type: "link-added", time, nodeId: src, actor: sourceNode?.assignee ? { handle: sourceNode.assignee } : undefined, detail: `${link.type} ${tgt}`, meta: { linkType: link.type, target: tgt }, }); } } } // --- Comment, claim, and like events --- if (allComments) { for (const comment of allComments) { const time = new Date(comment.createdAt).getTime(); if (isNaN(time)) continue; const actor: ActivityActor = { handle: comment.handle, avatar: comment.avatar, did: comment.did, }; const isClaim = comment.text.startsWith("@") && comment.text.trim().indexOf(" ") === -1; if (isClaim) { add({ id: `task-claimed:${comment.nodeId}:${time}`, type: "task-claimed", time, nodeId: comment.nodeId, actor, detail: comment.text, }); } else if (comment.replyTo) { add({ id: `reply-added:${comment.nodeId}:${comment.rkey}`, type: "reply-added", time, nodeId: comment.nodeId, actor, detail: comment.text.length > 80 ? comment.text.slice(0, 80) + "..." : comment.text, }); } else { add({ id: `comment-added:${comment.nodeId}:${comment.rkey}`, type: "comment-added", time, nodeId: comment.nodeId, actor, detail: comment.text.length > 80 ? comment.text.slice(0, 80) + "..." : comment.text, }); } // Likes on this comment for (const like of comment.likes) { const likeTime = new Date(like.createdAt).getTime(); if (isNaN(likeTime)) continue; add({ id: `like-added:${comment.nodeId}:${like.rkey}`, type: "like-added", time: likeTime, nodeId: comment.nodeId, actor: { handle: like.handle, avatar: like.avatar, did: like.did, }, detail: `Liked comment by ${comment.handle}`, }); } } } // Sort newest-first events.sort((a, b) => b.time - a.time); return events; } // ============================================================================ // Real-time diff -> events converter // ============================================================================ /** * Convert a BeadsDiff into ActivityEvent items. * Called on each SSE message after diffBeadsData(). */ export function diffToActivityEvents( diff: BeadsDiff, nodes: GraphNode[] ): ActivityEvent[] { const events: ActivityEvent[] = []; const now = Date.now(); const nodeMap = new Map(nodes.map((n) => [n.id, n])); // Added nodes for (const id of diff.addedNodeIds) { const node = nodeMap.get(id); events.push({ id: `node-created:${id}:${now}`, type: "node-created", time: now, nodeId: id, nodeTitle: node?.title, actor: node?.assignee ? { handle: node.assignee } : undefined, detail: node?.issueType || "task", meta: node ? { issueType: node.issueType, prefix: node.prefix } : undefined, }); } // Removed nodes for (const id of diff.removedNodeIds) { const node = nodeMap.get(id); events.push({ id: `node-closed:${id}:${now}`, type: "node-closed", time: now, nodeId: id, actor: node?.assignee ? { handle: node.assignee } : undefined, detail: "Removed", }); } // Changed nodes for (const [id, changes] of diff.changedNodes) { const node = nodeMap.get(id); for (const change of changes) { let type: ActivityEventType; switch (change.field) { case "status": type = "node-status-changed"; break; case "priority": type = "node-priority-changed"; break; case "title": type = "node-title-changed"; break; case "owner": type = "node-owner-changed"; break; case "assignee": type = "node-assignee-changed"; break; default: type = "node-status-changed"; // fallback } events.push({ id: `${type}:${id}:${now}:${change.field}`, type, time: now, nodeId: id, nodeTitle: node?.title, actor: node?.assignee ? { handle: node.assignee } : undefined, detail: `${change.from} \u2192 ${change.to}`, meta: { field: change.field, from: change.from, to: change.to }, }); } } // Added links for (const key of diff.addedLinkKeys) { // key format: "source->target:type" const match = key.match(/^(.+)->(.+):(.+)$/); if (match) { const [, src, tgt, linkType] = match; const sourceNode = nodeMap.get(src); events.push({ id: `link-added:${key}:${now}`, type: "link-added", time: now, nodeId: src, actor: sourceNode?.assignee ? { handle: sourceNode.assignee } : undefined, detail: `${linkType} ${tgt}`, meta: { linkType, target: tgt }, }); } } // Removed links for (const key of diff.removedLinkKeys) { const match = key.match(/^(.+)->(.+):(.+)$/); if (match) { const [, src, tgt, linkType] = match; events.push({ id: `link-removed:${key}:${now}`, type: "link-removed", time: now, nodeId: src, detail: `${linkType} ${tgt}`, meta: { linkType, target: tgt }, }); } } return events; } // ============================================================================ // Feed management helpers // ============================================================================ /** Number of events to show per page in the ActivityPanel UI */ export const ACTIVITY_PAGE_SIZE = 50; /** * Merge new events into an existing feed, deduplicating by event ID. * Returns a new array sorted newest-first. */ export function mergeFeedEvents( existing: ActivityEvent[], incoming: ActivityEvent[] ): ActivityEvent[] { const seen = new Set(existing.map((e) => e.id)); const merged = [...existing]; for (const event of incoming) { if (!seen.has(event.id)) { seen.add(event.id); merged.push(event); } } merged.sort((a, b) => b.time - a.time); return merged; }