[W9-3b] Enrich product table with cell mappings + task progress (#530)

Backend: ProductSummaryResponse gains cells: [{team, project_id, project_name}] and progress: {done, active, blocked}. ProductService.progress_for_products does one grouped query over tasks for every distinct project_id any product references, summed per product (monorepo case dedups a project once per product via a seen set). list_all eager-loads cells + each cell's project (selectinload + joinedload) so product_to_summary reads project.name without an N+1. No migration — reads existing tasks.status + product_projects.

Frontend: ProductTable renders a Cells column (team badges + project names, Unmapped when empty) and a Progress column (done/active/blocked counts + a health dot: amber at-risk when blocked>0). Both desktop Table and mobile ResponsiveTableCard variants. Mock products carry the new shape.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:34:01 +02:00
committed by GitHub
co-authored by Renn F
parent 9a364abb74
commit 86d31bf3d3
8 changed files with 444 additions and 15 deletions
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProductTable } from "../product-table";
import { Team } from "@/types";
import type { ProductSummary } from "@/types";
const product: ProductSummary = {
id: "p1",
name: "RoboCo Platform",
slug: "roboco-platform",
cell_count: 3,
cells: [
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
],
progress: { done: 42, active: 5, blocked: 1 },
};
describe("ProductTable", () => {
it("renders the product name, cells, and progress", () => {
render(<ProductTable products={[product]} isLoading={false} />);
expect(screen.getByText("RoboCo Platform")).toBeInTheDocument();
expect(screen.getByText("Backend")).toBeInTheDocument();
expect(screen.getAllByText("roboco").length).toBe(2);
expect(screen.getByText("42 done")).toBeInTheDocument();
expect(screen.getByText("1 blocked")).toBeInTheDocument();
});
it("shows the empty state when there are no products", () => {
render(<ProductTable products={[]} isLoading={false} />);
expect(screen.getByText("No products found")).toBeInTheDocument();
});
it("shows the unmapped label for a product with no cells", () => {
const bare: ProductSummary = {
id: "p2",
name: "Bare Product",
slug: "bare",
cell_count: 0,
cells: [],
progress: { done: 0, active: 0, blocked: 0 },
};
render(<ProductTable products={[bare]} isLoading={false} />);
expect(screen.getAllByText("Unmapped").length).toBeGreaterThan(0);
});
it("does not show the empty state while loading", () => {
render(<ProductTable products={undefined} isLoading={true} />);
expect(screen.queryByText("No products found")).not.toBeInTheDocument();
});
});
@@ -19,14 +19,73 @@ import {
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary } from "@/types";
import type { ProductSummary, Team } from "@/types";
import { EditProductDialog } from "./edit-product-dialog";
const TEAM_LABELS: Record<Team, string> = {
board: "Board",
main_pm: "Main PM",
backend: "Backend",
frontend: "Frontend",
ux_ui: "UX/UI",
marketing: "Marketing",
};
interface ProductTableProps {
products: ProductSummary[] | undefined;
isLoading: boolean;
}
function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
if (cells.length === 0) {
return <span className="text-muted-foreground text-sm">Unmapped</span>;
}
return (
<div className="flex flex-col gap-1">
{cells.map((c) => (
<div key={`${c.team}-${c.project_id}`} className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-blue-500/10 text-blue-500 text-xs"
>
{TEAM_LABELS[c.team] ?? c.team}
</Badge>
<span className="text-muted-foreground text-xs truncate">
{c.project_name || "—"}
</span>
</div>
))}
</div>
);
}
function ProgressCell({
progress,
}: {
progress: ProductSummary["progress"];
}) {
const { done, active, blocked } = progress;
const atRisk = blocked > 0;
return (
<div className="flex items-center gap-2">
<span
className={
"h-2 w-2 rounded-full " +
(atRisk ? "bg-amber-500" : 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">{done} done</span>
<span className="text-muted-foreground">{active} active</span>
{blocked > 0 && (
<span className="text-amber-600 dark:text-amber-400">{blocked} blocked</span>
)}
</div>
</div>
);
}
export function ProductTable({ products, isLoading }: ProductTableProps) {
const [editingProductId, setEditingProductId] = useState<string | null>(null);
@@ -59,7 +118,8 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Cells Mapped</TableHead>
<TableHead>Cells</TableHead>
<TableHead>Progress</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -81,9 +141,10 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</div>
</TableCell>
<TableCell>
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
<CellsList cells={product.cells} />
</TableCell>
<TableCell>
<ProgressCell progress={product.progress} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
@@ -131,10 +192,25 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</Button>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cells Mapped">
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
<ResponsiveTableCardRow label="Cells">
<div className="flex flex-col items-end gap-1">
{product.cells.map((c) => (
<span
key={`${c.team}-${c.project_id}`}
className="text-xs text-muted-foreground"
>
{TEAM_LABELS[c.team] ?? c.team}: {c.project_name || "—"}
</span>
))}
{product.cells.length === 0 && (
<span className="text-muted-foreground text-xs">
Unmapped
</span>
)}
</div>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Progress">
<ProgressCell progress={product.progress} />
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
+31 -1
View File
@@ -6,10 +6,40 @@ import type {
ProductSummary,
ProductUpdate,
} from "@/types";
import { Team } from "@/types";
// Mock products — shapes match the real ProductSummaryResponse (cells + progress).
function mockProducts(): ProductSummary[] {
return [
{
id: "p-mock-1",
name: "RoboCo Platform",
slug: "roboco-platform",
cell_count: 3,
cells: [
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
{ team: Team.UX_UI, project_id: "proj-1", project_name: "roboco" },
],
progress: { done: 42, active: 5, blocked: 1 },
},
{
id: "p-mock-2",
name: "Docs Site",
slug: "docs-site",
cell_count: 2,
cells: [
{ team: Team.FRONTEND, project_id: "proj-2", project_name: "roboco-website" },
{ team: Team.BACKEND, project_id: "proj-3", project_name: "docs-api" },
],
progress: { done: 18, active: 3, blocked: 0 },
},
];
}
export const productsApi = {
list: async (): Promise<ProductSummary[]> => {
if (isMockMode()) return [];
if (isMockMode()) return mockProducts();
const { data } = await api.get<ProductSummary[]>("/products");
return data;
},
+14
View File
@@ -1128,11 +1128,25 @@ export interface Product {
updated_at: string | null;
}
export interface ProductCellSummary {
team: Team;
project_id: string;
project_name: string;
}
export interface ProductProgressSummary {
done: number;
active: number;
blocked: number;
}
export interface ProductSummary {
id: string;
name: string;
slug: string;
cell_count: number;
cells: ProductCellSummary[];
progress: ProductProgressSummary;
}
export interface ProductCreate {
+18 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import cast as typing_cast
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
@@ -10,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
from roboco.api.deps import CurrentAgentContext, DbSession, require_pm_or_above
from roboco.api.schemas.product import (
ProductCreateRequest,
ProductProgressSummary,
ProductResponse,
ProductSummaryResponse,
ProductUpdateRequest,
@@ -36,7 +38,22 @@ async def list_products(
) -> list[ProductSummaryResponse]:
service = get_product_service(db)
products = await service.list_all(limit=limit, offset=offset)
return [product_to_summary(p) for p in products]
progress = await service.progress_for_products(products)
out: list[ProductSummaryResponse] = []
for p in products:
pid = typing_cast("UUID", p.id)
counts = progress.get(pid, {"done": 0, "active": 0, "blocked": 0})
out.append(
product_to_summary(
p,
progress=ProductProgressSummary(
done=counts["done"],
active=counts["active"],
blocked=counts["blocked"],
),
)
)
return out
@router.post("", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
+42 -1
View File
@@ -33,11 +33,40 @@ class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ProductCellSummary(BaseModel):
"""One cell->project mapping for the summary list (carries the project name
so the panel can show which repo each cell points at without a second fetch).
"""
team: Team
project_id: UUID
project_name: str
model_config = ConfigDict(from_attributes=True)
class ProductProgressSummary(BaseModel):
"""Task progress across a product's cell projects.
done = completed; blocked = blocked; active = every non-terminal,
non-cancelled, non-blocked status. Cancelled tasks are excluded (abandoned
work is not progress).
"""
done: int = 0
active: int = 0
blocked: int = 0
model_config = ConfigDict(from_attributes=True)
class ProductSummaryResponse(BaseModel):
id: UUID
name: str
slug: str
cell_count: int = 0
cells: list[ProductCellSummary] = Field(default_factory=list)
progress: ProductProgressSummary = Field(default_factory=ProductProgressSummary)
model_config = ConfigDict(from_attributes=True)
@@ -71,10 +100,22 @@ def product_to_response(product: "ProductTable") -> ProductResponse:
)
def product_to_summary(product: "ProductTable") -> ProductSummaryResponse:
def product_to_summary(
product: "ProductTable",
progress: ProductProgressSummary | None = None,
) -> ProductSummaryResponse:
return ProductSummaryResponse(
id=typing_cast("UUID", product.id),
name=str(product.name),
slug=str(product.slug),
cell_count=len(product.cells),
cells=[
ProductCellSummary(
team=c.team,
project_id=typing_cast("UUID", c.project_id),
project_name=str(c.project.name) if c.project else "",
)
for c in product.cells
],
progress=progress or ProductProgressSummary(),
)
+92 -3
View File
@@ -1,16 +1,27 @@
"""ProductService — CRUD + the per-cell project_for routing resolver."""
from typing import ClassVar
from typing import cast as typing_cast
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from roboco.db.tables import ProductProjectTable, ProductTable
from roboco.db.tables import ProductProjectTable, ProductTable, TaskTable
from roboco.foundation.identity import Team
from roboco.models.base import TaskStatus
from roboco.models.product import ProductCellMapping, ProductCreate, ProductUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError
# Statuses that are NOT active progress: completed (done), cancelled
# (abandoned), blocked (its own bucket). Everything else counts as active.
_INACTIVE_STATUSES = (
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
TaskStatus.BLOCKED,
)
class ProductService(BaseService):
service_name: ClassVar[str] = "product"
@@ -78,12 +89,90 @@ class ProductService(BaseService):
return True
async def list_all(self, limit: int = 100, offset: int = 0) -> list[ProductTable]:
# Eager-load cells + each cell's project so product_to_summary can read
# project.name without an N+1 lazy join per cell.
query = (
select(ProductTable).order_by(ProductTable.name).limit(limit).offset(offset)
select(ProductTable)
.options(
selectinload(ProductTable.cells).joinedload(ProductProjectTable.project)
)
.order_by(ProductTable.name)
.limit(limit)
.offset(offset)
)
result = await self.session.execute(query)
return list(result.scalars().all())
async def progress_for_products(
self, products: list[ProductTable]
) -> dict[UUID, dict[str, int]]:
"""Per-product task progress (done/active/blocked) across cell projects.
One grouped query over tasks for every distinct project_id any product
references, summed per product. A product's cells may point several teams
at the same project (the monorepo case), so each project is counted once
per product. Returns {product_id: {done, active, blocked}}.
"""
# distinct (product_id, project_id) pairs — dedup the monorepo case.
proj_to_products: dict[UUID, list[UUID]] = {}
for product in products:
seen: set[UUID] = set()
for cell in product.cells:
pid = typing_cast("UUID", cell.project_id)
if pid in seen:
continue
seen.add(pid)
proj_to_products.setdefault(pid, []).append(
typing_cast("UUID", product.id)
)
if not proj_to_products:
return {}
result = await self.session.execute(
select(
TaskTable.project_id,
func.coalesce(
func.sum(
case((TaskTable.status == TaskStatus.COMPLETED, 1), else_=0)
),
0,
).label("done"),
func.coalesce(
func.sum(
case((TaskTable.status == TaskStatus.BLOCKED, 1), else_=0)
),
0,
).label("blocked"),
func.coalesce(
func.sum(
case((TaskTable.status.in_(_INACTIVE_STATUSES), 0), else_=1)
),
0,
).label("active"),
)
.where(TaskTable.project_id.in_(list(proj_to_products.keys())))
.group_by(TaskTable.project_id)
)
per_project: dict[UUID, dict[str, int]] = {}
for row in result.fetchall():
per_project[typing_cast("UUID", row.project_id)] = {
"done": int(row.done or 0),
"active": int(row.active or 0),
"blocked": int(row.blocked or 0),
}
out: dict[UUID, dict[str, int]] = {}
for project_id, product_ids in proj_to_products.items():
counts = per_project.get(project_id)
if not counts:
continue
for pid in product_ids:
agg = out.setdefault(pid, {"done": 0, "active": 0, "blocked": 0})
agg["done"] += counts["done"]
agg["active"] += counts["active"]
agg["blocked"] += counts["blocked"]
return out
async def project_for(self, product_id: UUID, team: Team | str) -> UUID | None:
"""Resolve the Project a given cell maps to within a product.
+110
View File
@@ -0,0 +1,110 @@
"""Unit tests for ProductService.progress_for_products.
Mocks the SQLAlchemy AsyncSession.execute() boundary and verifies the
per-product aggregation (one grouped query, summed per product, monorepo
dedup of the same project across a product's cells).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID
import pytest
from roboco.services.product import ProductService
_PRODUCT_A = UUID("11111111-1111-1111-1111-111111111111")
_PRODUCT_B = UUID("22222222-2222-2222-2222-222222222222")
_PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
_PROJECT_2 = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
def _cell(project_id: UUID) -> MagicMock:
cell = MagicMock()
cell.project_id = project_id
return cell
def _product(pid: UUID, project_ids: list[UUID]) -> MagicMock:
p = MagicMock()
p.id = pid
p.cells = [_cell(pid_proj) for pid_proj in project_ids]
return p
def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
result = MagicMock()
result.fetchall = MagicMock(return_value=rows)
return result
def _row(project_id: UUID, done: int, active: int, blocked: int) -> MagicMock:
row = MagicMock()
row.project_id = project_id
row.done = done
row.active = active
row.blocked = blocked
return row
class TestProgressForProducts:
@pytest.mark.asyncio
async def test_sums_per_product_across_its_projects(self) -> None:
"""Product A spans projects 1+2; each project's counts are summed."""
session = MagicMock()
session.execute = AsyncMock(
return_value=_result_fetchall(
[
_row(_PROJECT_1, done=3, active=2, blocked=1),
_row(_PROJECT_2, done=5, active=0, blocked=0),
]
)
)
svc = ProductService(session)
products = [_product(_PRODUCT_A, [_PROJECT_1, _PROJECT_2])]
out = await svc.progress_for_products(products)
assert out[_PRODUCT_A] == {"done": 8, "active": 2, "blocked": 1}
@pytest.mark.asyncio
async def test_monorepo_dedup_counts_project_once_per_product(self) -> None:
"""Two cells of the same product pointing at the same project must not
double-count that project's tasks for the product."""
session = MagicMock()
session.execute = AsyncMock(
return_value=_result_fetchall(
[_row(_PROJECT_1, done=4, active=1, blocked=0)]
)
)
svc = ProductService(session)
# Product A has two cells both -> project 1 (monorepo).
products = [_product(_PRODUCT_A, [_PROJECT_1, _PROJECT_1])]
out = await svc.progress_for_products(products)
assert out[_PRODUCT_A] == {"done": 4, "active": 1, "blocked": 0}
@pytest.mark.asyncio
async def test_shared_project_attributed_to_both_products(self) -> None:
"""A project referenced by two products contributes to each once."""
session = MagicMock()
session.execute = AsyncMock(
return_value=_result_fetchall(
[_row(_PROJECT_2, done=2, active=1, blocked=0)]
)
)
svc = ProductService(session)
products = [
_product(_PRODUCT_A, [_PROJECT_1, _PROJECT_2]),
_product(_PRODUCT_B, [_PROJECT_2]),
]
out = await svc.progress_for_products(products)
# Project 1 has no row -> contributes 0; project 2 -> both products.
assert out[_PRODUCT_A] == {"done": 2, "active": 1, "blocked": 0}
assert out[_PRODUCT_B] == {"done": 2, "active": 1, "blocked": 0}
@pytest.mark.asyncio
async def test_no_cells_returns_empty(self) -> None:
session = MagicMock()
session.execute = AsyncMock()
svc = ProductService(session)
out = await svc.progress_for_products([_product(_PRODUCT_A, [])])
assert out == {}
session.execute.assert_not_called()