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.
This commit is contained in:
Lukas May
2026-03-04 11:40:32 +01:00
parent b6ac797644
commit 069eb66acb
6 changed files with 62 additions and 9 deletions

View File

@@ -4,10 +4,10 @@
* Implements InitiativeRepository interface using Drizzle ORM. * Implements InitiativeRepository interface using Drizzle ORM.
*/ */
import { eq } from 'drizzle-orm'; import { eq, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js'; 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 { import type {
InitiativeRepository, InitiativeRepository,
CreateInitiativeData, CreateInitiativeData,
@@ -59,6 +59,18 @@ export class DrizzleInitiativeRepository implements InitiativeRepository {
.where(eq(initiatives.status, status)); .where(eq(initiatives.status, status));
} }
async findByProjectId(projectId: string): Promise<Initiative[]> {
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<Initiative> { async update(id: string, data: UpdateInitiativeData): Promise<Initiative> {
const [updated] = await this.db const [updated] = await this.db
.update(initiatives) .update(initiatives)

View File

@@ -57,6 +57,12 @@ export interface InitiativeRepository {
*/ */
update(id: string, data: UpdateInitiativeData): Promise<Initiative>; update(id: string, data: UpdateInitiativeData): Promise<Initiative>;
/**
* Find all initiatives linked to a specific project.
* Returns empty array if none exist.
*/
findByProjectId(projectId: string): Promise<Initiative[]>;
/** /**
* Delete an initiative. * Delete an initiative.
* Throws if initiative not found. * Throws if initiative not found.

View File

@@ -111,12 +111,22 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
listInitiatives: publicProcedure listInitiatives: publicProcedure
.input(z.object({ .input(z.object({
status: z.enum(['active', 'completed', 'archived']).optional(), status: z.enum(['active', 'completed', 'archived']).optional(),
projectId: z.string().min(1).optional(),
}).optional()) }).optional())
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx); const repo = requireInitiativeRepository(ctx);
const initiatives = input?.status
? await repo.findByStatus(input.status) let initiatives;
: await repo.findAll(); 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 // Fetch active architect agents once for all initiatives
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine']; const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];

View File

@@ -8,18 +8,27 @@ import { trpc } from "@/lib/trpc";
interface InitiativeListProps { interface InitiativeListProps {
statusFilter?: "all" | "active" | "completed" | "archived"; statusFilter?: "all" | "active" | "completed" | "archived";
projectFilter?: string;
onCreateNew: () => void; onCreateNew: () => void;
onViewInitiative: (id: string) => void; onViewInitiative: (id: string) => void;
} }
export function InitiativeList({ export function InitiativeList({
statusFilter = "all", statusFilter = "all",
projectFilter,
onCreateNew, onCreateNew,
onViewInitiative, onViewInitiative,
}: InitiativeListProps) { }: InitiativeListProps) {
const initiativesQuery = trpc.listInitiatives.useQuery( const queryInput = (() => {
statusFilter === "all" ? undefined : { status: statusFilter }, 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 // Loading state
if (initiativesQuery.isLoading) { if (initiativesQuery.isLoading) {

View File

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { InitiativeList } from "@/components/InitiativeList"; import { InitiativeList } from "@/components/InitiativeList";
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog"; import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
import { useLiveUpdates } from "@/hooks"; import { useLiveUpdates } from "@/hooks";
import { trpc } from "@/lib/trpc";
export const Route = createFileRoute("/initiatives/")({ export const Route = createFileRoute("/initiatives/")({
component: DashboardPage, component: DashboardPage,
@@ -23,7 +24,9 @@ const filterOptions: { value: StatusFilter; label: string }[] = [
function DashboardPage() { function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const projectsQuery = trpc.listProjects.useQuery();
// Single SSE stream for live updates // Single SSE stream for live updates
useLiveUpdates([ useLiveUpdates([
@@ -42,6 +45,18 @@ function DashboardPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="font-display text-2xl font-semibold">Initiatives</h1> <h1 className="font-display text-2xl font-semibold">Initiatives</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<select
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="all">All projects</option>
{(projectsQuery.data ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => onChange={(e) =>
@@ -70,6 +85,7 @@ function DashboardPage() {
> >
<InitiativeList <InitiativeList
statusFilter={statusFilter} statusFilter={statusFilter}
projectFilter={projectFilter === "all" ? undefined : projectFilter}
onCreateNew={() => setCreateDialogOpen(true)} onCreateNew={() => setCreateDialogOpen(true)}
onViewInitiative={(id) => onViewInitiative={(id) =>
navigate({ to: "/initiatives/$id", params: { id } }) navigate({ to: "/initiatives/$id", params: { id } })

View File

@@ -88,7 +88,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| Procedure | Type | Description | | Procedure | Type | Description |
|-----------|------|-------------| |-----------|------|-------------|
| createInitiative | mutation | Create with optional branch/projectIds/description, auto-creates root page (seeded with description); if description provided, auto-spawns refine agent | | createInitiative | mutation | Create with optional branch/projectIds/description, auto-creates root page (seeded with description); if description provided, auto-spawns refine agent |
| listInitiatives | query | Filter by status; returns `activity` (state, activePhase, phase counts) computed from phases | | listInitiatives | query | Filter by status and/or projectId; returns `activity` (state, activePhase, phase counts) computed from phases |
| getInitiative | query | With projects array | | getInitiative | query | With projects array |
| updateInitiative | mutation | Name, status | | updateInitiative | mutation | Name, status |
| deleteInitiative | mutation | Cascade delete initiative and all children | | deleteInitiative | mutation | Cascade delete initiative and all children |