Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-23 16:56:04 -06:00
parent fb55c1d256
commit fcae672870
6 changed files with 305 additions and 129 deletions

View File

@ -1,7 +1,6 @@
import { DashboardLayout } from "@/components/layout/sidebar"; import { DashboardLayout } from "@/components/layout/sidebar";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { ActivityFeed } from "@/components/activity/activity-feed"; import { ActivityPageClient } from "@/components/activity/activity-page-client";
import { ActivityStats } from "@/components/activity/activity-stats";
// Force dynamic rendering to avoid prerendering issues with client components // Force dynamic rendering to avoid prerendering issues with client components
export const dynamic = "force-dynamic"; 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." description="Everything that's happening across your projects, pulled live from your task data."
/> />
{/* Activity Stats */} <ActivityPageClient />
<ActivityStats />
{/* Activity Feed with Real Data */}
<ActivityFeed />
</div> </div>
</DashboardLayout> </DashboardLayout>
); );

View File

@ -27,7 +27,21 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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<Project, "id" | "name" | "color">[];
loading: boolean;
error: string | null;
refresh: () => void;
filterType: ActivityFilterType;
onFilterTypeChange: (value: ActivityFilterType) => void;
projectId: string;
onProjectIdChange: (value: string) => void;
}
const activityTypeConfig: Record< const activityTypeConfig: Record<
ActivityItem["type"], ActivityItem["type"],
@ -72,7 +86,10 @@ const filterOptions: { value: ActivityFilterType; label: string }[] = [
function ActivityItemCard({ activity }: { activity: ActivityItem }) { function ActivityItemCard({ activity }: { activity: ActivityItem }) {
const config = activityTypeConfig[activity.type]; const config = activityTypeConfig[activity.type];
const Icon = config.icon; 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 ( return (
<div className="flex gap-4 pb-6 border-b border-border last:border-0 last:pb-0"> <div className="flex gap-4 pb-6 border-b border-border last:border-0 last:pb-0">
@ -162,16 +179,17 @@ function ActivitySkeleton() {
); );
} }
export function ActivityFeed() { export function ActivityFeedContent({
const [filterType, setFilterType] = useState<ActivityFilterType>("all"); activities,
const [projectId, setProjectId] = useState<string>("all"); projects,
loading,
const { activities, projects, loading, error, refresh } = useActivityFeed({ error,
limit: 50, refresh,
projectId: projectId === "all" ? undefined : projectId, filterType,
filterType: filterType === "all" ? undefined : filterType, onFilterTypeChange,
}); projectId,
onProjectIdChange,
}: ActivityFeedContentProps) {
return ( return (
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
@ -182,7 +200,7 @@ export function ActivityFeed() {
</CardTitle> </CardTitle>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Filter by type */} {/* Filter by type */}
<Select value={filterType} onValueChange={(v) => setFilterType(v as ActivityFilterType)}> <Select value={filterType} onValueChange={(v) => onFilterTypeChange(v as ActivityFilterType)}>
<SelectTrigger className="w-[140px] h-9"> <SelectTrigger className="w-[140px] h-9">
<Filter className="w-4 h-4 mr-2" /> <Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filter type" /> <SelectValue placeholder="Filter type" />
@ -197,7 +215,7 @@ export function ActivityFeed() {
</Select> </Select>
{/* Filter by project */} {/* Filter by project */}
<Select value={projectId} onValueChange={setProjectId}> <Select value={projectId} onValueChange={onProjectIdChange}>
<SelectTrigger className="w-[160px] h-9"> <SelectTrigger className="w-[160px] h-9">
<FolderKanban className="w-4 h-4 mr-2" /> <FolderKanban className="w-4 h-4 mr-2" />
<SelectValue placeholder="All projects" /> <SelectValue placeholder="All projects" />
@ -267,3 +285,28 @@ export function ActivityFeed() {
</Card> </Card>
); );
} }
export function ActivityFeed() {
const [filterType, setFilterType] = useState<ActivityFilterType>("all");
const [projectId, setProjectId] = useState<string>("all");
const { activities, projects, loading, error, refresh } = useActivityFeed({
limit: 50,
projectId: projectId === "all" ? undefined : projectId,
filterType: filterType === "all" ? undefined : filterType,
});
return (
<ActivityFeedContent
activities={activities}
projects={projects}
loading={loading}
error={error}
refresh={refresh}
filterType={filterType}
onFilterTypeChange={setFilterType}
projectId={projectId}
onProjectIdChange={setProjectId}
/>
);
}

View File

@ -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<ActivityFilterType>("all");
const [projectId, setProjectId] = useState<string>("all");
const { activities, projects, loading, error, refresh } = useActivityFeed({
limit: 100,
projectId: projectId === "all" ? undefined : projectId,
filterType: filterType === "all" ? undefined : filterType,
});
return (
<>
<ActivityStatsContent activities={activities} loading={loading} />
<ActivityFeedContent
activities={activities.slice(0, 50)}
projects={projects}
loading={loading}
error={error}
refresh={refresh}
filterType={filterType}
onFilterTypeChange={setFilterType}
projectId={projectId}
onProjectIdChange={setProjectId}
/>
</>
);
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useActivityFeed } from "@/hooks/use-activity-feed"; 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 { Skeleton } from "@/components/ui/skeleton";
import { import {
CheckCircle2, CheckCircle2,
@ -9,11 +9,14 @@ import {
MessageSquare, MessageSquare,
Clock, Clock,
} from "lucide-react"; } from "lucide-react";
import type { ActivityItem } from "@/lib/supabase/database.types";
export function ActivityStats() { interface ActivityStatsContentProps {
const { activities, loading } = useActivityFeed({ limit: 100 }); activities: ActivityItem[];
loading: boolean;
}
// Calculate stats export function ActivityStatsContent({ activities, loading }: ActivityStatsContentProps) {
const stats = { const stats = {
completed: activities.filter((a) => a.type === "task_completed").length, completed: activities.filter((a) => a.type === "task_completed").length,
created: activities.filter((a) => a.type === "task_created").length, created: activities.filter((a) => a.type === "task_created").length,
@ -79,9 +82,7 @@ export function ActivityStats() {
<p className="text-2xl font-bold">{stat.value}</p> <p className="text-2xl font-bold">{stat.value}</p>
<p className="text-sm text-muted-foreground">{stat.label}</p> <p className="text-sm text-muted-foreground">{stat.label}</p>
</div> </div>
<div <div className={`w-10 h-10 rounded-full flex items-center justify-center ${stat.bgColor}`}>
className={`w-10 h-10 rounded-full flex items-center justify-center ${stat.bgColor}`}
>
<Icon className={`w-5 h-5 ${stat.color}`} /> <Icon className={`w-5 h-5 ${stat.color}`} />
</div> </div>
</div> </div>
@ -92,3 +93,8 @@ export function ActivityStats() {
</div> </div>
); );
} }
export function ActivityStats() {
const { activities, loading } = useActivityFeed({ limit: 100 });
return <ActivityStatsContent activities={activities} loading={loading} />;
}

View File

@ -2,18 +2,11 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { supabaseClient } from "@/lib/supabase/client"; 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 Task = Database['public']['Tables']['tasks']['Row'];
type Project = Database['public']['Tables']['projects']['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'; export type ActivityFilterType = 'all' | 'task_created' | 'task_completed' | 'task_updated' | 'comment_added' | 'task_assigned';
interface UseActivityFeedOptions { interface UseActivityFeedOptions {
@ -22,74 +15,129 @@ interface UseActivityFeedOptions {
filterType?: ActivityFilterType; filterType?: ActivityFilterType;
} }
// User name cache to avoid repeated lookups let canUseLeanTasksSelect = true;
const userNameCache: Record<string, string> = {
// Hardcoded known users
"9c29cc99-81a1-4e75-8dff-cd7cc5ceb5aa": "Max",
"0a3e400c-3932-48ae-9b65-f3f9c6f26fe9": "Matt",
};
function getUserName(userId: string | null, users: User[]): string { 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'; 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'; 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 = {}) { export function useActivityFeed(options: UseActivityFeedOptions = {}) {
const { limit = 50, projectId, filterType = 'all' } = options; const { limit = 50, projectId, filterType = 'all' } = options;
const [activities, setActivities] = useState<ActivityItem[]>([]); const [activities, setActivities] = useState<ActivityItem[]>([]);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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 () => { const fetchProjects = useCallback(async () => {
try { try {
const { data, error } = await supabaseClient const { data, error } = await supabaseClient
.from('projects') .from('projects')
.select('*') .select('id, name, color')
.order('name'); .order('name');
if (error) throw error; if (error) throw error;
@ -104,35 +152,81 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
setError(null); setError(null);
try { try {
// Build the query for tasks const { data: userRows, error: usersError } = await supabaseClient
let query = supabaseClient .from("users")
.from('tasks') .select("id, name, avatar_url");
.select(`
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 (*), projects:project_id (*),
assignee:assignee_id (id, name) assignee:assignee_id (id, name)
`) `);
.order('updated_at', { ascending: false }) tasks = fallback.data;
.limit(limit); tasksError = fallback.error;
if (projectId) {
query = query.eq('project_id', projectId);
} }
const { data: tasks, error: tasksError } = await query;
if (tasksError) throw tasksError; if (tasksError) throw tasksError;
// Convert tasks to activity items // Convert tasks to activity items
const activityItems: ActivityItem[] = []; const activityItems: ActivityItem[] = [];
tasks?.forEach((task: Task & { projects: Project; assignee?: User }) => { 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 project = task.projects;
// Get user names from our users array const createdByName = resolveUserName(task.created_by_id, userDirectory, task.created_by_name);
const createdByName = getUserName(task.created_by_id, users); const updatedByName = resolveUserName(task.updated_by_id, userDirectory, task.updated_by_name);
const updatedByName = getUserName(task.updated_by_id, users); const assigneeName = resolveUserName(task.assignee_id, userDirectory, task.assignee_name || task.assignee?.name || null);
const assigneeName = task.assignee?.name || getUserName(task.assignee_id, users); const createdByAvatarUrl = resolveUserAvatarUrl(task.created_by_id, userDirectory);
const updatedByAvatarUrl = resolveUserAvatarUrl(task.updated_by_id, userDirectory);
// Task creation activity // Task creation activity
if (filterType === 'all' || filterType === 'task_created') { if (filterType === 'all' || filterType === 'task_created') {
@ -146,7 +240,8 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
project_color: project?.color || '#6B7280', project_color: project?.color || '#6B7280',
user_id: task.created_by_id || '', user_id: task.created_by_id || '',
user_name: createdByName, 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', project_color: project?.color || '#6B7280',
user_id: task.updated_by_id || task.created_by_id || '', user_id: task.updated_by_id || task.created_by_id || '',
user_name: updatedByName || createdByName, 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', project_color: project?.color || '#6B7280',
user_id: task.updated_by_id || task.created_by_id || '', user_id: task.updated_by_id || task.created_by_id || '',
user_name: updatedByName || createdByName, 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}`, details: `Assigned to ${assigneeName}`,
}); });
} }
@ -197,26 +294,34 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
project_color: project?.color || '#6B7280', project_color: project?.color || '#6B7280',
user_id: task.updated_by_id || '', user_id: task.updated_by_id || '',
user_name: updatedByName, user_name: updatedByName,
timestamp: task.updated_at, user_avatar_url: updatedByAvatarUrl,
timestamp: pickTimestamp(task.updated_at, task.created_at),
details: `Status: ${task.status}`, details: `Status: ${task.status}`,
}); });
} }
// Comment activities // Comment activities
if (task.comments && Array.isArray(task.comments) && (filterType === 'all' || filterType === 'comment_added')) { if (task.comments && Array.isArray(task.comments) && (filterType === 'all' || filterType === 'comment_added')) {
const comments: TaskComment[] = task.comments; const comments = normalizeTaskComments(task.comments);
comments.forEach((comment) => { const flattenedComments = flattenTaskComments(comments);
flattenedComments.forEach(({ comment, path }) => {
const commentAuthorId = comment.commentAuthorId;
const commentAuthorName = resolveUserName(commentAuthorId, userDirectory);
const commentAuthorAvatarUrl = resolveUserAvatarUrl(commentAuthorId, userDirectory);
activityItems.push({ activityItems.push({
id: `${task.id}-comment-${comment.id}`, id: `${task.id}-comment-${comment.id}-${path}`,
type: 'comment_added', type: 'comment_added',
task_id: task.id, task_id: task.id,
task_title: task.title, task_title: task.title,
project_id: task.project_id, project_id: task.project_id,
project_name: project?.name || 'Unknown Project', project_name: project?.name || 'Unknown Project',
project_color: project?.color || '#6B7280', project_color: project?.color || '#6B7280',
user_id: comment.user_id, user_id: commentAuthorId,
user_name: comment.user_name || getUserName(comment.user_id, users), user_name: commentAuthorName,
timestamp: comment.created_at, user_avatar_url: commentAuthorAvatarUrl,
timestamp: pickTimestamp(comment.createdAt, task.updated_at),
comment_text: comment.text, comment_text: comment.text,
}); });
}); });
@ -236,18 +341,12 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [limit, projectId, filterType, users]); }, [limit, projectId, filterType]);
useEffect(() => { useEffect(() => {
fetchUsers();
fetchProjects(); fetchProjects();
}, [fetchUsers, fetchProjects]); fetchActivities();
}, [fetchProjects, fetchActivities]);
useEffect(() => {
if (users.length > 0) {
fetchActivities();
}
}, [fetchActivities, users]);
// Poll for updates every 30 seconds (since realtime WebSocket is disabled) // Poll for updates every 30 seconds (since realtime WebSocket is disabled)
useEffect(() => { useEffect(() => {

View File

@ -155,10 +155,9 @@ type Json = any;
export interface TaskComment { export interface TaskComment {
id: string; id: string;
text: string; text: string;
created_at: string; createdAt: string;
user_id: string; commentAuthorId: string;
user_name: string; replies?: TaskComment[];
user_avatar_url?: string;
} }
export interface ActivityItem { export interface ActivityItem {