[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:
Renzo F
2026-07-15 04:34:06 +02:00
committed by GitHub
co-authored by Renn F
parent 86d31bf3d3
commit 1054538d2f
9 changed files with 379 additions and 21 deletions
@@ -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();
});
});
+76 -14
View File
@@ -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>
+48 -2
View File
@@ -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,
}));
}
+8
View File
@@ -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 {