Files
fusion/packages/dashboard/app/components/ProjectOverview.tsx
Fusion a51e75941c feat(FN-1862): add completion state tracking for onboarding
- Add markOnboardingCompleted() to set completedAt timestamp when user finishes onboarding
- Add isOnboardingCompleted() to check if onboarding was completed (vs dismissed)
- Update ModelOnboardingModal to call markOnboardingCompleted on successful completion
- Skip onboarding auto-open when already completed locally
- Add completion state tracking tests in ModelOnboardingModal.test.tsx
- Add model-onboarding-state unit tests
- Update useAuthOnboarding to check local completion state before auto-opening
- Document localStorage completion state tracking pattern in .fusion/memory.md
2026-04-14 23:44:40 -07:00

372 lines
13 KiB
TypeScript

import { useState, useMemo, useCallback, useEffect } from "react";
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
import type { ProjectInfo, ProjectHealth, NodeInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { ProjectCard } from "./ProjectCard";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
import { useProjectHealth } from "../hooks/useProjectHealth";
export interface ProjectOverviewProps {
projects: ProjectInfo[];
loading?: boolean;
onSelectProject: (project: ProjectInfo) => void;
onAddProject: () => void;
onPauseProject: (project: ProjectInfo) => void;
onResumeProject: (project: ProjectInfo) => void;
onRemoveProject: (project: ProjectInfo) => void;
onViewAllProjects?: () => void;
nodes?: NodeInfo[];
}
type FilterTab = "all" | "active" | "paused" | "errored";
type SortOption = "name" | "activity" | "status";
interface ProjectWithHealth {
project: ProjectInfo;
health: ProjectHealth | null;
}
/**
* ProjectOverview - Multi-project grid view with stats and filtering
*
* Displays all projects in a responsive grid with:
* - Header stats: total projects, active tasks, completed tasks
* - Filter tabs: All, Active, Paused, Errored
* - Sort dropdown: Name, Last Activity, Status
* - Project cards with health indicators
* - Empty state when no projects
*/
export function ProjectOverview({
projects,
loading = false,
onSelectProject,
onAddProject,
onPauseProject,
onResumeProject,
onRemoveProject,
nodes = [],
}: ProjectOverviewProps) {
const [activeFilter, setActiveFilter] = useState<FilterTab>("all");
const [sortBy, setSortBy] = useState<SortOption>("activity");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
// Track recently accessed projects for quick selection
useEffect(() => {
if (typeof window === "undefined") return;
// Load recently accessed from localStorage
const recent = localStorage.getItem("kb-dashboard-recent-projects");
if (recent) {
try {
const parsed = JSON.parse(recent) as string[];
setRecentProjectIds(parsed);
} catch {
// Ignore parse errors
}
}
}, []);
const [recentProjectIds, setRecentProjectIds] = useState<string[]>([]);
// Fetch health for all projects
const projectIds = useMemo(() => projects.map((p) => p.id), [projects]);
const { healthMap, loading: healthLoading } = useProjectHealth(projectIds);
// Combine projects with their health data
const projectsWithHealth: ProjectWithHealth[] = useMemo(() => {
return projects.map((project) => ({
project,
health: healthMap[project.id] || null,
}));
}, [projects, healthMap]);
// Filter projects
const filteredProjects = useMemo(() => {
let filtered = [...projectsWithHealth];
if (activeFilter !== "all") {
filtered = filtered.filter(({ project }) => project.status === activeFilter);
}
return filtered;
}, [projectsWithHealth, activeFilter]);
// Sort projects
const sortedProjects = useMemo(() => {
const sorted = [...filteredProjects];
sorted.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case "name":
comparison = a.project.name.localeCompare(b.project.name);
break;
case "activity":
const aTime = a.project.lastActivityAt || a.health?.lastActivityAt || a.project.updatedAt;
const bTime = b.project.lastActivityAt || b.health?.lastActivityAt || b.project.updatedAt;
comparison = new Date(bTime).getTime() - new Date(aTime).getTime();
break;
case "status":
const statusOrder: Record<ProjectStatus, number> = {
errored: 0,
initializing: 1,
paused: 2,
active: 3,
};
comparison = statusOrder[a.project.status] - statusOrder[b.project.status];
break;
}
return sortDirection === "asc" ? comparison : -comparison;
});
return sorted;
}, [filteredProjects, sortBy, sortDirection]);
// Calculate stats
const stats = useMemo(() => {
const totalProjects = projects.length;
const activeProjects = projects.filter((p) => p.status === "active").length;
const erroredProjects = projects.filter((p) => p.status === "errored").length;
let totalActiveTasks = 0;
let totalCompletedTasks = 0;
let totalInFlightAgents = 0;
Object.values(healthMap).forEach((health) => {
if (health) {
totalActiveTasks += health.activeTaskCount;
totalCompletedTasks += health.totalTasksCompleted;
totalInFlightAgents += health.inFlightAgentCount;
}
});
return {
totalProjects,
activeProjects,
erroredProjects,
totalActiveTasks,
totalCompletedTasks,
totalInFlightAgents,
};
}, [projects, healthMap]);
// Filter counts
const filterCounts = useMemo(() => {
return {
all: projects.length,
active: projects.filter((p) => p.status === "active").length,
paused: projects.filter((p) => p.status === "paused").length,
errored: projects.filter((p) => p.status === "errored").length,
};
}, [projects]);
// Handle sort change
const handleSort = useCallback((option: SortOption) => {
if (sortBy === option) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(option);
setSortDirection(option === "name" ? "asc" : "desc");
}
}, [sortBy]);
// Handle project selection
const handleSelectProject = useCallback((project: ProjectInfo) => {
// Update recent projects in localStorage
const updated = [project.id, ...recentProjectIds.filter((id) => id !== project.id)].slice(0, 3);
setRecentProjectIds(updated);
if (typeof window !== "undefined") {
localStorage.setItem("kb-dashboard-recent-projects", JSON.stringify(updated));
}
onSelectProject(project);
}, [onSelectProject, recentProjectIds]);
// Determine if we need to show skeleton
// Show skeleton for initial load if:
// 1. Projects list is still loading, OR
// 2. Projects exist but we haven't fetched health data yet (healthLoading with no data)
// Don't show skeleton during background health polling when health data already exists
const needsInitialSkeleton = loading || (healthLoading && projects.length > 0 && Object.keys(healthMap).length === 0);
// Show skeleton while loading
if (needsInitialSkeleton) {
return <ProjectGridSkeleton />;
}
// Empty state when no projects
if (projects.length === 0) {
return (
<div className="project-overview project-overview--empty">
<div className="project-empty-state">
<div className="project-empty-state__icon">
<Inbox size={48} />
</div>
<h2 className="project-empty-state__title">No Projects Found</h2>
<p className="project-empty-state__description">
Get started by adding your first project. Projects allow you to organize
and track tasks across multiple repositories.
</p>
<button
className="btn btn-primary project-empty-state__cta"
onClick={onAddProject}
>
<Plus size={16} />
Add Your First Project
</button>
</div>
</div>
);
}
return (
<div className="project-overview">
{/* Header with stats */}
<div className="project-overview__header">
<h2 className="project-overview__title">
<LayoutGrid size={20} />
Projects
</h2>
<div className="project-overview__stats">
<div className="project-stat">
<div className="project-stat__icon">
<Folder size={16} />
</div>
<div className="project-stat__content">
<span className="project-stat__value">{stats.totalProjects}</span>
<span className="project-stat__label">Total</span>
</div>
</div>
<div className="project-stat project-stat--active">
<div className="project-stat__icon">
<Activity size={16} />
</div>
<div className="project-stat__content">
<span className="project-stat__value">{stats.totalActiveTasks}</span>
<span className="project-stat__label">Active Tasks</span>
</div>
</div>
<div className="project-stat project-stat--completed">
<div className="project-stat__icon">
<CheckCircle size={16} />
</div>
<div className="project-stat__content">
<span className="project-stat__value">{stats.totalCompletedTasks}</span>
<span className="project-stat__label">Completed</span>
</div>
</div>
{stats.erroredProjects > 0 && (
<div className="project-stat project-stat--error">
<div className="project-stat__icon">
<AlertCircle size={16} />
</div>
<div className="project-stat__content">
<span className="project-stat__value">{stats.erroredProjects}</span>
<span className="project-stat__label">Errored</span>
</div>
</div>
)}
</div>
<button
className="btn btn-primary project-overview__add-btn"
onClick={onAddProject}
>
<Plus size={16} />
Add Project
</button>
</div>
{/* Filter tabs */}
<div className="project-overview__filters">
<div className="project-filter-tabs">
<button
className={`project-filter-tab ${activeFilter === "all" ? "active" : ""}`}
onClick={() => setActiveFilter("all")}
>
All
<span className="project-filter-count">{filterCounts.all}</span>
</button>
<button
className={`project-filter-tab ${activeFilter === "active" ? "active" : ""}`}
onClick={() => setActiveFilter("active")}
>
Active
<span className="project-filter-count">{filterCounts.active}</span>
</button>
<button
className={`project-filter-tab ${activeFilter === "paused" ? "active" : ""}`}
onClick={() => setActiveFilter("paused")}
>
Paused
<span className="project-filter-count">{filterCounts.paused}</span>
</button>
<button
className={`project-filter-tab ${activeFilter === "errored" ? "active" : ""} ${filterCounts.errored > 0 ? "has-errors" : ""}`}
onClick={() => setActiveFilter("errored")}
>
Errored
<span className="project-filter-count">{filterCounts.errored}</span>
</button>
</div>
{/* Sort dropdown */}
<div className="project-sort">
<Filter size={14} />
<select
value={`${sortBy}-${sortDirection}`}
onChange={(e) => {
const [newSort, newDir] = e.target.value.split("-") as [SortOption, "asc" | "desc"];
setSortBy(newSort);
setSortDirection(newDir);
}}
className="project-sort-select"
aria-label="Sort projects"
>
<option value="activity-desc">Last Activity (Newest)</option>
<option value="activity-asc">Last Activity (Oldest)</option>
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="status-asc">Status (Error Active)</option>
<option value="status-desc">Status (Active Error)</option>
</select>
<ArrowUpDown size={14} />
</div>
</div>
{/* Project grid */}
<div className="project-grid">
{sortedProjects.map(({ project, health }) => {
const projectNode = nodes.find((node) => node.id === project.nodeId);
return (
<ProjectCard
key={project.id}
project={project}
health={health}
node={projectNode}
onSelect={handleSelectProject}
onPause={onPauseProject}
onResume={onResumeProject}
onRemove={onRemoveProject}
/>
);
})}
</div>
{/* No results state */}
{sortedProjects.length === 0 && (
<div className="project-overview__no-results">
<Filter size={32} />
<p>No projects match the current filter</p>
<button
className="btn btn-secondary"
onClick={() => setActiveFilter("all")}
>
Show All Projects
</button>
</div>
)}
</div>
);
}