feat: an ordered backlog

A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:01:44 +02:00
parent d4da33d4e8
commit 0606f53026
53 changed files with 1599 additions and 32 deletions

View File

@@ -14,6 +14,7 @@ import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueDeletedEvent } from '../events/issue-deleted.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueRankedEvent } from '../events/issue-ranked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
@@ -32,6 +33,7 @@ import {
normalizeChecklistText,
} from './issue-checklist';
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { implicitRank, rankBetween } from './issue-rank';
import { IssueLinkType, IssuePriority } from './issue.enums';
export interface IssueLink {
@@ -108,6 +110,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
/** The epic or ticket this one breaks down. */
public parentId: string | null = null;
public checklist: ChecklistItem[] = [];
/** Place in the backlog once someone moved it (see `backlogRank`). */
public rank: string | null = null;
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -316,6 +320,28 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/** Where it sits in the backlog: where it was put, or its creation order. */
get backlogRank(): string {
return this.rank ?? implicitRank(this.number);
}
/** Puts the ticket between two others of the backlog (null: an end). */
moveBetween(actorId: string, above: Issue | null, below: Issue | null): void {
for (const neighbour of [above, below]) {
if (neighbour?.id === this.id)
throw new BusinessRuleError(`${this.key} cannot move next to itself`);
if (neighbour && neighbour.projectId !== this.projectId)
throw new BusinessRuleError(
`${neighbour.key} belongs to another project`,
);
}
const rank = rankBetween(
above?.backlogRank ?? null,
below?.backlogRank ?? null,
);
this.apply(new IssueRankedEvent(actorId, this.id, this.projectId, rank));
}
/** Adds a step to the checklist; `id` is generated by the caller. */
addChecklistItem(actorId: string, id: string, text: string): void {
if (this.checklist.some((i) => i.id === id)) return;
@@ -463,6 +489,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.updatedAt = event.occurredAt;
}
onIssueRankedEvent(event: IssueRankedEvent): void {
// Reordering is not a change to the ticket: `updatedAt` stays.
this.rank = event.rank;
}
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
this.checklist = [...this.checklist, { ...event.item }];
this.updatedAt = event.occurredAt;