diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 0d248a8..1e19809 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -36,6 +36,7 @@ import type { ResumableAgent, Skill, SkillScope, + Sprint, Template, TerminalSession, Ticket, @@ -1813,12 +1814,14 @@ export class MockTicketGateway implements TicketGateway { private tickets = new Map>(); private carnets = new Map>(); private counters = new Map(); + private sprints = new Map>(); constructor(private readonly system?: MockSystemGateway) {} /** Seeds a ticket for deterministic tests; returns the stored clone. */ _seedTicket(projectId: string, ticket: Ticket): Ticket { const stored = structuredClone(ticket); + if (stored.sprintId === undefined) stored.sprintId = null; this.projectTickets(projectId).set(stored.ref, stored); this.counters.set( projectId, @@ -1827,6 +1830,41 @@ export class MockTicketGateway implements TicketGateway { return structuredClone(stored); } + /** Seeds a sprint for deterministic tests; returns the stored clone. */ + _seedSprint( + projectId: string, + sprint: Pick & + Partial>, + ): Sprint { + const stored: Sprint = { + status: "planned", + ticketCount: 0, + version: 1, + ...sprint, + }; + this.projectSprints(projectId).set(stored.id, stored); + return structuredClone(stored); + } + + private projectSprints(projectId: string): Map { + let map = this.sprints.get(projectId); + if (!map) { + map = new Map(); + this.sprints.set(projectId, map); + } + return map; + } + + /** Recomputes `ticketCount` for every sprint from the current tickets. */ + private recountSprints(projectId: string): void { + const sprints = this.projectSprints(projectId); + const counts = new Map(); + for (const t of this.projectTickets(projectId).values()) { + if (t.sprintId) counts.set(t.sprintId, (counts.get(t.sprintId) ?? 0) + 1); + } + for (const s of sprints.values()) s.ticketCount = counts.get(s.id) ?? 0; + } + private projectTickets(projectId: string): Map { let map = this.tickets.get(projectId); if (!map) { @@ -1874,6 +1912,7 @@ export class MockTicketGateway implements TicketGateway { description: input.description ?? "", status: input.status ?? "open", priority: input.priority ?? "medium", + sprintId: null, links: [], assignedAgentIds: [...(input.assignedAgentIds ?? [])], createdBy: { kind: "user" }, @@ -1930,6 +1969,7 @@ export class MockTicketGateway implements TicketGateway { title: t.title, status: t.status, priority: t.priority, + sprintId: t.sprintId ?? null, assignedAgentIds: [...t.assignedAgentIds], updatedAt: t.updatedAt, })), @@ -2066,6 +2106,41 @@ export class MockTicketGateway implements TicketGateway { }); return structuredClone(ticket); } + + async listSprints(projectId: string): Promise { + this.recountSprints(projectId); + return [...this.projectSprints(projectId).values()] + .sort((a, b) => a.order - b.order) + .map((s) => structuredClone(s)); + } + + async setTicketSprint( + projectId: string, + ref: string, + sprintId: string | null, + expectedVersion: number, + ): Promise { + const ticket = this.require(projectId, ref); + this.guard(ticket, expectedVersion); + if (sprintId !== null && !this.projectSprints(projectId).has(sprintId)) { + throw { + code: "NOT_FOUND", + message: `sprint ${sprintId} not found`, + } as GatewayError; + } + const from = ticket.sprintId ?? null; + ticket.sprintId = sprintId; + this.bump(ticket); + this.recountSprints(projectId); + this.system?.emit({ + type: "issueSprintChanged", + issueRef: ref, + from, + to: sprintId, + version: ticket.version, + }); + return structuredClone(ticket); + } } function mostRestrictive( diff --git a/frontend/src/adapters/ticket.ts b/frontend/src/adapters/ticket.ts index 3b25729..4c7d02d 100644 --- a/frontend/src/adapters/ticket.ts +++ b/frontend/src/adapters/ticket.ts @@ -14,6 +14,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { GatewayError, + Sprint, Ticket, TicketCarnet, TicketLinkKind, @@ -122,4 +123,29 @@ export class TauriTicketGateway implements TicketGateway { request: { projectId, ref, agentId, assigned, expectedVersion }, }); } + + async listSprints(projectId: string): Promise { + // `sprint_list` returns a `SprintListDto { items }`; unwrap to the array. + const list = await invoke<{ items: Sprint[] }>("sprint_list", { + request: { projectId }, + }); + return list.items; + } + + async setTicketSprint( + projectId: string, + ref: string, + sprintId: string | null, + expectedVersion: number, + ): Promise { + // Two backend commands: assign to a sprint, or unassign (clear membership). + if (sprintId === null) { + return invoke("ticket_unassign_sprint", { + request: { projectId, ref, expectedVersion }, + }); + } + return invoke("ticket_assign_sprint", { + request: { projectId, ref, sprintId, expectedVersion }, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index c344c22..54042b3 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -165,6 +165,28 @@ export type DomainEvent = agentId: string; version: number; } + | { + /** + * A ticket changed sprint membership (ticket #10). Mirror of the backend + * `DomainEventDto::IssueSprintChanged`. `from`/`to` are sprint ids (`null` + * ⇒ no sprint). Starts with `issue`, so {@link isTicketEvent} matches it + * and ticket lists refresh automatically. + */ + type: "issueSprintChanged"; + issueRef: string; + from: string | null; + to: string | null; + version: number; + } + | { type: "sprintCreated"; sprintId: string; order: number } + | { type: "sprintRenamed"; sprintId: string; name: string; version: number } + | { + type: "sprintReordered"; + sprintId: string; + order: number; + version: number; + } + | { type: "sprintDeleted"; sprintId: string } | { /** * An intermediate assistant announcement emitted **during** an inter-agent @@ -197,6 +219,17 @@ export function isTicketEvent( return event.type.startsWith("issue"); } +/** + * Whether a domain event is a sprint lifecycle event (`sprint*`). Lets the + * ticket view-model re-fetch the sprint list when sprints are created, renamed, + * reordered or deleted (ticket #10). + */ +export function isSprintEvent( + event: DomainEvent, +): event is Extract { + return event.type.startsWith("sprint"); +} + /** Where a project physically lives (mirror of the backend `RemoteRef`). */ export type RemoteRef = | { kind: "local" } @@ -865,6 +898,22 @@ export interface TicketLink { kind: TicketLinkKind; } +/** Lifecycle status of a sprint (mirror of the backend `SprintStatus`). */ +export type SprintStatus = "planned" | "active" | "done"; + +/** A sprint bucket tickets can belong to (mirror of `SprintDto`). */ +export interface Sprint { + id: string; + /** Reorderable position; sections are displayed by ascending `order`. */ + order: number; + name: string; + status: SprintStatus; + /** How many tickets currently belong to this sprint. */ + ticketCount: number; + /** Optimistic-concurrency version. */ + version: number; +} + /** The full ticket, as returned by `ticket_read`/`ticket_create`/… . */ export interface Ticket { id: string; @@ -874,6 +923,8 @@ export interface Ticket { description: string; status: TicketStatus; priority: TicketPriority; + /** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */ + sprintId?: string | null; /** Present only when the ticket was read with `includeCarnet`. */ carnet?: string; links: TicketLink[]; @@ -893,6 +944,8 @@ export interface TicketSummary { title: string; status: TicketStatus; priority: TicketPriority; + /** Sprint membership (`null` ⇒ no sprint). Additive (ticket #10). */ + sprintId?: string | null; assignedAgentIds: string[]; updatedAt: number; } diff --git a/frontend/src/features/tickets/TicketsPanel.tsx b/frontend/src/features/tickets/TicketsPanel.tsx index 1ed7b6f..5ad5f7d 100644 --- a/frontend/src/features/tickets/TicketsPanel.tsx +++ b/frontend/src/features/tickets/TicketsPanel.tsx @@ -9,7 +9,12 @@ import { useState } from "react"; -import type { TicketPriority, TicketStatus } from "@/domain"; +import type { + Sprint, + TicketPriority, + TicketStatus, + TicketSummary, +} from "@/domain"; import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; @@ -44,6 +49,20 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { const items = vm.list?.items ?? []; + // Group tickets by sprint (ticket #10): one ordered section per sprint that + // holds tickets, then a "Sans sprint" bucket. Tickets referencing an unknown + // sprint fall into the bucket so none are ever hidden. + const sprintIds = new Set(vm.sprints.map((s) => s.id)); + const grouped = vm.sprints + .map((sprint) => ({ + sprint, + tickets: items.filter((t) => t.sprintId === sprint.id), + })) + .filter((g) => g.tickets.length > 0); + const noSprint = items.filter( + (t) => !t.sprintId || !sprintIds.has(t.sprintId), + ); + async function submitCreate(e: React.FormEvent) { e.preventDefault(); if (!newTitle.trim()) return; @@ -196,7 +215,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { - {/* ── List ── */} + {/* ── List, grouped by sprint (F2) ── */} {vm.busy && vm.list === null ? (
@@ -205,33 +224,31 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { ) : items.length === 0 ? (

No tickets.

) : ( -
    - {items.map((t) => ( -
  • -
    - - - - - - -
    -
  • +
    + {grouped.map((g) => ( + ))} -
+ {noSprint.length > 0 && ( + + )} +
)} {vm.list?.nextCursor && ( @@ -254,3 +271,81 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { ); } + +/** + * One sprint section (F2): a heading with the ticket count and the grouped + * ticket rows. Each row carries a simple sprint selector wired to + * `onAssignSprint` (assignment only — creation/reorder is ticket #11). + */ +function SprintSection({ + heading, + count, + tickets, + sprints, + onOpen, + nameOf, + onAssignSprint, +}: { + heading: string; + count: number; + tickets: TicketSummary[]; + sprints: Sprint[]; + onOpen: (ref: string) => void; + nameOf: (id: string) => string; + onAssignSprint: (ref: string, sprintId: string | null) => void; +}) { + return ( +
+

+ {heading} + + {count} + +

+
    + {tickets.map((t) => ( +
  • +
    + + + + + + +
    +
    + +
    +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/features/tickets/tickets.test.tsx b/frontend/src/features/tickets/tickets.test.tsx index 8a5db37..ffea15f 100644 --- a/frontend/src/features/tickets/tickets.test.tsx +++ b/frontend/src/features/tickets/tickets.test.tsx @@ -347,6 +347,71 @@ describe("TicketsView", () => { await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); + it("groups tickets by sprint with a « Sans sprint » bucket (#10)", async () => { + // Two ordered sprints + one ticket in each + one loose ticket. + ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" }); + ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Sprint Two" }); + const t1 = await ticket.create(PROJECT_ID, { title: "In one" }); + const t2 = await ticket.create(PROJECT_ID, { title: "In two" }); + await ticket.create(PROJECT_ID, { title: "Loose" }); + await ticket.setTicketSprint(PROJECT_ID, t1.ref, "s1", t1.version); + await ticket.setTicketSprint(PROJECT_ID, t2.ref, "s2", t2.version); + + renderView(ticket, system, agent); + + // Sections appear in sprint `order`, then the "Sans sprint" bucket last. + await screen.findByRole("region", { name: "sprint section Sprint One" }); + const sections = screen + .getAllByRole("region") + .map((r) => r.getAttribute("aria-label")) + .filter((l) => l?.startsWith("sprint section")); + expect(sections).toEqual([ + "sprint section Sprint One", + "sprint section Sprint Two", + "sprint section Sans sprint", + ]); + + // The loose ticket lives in the bucket, the others under their sprint. + const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" }); + expect(within(bucket).getByText("Loose")).toBeTruthy(); + const one = screen.getByRole("region", { name: "sprint section Sprint One" }); + expect(within(one).getByText("In one")).toBeTruthy(); + }); + + it("assigns a ticket to a sprint via the row selector (#10)", async () => { + ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" }); + const t = await ticket.create(PROJECT_ID, { title: "Movable" }); + + renderView(ticket, system, agent); + + // It starts in the "Sans sprint" bucket. + const bucket = await screen.findByRole("region", { + name: "sprint section Sans sprint", + }); + expect(within(bucket).getByText("Movable")).toBeTruthy(); + + // Pick the sprint in the row selector → assign it. + fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), { + target: { value: "s1" }, + }); + + // The gateway recorded the membership… + await waitFor(async () => { + const fresh = await ticket.read(PROJECT_ID, t.ref); + expect(fresh.sprintId).toBe("s1"); + }); + // …and the UI regrouped it under Sprint One, emptying the bucket. + await waitFor(() => { + const one = screen.getByRole("region", { + name: "sprint section Sprint One", + }); + expect(within(one).getByText("Movable")).toBeTruthy(); + }); + expect( + screen.queryByRole("region", { name: "sprint section Sans sprint" }), + ).toBeNull(); + }); + it("copies a delegation context prompt (F7)", async () => { const writeText = stubClipboard(); const t = await ticket.create(PROJECT_ID, { title: "Delegate me" }); diff --git a/frontend/src/features/tickets/useTickets.ts b/frontend/src/features/tickets/useTickets.ts index cb9ae0b..80e4565 100644 --- a/frontend/src/features/tickets/useTickets.ts +++ b/frontend/src/features/tickets/useTickets.ts @@ -5,22 +5,41 @@ * `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 { isTicketEvent, type GatewayError, type Ticket, type TicketList } from "@/domain"; +import { + isSprintEvent, + isTicketEvent, + type GatewayError, + type Sprint, + type Ticket, + type TicketList, +} 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; 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; } function describe(e: unknown): string { @@ -33,6 +52,7 @@ function describe(e: unknown): string { 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({}); @@ -55,6 +75,14 @@ export function useTickets(projectId: string): TicketsViewModel { // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, ticket, queryKey]); + 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); @@ -70,10 +98,32 @@ export function useTickets(projectId: string): TicketsViewModel { [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], + ); + useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + void refreshSprints(); + }, [refreshSprints]); + useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined; @@ -81,6 +131,11 @@ export function useTickets(projectId: string): TicketsViewModel { 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(); @@ -90,7 +145,17 @@ export function useTickets(projectId: string): TicketsViewModel { cancelled = true; unsubscribe?.(); }; - }, [refresh, system]); + }, [refresh, refreshSprints, system]); - return { list, error, busy, query, setQuery, refresh, create }; + return { + list, + sprints, + error, + busy, + query, + setQuery, + refresh, + create, + assignSprint, + }; } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 65b3cd8..ce4d4e9 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -39,6 +39,7 @@ import type { ResumableAgent, Skill, SkillScope, + Sprint, Template, TerminalSession, Ticket, @@ -794,6 +795,18 @@ export interface TicketGateway { assigned: boolean, expectedVersion: number, ): Promise; + /** Lists the project's sprints, ordered by `order` (ticket #10). */ + listSprints(projectId: string): Promise; + /** + * Sets (or clears with `sprintId === null`) a ticket's sprint membership + * (ticket #10). Optimistic concurrency: `expectedVersion` must match. + */ + setTicketSprint( + projectId: string, + ref: string, + sprintId: string | null, + expectedVersion: number, + ): Promise; } /**