From bf585998488fc8a600078d415f8a339a721c62c6 Mon Sep 17 00:00:00 2001 From: scotttong Date: Tue, 2 Jun 2026 19:54:02 -0700 Subject: [PATCH] =?UTF-8?q?PAP-60:=20IA=20Phase=204=20=E2=80=94=20Projects?= =?UTF-8?q?=20list=20task=20count=20+=20budget=20data=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface per-project task count and budget on the projects list view. - shared: add ProjectBudgetSummary + optional taskCount/budget on Project (list-only payload fields). - server: projectService.list attaches taskCount + budget via two aggregate queries (count(*) grouped by project, active billed_cents project budget policies) — no N+1. Extracted buildProjectListMetricMaps as a pure helper with unit coverage. - ui: render task count and budget (when set) in each Projects row; add formatProjectBudget (/mo suffix for monthly, bare amount for lifetime). Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 2 +- packages/shared/src/types/project.ts | 23 ++++- .../__tests__/project-list-metrics.test.ts | 51 +++++++++++ server/src/services/projects.ts | 91 ++++++++++++++++++- ui/src/lib/project-budget-format.test.ts | 16 ++++ ui/src/lib/utils.ts | 9 ++ ui/src/pages/Projects.tsx | 15 ++- 8 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 server/src/__tests__/project-list-metrics.test.ts create mode 100644 ui/src/lib/project-budget-format.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2cb1e69822d..dcb3a3d9cea 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -398,6 +398,7 @@ export type { AdapterEnvironmentTestResult, AssetImage, Project, + ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a235150f068..d960d4139ea 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -159,7 +159,7 @@ export type { DocumentTextRange, UpdateDocumentAnnotationThreadRequest, } from "./document-annotation.js"; -export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; +export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { CompanySearchHighlight, CompanySearchIssueSummary, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 302d504879b..60d5f56c170 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,4 +1,4 @@ -import type { PauseReason, ProjectStatus } from "../constants.js"; +import type { BudgetWindowKind, PauseReason, ProjectStatus } from "../constants.js"; import type { ProjectExecutionWorkspacePolicy, ProjectWorkspaceRuntimeConfig, @@ -14,6 +14,17 @@ export interface ProjectGoalRef { title: string; } +/** + * Lightweight per-project budget summary surfaced on the projects list payload + * (IA Phase 4 — PAP-60). Reflects the active `billed_cents` budget policy scoped + * to the project, when one is set. + */ +export interface ProjectBudgetSummary { + /** Budget limit in cents. */ + amountCents: number; + windowKind: BudgetWindowKind; +} + export interface ProjectWorkspace { id: string; companyId: string; @@ -86,6 +97,16 @@ export interface Project { workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; managedByPlugin?: ProjectManagedByPlugin | null; + /** + * Number of tasks (issues) in the project. Populated by the projects list + * endpoint (IA Phase 4 — PAP-60); omitted on single-project payloads. + */ + taskCount?: number; + /** + * Active budget for the project, when set. Populated by the projects list + * endpoint (IA Phase 4 — PAP-60); omitted on single-project payloads. + */ + budget?: ProjectBudgetSummary | null; archivedAt: Date | null; createdAt: Date; updatedAt: Date; diff --git a/server/src/__tests__/project-list-metrics.test.ts b/server/src/__tests__/project-list-metrics.test.ts new file mode 100644 index 00000000000..267cac63079 --- /dev/null +++ b/server/src/__tests__/project-list-metrics.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { buildProjectListMetricMaps } from "../services/projects.ts"; + +describe("buildProjectListMetricMaps", () => { + it("maps task counts by project, coercing string counts to numbers", () => { + const { taskCountByProjectId } = buildProjectListMetricMaps( + [ + { projectId: "p1", count: 24 }, + { projectId: "p2", count: 11 as unknown as number }, + ], + [], + ); + + expect(taskCountByProjectId.get("p1")).toBe(24); + expect(taskCountByProjectId.get("p2")).toBe(11); + }); + + it("ignores task-count rows with a null project id", () => { + const { taskCountByProjectId } = buildProjectListMetricMaps( + [{ projectId: null, count: 5 }], + [], + ); + + expect(taskCountByProjectId.size).toBe(0); + }); + + it("maps positive budgets with their window kind", () => { + const { budgetByProjectId } = buildProjectListMetricMaps( + [], + [ + { scopeId: "p1", amount: 120_000, windowKind: "calendar_month_utc" }, + { scopeId: "p2", amount: 50_000, windowKind: "lifetime" }, + ], + ); + + expect(budgetByProjectId.get("p1")).toEqual({ amountCents: 120_000, windowKind: "calendar_month_utc" }); + expect(budgetByProjectId.get("p2")).toEqual({ amountCents: 50_000, windowKind: "lifetime" }); + }); + + it("omits zero/negative budgets so they do not surface as 'set'", () => { + const { budgetByProjectId } = buildProjectListMetricMaps( + [], + [ + { scopeId: "p1", amount: 0, windowKind: "lifetime" }, + { scopeId: "p2", amount: -10, windowKind: "lifetime" }, + ], + ); + + expect(budgetByProjectId.size).toBe(0); + }); +}); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 4d568c68706..b05b5032ffb 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,9 +1,11 @@ -import { and, asc, desc, eq, inArray } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { projects, projectGoals, goals, + issues, + budgetPolicies, pluginManagedResources, plugins, projectWorkspaces, @@ -15,6 +17,8 @@ import { hasNonAsciiContent, isUuidLike, normalizeProjectUrlKey, + type BudgetWindowKind, + type ProjectBudgetSummary, type ProjectCodebase, type ProjectExecutionWorkspacePolicy, type ProjectGoalRef, @@ -62,6 +66,8 @@ interface ProjectWithGoals extends Omit workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; managedByPlugin: ProjectManagedByPlugin | null; + taskCount?: number; + budget?: ProjectBudgetSummary | null; } interface ProjectShortnameRow { @@ -315,6 +321,86 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise(); + for (const row of taskCountRows) { + if (row.projectId) taskCountByProjectId.set(row.projectId, Number(row.count) || 0); + } + + const budgetByProjectId = new Map(); + for (const row of budgetRows) { + if (row.amount > 0) { + budgetByProjectId.set(row.scopeId, { + amountCents: row.amount, + windowKind: row.windowKind as BudgetWindowKind, + }); + } + } + + return { taskCountByProjectId, budgetByProjectId }; +} + +/** + * Attach lightweight list-only metrics (task count + budget) to a set of + * projects using two aggregate queries (no N+1). Used by the projects list + * view (IA Phase 4 — PAP-60). + */ +async function attachListMetrics( + db: Db, + companyId: string, + rows: ProjectWithGoals[], +): Promise { + if (rows.length === 0) return rows; + + const projectIds = rows.map((r) => r.id); + + const [taskCountRows, budgetRows] = await Promise.all([ + db + .select({ + projectId: issues.projectId, + count: sql`count(*)::int`, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds))) + .groupBy(issues.projectId), + db + .select({ + scopeId: budgetPolicies.scopeId, + amount: budgetPolicies.amount, + windowKind: budgetPolicies.windowKind, + }) + .from(budgetPolicies) + .where( + and( + eq(budgetPolicies.companyId, companyId), + eq(budgetPolicies.scopeType, "project"), + eq(budgetPolicies.metric, "billed_cents"), + eq(budgetPolicies.isActive, true), + inArray(budgetPolicies.scopeId, projectIds), + ), + ), + ]); + + const { taskCountByProjectId, budgetByProjectId } = buildProjectListMetricMaps( + taskCountRows, + budgetRows, + ); + + return rows.map((row) => ({ + ...row, + taskCount: taskCountByProjectId.get(row.id) ?? 0, + budget: budgetByProjectId.get(row.id) ?? null, + })); +} + /** Sync the project_goals join table for a single project. */ async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) { // Delete existing links @@ -512,7 +598,8 @@ export function projectService(db: Db) { list: async (companyId: string): Promise => { const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)); const withGoals = await attachGoals(db, rows); - return attachWorkspaces(db, withGoals); + const withWorkspaces = await attachWorkspaces(db, withGoals); + return attachListMetrics(db, companyId, withWorkspaces); }, listByIds: async (companyId: string, ids: string[]): Promise => { diff --git a/ui/src/lib/project-budget-format.test.ts b/ui/src/lib/project-budget-format.test.ts new file mode 100644 index 00000000000..92e79749891 --- /dev/null +++ b/ui/src/lib/project-budget-format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { formatProjectBudget } from "./utils"; + +describe("formatProjectBudget", () => { + it("renders a /mo suffix for monthly budgets", () => { + expect(formatProjectBudget({ amountCents: 120_000, windowKind: "calendar_month_utc" })).toBe("$1,200.00/mo"); + }); + + it("renders the bare amount for lifetime budgets", () => { + expect(formatProjectBudget({ amountCents: 50_000, windowKind: "lifetime" })).toBe("$500.00"); + }); + + it("formats sub-dollar amounts with cents", () => { + expect(formatProjectBudget({ amountCents: 150, windowKind: "lifetime" })).toBe("$1.50"); + }); +}); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 064c02545e4..d288ce4878a 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -29,6 +29,15 @@ export function formatNumber(n: number): string { return n.toLocaleString("en-US"); } +/** + * Format a project's budget for the projects list view (IA Phase 4 — PAP-60). + * Monthly budgets render a `/mo` suffix; lifetime budgets show the bare amount. + */ +export function formatProjectBudget(budget: { amountCents: number; windowKind: string }): string { + const amount = formatCents(budget.amountCents); + return budget.windowKind === "calendar_month_utc" ? `${amount}/mo` : amount; +} + export function formatDate(date: Date | string): string { return new Date(date).toLocaleDateString("en-US", { month: "short", diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 934d30cb64d..16321a28b57 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -12,7 +12,7 @@ import { StatusBadge } from "../components/StatusBadge"; import { MembershipAction } from "../components/MembershipAction"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; -import { formatDate, projectUrl } from "../lib/utils"; +import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils"; import { resourceMembershipState, useResourceMembershipMutation, @@ -215,8 +215,19 @@ export function Projects() { className={state === "left" ? "group text-foreground/55" : "group"} trailing={
+ + {formatNumber(project.taskCount ?? 0)} task{(project.taskCount ?? 0) === 1 ? "" : "s"} + + {project.budget && ( + + {formatProjectBudget(project.budget)} + + )} {project.targetDate && ( - + {formatDate(project.targetDate)} )}