"use client"; import { useState, useEffect, useCallback } from "react"; import { supabaseClient } from "@/lib/supabase/client"; import type { Database, ActivityItem } from "@/lib/supabase/database.types"; type Task = Database['public']['Tables']['tasks']['Row']; type Project = Database['public']['Tables']['projects']['Row']; export type ActivityFilterType = 'all' | 'task_created' | 'task_completed' | 'task_updated' | 'comment_added' | 'task_assigned'; interface UseActivityFeedOptions { limit?: number; projectId?: string; filterType?: ActivityFilterType; } let canUseLeanTasksSelect = true; interface UserDirectoryEntry { name: string; avatarUrl?: string; } type UserDirectory = Record; interface NormalizedTaskComment { id: string; text: string; createdAt: string; commentAuthorId: string; replies: NormalizedTaskComment[]; } interface UserDirectoryRow { id: string; name: string | null; avatar_url: string | null; } 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; 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 buildUserDirectory(rows: Array<{ id: string; name: string | null; avatar_url: string | null }>): UserDirectory { const directory: UserDirectory = {}; rows.forEach((row) => { const rawId = toNonEmptyString(row.id); const id = normalizeUserId(rawId); const name = toNonEmptyString(row.name); if (!id || !name) return; directory[id] = { name, avatarUrl: toNonEmptyString(row.avatar_url) ?? undefined, }; }); 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 fetchProjects = useCallback(async () => { try { const { data, error } = await supabaseClient .from('projects') .select('id, name, color') .order('name'); if (error) throw error; setProjects(data || []); } catch (err) { console.error('Error fetching projects:', err); } }, []); const fetchActivities = useCallback(async () => { setLoading(true); setError(null); try { let userRows: UserDirectoryRow[] = []; try { const response = await fetch("/api/users/directory", { cache: "no-store" }); if (response.ok) { const payload = (await response.json()) as { users?: UserDirectoryRow[] }; userRows = Array.isArray(payload.users) ? payload.users : []; } else { if (isDebug) { console.warn("[activity-feed] users directory API returned non-OK status", { status: response.status }); } } } catch (error) { if (isDebug) { console.warn("[activity-feed] users directory API request failed, falling back to client query", error); } } if (userRows.length === 0) { const { data: fallbackUserRows, error: usersError } = await supabaseClient .from("users") .select("id, name, avatar_url"); if (usersError) throw usersError; userRows = (fallbackUserRows || []) as UserDirectoryRow[]; } const userDirectory = buildUserDirectory(userRows); if (isDebug) { console.log("[activity-feed] user directory loaded", { userCount: Object.keys(userDirectory).length, sampleUserIds: Object.keys(userDirectory).slice(0, 5), }); } const runTasksQuery = async (selectClause: string) => { let query = supabaseClient .from('tasks') .select(selectClause) .order('updated_at', { ascending: false }) .limit(limit); if (projectId) { query = query.eq('project_id', projectId); } return query; }; let tasks: unknown[] | null = null; let tasksError: unknown = null; if (canUseLeanTasksSelect) { const leanResult = await runTasksQuery(` id, title, status, project_id, created_at, updated_at, created_by_id, created_by_name, updated_by_id, updated_by_name, assignee_id, assignee_name, comments, projects:project_id (name, color) `); tasks = leanResult.data; tasksError = leanResult.error; } if (tasksError || !canUseLeanTasksSelect) { if (tasksError) canUseLeanTasksSelect = false; const fallback = await runTasksQuery(` *, projects:project_id (*), assignee:assignee_id (id, name) `); tasks = fallback.data; tasksError = fallback.error; } if (tasksError) throw tasksError; // Convert tasks to activity items const activityItems: ActivityItem[] = []; const typedTasks = (tasks || []) as Array | null; assignee?: { id?: string; name?: string | null } | null; }>; const globalTaskUserNameById: Record = {}; typedTasks.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 || ""; } }); typedTasks.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, }); } // Task creation activity 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), }); } // Task completion activity 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), }); } // Assignment activity 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}`, }); } // Task update activity (if updated after creation and not completed) 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}`, }); } // Comment activities 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, }); }); } }); // Sort by timestamp descending activityItems.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); // Apply limit after all activities are collected 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(() => { fetchProjects(); fetchActivities(); }, [fetchProjects, fetchActivities]); // Poll for updates every 30 seconds (since realtime WebSocket is disabled) useEffect(() => { const interval = setInterval(() => { fetchActivities(); }, 30000); // 30 seconds return () => clearInterval(interval); }, [fetchActivities]); // Note: Real-time subscription disabled due to WebSocket connection issues // The activity feed uses regular HTTP polling instead (30s interval) // To re-enable realtime, configure Supabase Realtime in your project settings const refresh = useCallback(() => { fetchActivities(); }, [fetchActivities]); return { activities, projects, loading, error, refresh, }; }