From fcae672870c2b2e218e68ac105f5bc8017494de3 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 23 Feb 2026 16:56:04 -0600 Subject: [PATCH] Signed-off-by: OpenClaw Bot --- app/activity/page.tsx | 9 +- components/activity/activity-feed.tsx | 71 ++++- components/activity/activity-page-client.tsx | 34 +++ components/activity/activity-stats.tsx | 20 +- hooks/use-activity-feed.ts | 293 +++++++++++++------ lib/supabase/database.types.ts | 7 +- 6 files changed, 305 insertions(+), 129 deletions(-) create mode 100644 components/activity/activity-page-client.tsx diff --git a/app/activity/page.tsx b/app/activity/page.tsx index ab328a4..d4806fd 100644 --- a/app/activity/page.tsx +++ b/app/activity/page.tsx @@ -1,7 +1,6 @@ import { DashboardLayout } from "@/components/layout/sidebar"; import { PageHeader } from "@/components/layout/page-header"; -import { ActivityFeed } from "@/components/activity/activity-feed"; -import { ActivityStats } from "@/components/activity/activity-stats"; +import { ActivityPageClient } from "@/components/activity/activity-page-client"; // Force dynamic rendering to avoid prerendering issues with client components export const dynamic = "force-dynamic"; @@ -15,11 +14,7 @@ export default function ActivityPage() { description="Everything that's happening across your projects, pulled live from your task data." /> - {/* Activity Stats */} - - - {/* Activity Feed with Real Data */} - + ); diff --git a/components/activity/activity-feed.tsx b/components/activity/activity-feed.tsx index c97ca37..b526741 100644 --- a/components/activity/activity-feed.tsx +++ b/components/activity/activity-feed.tsx @@ -27,7 +27,21 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { ActivityItem } from "@/lib/supabase/database.types"; +import type { ActivityItem, Database } from "@/lib/supabase/database.types"; + +type Project = Database["public"]["Tables"]["projects"]["Row"]; + +interface ActivityFeedContentProps { + activities: ActivityItem[]; + projects: Pick[]; + loading: boolean; + error: string | null; + refresh: () => void; + filterType: ActivityFilterType; + onFilterTypeChange: (value: ActivityFilterType) => void; + projectId: string; + onProjectIdChange: (value: string) => void; +} const activityTypeConfig: Record< ActivityItem["type"], @@ -72,7 +86,10 @@ const filterOptions: { value: ActivityFilterType; label: string }[] = [ function ActivityItemCard({ activity }: { activity: ActivityItem }) { const config = activityTypeConfig[activity.type]; const Icon = config.icon; - const timeAgo = formatDistanceToNow(new Date(activity.timestamp), { addSuffix: true }); + const timestampMs = new Date(activity.timestamp).getTime(); + const timeAgo = Number.isNaN(timestampMs) + ? "just now" + : formatDistanceToNow(new Date(timestampMs), { addSuffix: true }); return (
@@ -162,16 +179,17 @@ function ActivitySkeleton() { ); } -export function ActivityFeed() { - const [filterType, setFilterType] = useState("all"); - const [projectId, setProjectId] = useState("all"); - - const { activities, projects, loading, error, refresh } = useActivityFeed({ - limit: 50, - projectId: projectId === "all" ? undefined : projectId, - filterType: filterType === "all" ? undefined : filterType, - }); - +export function ActivityFeedContent({ + activities, + projects, + loading, + error, + refresh, + filterType, + onFilterTypeChange, + projectId, + onProjectIdChange, +}: ActivityFeedContentProps) { return ( @@ -182,7 +200,7 @@ export function ActivityFeed() {
{/* Filter by type */} - onFilterTypeChange(v as ActivityFilterType)}> @@ -197,7 +215,7 @@ export function ActivityFeed() { {/* Filter by project */} - @@ -267,3 +285,28 @@ export function ActivityFeed() { ); } + +export function ActivityFeed() { + const [filterType, setFilterType] = useState("all"); + const [projectId, setProjectId] = useState("all"); + + const { activities, projects, loading, error, refresh } = useActivityFeed({ + limit: 50, + projectId: projectId === "all" ? undefined : projectId, + filterType: filterType === "all" ? undefined : filterType, + }); + + return ( + + ); +} diff --git a/components/activity/activity-page-client.tsx b/components/activity/activity-page-client.tsx new file mode 100644 index 0000000..4b4cf7d --- /dev/null +++ b/components/activity/activity-page-client.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState } from "react"; +import { useActivityFeed, ActivityFilterType } from "@/hooks/use-activity-feed"; +import { ActivityStatsContent } from "@/components/activity/activity-stats"; +import { ActivityFeedContent } from "@/components/activity/activity-feed"; + +export function ActivityPageClient() { + const [filterType, setFilterType] = useState("all"); + const [projectId, setProjectId] = useState("all"); + + const { activities, projects, loading, error, refresh } = useActivityFeed({ + limit: 100, + projectId: projectId === "all" ? undefined : projectId, + filterType: filterType === "all" ? undefined : filterType, + }); + + return ( + <> + + + + ); +} diff --git a/components/activity/activity-stats.tsx b/components/activity/activity-stats.tsx index b75a185..04cc0f9 100644 --- a/components/activity/activity-stats.tsx +++ b/components/activity/activity-stats.tsx @@ -1,7 +1,7 @@ "use client"; import { useActivityFeed } from "@/hooks/use-activity-feed"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { CheckCircle2, @@ -9,11 +9,14 @@ import { MessageSquare, Clock, } from "lucide-react"; +import type { ActivityItem } from "@/lib/supabase/database.types"; -export function ActivityStats() { - const { activities, loading } = useActivityFeed({ limit: 100 }); +interface ActivityStatsContentProps { + activities: ActivityItem[]; + loading: boolean; +} - // Calculate stats +export function ActivityStatsContent({ activities, loading }: ActivityStatsContentProps) { const stats = { completed: activities.filter((a) => a.type === "task_completed").length, created: activities.filter((a) => a.type === "task_created").length, @@ -79,9 +82,7 @@ export function ActivityStats() {

{stat.value}

{stat.label}

-
+
@@ -92,3 +93,8 @@ export function ActivityStats() {
); } + +export function ActivityStats() { + const { activities, loading } = useActivityFeed({ limit: 100 }); + return ; +} diff --git a/hooks/use-activity-feed.ts b/hooks/use-activity-feed.ts index 0ee77f8..6c066f2 100644 --- a/hooks/use-activity-feed.ts +++ b/hooks/use-activity-feed.ts @@ -2,18 +2,11 @@ import { useState, useEffect, useCallback } from "react"; import { supabaseClient } from "@/lib/supabase/client"; -import type { Database, ActivityItem, TaskComment } from "@/lib/supabase/database.types"; +import type { Database, ActivityItem } from "@/lib/supabase/database.types"; type Task = Database['public']['Tables']['tasks']['Row']; type Project = Database['public']['Tables']['projects']['Row']; -interface User { - id: string; - name: string; - email: string; - avatar_url: string | null; -} - export type ActivityFilterType = 'all' | 'task_created' | 'task_completed' | 'task_updated' | 'comment_added' | 'task_assigned'; interface UseActivityFeedOptions { @@ -22,74 +15,129 @@ interface UseActivityFeedOptions { filterType?: ActivityFilterType; } -// User name cache to avoid repeated lookups -const userNameCache: Record = { - // Hardcoded known users - "9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa": "Max", - "0a3e400c-3932-48ae-9b65-f3f9c6f26fe9": "Matt", -}; +let canUseLeanTasksSelect = true; -function getUserName(userId: string | null, users: User[]): string { +interface UserDirectoryEntry { + name: string; + avatarUrl?: string; +} + +type UserDirectory = Record; + +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'; - - // Check cache first - if (userNameCache[userId]) { - return userNameCache[userId]; - } - - // Look up in users array - const user = users.find(u => u.id === userId); - if (user?.name) { - userNameCache[userId] = user.name; - return user.name; - } - 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; + + 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([]); const [projects, setProjects] = useState([]); - const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchUsers = useCallback(async () => { - try { - const { data, error } = await supabaseClient - .from('users') - .select('*') - .order('name'); - - if (error) throw error; - - const typedUsers: User[] = (data || []).map((u: any) => ({ - id: u.id, - name: u.name, - email: u.email, - avatar_url: u.avatar_url, - })); - - setUsers(typedUsers); - - // Populate cache - typedUsers.forEach(user => { - if (user.name) { - userNameCache[user.id] = user.name; - } - }); - } catch (err) { - console.error('Error fetching users:', err); - } - }, []); - const fetchProjects = useCallback(async () => { try { const { data, error } = await supabaseClient .from('projects') - .select('*') + .select('id, name, color') .order('name'); if (error) throw error; @@ -104,35 +152,81 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { setError(null); try { - // Build the query for tasks - let query = supabaseClient - .from('tasks') - .select(` + 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) - `) - .order('updated_at', { ascending: false }) - .limit(limit); - - if (projectId) { - query = query.eq('project_id', projectId); + `); + tasks = fallback.data; + tasksError = fallback.error; } - - const { data: tasks, error: tasksError } = await query; - + if (tasksError) throw tasksError; // Convert tasks to activity items const activityItems: ActivityItem[] = []; - tasks?.forEach((task: Task & { projects: Project; assignee?: User }) => { + const typedTasks = (tasks || []) as Array | null; + assignee?: { id?: string; name?: string | null } | null; + }>; + + typedTasks.forEach((task) => { const project = task.projects; - // Get user names from our users array - const createdByName = getUserName(task.created_by_id, users); - const updatedByName = getUserName(task.updated_by_id, users); - const assigneeName = task.assignee?.name || getUserName(task.assignee_id, users); + 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') { @@ -146,7 +240,8 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { project_color: project?.color || '#6B7280', user_id: task.created_by_id || '', user_name: createdByName, - timestamp: task.created_at, + user_avatar_url: createdByAvatarUrl, + timestamp: pickTimestamp(task.created_at, task.updated_at), }); } @@ -162,7 +257,8 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { project_color: project?.color || '#6B7280', user_id: task.updated_by_id || task.created_by_id || '', user_name: updatedByName || createdByName, - timestamp: task.updated_at, + user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl, + timestamp: pickTimestamp(task.updated_at, task.created_at), }); } @@ -178,7 +274,8 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { project_color: project?.color || '#6B7280', user_id: task.updated_by_id || task.created_by_id || '', user_name: updatedByName || createdByName, - timestamp: task.updated_at, + user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl, + timestamp: pickTimestamp(task.updated_at, task.created_at), details: `Assigned to ${assigneeName}`, }); } @@ -197,26 +294,34 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { project_color: project?.color || '#6B7280', user_id: task.updated_by_id || '', user_name: updatedByName, - timestamp: task.updated_at, + 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: TaskComment[] = task.comments; - comments.forEach((comment) => { + 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}`, + 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: comment.user_id, - user_name: comment.user_name || getUserName(comment.user_id, users), - timestamp: comment.created_at, + user_id: commentAuthorId, + user_name: commentAuthorName, + user_avatar_url: commentAuthorAvatarUrl, + timestamp: pickTimestamp(comment.createdAt, task.updated_at), comment_text: comment.text, }); }); @@ -236,18 +341,12 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { } finally { setLoading(false); } - }, [limit, projectId, filterType, users]); + }, [limit, projectId, filterType]); useEffect(() => { - fetchUsers(); fetchProjects(); - }, [fetchUsers, fetchProjects]); - - useEffect(() => { - if (users.length > 0) { - fetchActivities(); - } - }, [fetchActivities, users]); + fetchActivities(); + }, [fetchProjects, fetchActivities]); // Poll for updates every 30 seconds (since realtime WebSocket is disabled) useEffect(() => { @@ -273,4 +372,4 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) { error, refresh, }; -} \ No newline at end of file +} diff --git a/lib/supabase/database.types.ts b/lib/supabase/database.types.ts index 4904926..3a022a9 100644 --- a/lib/supabase/database.types.ts +++ b/lib/supabase/database.types.ts @@ -155,10 +155,9 @@ type Json = any; export interface TaskComment { id: string; text: string; - created_at: string; - user_id: string; - user_name: string; - user_avatar_url?: string; + createdAt: string; + commentAuthorId: string; + replies?: TaskComment[]; } export interface ActivityItem {