Compare commits
2 Commits
fb55c1d256
...
e46e92362e
| Author | SHA1 | Date | |
|---|---|---|---|
| e46e92362e | |||
| fcae672870 |
@ -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 */}
|
||||
<ActivityStats />
|
||||
|
||||
{/* Activity Feed with Real Data */}
|
||||
<ActivityFeed />
|
||||
<ActivityPageClient />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
29
app/api/users/directory/route.ts
Normal file
29
app/api/users/directory/route.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||
|
||||
type UserDirectoryRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = getServiceSupabase();
|
||||
const { data, error } = await supabase
|
||||
.from("users")
|
||||
.select("id, name, avatar_url")
|
||||
.order("name", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error("[users/directory] Supabase query failed", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ users: (data ?? []) as UserDirectoryRow[] });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch user directory";
|
||||
console.error("[users/directory] Request failed", error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -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<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<
|
||||
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 (
|
||||
<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() {
|
||||
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,
|
||||
});
|
||||
|
||||
export function ActivityFeedContent({
|
||||
activities,
|
||||
projects,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
filterType,
|
||||
onFilterTypeChange,
|
||||
projectId,
|
||||
onProjectIdChange,
|
||||
}: ActivityFeedContentProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
@ -182,7 +200,7 @@ export function ActivityFeed() {
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 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">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Filter type" />
|
||||
@ -197,7 +215,7 @@ export function ActivityFeed() {
|
||||
</Select>
|
||||
|
||||
{/* Filter by project */}
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<Select value={projectId} onValueChange={onProjectIdChange}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<FolderKanban className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="All projects" />
|
||||
@ -267,3 +285,28 @@ export function ActivityFeed() {
|
||||
</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";
|
||||
|
||||
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() {
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${stat.bgColor}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${stat.bgColor}`}>
|
||||
<Icon className={`w-5 h-5 ${stat.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
@ -92,3 +93,8 @@ export function ActivityStats() {
|
||||
</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 { 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,149 @@ interface UseActivityFeedOptions {
|
||||
filterType?: ActivityFilterType;
|
||||
}
|
||||
|
||||
// User name cache to avoid repeated lookups
|
||||
const userNameCache: Record<string, string> = {
|
||||
// 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<string, UserDirectoryEntry>;
|
||||
|
||||
interface NormalizedTaskComment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
commentAuthorId: string;
|
||||
replies: NormalizedTaskComment[];
|
||||
}
|
||||
|
||||
interface UserDirectoryRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
if (fallbackName && fallbackName.trim().length > 0) return fallbackName;
|
||||
if (!userId) return 'Unknown';
|
||||
|
||||
// Check cache first
|
||||
if (userNameCache[userId]) {
|
||||
return userNameCache[userId];
|
||||
const normalizedId = userId.trim().toLowerCase();
|
||||
if (normalizedId === "assistant") return "Assistant";
|
||||
if (normalizedId === "user" || normalizedId === "legacy-user") return "User";
|
||||
return "User";
|
||||
}
|
||||
|
||||
// Look up in users array
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (user?.name) {
|
||||
userNameCache[userId] = user.name;
|
||||
return user.name;
|
||||
function resolveUserAvatarUrl(userId: string | null, users: UserDirectory): string | undefined {
|
||||
const normalizedUserId = normalizeUserId(userId);
|
||||
if (!normalizedUserId) return undefined;
|
||||
return users[normalizedUserId]?.avatarUrl;
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
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 rawId = toNonEmptyString(row.id);
|
||||
const id = normalizeUserId(rawId);
|
||||
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 isDebug = process.env.NODE_ENV !== "production";
|
||||
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 () => {
|
||||
try {
|
||||
const { data, error } = await supabaseClient
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.select('id, name, color')
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
@ -104,14 +172,43 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build the query for tasks
|
||||
let userRows: UserDirectoryRow[] = [];
|
||||
try {
|
||||
const response = await fetch("/api/users/directory", { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
const payload = (await response.json()) as { users?: UserDirectoryRow[] };
|
||||
userRows = Array.isArray(payload.users) ? payload.users : [];
|
||||
} else {
|
||||
if (isDebug) {
|
||||
console.warn("[activity-feed] users directory API returned non-OK status", { status: response.status });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDebug) {
|
||||
console.warn("[activity-feed] users directory API request failed, falling back to client query", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (userRows.length === 0) {
|
||||
const { data: fallbackUserRows, error: usersError } = await supabaseClient
|
||||
.from("users")
|
||||
.select("id, name, avatar_url");
|
||||
if (usersError) throw usersError;
|
||||
userRows = (fallbackUserRows || []) as UserDirectoryRow[];
|
||||
}
|
||||
|
||||
const userDirectory = buildUserDirectory(userRows);
|
||||
if (isDebug) {
|
||||
console.log("[activity-feed] user directory loaded", {
|
||||
userCount: Object.keys(userDirectory).length,
|
||||
sampleUserIds: Object.keys(userDirectory).slice(0, 5),
|
||||
});
|
||||
}
|
||||
|
||||
const runTasksQuery = async (selectClause: string) => {
|
||||
let query = supabaseClient
|
||||
.from('tasks')
|
||||
.select(`
|
||||
*,
|
||||
projects:project_id (*),
|
||||
assignee:assignee_id (id, name)
|
||||
`)
|
||||
.select(selectClause)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
@ -119,20 +216,110 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
|
||||
query = query.eq('project_id', projectId);
|
||||
}
|
||||
|
||||
const { data: tasks, error: tasksError } = await query;
|
||||
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[] = [];
|
||||
|
||||
tasks?.forEach((task: Task & { projects: Project; assignee?: User }) => {
|
||||
const project = task.projects;
|
||||
const typedTasks = (tasks || []) as Array<Task & {
|
||||
projects: Pick<Project, "name" | "color"> | null;
|
||||
assignee?: { id?: string; name?: string | null } | null;
|
||||
}>;
|
||||
|
||||
// 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 globalTaskUserNameById: Record<string, string> = {};
|
||||
typedTasks.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 || "";
|
||||
}
|
||||
});
|
||||
|
||||
typedTasks.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,
|
||||
});
|
||||
}
|
||||
|
||||
// Task creation activity
|
||||
if (filterType === 'all' || filterType === 'task_created') {
|
||||
@ -146,7 +333,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 +350,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 +367,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 +387,51 @@ 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 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}`,
|
||||
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,
|
||||
});
|
||||
});
|
||||
@ -230,24 +445,26 @@ export function useActivityFeed(options: UseActivityFeedOptions = {}) {
|
||||
|
||||
// Apply limit after all activities are collected
|
||||
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, users]);
|
||||
}, [limit, projectId, filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchProjects();
|
||||
}, [fetchUsers, fetchProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0) {
|
||||
fetchActivities();
|
||||
}
|
||||
}, [fetchActivities, users]);
|
||||
}, [fetchProjects, fetchActivities]);
|
||||
|
||||
// Poll for updates every 30 seconds (since realtime WebSocket is disabled)
|
||||
useEffect(() => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -21,18 +21,10 @@ export interface Comment {
|
||||
id: string
|
||||
text: string
|
||||
createdAt: string
|
||||
author: CommentAuthor | 'user' | 'assistant'
|
||||
commentAuthorId: string
|
||||
replies?: Comment[]
|
||||
}
|
||||
|
||||
export interface CommentAuthor {
|
||||
id: string
|
||||
name: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
type: 'human' | 'assistant'
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
name: string
|
||||
@ -120,7 +112,7 @@ interface TaskStore {
|
||||
getTasksBySprint: (sprintId: string) => Task[]
|
||||
|
||||
// Comment actions
|
||||
addComment: (taskId: string, text: string, author?: CommentAuthor | 'user' | 'assistant') => void
|
||||
addComment: (taskId: string, text: string, commentAuthorId: string) => void
|
||||
deleteComment: (taskId: string, commentId: string) => void
|
||||
|
||||
// Filters
|
||||
@ -165,8 +157,8 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
],
|
||||
tags: ['ui', 'rewrite']
|
||||
},
|
||||
@ -195,11 +187,11 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['gitea', 'git', 'automation', 'infrastructure']
|
||||
},
|
||||
@ -215,9 +207,9 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['ui', 'ux', 'redesign', 'dashboard', 'monitoring']
|
||||
},
|
||||
@ -233,12 +225,12 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['ui', 'sync', 'localstorage', 'real-time']
|
||||
},
|
||||
@ -254,11 +246,11 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['blog', 'ui', 'markdown', 'links']
|
||||
},
|
||||
@ -274,12 +266,12 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['monitoring', 'cron', 'bug', 'infrastructure', 'urgent']
|
||||
},
|
||||
@ -295,10 +287,10 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['debugging', 'research', 'infrastructure', 'root-cause']
|
||||
},
|
||||
@ -314,9 +306,9 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['ui', 'kanban', 'feature', 'priority']
|
||||
},
|
||||
@ -332,19 +324,19 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['research', 'screenshot', 'macos', 'openclaw', 'investigation']
|
||||
},
|
||||
@ -360,13 +352,13 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['ios', 'mrr', 'research', 'side-project', 'entrepreneurship', 'app-ideas']
|
||||
},
|
||||
@ -382,13 +374,13 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['blog', 'ui', 'markdown', 'frontend']
|
||||
},
|
||||
@ -404,10 +396,10 @@ const defaultTasks: Task[] = [
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
comments: [
|
||||
{ id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), author: 'user' },
|
||||
{ id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), author: 'assistant' },
|
||||
{ id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), author: 'assistant' }
|
||||
{ id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), commentAuthorId: 'user' },
|
||||
{ id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' },
|
||||
{ id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), commentAuthorId: 'assistant' }
|
||||
],
|
||||
tags: ['research', 'tts', 'podcast', 'audio', 'digest', 'accessibility']
|
||||
}
|
||||
@ -430,57 +422,15 @@ const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCur
|
||||
return { id, name, email, avatarUrl }
|
||||
}
|
||||
|
||||
const profileToCommentAuthor = (profile: UserProfile): CommentAuthor => ({
|
||||
const profileToCommentAuthor = (profile: UserProfile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
type: 'human',
|
||||
})
|
||||
|
||||
const assistantAuthor: CommentAuthor = {
|
||||
id: 'assistant',
|
||||
name: 'Assistant',
|
||||
type: 'assistant',
|
||||
}
|
||||
|
||||
const normalizeCommentAuthor = (value: unknown): CommentAuthor => {
|
||||
if (value === 'assistant') return assistantAuthor
|
||||
if (value === 'user') {
|
||||
return {
|
||||
id: 'legacy-user',
|
||||
name: 'User',
|
||||
type: 'human',
|
||||
}
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
id: 'legacy-user',
|
||||
name: 'User',
|
||||
type: 'human',
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = value as Partial<CommentAuthor>
|
||||
const type: CommentAuthor['type'] =
|
||||
candidate.type === 'assistant' || candidate.id === 'assistant' ? 'assistant' : 'human'
|
||||
|
||||
const id = typeof candidate.id === 'string' && candidate.id.trim().length > 0
|
||||
? candidate.id
|
||||
: type === 'assistant'
|
||||
? 'assistant'
|
||||
: 'legacy-user'
|
||||
const name = typeof candidate.name === 'string' && candidate.name.trim().length > 0
|
||||
? candidate.name.trim()
|
||||
: type === 'assistant'
|
||||
? 'Assistant'
|
||||
: 'User'
|
||||
const email = typeof candidate.email === 'string' && candidate.email.trim().length > 0 ? candidate.email.trim() : undefined
|
||||
const avatarUrl = typeof candidate.avatarUrl === 'string' && candidate.avatarUrl.trim().length > 0 ? candidate.avatarUrl : undefined
|
||||
|
||||
return { id, name, email, avatarUrl, type }
|
||||
}
|
||||
const normalizeCommentAuthorId = (value: unknown): string | null =>
|
||||
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
|
||||
const normalizeComments = (value: unknown): Comment[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
@ -490,13 +440,15 @@ const normalizeComments = (value: unknown): Comment[] => {
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const candidate = entry as Partial<Comment>
|
||||
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string') continue
|
||||
if (typeof candidate.id !== 'string' || typeof candidate.text !== 'string' || typeof candidate.createdAt !== 'string') continue
|
||||
const commentAuthorId = normalizeCommentAuthorId(candidate.commentAuthorId)
|
||||
if (!commentAuthorId) continue
|
||||
|
||||
comments.push({
|
||||
id: candidate.id,
|
||||
text: candidate.text,
|
||||
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : new Date().toISOString(),
|
||||
author: normalizeCommentAuthor(candidate.author),
|
||||
createdAt: candidate.createdAt,
|
||||
commentAuthorId,
|
||||
replies: normalizeComments(candidate.replies),
|
||||
})
|
||||
}
|
||||
@ -787,13 +739,14 @@ export const useTaskStore = create<TaskStore>()(
|
||||
return get().tasks.filter((t) => t.sprintId === sprintId)
|
||||
},
|
||||
|
||||
addComment: (taskId, text, author) => {
|
||||
const actor = normalizeCommentAuthor(author ?? profileToCommentAuthor(get().currentUser))
|
||||
addComment: (taskId, text, commentAuthorId) => {
|
||||
const normalizedCommentAuthorId = normalizeCommentAuthorId(commentAuthorId)
|
||||
if (!normalizedCommentAuthorId) return
|
||||
const newComment: Comment = {
|
||||
id: Date.now().toString(),
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
author: actor,
|
||||
commentAuthorId: normalizedCommentAuthorId,
|
||||
replies: [],
|
||||
}
|
||||
set((state) => {
|
||||
@ -849,6 +802,30 @@ export const useTaskStore = create<TaskStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'task-store',
|
||||
version: 2,
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const state = persistedState as {
|
||||
currentUser?: unknown
|
||||
selectedProjectId?: unknown
|
||||
selectedTaskId?: unknown
|
||||
selectedSprintId?: unknown
|
||||
}
|
||||
|
||||
const normalizedCurrentUser = normalizeUserProfile(state.currentUser, defaultCurrentUser)
|
||||
const shouldReplaceLegacyUnknown =
|
||||
normalizedCurrentUser.name.trim().toLowerCase() === 'unknown user' || normalizedCurrentUser.id.trim().length === 0
|
||||
|
||||
return {
|
||||
currentUser: shouldReplaceLegacyUnknown ? createLocalUserProfile() : normalizedCurrentUser,
|
||||
selectedProjectId: typeof state.selectedProjectId === 'string' ? state.selectedProjectId : null,
|
||||
selectedTaskId: typeof state.selectedTaskId === 'string' ? state.selectedTaskId : null,
|
||||
selectedSprintId: typeof state.selectedSprintId === 'string' ? state.selectedSprintId : null,
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
// Persist user identity and UI state, not task data
|
||||
currentUser: state.currentUser,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user