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:
@@ -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';
|
||||
|
||||
15
libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts
Normal file
15
libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
42
libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts
Normal file
42
libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
libs/api/tracker/domain/src/lib/issue/issue-rank.ts
Normal file
45
libs/api/tracker/domain/src/lib/issue/issue-rank.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user