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

482 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import type { Database, ActivityItem } from "@/lib/supabase/database.types";
type Project = Pick<Database["public"]["Tables"]["projects"]["Row"], "id" | "name" | "color">;
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<string, UserDirectoryEntry>;
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<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 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<ActivityItem[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string, string> = {};
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<string, string> = {};
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,
};
}