mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[W9-3c] Enrich project table with task counts + CI-watch badge (#531)
Backend: ProjectSummaryResponse gains task_counts (done/active/blocked) + ci_watch_enabled. ProjectService.task_counts_for_projects does one GROUP BY project_id over TaskTable for every distinct project_id in the list (a project with no tasks is absent — route falls back to None). ci_watch_enabled is read straight off the Project row (already a column) — a 0-cost schema extension, honest signal that CI-watch is armed, no live-conclusion fan-out. project_to_summary takes an optional task_counts. No migration. Frontend: ProjectTable gains a Tasks column (done/active/blocked + health dot, amber at-risk when blocked>0) and a CI-Watch badge under the project name when ci_watch_enabled. Both desktop Table and mobile ResponsiveTableCard variants. Mock projects carry the new shape (two sample repos). Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -47,6 +47,8 @@ function project(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: false,
|
||||
ci_watch_enabled: false,
|
||||
task_counts: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { ProjectTable } from "../project-table";
|
||||
import { Team } from "@/types";
|
||||
import type { ProjectSummary } from "@/types";
|
||||
|
||||
const project: ProjectSummary = {
|
||||
id: "p1",
|
||||
name: "RoboCo Core",
|
||||
slug: "roboco",
|
||||
git_url: "https://github.com/rennf93/roboco.git",
|
||||
assigned_cell: Team.BACKEND,
|
||||
is_active: true,
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: false,
|
||||
ci_watch_enabled: true,
|
||||
task_counts: { done: 42, active: 5, blocked: 1 },
|
||||
};
|
||||
|
||||
describe("ProjectTable", () => {
|
||||
it("renders the project name, task counts, and CI-watch badge", () => {
|
||||
render(<ProjectTable projects={[project]} isLoading={false} />);
|
||||
expect(screen.getByText("RoboCo Core")).toBeInTheDocument();
|
||||
expect(screen.getByText("42 done")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 blocked")).toBeInTheDocument();
|
||||
expect(screen.getByText("CI-Watch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no projects", () => {
|
||||
render(<ProjectTable projects={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No projects found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an em-dash placeholder when task_counts is null", () => {
|
||||
const bare: ProjectSummary = {
|
||||
...project,
|
||||
id: "p2",
|
||||
name: "bare-project",
|
||||
ci_watch_enabled: false,
|
||||
task_counts: null,
|
||||
};
|
||||
render(<ProjectTable projects={[bare]} isLoading={false} />);
|
||||
// Renders the row (not the empty state) and the CI-Watch badge is absent.
|
||||
expect(screen.getByText("bare-project")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CI-Watch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<ProjectTable projects={undefined} isLoading={true} />);
|
||||
expect(screen.queryByText("No projects found")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
ResponsiveTableCardRow,
|
||||
} from "@/components/ui/responsive-table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ExternalLink, Pencil, GitBranch, Key, KeyRound } from "lucide-react";
|
||||
import type { ProjectSummary, Team } from "@/types";
|
||||
import { ExternalLink, Pencil, GitBranch, Key, KeyRound, Radar } from "lucide-react";
|
||||
import type { ProjectSummary, ProjectTaskCounts, Team } from "@/types";
|
||||
import { EditProjectDialog } from "./edit-project-dialog";
|
||||
|
||||
interface ProjectTableProps {
|
||||
@@ -62,6 +62,53 @@ function getTokenBadge(hasGitToken: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
|
||||
if (!counts) {
|
||||
return <span className="text-muted-foreground text-xs">—</span>;
|
||||
}
|
||||
const atRisk = counts.blocked > 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
"h-2 w-2 rounded-full " +
|
||||
(atRisk
|
||||
? "bg-amber-500"
|
||||
: counts.done > 0
|
||||
? "bg-emerald-500"
|
||||
: "bg-muted")
|
||||
}
|
||||
title={atRisk ? "At risk: blocked tasks" : "Healthy"}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{counts.done} done
|
||||
</span>
|
||||
<span className="text-muted-foreground">{counts.active} active</span>
|
||||
{counts.blocked > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
{counts.blocked} blocked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CiWatchBadge({ enabled }: { enabled: boolean }) {
|
||||
if (!enabled) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-sky-500/10 text-sky-500 border-sky-500/30"
|
||||
title="CI-watch armed"
|
||||
>
|
||||
<Radar className="h-3 w-3 mr-1" />
|
||||
CI-Watch
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
|
||||
@@ -111,6 +158,7 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Cell</TableHead>
|
||||
<TableHead>Tasks</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
@@ -131,6 +179,11 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{project.slug}
|
||||
</p>
|
||||
{project.ci_watch_enabled && (
|
||||
<div className="mt-1">
|
||||
<CiWatchBadge enabled />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -138,6 +191,9 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
{teamLabels[project.assigned_cell]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TasksCell counts={project.task_counts} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getTokenBadge(project.has_git_token)}
|
||||
</TableCell>
|
||||
@@ -235,22 +291,28 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
{teamLabels[project.assigned_cell]}
|
||||
</Badge>
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Tasks">
|
||||
<TasksCell counts={project.task_counts} />
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Token">
|
||||
{getTokenBadge(project.has_git_token)}
|
||||
</ResponsiveTableCardRow>
|
||||
<ResponsiveTableCardRow label="Status">
|
||||
{project.is_active ? (
|
||||
<Badge className="bg-green-500/10 text-green-500">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{project.is_active ? (
|
||||
<Badge className="bg-green-500/10 text-green-500">
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
{project.ci_watch_enabled && <CiWatchBadge enabled />}
|
||||
</div>
|
||||
</ResponsiveTableCardRow>
|
||||
</div>
|
||||
</ResponsiveTableCard>
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import api from "./client";
|
||||
import { Team } from "@/types";
|
||||
import type {
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
ProjectSummary,
|
||||
Team,
|
||||
} from "@/types";
|
||||
import { isMockMode } from "@/lib/mock-data";
|
||||
|
||||
// Mock data for offline mode
|
||||
const mockProjects: Project[] = [];
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: "proj-mock-1",
|
||||
name: "roboco",
|
||||
slug: "roboco",
|
||||
git_url: "https://github.com/rennf93/roboco.git",
|
||||
default_branch: "master",
|
||||
protected_branches: ["master", "slave"],
|
||||
assigned_cell: Team.BACKEND,
|
||||
is_active: true,
|
||||
has_git_token: false,
|
||||
ci_watch_enabled: true,
|
||||
ci_watch_workflow: "CI",
|
||||
video_engine_enabled: false,
|
||||
workspace_path: "/data/workspaces/roboco",
|
||||
created_by: "ceo",
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: null,
|
||||
} as Project,
|
||||
{
|
||||
id: "proj-mock-2",
|
||||
name: "roboco-website",
|
||||
slug: "roboco-website",
|
||||
git_url: "https://github.com/rennf93/roboco-website.git",
|
||||
default_branch: "master",
|
||||
protected_branches: ["master"],
|
||||
assigned_cell: Team.FRONTEND,
|
||||
is_active: true,
|
||||
has_git_token: false,
|
||||
ci_watch_enabled: false,
|
||||
ci_watch_workflow: null,
|
||||
video_engine_enabled: false,
|
||||
workspace_path: null,
|
||||
created_by: "ceo",
|
||||
created_at: "2026-06-15T00:00:00Z",
|
||||
updated_at: null,
|
||||
} as Project,
|
||||
];
|
||||
|
||||
// Mock task counts per project (mock-mode only — real data comes from the
|
||||
// backend's grouped query). Keyed by project id.
|
||||
const mockTaskCounts: Record<string, { done: number; active: number; blocked: number }> = {
|
||||
"proj-mock-1": { done: 120, active: 8, blocked: 1 },
|
||||
"proj-mock-2": { done: 34, active: 2, blocked: 0 },
|
||||
};
|
||||
|
||||
export interface ProjectFilters {
|
||||
assigned_cell?: Team;
|
||||
@@ -41,6 +85,8 @@ export const projectsApi = {
|
||||
has_workspace: !!p.workspace_path,
|
||||
has_git_token: false, // Mock mode has no tokens
|
||||
video_engine_enabled: p.video_engine_enabled,
|
||||
ci_watch_enabled: !!p.ci_watch_enabled,
|
||||
task_counts: mockTaskCounts[p.id] ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1100,6 +1100,12 @@ export interface ProjectUpdate {
|
||||
sandbox_extensions?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface ProjectTaskCounts {
|
||||
done: number;
|
||||
active: number;
|
||||
blocked: number;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1110,6 +1116,8 @@ export interface ProjectSummary {
|
||||
has_workspace: boolean;
|
||||
has_git_token: boolean;
|
||||
video_engine_enabled: boolean;
|
||||
ci_watch_enabled: boolean;
|
||||
task_counts: ProjectTaskCounts | null;
|
||||
}
|
||||
|
||||
export interface ProductCellMapping {
|
||||
|
||||
Reference in New Issue
Block a user