mission-control/hooks/use-activity-feed.ts

376 lines
12 KiB
TypeScript

"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<string, UserDirectoryEntry>;
interface NormalizedTaskComment {
id: string;
text: string;
createdAt: string;
commentAuthorId: string;
replies: NormalizedTaskComment[];
}
function resolveUserName(
userId: string | null,
users: UserDirectory,
fallbackName?: string | null
): string {
if (userId && users[userId]?.name) return users[userId].name;
if (fallbackName && fallbackName.trim().length > 0) return fallbackName;
if (!userId) return 'Unknown';
return 'Unknown';
}
function resolveUserAvatarUrl(userId: string | null, users: UserDirectory): string | undefined {
if (!userId) return undefined;
return users[userId]?.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<string, unknown>;
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 id = toNonEmptyString(row.id);
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 [activities, setActivities] = useState<ActivityItem[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 {
const { data: userRows, error: usersError } = await supabaseClient
.from("users")
.select("id, name, avatar_url");
if (usersError) throw usersError;
const userDirectory = buildUserDirectory(userRows || []);
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<Task & {
projects: Pick<Project, "name" | "color"> | null;
assignee?: { id?: string; name?: string | null } | null;
}>;
typedTasks.forEach((task) => {
const project = task.projects;
const createdByName = resolveUserName(task.created_by_id, userDirectory, task.created_by_name);
const updatedByName = resolveUserName(task.updated_by_id, userDirectory, task.updated_by_name);
const assigneeName = resolveUserName(task.assignee_id, userDirectory, task.assignee_name || task.assignee?.name || null);
const createdByAvatarUrl = resolveUserAvatarUrl(task.created_by_id, userDirectory);
const updatedByAvatarUrl = resolveUserAvatarUrl(task.updated_by_id, userDirectory);
// 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 commentAuthorName = resolveUserName(commentAuthorId, userDirectory);
const commentAuthorAvatarUrl = resolveUserAvatarUrl(commentAuthorId, userDirectory);
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));
} 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,
};
}