Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
fb55c1d256
commit
fcae672870
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
34
components/activity/activity-page-client.tsx
Normal file
34
components/activity/activity-page-client.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user