feat: sprints with burndown and carry-over

Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:59:01 +02:00
parent d2e31789a7
commit 50d3ffd1dd
104 changed files with 7209 additions and 33 deletions

View File

@@ -24,6 +24,7 @@ import {
ProjectRole,
ProjectStatsReadModel,
ReleaseProgressReadModel,
SprintProgressReadModel,
ReleaseReadModel,
ReleaseStatus,
StatusCategory,
@@ -281,6 +282,37 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
});
}
async sprintProgress(sprintId: string): Promise<SprintProgressReadModel> {
const finished = { $in: ['$statusCategory', FINISHED] };
const points = { $ifNull: ['$storyPoints', 0] };
const [row] = await this.issues.aggregate<{
total: number;
finished: number;
storyPoints: number;
finishedStoryPoints: number;
}>([
{ $match: { 'sprint.id': sprintId } },
{
$group: {
_id: null,
total: { $sum: 1 },
finished: { $sum: { $cond: [finished, 1, 0] } },
storyPoints: { $sum: points },
finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
},
},
]);
const total = row?.total ?? 0;
return {
total,
finished: row?.finished ?? 0,
percent:
total === 0 ? 0 : Math.round(((row?.finished ?? 0) / total) * 100),
storyPoints: row?.storyPoints ?? 0,
finishedStoryPoints: row?.finishedStoryPoints ?? 0,
};
}
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
const rows = await this.issues.aggregate<{
_id: { status: string; category: StatusCategory };
@@ -322,6 +354,8 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
if (filter.releaseId) query['release.id'] = filter.releaseId;
if (filter.parentId) query['parent.id'] = filter.parentId;
if (filter.noSprint) query['sprint'] = null;
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.search?.trim()) {

View File

@@ -8,6 +8,7 @@ import {
ProjectReadModel,
ReleaseReadModel,
SavedFilterReadModel,
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose';
@@ -24,6 +25,7 @@ export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
};
export type CommentDoc = Doc<CommentReadModel>;
export type ReleaseDoc = Doc<ReleaseReadModel>;
export type SprintDoc = Doc<SprintReadModel>;
export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
@@ -43,6 +45,7 @@ export const ReadModels = {
Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
Sprint: 'SprintReadModel',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -79,6 +82,7 @@ export const IssueSchema = new Schema<IssueDoc>(
reporter: userRef,
assignee: userRef,
release: { _id: false, id: String, name: String },
sprint: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
@@ -108,6 +112,7 @@ IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'parent.id': 1 });
IssueSchema.index({ projectId: 1, rank: 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>(
@@ -126,6 +131,16 @@ export const ReleaseSchema = new Schema<ReleaseDoc>(
{ ...loose, collection: 'releases_view' },
);
export const SprintSchema = new Schema<SprintDoc>(
{
_id: String,
projectId: { type: String, index: true },
status: String,
version: Number,
},
{ ...loose, collection: 'sprints_view' },
);
export const ActivitySchema = new Schema<ActivityDoc>(
{
_id: String,
@@ -182,6 +197,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Issue, schema: IssueSchema },
{ name: ReadModels.Comment, schema: CommentSchema },
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Sprint, schema: SprintSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },
@@ -207,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
watchers: rest.watchers ?? [],
parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number),
sprint: rest.sprint?.id ? rest.sprint : null,
checklist: rest.checklist ?? [],
};
}

View File

@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
FindSprintsRepository,
UpsertMaterializedSprintRepository,
} from '@task-stream/api/tracker/application';
import { SprintReadModel, SprintStatus } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { ReadModels, SprintDoc, fromDoc } from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer';
const ORDER: Record<SprintStatus, number> = {
[SprintStatus.ACTIVE]: 0,
[SprintStatus.PLANNED]: 1,
[SprintStatus.CLOSED]: 2,
};
@Injectable()
export class MongoFindSprintsRepository extends FindSprintsRepository {
constructor(
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
) {
super();
}
async findById(id: string): Promise<SprintReadModel | null> {
const doc = await this.sprints.findById(id).lean<SprintDoc>();
return doc ? fromDoc(doc) : null;
}
async findByProject(projectId: string): Promise<SprintReadModel[]> {
const docs = await this.sprints.find({ projectId }).lean<SprintDoc[]>();
return docs
.map(fromDoc)
.sort(
(a, b) =>
ORDER[a.status] - ORDER[b.status] ||
(a.status === SprintStatus.CLOSED
? b.startDate.localeCompare(a.startDate)
: a.startDate.localeCompare(b.startDate)) ||
a.name.localeCompare(b.name),
);
}
async findActive(projectId: string): Promise<SprintReadModel | null> {
const doc = await this.sprints
.findOne({ projectId, status: SprintStatus.ACTIVE })
.lean<SprintDoc>();
return doc ? fromDoc(doc) : null;
}
}
@Injectable()
export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedSprintRepository {
constructor(
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
) {
super();
}
async upsert({
id,
version,
...fields
}: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>) {
await upsertIfNewer(this.sprints, id, version, fields);
}
async remove(sprintId: string) {
await this.sprints.deleteOne({ _id: sprintId });
}
}

View File

@@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
if (ops.length) await this.issues.bulkWrite(ops);
}
async renameSprint(sprintId: string, name: string) {
await this.issues.updateMany(
{ 'sprint.id': sprintId },
{ $set: { 'sprint.name': name } },
);
}
async renameRelease(releaseId: string, name: string) {
await this.issues.updateMany(
{ 'release.id': releaseId },