"use client"; import { useState, useEffect, useCallback } from "react"; import type { Database, ActivityItem } from "@/lib/supabase/database.types"; type Project = Pick; type TaskStatus = Database["public"]["Tables"]["tasks"]["Row"]["status"]; export type ActivityFilterType = | "all" | "task_created" | "task_completed" | "task_updated" | "comment_added" | "task_assigned"; interface UseActivityFeedOptions { limit?: number; projectId?: string; filterType?: ActivityFilterType; } interface UserDirectoryEntry { name?: string; avatarUrl?: string; } type UserDirectory = Record; interface NormalizedTaskComment { id: string; text: string; createdAt: string; commentAuthorId: string; replies: NormalizedTaskComment[]; } interface ActivityApiTask { id: string; title: string; status: TaskStatus; projectId: string; createdAt: string; updatedAt: string; createdById?: string; createdByName?: string; createdByAvatarUrl?: string; updatedById?: string; updatedByName?: string; updatedByAvatarUrl?: string; assigneeId?: string; assigneeName?: string; assigneeAvatarUrl?: string; comments?: unknown; } interface ActivityApiProject { id: string; name: string; color: string; } interface ActivityApiResponse { tasks?: ActivityApiTask[]; projects?: ActivityApiProject[]; error?: string; } function normalizeUserId(value: string | null | undefined): string | null { if (!value) return null; const normalized = value.trim().toLowerCase(); return normalized.length > 0 ? normalized : null; } function resolveUserName( userId: string | null, users: UserDirectory, fallbackName?: string | null ): string { const normalizedUserId = normalizeUserId(userId); if (normalizedUserId && users[normalizedUserId]?.name) return users[normalizedUserId].name as string; if (fallbackName && fallbackName.trim().length > 0) return fallbackName; if (!userId) return "Unknown"; const normalizedId = userId.trim().toLowerCase(); if (normalizedId === "assistant") return "Assistant"; if (normalizedId === "user" || normalizedId === "legacy-user") return "User"; return "User"; } function resolveUserAvatarUrl(userId: string | null, users: UserDirectory): string | undefined { const normalizedUserId = normalizeUserId(userId); if (!normalizedUserId) return undefined; return users[normalizedUserId]?.avatarUrl; } function isValidTimestamp(value: string | null | undefined): value is string { return !!value && !Number.isNaN(new Date(value).getTime()); } function toNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function pickTimestamp(primary?: string | null, fallback?: string | null): string { if (isValidTimestamp(primary)) return primary; if (isValidTimestamp(fallback)) return fallback; return new Date().toISOString(); } function normalizeTaskComments(value: unknown): NormalizedTaskComment[] { if (!Array.isArray(value)) return []; const comments: NormalizedTaskComment[] = []; for (const entry of value) { if (!entry || typeof entry !== "object") continue; const candidate = entry as Record; const id = toNonEmptyString(candidate.id); const text = toNonEmptyString(candidate.text); const createdAt = toNonEmptyString(candidate.createdAt); const commentAuthorId = toNonEmptyString(candidate.commentAuthorId); if (!id || !text || !createdAt || !commentAuthorId) continue; comments.push({ id, text, createdAt: pickTimestamp(createdAt, null), commentAuthorId, replies: normalizeTaskComments(candidate.replies), }); } return comments; } function flattenTaskComments( comments: NormalizedTaskComment[], pathPrefix = "" ): Array<{ comment: NormalizedTaskComment; path: string }> { const flattened: Array<{ comment: NormalizedTaskComment; path: string }> = []; comments.forEach((comment, index) => { const path = pathPrefix ? `${pathPrefix}.${index}` : `${index}`; flattened.push({ comment, path }); if (comment.replies.length > 0) { flattened.push(...flattenTaskComments(comment.replies, path)); } }); return flattened; } function buildUserDirectoryFromTasks(tasks: ActivityApiTask[]): UserDirectory { const directory: UserDirectory = {}; const upsert = (userId?: string, name?: string, avatarUrl?: string) => { const normalizedUserId = normalizeUserId(userId); if (!normalizedUserId) return; const existing = directory[normalizedUserId] ?? {}; const nextName = toNonEmptyString(name) ?? existing.name; const nextAvatarUrl = toNonEmptyString(avatarUrl) ?? existing.avatarUrl; if (!nextName && !nextAvatarUrl) return; directory[normalizedUserId] = { ...(nextName ? { name: nextName } : {}), ...(nextAvatarUrl ? { avatarUrl: nextAvatarUrl } : {}), }; }; tasks.forEach((task) => { upsert(task.createdById, task.createdByName, task.createdByAvatarUrl); upsert(task.updatedById, task.updatedByName, task.updatedByAvatarUrl); upsert(task.assigneeId, task.assigneeName, task.assigneeAvatarUrl); }); return directory; } export function useActivityFeed(options: UseActivityFeedOptions = {}) { const { limit = 50, projectId, filterType = "all" } = options; const isDebug = process.env.NODE_ENV !== "production"; const [activities, setActivities] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const fetchActivities = useCallback(async () => { setLoading(true); setError(null); try { const response = await fetch("/api/activity", { cache: "no-store" }); const payload = (await response.json().catch(() => null)) as ActivityApiResponse | null; if (!response.ok) { const message = payload?.error || `Activity API request failed with status ${response.status}`; throw new Error(message); } const apiTasks = Array.isArray(payload?.tasks) ? payload.tasks : []; const apiProjects = (Array.isArray(payload?.projects) ? payload.projects : []).filter( (project) => typeof project?.id === "string" && typeof project?.name === "string" && typeof project?.color === "string" ); const visibleProjects = apiProjects.map((project) => ({ id: project.id, name: project.name, color: project.color, })); setProjects(visibleProjects); const userDirectory = buildUserDirectoryFromTasks(apiTasks); if (isDebug) { console.log("[activity-feed] user directory derived from tasks", { userCount: Object.keys(userDirectory).length, sampleUserIds: Object.keys(userDirectory).slice(0, 5), }); } const projectById = new Map(visibleProjects.map((project) => [project.id, project])); const tasks = apiTasks .filter((task) => (projectId ? task.projectId === projectId : true)) .map((task) => ({ id: task.id, title: task.title, status: task.status, project_id: task.projectId, created_at: task.createdAt, updated_at: task.updatedAt, created_by_id: task.createdById ?? null, created_by_name: task.createdByName ?? null, updated_by_id: task.updatedById ?? null, updated_by_name: task.updatedByName ?? null, assignee_id: task.assigneeId ?? null, assignee_name: task.assigneeName ?? null, comments: task.comments, projects: projectById.get(task.projectId) ? { name: projectById.get(task.projectId)?.name, color: projectById.get(task.projectId)?.color, } : null, assignee: task.assigneeId ? { id: task.assigneeId, name: task.assigneeName ?? null, } : null, })); const activityItems: ActivityItem[] = []; const globalTaskUserNameById: Record = {}; tasks.forEach((task) => { const createdById = normalizeUserId(task.created_by_id); const updatedById = normalizeUserId(task.updated_by_id); const assigneeId = normalizeUserId(task.assignee_id); if (createdById && task.created_by_name) globalTaskUserNameById[createdById] = task.created_by_name; if (updatedById && task.updated_by_name) globalTaskUserNameById[updatedById] = task.updated_by_name; if (assigneeId && (task.assignee_name || task.assignee?.name)) { globalTaskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || ""; } }); tasks.forEach((task) => { const project = task.projects; const taskUserNameById: Record = {}; const createdById = normalizeUserId(task.created_by_id); const updatedById = normalizeUserId(task.updated_by_id); const assigneeId = normalizeUserId(task.assignee_id); if (createdById && task.created_by_name) { taskUserNameById[createdById] = task.created_by_name; } if (updatedById && task.updated_by_name) { taskUserNameById[updatedById] = task.updated_by_name; } if (assigneeId && (task.assignee_name || task.assignee?.name)) { taskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || ""; } const createdByName = resolveUserName( task.created_by_id, userDirectory, task.created_by_name || (createdById ? globalTaskUserNameById[createdById] : null) || null ); const updatedByName = resolveUserName( task.updated_by_id, userDirectory, task.updated_by_name || (updatedById ? globalTaskUserNameById[updatedById] : null) || null ); const assigneeName = resolveUserName( task.assignee_id, userDirectory, task.assignee_name || task.assignee?.name || (assigneeId ? globalTaskUserNameById[assigneeId] : null) || null ); const createdByAvatarUrl = resolveUserAvatarUrl(task.created_by_id, userDirectory); const updatedByAvatarUrl = resolveUserAvatarUrl(task.updated_by_id, userDirectory); if (isDebug && (!task.created_by_id || createdByName === "User")) { console.log("[activity-feed] created_by fallback", { taskId: task.id, createdById: task.created_by_id, createdByNameFromTask: task.created_by_name, resolvedName: createdByName, }); } if (filterType === "all" || filterType === "task_created") { activityItems.push({ id: `${task.id}-created`, type: "task_created", task_id: task.id, task_title: task.title, project_id: task.project_id, project_name: project?.name || "Unknown Project", project_color: project?.color || "#6B7280", user_id: task.created_by_id || "", user_name: createdByName, user_avatar_url: createdByAvatarUrl, timestamp: pickTimestamp(task.created_at, task.updated_at), }); } if (task.status === "done" && (filterType === "all" || filterType === "task_completed")) { activityItems.push({ id: `${task.id}-completed`, type: "task_completed", task_id: task.id, task_title: task.title, project_id: task.project_id, project_name: project?.name || "Unknown Project", project_color: project?.color || "#6B7280", user_id: task.updated_by_id || task.created_by_id || "", user_name: updatedByName || createdByName, user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl, timestamp: pickTimestamp(task.updated_at, task.created_at), }); } if (task.assignee_id && (filterType === "all" || filterType === "task_assigned")) { activityItems.push({ id: `${task.id}-assigned`, type: "task_assigned", task_id: task.id, task_title: task.title, project_id: task.project_id, project_name: project?.name || "Unknown Project", project_color: project?.color || "#6B7280", user_id: task.updated_by_id || task.created_by_id || "", user_name: updatedByName || createdByName, user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl, timestamp: pickTimestamp(task.updated_at, task.created_at), details: `Assigned to ${assigneeName}`, }); } if ( task.updated_at !== task.created_at && task.status !== "done" && (filterType === "all" || filterType === "task_updated") ) { activityItems.push({ id: `${task.id}-updated`, type: "task_updated", task_id: task.id, task_title: task.title, project_id: task.project_id, project_name: project?.name || "Unknown Project", project_color: project?.color || "#6B7280", user_id: task.updated_by_id || "", user_name: updatedByName, user_avatar_url: updatedByAvatarUrl, timestamp: pickTimestamp(task.updated_at, task.created_at), details: `Status: ${task.status}`, }); } if (task.comments && Array.isArray(task.comments) && (filterType === "all" || filterType === "comment_added")) { const comments = normalizeTaskComments(task.comments); const flattenedComments = flattenTaskComments(comments); flattenedComments.forEach(({ comment, path }) => { const commentAuthorId = comment.commentAuthorId; const normalizedCommentAuthorId = normalizeUserId(commentAuthorId); const commentAuthorName = resolveUserName( commentAuthorId, userDirectory, (normalizedCommentAuthorId ? taskUserNameById[normalizedCommentAuthorId] || globalTaskUserNameById[normalizedCommentAuthorId] : null) ?? null ); const commentAuthorAvatarUrl = resolveUserAvatarUrl(commentAuthorId, userDirectory); if (isDebug && (commentAuthorName === "User" || commentAuthorName === "Unknown")) { console.log("[activity-feed] comment author fallback", { taskId: task.id, commentId: comment.id, path, commentAuthorId, taskFallbackName: taskUserNameById[commentAuthorId] ?? null, resolvedName: commentAuthorName, }); } activityItems.push({ id: `${task.id}-comment-${comment.id}-${path}`, type: "comment_added", task_id: task.id, task_title: task.title, project_id: task.project_id, project_name: project?.name || "Unknown Project", project_color: project?.color || "#6B7280", user_id: commentAuthorId, user_name: commentAuthorName, user_avatar_url: commentAuthorAvatarUrl, timestamp: pickTimestamp(comment.createdAt, task.updated_at), comment_text: comment.text, }); }); } }); activityItems.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); setActivities(activityItems.slice(0, limit)); if (isDebug) { console.log("[activity-feed] activities prepared", { totalActivities: activityItems.length, returnedActivities: Math.min(activityItems.length, limit), filterType, projectId: projectId ?? null, }); } } catch (err) { console.error("Error fetching activities:", err); setError(err instanceof Error ? err.message : "Failed to fetch activities"); } finally { setLoading(false); } }, [limit, projectId, filterType]); useEffect(() => { fetchActivities(); }, [fetchActivities]); useEffect(() => { const interval = setInterval(() => { fetchActivities(); }, 30000); return () => clearInterval(interval); }, [fetchActivities]); const refresh = useCallback(() => { fetchActivities(); }, [fetchActivities]); return { activities, projects, loading, error, refresh, }; }