/** * View-model hook for the project ticket list (F2). * * Consumes the {@link TicketGateway} for reads/creates and subscribes to the * `Issue*` domain events (via {@link SystemGateway}) to refresh the list on any * ticket mutation — including those made by agents through MCP. Filters/search * are part of the query and drive a re-fetch when they change. * * Sprints (ticket #10): the hook also loads the project's sprints so the panel * can group tickets by sprint, and exposes `assignSprint` to move a ticket into * or out of a sprint. Sprint lifecycle events refresh the sprint list. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { isSprintEvent, isTicketEvent, type GatewayError, type Sprint, type Ticket, type TicketList, type TicketPriority, type TicketStatus, } from "@/domain"; import type { CreateTicketInput, TicketListQuery } from "@/ports"; import { useGateways } from "@/app/di"; export interface TicketsViewModel { list: TicketList | null; /** The project's sprints, ordered by `order` (ticket #10). */ sprints: Sprint[]; error: string | null; busy: boolean; query: TicketListQuery; setQuery: (next: TicketListQuery) => void; /** * Toggles a status in the multi-select filter (ticket #12): adds it when * absent, removes it when present. An empty set ⇒ no status constraint (all). * The list re-fetches automatically as the query changes. */ toggleStatus: (status: TicketStatus) => void; /** Toggles a priority in the multi-select filter (ticket #12). */ togglePriority: (priority: TicketPriority) => void; /** Clears both facet filters (status + priority), leaving other criteria. */ clearFacets: () => void; refresh: () => Promise; create: (input: CreateTicketInput) => Promise; /** * Assigns a ticket to a sprint (or clears it with `sprintId === null`). Reads * the ticket's current version first so the summary-based list needs no * version column. Refreshes on the resulting `issueSprintChanged` event. */ assignSprint: (ref: string, sprintId: string | null) => Promise; /** Creates a sprint (#11). Returns the created sprint, or `null` on error. */ createSprint: (name: string) => Promise; /** Renames a sprint using its current version (#11). */ renameSprint: (sprintId: string, name: string) => Promise; /** * Moves a sprint one slot up/down in execution order via `reorderSprints` * (#11). A no-op at the ends. Accessible (button-driven), non-DnD. */ moveSprint: (sprintId: string, direction: "up" | "down") => Promise; /** * Deletes a sprint (#11). The backend unassigns its tickets (does NOT delete * them). Refreshes both the sprint list and the tickets. */ deleteSprint: (sprintId: string) => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } export function useTickets(projectId: string): TicketsViewModel { const { ticket, system } = useGateways(); const [list, setList] = useState(null); const [sprints, setSprints] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [query, setQuery] = useState({}); // Stable dependency key so `refresh` only changes when a filter actually does. const queryKey = useMemo(() => JSON.stringify(query), [query]); const refresh = useCallback(async () => { setBusy(true); setError(null); try { setList(await ticket.list(projectId, query)); } catch (e) { setError(describe(e)); } finally { setBusy(false); } // `query` is captured via `queryKey`; listing it directly would rebuild the // callback on every render (a new object identity each time). // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, ticket, queryKey]); // Toggle a value in one facet set, dropping the key entirely when it empties // so the query stays clean ("no constraint"). Setting a fresh object identity // re-fetches via the `queryKey` effect (ticket #12). const toggleStatus = useCallback((status: TicketStatus) => { setQuery((prev) => { const current = prev.statuses ?? []; const next = current.includes(status) ? current.filter((s) => s !== status) : [...current, status]; const { statuses: _drop, ...rest } = prev; return next.length > 0 ? { ...rest, statuses: next } : rest; }); }, []); const togglePriority = useCallback((priority: TicketPriority) => { setQuery((prev) => { const current = prev.priorities ?? []; const next = current.includes(priority) ? current.filter((p) => p !== priority) : [...current, priority]; const { priorities: _drop, ...rest } = prev; return next.length > 0 ? { ...rest, priorities: next } : rest; }); }, []); const clearFacets = useCallback(() => { setQuery(({ statuses: _s, priorities: _p, ...rest }) => rest); }, []); const refreshSprints = useCallback(async () => { try { setSprints(await ticket.listSprints(projectId)); } catch (e) { setError(describe(e)); } }, [projectId, ticket]); const create = useCallback( async (input: CreateTicketInput): Promise => { setError(null); try { const created = await ticket.create(projectId, input); await refresh(); return created; } catch (e) { setError(describe(e)); return null; } }, [projectId, ticket, refresh], ); const assignSprint = useCallback( async (ref: string, sprintId: string | null): Promise => { setError(null); try { const current = await ticket.read(projectId, ref); await ticket.setTicketSprint(projectId, ref, sprintId, current.version); // The `issueSprintChanged` event refreshes the list; refresh the sprint // counts too (and update immediately in case events are not wired). await Promise.all([refresh(), refreshSprints()]); return true; } catch (e) { setError(describe(e)); return false; } }, [projectId, ticket, refresh, refreshSprints], ); const createSprint = useCallback( async (name: string): Promise => { setError(null); try { const created = await ticket.createSprint(projectId, name); await refreshSprints(); return created; } catch (e) { setError(describe(e)); return null; } }, [projectId, ticket, refreshSprints], ); const renameSprint = useCallback( async (sprintId: string, name: string): Promise => { setError(null); try { const current = sprints.find((s) => s.id === sprintId); if (!current) return false; await ticket.renameSprint(projectId, sprintId, name, current.version); await refreshSprints(); return true; } catch (e) { setError(describe(e)); return false; } }, [projectId, ticket, sprints, refreshSprints], ); const moveSprint = useCallback( async (sprintId: string, direction: "up" | "down"): Promise => { setError(null); const ordered = [...sprints].sort((a, b) => a.order - b.order); const index = ordered.findIndex((s) => s.id === sprintId); const swapWith = direction === "up" ? index - 1 : index + 1; if (index === -1 || swapWith < 0 || swapWith >= ordered.length) { return false; } const next = [...ordered]; [next[index], next[swapWith]] = [next[swapWith], next[index]]; try { await ticket.reorderSprints( projectId, next.map((s) => s.id), ); await refreshSprints(); return true; } catch (e) { setError(describe(e)); return false; } }, [projectId, ticket, sprints, refreshSprints], ); const deleteSprint = useCallback( async (sprintId: string): Promise => { setError(null); try { await ticket.deleteSprint(projectId, sprintId); // Delete unassigns tickets → refresh both projections. await Promise.all([refresh(), refreshSprints()]); return true; } catch (e) { setError(describe(e)); return false; } }, [projectId, ticket, refresh, refreshSprints], ); useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { void refreshSprints(); }, [refreshSprints]); useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (isTicketEvent(event)) void refresh(); // Sprint membership (`issueSprintChanged`) and sprint lifecycle events // both change the grouping → refresh the sprint list. if (isSprintEvent(event) || event.type === "issueSprintChanged") { void refreshSprints(); } }) .then((u) => { if (cancelled) u(); else unsubscribe = u; }); return () => { cancelled = true; unsubscribe?.(); }; }, [refresh, refreshSprints, system]); return { list, sprints, error, busy, query, setQuery, toggleStatus, togglePriority, clearFacets, refresh, create, assignSprint, createSprint, renameSprint, moveSprint, deleteSprint, }; }