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

@@ -9,6 +9,7 @@ import {
FindReleasesRepository,
FindTimelineRepository,
IssueFilter,
IssueSort,
Page,
} from '@task-stream/api/tracker/application';
import {
@@ -135,7 +136,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
const [docs, total] = await Promise.all([
this.issues
.find(query)
.sort({ updatedAt: -1 })
.sort(
filter.sort === IssueSort.RANK
? { rank: 1, number: 1 }
: { updatedAt: -1 },
)
.skip(filter.offset)
.limit(filter.limit)
.lean<IssueDoc[]>(),

View File

@@ -0,0 +1,36 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { implicitRank } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { IssueDoc, ReadModels } from './read-model.schemas';
/**
* Tickets projected before the backlog could be ordered lack `rank`; give
* them their creation order once at startup, the rank they have anyway.
*/
@Injectable()
export class RankBackfill implements OnApplicationBootstrap {
private readonly logger = new Logger(RankBackfill.name);
constructor(
@InjectModel(ReadModels.Issue) private readonly issues: Model<IssueDoc>,
) {}
async onApplicationBootstrap(): Promise<void> {
const missing = await this.issues
.find({ rank: { $exists: false } }, { number: 1 })
.lean<Pick<IssueDoc, '_id' | 'number'>[]>();
if (missing.length === 0) return;
const result = await this.issues.bulkWrite(
missing.map((doc) => ({
updateOne: {
filter: { _id: doc._id, rank: { $exists: false } },
update: { $set: { rank: implicitRank(doc.number) } },
},
})),
);
this.logger.log(
`Backlog rank filled in on ${result.modifiedCount} tickets`,
);
}
}

View File

@@ -7,6 +7,7 @@ import {
IssueTimelineEntry,
ProjectReadModel,
ReleaseReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose';
@@ -102,6 +103,7 @@ IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'parent.id': 1 });
IssueSchema.index({ projectId: 1, rank: 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
@@ -186,6 +188,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [],
parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number),
checklist: rest.checklist ?? [],
};
}

View File

@@ -45,6 +45,7 @@ import {
MongoUpsertMaterializedTimelineRepository,
} from './mongo/upsert-materialized.repositories';
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
import { RankBackfill } from './mongo/rank.backfill';
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import {
PostgresIssueNumbersRepository,
@@ -120,7 +121,7 @@ const PORTS = [
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
],
providers: [...PORTS, StatusCategoryBackfill],
providers: [...PORTS, StatusCategoryBackfill, RankBackfill],
exports: PORTS.map((port) => port.provide),
})
export class TrackerInfrastructureModule {}