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

@@ -30,3 +30,4 @@ export * from './issue-parent-changed.event';
export * from './issue-checklist-item-added.event';
export * from './issue-checklist-item-updated.event';
export * from './issue-checklist-item-removed.event';
export * from './issue-ranked.event';

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket was moved to another place in the backlog. */
@AutowiredEvent
export class IssueRankedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly rank: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,42 @@
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { implicitRank, rankBetween } from './issue-rank';
describe('backlog ranks', () => {
it('orders tickets never moved by creation', () => {
const ranks = [1, 2, 10, 36, 999].map(implicitRank);
expect([...ranks].sort()).toEqual(ranks);
});
it('always finds a rank between two, at the ends too', () => {
const pairs: [string | null, string | null][] = [
[null, null],
[null, implicitRank(1)],
[implicitRank(1), implicitRank(2)],
[implicitRank(9), null],
['a', 'b'],
['az', 'b'],
['a', 'a1'],
['zz', null],
];
for (const [above, below] of pairs) {
const rank = rankBetween(above, below);
if (above !== null) expect(rank > above).toBe(true);
if (below !== null) expect(rank < below).toBe(true);
expect(rank.endsWith('0')).toBe(false);
}
});
it('keeps finding room when the same gap is split again and again', () => {
let above = implicitRank(1);
const below = implicitRank(2);
for (let i = 0; i < 200; i++) {
const rank = rankBetween(above, below);
expect(rank > above && rank < below).toBe(true);
above = rank;
}
});
it('refuses neighbours that are out of order', () => {
expect(() => rankBetween('b', 'a')).toThrow(BusinessRuleError);
});
});

View File

@@ -0,0 +1,45 @@
import { BusinessRuleError } from '@task-stream/api/shared/domain';
/*
* Backlog order as fractional ranks: strings compared character by character,
* so moving a ticket rewrites only that ticket. A rank is a base-36 fraction
* (`0.xyz…`) that never ends in `0`, which keeps a free key between any two.
*/
const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz';
const BASE = DIGITS.length;
const digit = (rank: string, at: number) =>
at < rank.length ? DIGITS.indexOf(rank[at]) : 0;
/**
* Where a ticket that was never moved sits: after anything placed above it,
* in creation order. Starts at `n`, the middle of the key space.
*/
export function implicitRank(issueNumber: number): string {
return `n${issueNumber.toString(BASE).padStart(7, '0')}i`;
}
/** A rank strictly between `above` and `below` (null: open end). */
export function rankBetween(
above: string | null,
below: string | null,
): string {
const low = above ?? '';
if (below !== null && low >= below)
throw new BusinessRuleError('The order changed meanwhile; reload it');
let upper = below;
let rank = '';
for (let at = 0; ; at++) {
const from = digit(low, at);
const to = upper === null ? BASE : digit(upper, at);
if (from === to) {
rank += DIGITS[from];
continue;
}
if (to - from > 1) return rank + DIGITS[(from + to) >> 1];
// Neighbours here: keep the lower digit, and anything after it fits below.
rank += DIGITS[from];
upper = null;
}
}

View File

@@ -279,6 +279,32 @@ describe('Issue', () => {
});
});
describe('backlog order', () => {
it('moves between two tickets, to the top, and survives a replay', () => {
const issue = newIssue();
const other = (number: number) =>
factory.create({
projectId: 'p-1',
projectKey: 'TS',
number,
title: `#${number}`,
type: IssueType.TASK,
reporterId: 'u',
});
const [one, two] = [other(1), other(2)];
issue.moveBetween('u-pm', one, two);
expect(issue.backlogRank > one.backlogRank).toBe(true);
expect(issue.backlogRank < two.backlogRank).toBe(true);
issue.moveBetween('u-pm', null, one);
expect(issue.backlogRank < one.backlogRank).toBe(true);
expect(replay(issue, Issue).rebuilt.rank).toBe(issue.rank);
expect(() => issue.moveBetween('u-pm', issue, null)).toThrow(
BusinessRuleError,
);
});
});
describe('checklist', () => {
it('adds, ticks, rewords and removes steps, and survives a replay', () => {
const issue = newIssue();

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;

View File

@@ -79,6 +79,8 @@ export class IssueReadModel {
/** People following the ticket. */
watchers!: UserRefReadModel[];
parent!: IssueParentReadModel | null;
/** Place in the backlog (and in each board column): ascending. */
rank!: string;
checklist!: ChecklistItemReadModel[];
commentCount!: number;
createdAt!: string;