From 069eb66acb6a2be5079cee5056b1660d58142e92 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 11:40:32 +0100 Subject: [PATCH] feat: Add project filter to listInitiatives Add findByProjectId to InitiativeRepository using a subquery on the initiative_projects junction table. Extend the listInitiatives tRPC procedure to accept an optional projectId filter that composes with the existing status filter. Add a project dropdown to the initiatives page alongside the status filter. --- .../server/db/repositories/drizzle/initiative.ts | 16 ++++++++++++++-- .../db/repositories/initiative-repository.ts | 6 ++++++ apps/server/trpc/routers/initiative.ts | 16 +++++++++++++--- apps/web/src/components/InitiativeList.tsx | 15 ++++++++++++--- apps/web/src/routes/initiatives/index.tsx | 16 ++++++++++++++++ docs/server-api.md | 2 +- 6 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/server/db/repositories/drizzle/initiative.ts b/apps/server/db/repositories/drizzle/initiative.ts index 464043c..34b61d5 100644 --- a/apps/server/db/repositories/drizzle/initiative.ts +++ b/apps/server/db/repositories/drizzle/initiative.ts @@ -4,10 +4,10 @@ * Implements InitiativeRepository interface using Drizzle ORM. */ -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; -import { agents, initiatives, type Initiative } from '../../schema.js'; +import { agents, initiatives, initiativeProjects, type Initiative } from '../../schema.js'; import type { InitiativeRepository, CreateInitiativeData, @@ -59,6 +59,18 @@ export class DrizzleInitiativeRepository implements InitiativeRepository { .where(eq(initiatives.status, status)); } + async findByProjectId(projectId: string): Promise { + const linkedIds = this.db + .select({ id: initiativeProjects.initiativeId }) + .from(initiativeProjects) + .where(eq(initiativeProjects.projectId, projectId)); + + return this.db + .select() + .from(initiatives) + .where(inArray(initiatives.id, linkedIds)); + } + async update(id: string, data: UpdateInitiativeData): Promise { const [updated] = await this.db .update(initiatives) diff --git a/apps/server/db/repositories/initiative-repository.ts b/apps/server/db/repositories/initiative-repository.ts index a16cce6..dfca891 100644 --- a/apps/server/db/repositories/initiative-repository.ts +++ b/apps/server/db/repositories/initiative-repository.ts @@ -57,6 +57,12 @@ export interface InitiativeRepository { */ update(id: string, data: UpdateInitiativeData): Promise; + /** + * Find all initiatives linked to a specific project. + * Returns empty array if none exist. + */ + findByProjectId(projectId: string): Promise; + /** * Delete an initiative. * Throws if initiative not found. diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index c0f2a47..b9d5e1b 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -111,12 +111,22 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { listInitiatives: publicProcedure .input(z.object({ status: z.enum(['active', 'completed', 'archived']).optional(), + projectId: z.string().min(1).optional(), }).optional()) .query(async ({ ctx, input }) => { const repo = requireInitiativeRepository(ctx); - const initiatives = input?.status - ? await repo.findByStatus(input.status) - : await repo.findAll(); + + let initiatives; + if (input?.projectId) { + const all = await repo.findByProjectId(input.projectId); + initiatives = input.status + ? all.filter(i => i.status === input.status) + : all; + } else { + initiatives = input?.status + ? await repo.findByStatus(input.status) + : await repo.findAll(); + } // Fetch active architect agents once for all initiatives const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine']; diff --git a/apps/web/src/components/InitiativeList.tsx b/apps/web/src/components/InitiativeList.tsx index 3360678..d2a791a 100644 --- a/apps/web/src/components/InitiativeList.tsx +++ b/apps/web/src/components/InitiativeList.tsx @@ -8,18 +8,27 @@ import { trpc } from "@/lib/trpc"; interface InitiativeListProps { statusFilter?: "all" | "active" | "completed" | "archived"; + projectFilter?: string; onCreateNew: () => void; onViewInitiative: (id: string) => void; } export function InitiativeList({ statusFilter = "all", + projectFilter, onCreateNew, onViewInitiative, }: InitiativeListProps) { - const initiativesQuery = trpc.listInitiatives.useQuery( - statusFilter === "all" ? undefined : { status: statusFilter }, - ); + const queryInput = (() => { + const hasStatus = statusFilter !== "all"; + if (!hasStatus && !projectFilter) return undefined; + return { + ...(hasStatus && { status: statusFilter as "active" | "completed" | "archived" }), + ...(projectFilter && { projectId: projectFilter }), + }; + })(); + + const initiativesQuery = trpc.listInitiatives.useQuery(queryInput); // Loading state if (initiativesQuery.isLoading) { diff --git a/apps/web/src/routes/initiatives/index.tsx b/apps/web/src/routes/initiatives/index.tsx index ca8f231..5407dd1 100644 --- a/apps/web/src/routes/initiatives/index.tsx +++ b/apps/web/src/routes/initiatives/index.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { InitiativeList } from "@/components/InitiativeList"; import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog"; import { useLiveUpdates } from "@/hooks"; +import { trpc } from "@/lib/trpc"; export const Route = createFileRoute("/initiatives/")({ component: DashboardPage, @@ -23,7 +24,9 @@ const filterOptions: { value: StatusFilter; label: string }[] = [ function DashboardPage() { const navigate = useNavigate(); const [statusFilter, setStatusFilter] = useState("all"); + const [projectFilter, setProjectFilter] = useState("all"); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const projectsQuery = trpc.listProjects.useQuery(); // Single SSE stream for live updates useLiveUpdates([ @@ -42,6 +45,18 @@ function DashboardPage() {

Initiatives

+