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:
@@ -10,5 +10,6 @@ export * from './lib/workflows.service';
|
||||
export * from './lib/notifications.service';
|
||||
export * from './lib/notifications';
|
||||
export * from './lib/saved-filters.service';
|
||||
export * from './lib/sprints.service';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/tracker-application.module';
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
StatusCategory as C,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeProjectAnalytics,
|
||||
computeReleaseBurnup,
|
||||
computeSprintBurndown,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
|
||||
@@ -182,3 +185,62 @@ describe('release burnup', () => {
|
||||
expect(at(burnup.done, '2026-10-14')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sprint burndown', () => {
|
||||
const sprint = {
|
||||
id: 's1',
|
||||
projectId: 'p',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-16',
|
||||
status: SprintStatus.ACTIVE,
|
||||
closedAt: null,
|
||||
} as SprintReadModel;
|
||||
const into = (issueId: string, at: string): IssueTimelineEntry => ({
|
||||
id: `${issueId}:${++seq}`,
|
||||
issueId,
|
||||
projectId: 'p',
|
||||
at,
|
||||
kind: 'sprint',
|
||||
sprintId: 's1',
|
||||
});
|
||||
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
|
||||
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
|
||||
const issues = [
|
||||
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
|
||||
{
|
||||
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
|
||||
storyPoints: 5,
|
||||
},
|
||||
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
|
||||
] as IssueReadModel[];
|
||||
const timeline = [
|
||||
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||
into('x', '2026-10-12T08:00:00Z'),
|
||||
into('y', '2026-10-12T08:00:00Z'),
|
||||
into('z', '2026-10-13T10:00:00Z'),
|
||||
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
|
||||
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
|
||||
];
|
||||
|
||||
it('counts open tickets and points per day, and leaves the future blank', () => {
|
||||
const burndown = computeSprintBurndown({
|
||||
sprint,
|
||||
issues,
|
||||
timeline,
|
||||
now: new Date('2026-10-14T12:00:00Z'),
|
||||
});
|
||||
expect(burndown.dates).toEqual([
|
||||
'2026-10-12',
|
||||
'2026-10-13',
|
||||
'2026-10-14',
|
||||
'2026-10-15',
|
||||
'2026-10-16',
|
||||
]);
|
||||
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
|
||||
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
|
||||
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
|
||||
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
ProjectAnalyticsReadModel,
|
||||
ReleaseBurnupReadModel,
|
||||
ReleaseReadModel,
|
||||
SprintBurndownReadModel,
|
||||
SprintReadModel,
|
||||
StatusCategory,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SprintBurndownInput {
|
||||
sprint: SprintReadModel;
|
||||
issues: IssueReadModel[];
|
||||
timeline: IssueTimelineEntry[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per day of the sprint (start to end date, UTC): tickets in it, how many
|
||||
* are still open and their points, and the ideal line. Points are today's
|
||||
* estimates (their history is not kept). Days after today, or after the
|
||||
* sprint closed, are null.
|
||||
*/
|
||||
export function computeSprintBurndown(
|
||||
input: SprintBurndownInput,
|
||||
): SprintBurndownReadModel {
|
||||
const { sprint } = input;
|
||||
const steps = stepsByIssue(input.issues, input.timeline);
|
||||
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
|
||||
const membership = new Map<
|
||||
string,
|
||||
{ at: number; sprintId: string | null }[]
|
||||
>();
|
||||
for (const entry of input.timeline) {
|
||||
if (entry.kind !== 'sprint') continue;
|
||||
const list = membership.get(entry.issueId) ?? [];
|
||||
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
|
||||
membership.set(entry.issueId, list);
|
||||
}
|
||||
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
|
||||
|
||||
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
|
||||
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
|
||||
const last = Math.min(
|
||||
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
|
||||
end + DAY - 1,
|
||||
);
|
||||
const dayEnds = days(start, end + DAY - 1);
|
||||
const scope: (number | null)[] = [];
|
||||
const remaining: (number | null)[] = [];
|
||||
const remainingPoints: (number | null)[] = [];
|
||||
for (const time of dayEnds) {
|
||||
// The day under way counts up to now; the ones to come are unknown.
|
||||
const at = Math.min(time, last);
|
||||
if (time - DAY >= last) {
|
||||
scope.push(null);
|
||||
remaining.push(null);
|
||||
remainingPoints.push(null);
|
||||
continue;
|
||||
}
|
||||
let inScope = 0;
|
||||
let open = 0;
|
||||
let openPoints = 0;
|
||||
for (const [issueId, list] of membership) {
|
||||
const current = list.filter((m) => m.at <= at).at(-1);
|
||||
if (current?.sprintId !== sprint.id) continue;
|
||||
const step = stepAt(steps.get(issueId) ?? [], at);
|
||||
if (!step || step.category === StatusCategory.CANCELLED) continue;
|
||||
inScope++;
|
||||
if (step.category !== StatusCategory.DONE) {
|
||||
open++;
|
||||
openPoints += points.get(issueId) ?? 0;
|
||||
}
|
||||
}
|
||||
scope.push(inScope);
|
||||
remaining.push(open);
|
||||
remainingPoints.push(openPoints);
|
||||
}
|
||||
const first = remaining[0] ?? 0;
|
||||
const span = Math.max(dayEnds.length - 1, 1);
|
||||
return {
|
||||
sprintId: sprint.id,
|
||||
dates: dayEnds.map(dayOf),
|
||||
scope,
|
||||
remaining,
|
||||
remainingPoints,
|
||||
ideal: dayEnds.map(
|
||||
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IssueChange,
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
SprintChange,
|
||||
} from './ports/change-feed';
|
||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
@@ -52,6 +53,14 @@ export class ChangesService {
|
||||
return this.changeFeed.releases(projectId);
|
||||
}
|
||||
|
||||
async watchSprints(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<SprintChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.sprints(projectId);
|
||||
}
|
||||
|
||||
async watchProject(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
@@ -71,6 +72,7 @@ export const COMMAND_HANDLERS = [
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
RankIssueCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import {
|
||||
CreateSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const sprints = {
|
||||
active: null as { id: string; name: string } | null,
|
||||
findActive: async () => sprints.active,
|
||||
};
|
||||
const files = new InMemoryAttachmentStorage();
|
||||
const users = new FakeUserDirectory()
|
||||
.add('owner')
|
||||
@@ -92,6 +103,20 @@ async function setup() {
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
// Read-model lookups: the sprint under way is set by each test.
|
||||
{ provide: FindSprintsRepository, useValue: sprints },
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
sprintProgress: async () => ({
|
||||
total: 0,
|
||||
finished: 0,
|
||||
percent: 0,
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
@@ -103,6 +128,7 @@ async function setup() {
|
||||
return {
|
||||
store,
|
||||
files,
|
||||
sprints,
|
||||
run,
|
||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||
};
|
||||
@@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('plans and starts sprints, one under way at a time, and fills them', async () => {
|
||||
const { run, rehydrator, sprints } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const plan = (name: string) =>
|
||||
run(
|
||||
new CreateSprintCommand(
|
||||
'owner',
|
||||
projectId,
|
||||
name,
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
);
|
||||
const first = await plan('Sprint 1');
|
||||
const second = await plan('Sprint 2');
|
||||
await expect(
|
||||
run(
|
||||
new CreateSprintCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
'X',
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
await run(new StartSprintCommand('owner', first.id));
|
||||
sprints.active = { id: first.id, name: 'Sprint 1' };
|
||||
await expect(
|
||||
run(new StartSprintCommand('owner', second.id)),
|
||||
).rejects.toThrow(/Sprint 1 is under way/);
|
||||
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
|
||||
);
|
||||
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
|
||||
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
|
||||
first.id,
|
||||
);
|
||||
await expect(
|
||||
run(new SetIssueSprintCommand('pm', issue.id, null)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -33,3 +33,4 @@ export * from './set-issue-parent.command';
|
||||
export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
Issue,
|
||||
Project,
|
||||
Sprint,
|
||||
SprintFactory,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import {
|
||||
CarryOverIssueCommand,
|
||||
CloseSprintCommand,
|
||||
CreateSprintCommand,
|
||||
DeleteSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
UpdateSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */
|
||||
|
||||
@CommandHandler(CreateSprintCommand)
|
||||
export class CreateSprintCommandHandler
|
||||
implements ICommandHandler<CreateSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly sprintFactory: SprintFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateSprintCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.assertCanManage(command.actorId);
|
||||
const sprint = this.sprintFactory.create(command.actorId, {
|
||||
projectId: project.id,
|
||||
name: command.name,
|
||||
goal: command.goal,
|
||||
startDate: command.startDate,
|
||||
endDate: command.endDate,
|
||||
});
|
||||
this.eventPublisher.mergeObjectContext(sprint);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
/** The sprint, checking the actor manages its project. */
|
||||
async function managedSprint(
|
||||
access: WriteAccess,
|
||||
sprintId: string,
|
||||
actorId: string,
|
||||
): Promise<Sprint> {
|
||||
const sprint = await access.sprint(sprintId);
|
||||
(await access.project(sprint.projectId)).assertCanManage(actorId);
|
||||
return sprint;
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateSprintCommand)
|
||||
export class UpdateSprintCommandHandler
|
||||
implements ICommandHandler<UpdateSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UpdateSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
sprint.update(command.actorId, command.changes);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(StartSprintCommand)
|
||||
export class StartSprintCommandHandler
|
||||
implements ICommandHandler<StartSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: StartSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
const active = await this.findSprints.findActive(sprint.projectId);
|
||||
sprint.start(
|
||||
command.actorId,
|
||||
active && active.id !== sprint.id ? active : null,
|
||||
);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CloseSprintCommand)
|
||||
export class CloseSprintCommandHandler
|
||||
implements ICommandHandler<CloseSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: CloseSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
const target = command.carryOverTo
|
||||
? await this.access.sprint(command.carryOverTo)
|
||||
: null;
|
||||
const progress = await this.findIssues.sprintProgress(sprint.id);
|
||||
sprint.close(command.actorId, target, progress.total - progress.finished);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteSprintCommand)
|
||||
export class DeleteSprintCommandHandler
|
||||
implements ICommandHandler<DeleteSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteSprintCommand): Promise<CommandResult | void> {
|
||||
if (command.cascade) {
|
||||
// Saga: idempotent, the sprint may be gone already.
|
||||
const sprint = await this.access.live(command.sprintId, Sprint);
|
||||
if (!sprint) return;
|
||||
sprint.delete(command.actorId, true);
|
||||
await sprint.commitAndWait();
|
||||
return;
|
||||
}
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
sprint.delete(command.actorId);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(SetIssueSprintCommand)
|
||||
export class SetIssueSprintCommandHandler
|
||||
implements ICommandHandler<SetIssueSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: SetIssueSprintCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// A closed sprint keeps its tickets: neither side may be closed.
|
||||
if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId);
|
||||
if (command.sprintId)
|
||||
await this.access.openSprintOf(project, command.sprintId);
|
||||
issue.setSprint(command.actorId, command.sprintId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
@CommandHandler(CarryOverIssueCommand)
|
||||
export class CarryOverIssueCommandHandler
|
||||
implements ICommandHandler<CarryOverIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: CarryOverIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone, or it left that sprint meanwhile: nothing to do.
|
||||
if (!issue || issue.sprintId !== command.fromSprintId) return;
|
||||
const project = await this.access.live(issue.projectId, Project);
|
||||
if (!project) return;
|
||||
if (
|
||||
command.onlyUnfinished &&
|
||||
FINISHED.includes(project.workflow.categoryOf(issue.status))
|
||||
)
|
||||
return;
|
||||
if (command.toSprintId) {
|
||||
const target = await this.access.live(command.toSprintId, Sprint);
|
||||
// The target closed or vanished meanwhile: back to the backlog instead.
|
||||
const open =
|
||||
target && !target.deleted && target.status !== SprintStatus.CLOSED;
|
||||
issue.setSprint(command.actorId, open ? command.toSprintId : null);
|
||||
} else {
|
||||
issue.setSprint(command.actorId, null);
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
export const SPRINT_COMMAND_HANDLERS = [
|
||||
CreateSprintCommandHandler,
|
||||
UpdateSprintCommandHandler,
|
||||
StartSprintCommandHandler,
|
||||
CloseSprintCommandHandler,
|
||||
DeleteSprintCommandHandler,
|
||||
SetIssueSprintCommandHandler,
|
||||
// Saga only.
|
||||
CarryOverIssueCommandHandler,
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { SprintDetails } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class CreateSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly name: string,
|
||||
public readonly startDate: string,
|
||||
public readonly endDate: string,
|
||||
public readonly goal: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly changes: SprintDetails,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class StartSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */
|
||||
export class CloseSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly carryOverTo: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */
|
||||
export class DeleteSprintCommand extends Command<CommandResult | void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly cascade = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a ticket in a sprint (null: back to the backlog). */
|
||||
export class SetIssueSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly sprintId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saga only: a sprint closed (or was deleted), so a ticket of it moves to
|
||||
* `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as
|
||||
* the record of what the sprint achieved.
|
||||
*/
|
||||
export class CarryOverIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly fromSprintId: string,
|
||||
public readonly toSprintId: string | null,
|
||||
public readonly onlyUnfinished: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Issue,
|
||||
Project,
|
||||
Release,
|
||||
Sprint,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
|
||||
@@ -38,6 +39,22 @@ export class WriteAccess {
|
||||
return this.load(id, Release);
|
||||
}
|
||||
|
||||
sprint(id: string): Promise<Sprint> {
|
||||
return this.load(id, Sprint);
|
||||
}
|
||||
|
||||
/** Sprint of the same project that still takes tickets (not closed). */
|
||||
async openSprintOf(project: Project, sprintId: string): Promise<Sprint> {
|
||||
const sprint = await this.sprint(sprintId);
|
||||
if (sprint.projectId !== project.id) {
|
||||
throw new BusinessRuleError(
|
||||
`Sprint ${sprint.name} belongs to another project`,
|
||||
);
|
||||
}
|
||||
sprint.assertAcceptsIssues();
|
||||
return sprint;
|
||||
}
|
||||
|
||||
comment(id: string): Promise<Comment> {
|
||||
return this.load(id, Comment);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ import {
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintUpdatedEvent,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
@@ -37,6 +43,8 @@ export interface SummaryContext {
|
||||
issueKey: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
/** '' when unknown (e.g. the sprint was deleted). */
|
||||
sprintName: string;
|
||||
/** The project's workflow: names of statuses and types. */
|
||||
workflow: ProjectWorkflow;
|
||||
}
|
||||
@@ -146,6 +154,12 @@ export function describeEvent(
|
||||
if (event instanceof IssueAttachmentRemovedEvent)
|
||||
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
||||
|
||||
if (event instanceof IssueSprintChangedEvent) {
|
||||
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
|
||||
return event.sprintId
|
||||
? `${actor} metió ${issueKey} en ${sprint}`
|
||||
: `${actor} sacó ${issueKey} de ${sprint}`;
|
||||
}
|
||||
if (event instanceof IssueParentChangedEvent) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
@@ -178,5 +192,20 @@ export function describeEvent(
|
||||
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
|
||||
if (event instanceof ReleaseArchivedEvent)
|
||||
return `${actor} archivó la release ${ctx.releaseName}`;
|
||||
const sprint = ctx.sprintName || 'un sprint';
|
||||
if (event instanceof SprintCreatedEvent)
|
||||
return `${actor} planificó el sprint ${event.sprint.name}`;
|
||||
if (event instanceof SprintUpdatedEvent)
|
||||
return `${actor} actualizó el sprint ${sprint}`;
|
||||
if (event instanceof SprintStartedEvent)
|
||||
return `${actor} empezó el sprint ${sprint} 🏁`;
|
||||
if (event instanceof SprintClosedEvent) {
|
||||
const left = event.unfinishedIssues
|
||||
? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
|
||||
: '';
|
||||
return `${actor} cerró el sprint ${sprint}${left}`;
|
||||
}
|
||||
if (event instanceof SprintDeletedEvent)
|
||||
return `${actor} borró el sprint ${sprint}`;
|
||||
return `${actor}: ${event.constructor.name}`;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ import {
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintUpdatedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
@@ -37,6 +43,7 @@ import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { describeEvent } from './activity-summary';
|
||||
@@ -70,6 +77,12 @@ import { versionOf } from './read-model-notifier';
|
||||
ReleaseUpdatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintUpdatedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
)
|
||||
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
constructor(
|
||||
@@ -77,6 +90,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
@@ -93,6 +107,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
const release = releaseId
|
||||
? await this.findReleases.findById(releaseId)
|
||||
: null;
|
||||
const sprint = await this.sprintOf(event);
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
// The project was deleted (cascade in progress): no feed to write to.
|
||||
if (!project) return;
|
||||
@@ -118,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
issueKey: issueKey ?? '',
|
||||
projectKey: project?.key ?? '',
|
||||
releaseName: release?.name ?? '',
|
||||
sprintName: sprint,
|
||||
workflow: new ProjectWorkflow(
|
||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
),
|
||||
@@ -129,6 +145,17 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name of the sprint the event is about ('' when it is gone). */
|
||||
private async sprintOf(event: TrackerEvent): Promise<string> {
|
||||
if (event instanceof SprintCreatedEvent) return event.sprint.name;
|
||||
const id = event.constructor.name.startsWith('Sprint')
|
||||
? event.aggregateId
|
||||
: event instanceof IssueSprintChangedEvent
|
||||
? (event.sprintId ?? event.previousSprintId)
|
||||
: null;
|
||||
return id ? ((await this.findSprints.findById(id))?.name ?? '') : '';
|
||||
}
|
||||
|
||||
private issueIdOf(event: TrackerEvent): string | null {
|
||||
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
|
||||
if (event instanceof CommentAddedEvent) return event.comment.issueId;
|
||||
|
||||
@@ -31,6 +31,8 @@ import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
||||
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
import { SprintEventHandler } from './sprint.event-handler';
|
||||
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
@@ -66,6 +68,8 @@ export const EVENT_HANDLERS = [
|
||||
IssueParentChangedEventHandler,
|
||||
IssueChecklistEventHandler,
|
||||
IssueRankedEventHandler,
|
||||
SprintEventHandler,
|
||||
IssueSprintChangedEventHandler,
|
||||
];
|
||||
|
||||
export { ReadModelNotifier };
|
||||
|
||||
@@ -50,6 +50,7 @@ export class IssueCreatedEventHandler
|
||||
assignee: users.ofNullable(issue.assigneeId),
|
||||
release: release ? { id: release.id, name: release.name } : null,
|
||||
parent: null,
|
||||
sprint: null,
|
||||
rank: implicitRank(issue.number),
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueSprintChangedEvent)
|
||||
export class IssueSprintChangedEventHandler
|
||||
implements IEventHandler<IssueSprintChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueSprintChangedEvent) {
|
||||
const sprint = event.sprintId
|
||||
? await this.findSprints.findById(event.sprintId)
|
||||
: null;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
sprint: event.sprintId
|
||||
? { id: event.sprintId, name: sprint?.name ?? '' }
|
||||
: null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueCreatedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
TrackerEvent,
|
||||
@@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier';
|
||||
|
||||
/**
|
||||
* The life of every ticket, step by step: where it went (status + category)
|
||||
* and which release it joined. Statistics are computed from it.
|
||||
* and which release and sprint it joined. Statistics are computed from it.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
)
|
||||
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
||||
constructor(
|
||||
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
||||
kind: 'release',
|
||||
releaseId: event.releaseId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event instanceof IssueSprintChangedEvent) {
|
||||
await this.timeline.record({
|
||||
...base,
|
||||
kind: 'sprint',
|
||||
sprintId: event.sprintId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
|
||||
/** Position of the event in its stream (stamped by the event store). */
|
||||
export function versionOf(event: TrackerEvent): number {
|
||||
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
) {}
|
||||
|
||||
async project(
|
||||
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
|
||||
await this.changeFeed.publishRelease({ ...this.meta(event), release });
|
||||
}
|
||||
|
||||
async sprint(sprintId: string, event: TrackerEvent): Promise<void> {
|
||||
const sprint = await this.findSprints.findById(sprintId);
|
||||
await this.changeFeed.publishSprint({
|
||||
...this.meta(event),
|
||||
sprintId,
|
||||
projectId: event.projectId,
|
||||
sprint,
|
||||
});
|
||||
}
|
||||
|
||||
private meta(event: TrackerEvent) {
|
||||
return {
|
||||
action: event.constructor.name,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintStatus,
|
||||
SprintUpdatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
type SprintEvent =
|
||||
| SprintCreatedEvent
|
||||
| SprintUpdatedEvent
|
||||
| SprintStartedEvent
|
||||
| SprintClosedEvent
|
||||
| SprintDeletedEvent;
|
||||
|
||||
/** The sprints view (`sprints_view`), one document per sprint. */
|
||||
@EventsHandler(
|
||||
SprintCreatedEvent,
|
||||
SprintUpdatedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
)
|
||||
export class SprintEventHandler implements IEventHandler<SprintEvent> {
|
||||
constructor(
|
||||
private readonly sprints: UpsertMaterializedSprintRepository,
|
||||
private readonly issues: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: SprintEvent) {
|
||||
const version = versionOf(event);
|
||||
const id = event.aggregateId;
|
||||
if (event instanceof SprintCreatedEvent) {
|
||||
await this.sprints.upsert({ ...event.sprint, version });
|
||||
} else if (event instanceof SprintUpdatedEvent) {
|
||||
await this.sprints.upsert({ id, ...event.changes, version });
|
||||
// Tickets show its name.
|
||||
if (event.changes.name)
|
||||
await this.issues.renameSprint(id, event.changes.name);
|
||||
} else if (event instanceof SprintStartedEvent) {
|
||||
await this.sprints.upsert({
|
||||
id,
|
||||
status: SprintStatus.ACTIVE,
|
||||
startedAt: event.occurredAt,
|
||||
version,
|
||||
});
|
||||
} else if (event instanceof SprintClosedEvent) {
|
||||
await this.sprints.upsert({
|
||||
id,
|
||||
status: SprintStatus.CLOSED,
|
||||
closedAt: event.occurredAt,
|
||||
version,
|
||||
});
|
||||
} else {
|
||||
await this.sprints.remove(id);
|
||||
}
|
||||
await this.notifier.sprint(id, event);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
SprintReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Every realtime message says which event caused it and the resulting version. */
|
||||
@@ -30,6 +31,13 @@ export interface ReleaseChange extends Change {
|
||||
release: ReleaseReadModel;
|
||||
}
|
||||
|
||||
export interface SprintChange extends Change {
|
||||
/** Null when the sprint was deleted. */
|
||||
sprint: SprintReadModel | null;
|
||||
sprintId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ProjectChange extends Change {
|
||||
project: ProjectReadModel;
|
||||
}
|
||||
@@ -43,6 +51,7 @@ export abstract class ChangeFeed {
|
||||
abstract publishComment(change: CommentChange): Promise<void>;
|
||||
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
|
||||
abstract publishRelease(change: ReleaseChange): Promise<void>;
|
||||
abstract publishSprint(change: SprintChange): Promise<void>;
|
||||
/** @param notifyUserIds users whose project list changed (joined / left). */
|
||||
abstract publishProject(
|
||||
change: ProjectChange,
|
||||
@@ -59,6 +68,7 @@ export abstract class ChangeFeed {
|
||||
projectId: string,
|
||||
): AsyncIterableIterator<ActivityReadModel>;
|
||||
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
||||
abstract sprints(projectId: string): AsyncIterableIterator<SprintChange>;
|
||||
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
||||
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
||||
abstract notifications(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IssueReadModel,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
SprintProgressReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
@@ -26,6 +27,9 @@ export interface IssueFilter {
|
||||
labels?: string[];
|
||||
/** Only the tickets hanging from this one. */
|
||||
parentId?: string;
|
||||
sprintId?: string;
|
||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||
noSprint?: boolean;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
sort?: IssueSort;
|
||||
search?: string;
|
||||
@@ -63,6 +67,7 @@ export abstract class FindIssuesRepository {
|
||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||
/** Top-level tickets with others hanging from them, by key. */
|
||||
abstract epics(projectId: string): Promise<EpicReadModel[]>;
|
||||
abstract sprintProgress(sprintId: string): Promise<SprintProgressReadModel>;
|
||||
abstract releaseProgress(
|
||||
releaseId: string,
|
||||
): Promise<ReleaseProgressReadModel>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class FindSprintsRepository {
|
||||
abstract findById(id: string): Promise<SprintReadModel | null>;
|
||||
/** Active first, then planned by start date, then closed (newest first). */
|
||||
abstract findByProject(projectId: string): Promise<SprintReadModel[]>;
|
||||
/** The sprint under way in the project, if any. */
|
||||
abstract findActive(projectId: string): Promise<SprintReadModel | null>;
|
||||
}
|
||||
@@ -21,3 +21,5 @@ export * from './attachment-storage';
|
||||
export * from './notifications.repository';
|
||||
export * from './notification-mailer';
|
||||
export * from './saved-filters.repository';
|
||||
export * from './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
export abstract class TrackerStreamsRepository {
|
||||
abstract issueIdsOfProject(projectId: string): Promise<string[]>;
|
||||
abstract releaseIdsOfProject(projectId: string): Promise<string[]>;
|
||||
abstract sprintIdsOfProject(projectId: string): Promise<string[]>;
|
||||
/** Tickets that were ever put in `sprintId` (they may have left since). */
|
||||
abstract issueIdsEverInSprint(sprintId: string): Promise<string[]>;
|
||||
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
||||
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
||||
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
||||
|
||||
@@ -60,6 +60,8 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
): Promise<void>;
|
||||
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
|
||||
abstract remove(issueId: string): Promise<void>;
|
||||
/** Keeps the denormalised sprint name of every issue in sync. */
|
||||
abstract renameSprint(sprintId: string, name: string): Promise<void>;
|
||||
/** Keeps the denormalised release name of every issue in sync. */
|
||||
abstract renameRelease(releaseId: string, name: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedSprintRepository {
|
||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||
abstract upsert(
|
||||
sprint: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>,
|
||||
): Promise<void>;
|
||||
abstract remove(sprintId: string): Promise<void>;
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
|
||||
export * from './get-public-projects.query';
|
||||
export * from './get-attachment.query';
|
||||
export * from './get-epics.query';
|
||||
export * from './sprint.queries';
|
||||
|
||||
@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
||||
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
||||
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
||||
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
|
||||
GetUpcomingReleasesQueryHandler,
|
||||
SearchIssuesQueryHandler,
|
||||
GetEpicsQueryHandler,
|
||||
...SPRINT_QUERY_HANDLERS,
|
||||
SearchMyIssuesQueryHandler,
|
||||
GetAttachmentQueryHandler,
|
||||
];
|
||||
|
||||
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintBurndownReadModel,
|
||||
SprintProgressReadModel,
|
||||
SprintReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeSprintBurndown } from '../analytics/analytics';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
export class GetSprintsQuery extends Query<SprintReadModel[]> {
|
||||
constructor(
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSprintQuery extends Query<SprintReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSprintProgressQuery extends Query<SprintProgressReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSprintBurndownQuery extends Query<SprintBurndownReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The sprint, if the viewer may see its project (same error otherwise). */
|
||||
async function visibleSprint(
|
||||
findSprints: FindSprintsRepository,
|
||||
access: ReadAccess,
|
||||
viewerId: string | null,
|
||||
sprintId: string,
|
||||
): Promise<SprintReadModel> {
|
||||
const sprint = access.assertFound(
|
||||
await findSprints.findById(sprintId),
|
||||
'Sprint',
|
||||
sprintId,
|
||||
);
|
||||
await access.assertCanView(sprint.projectId, viewerId, {
|
||||
entity: 'Sprint',
|
||||
ref: sprintId,
|
||||
});
|
||||
return sprint;
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintsQuery)
|
||||
export class GetSprintsQueryHandler
|
||||
implements IQueryHandler<GetSprintsQuery, SprintReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
return this.findSprints.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintQuery)
|
||||
export class GetSprintQueryHandler
|
||||
implements IQueryHandler<GetSprintQuery, SprintReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
execute(query: GetSprintQuery): Promise<SprintReadModel> {
|
||||
return visibleSprint(
|
||||
this.findSprints,
|
||||
this.access,
|
||||
query.viewerId,
|
||||
query.sprintId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintProgressQuery)
|
||||
export class GetSprintProgressQueryHandler
|
||||
implements IQueryHandler<GetSprintProgressQuery, SprintProgressReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetSprintProgressQuery,
|
||||
): Promise<SprintProgressReadModel> {
|
||||
const sprint = await visibleSprint(
|
||||
this.findSprints,
|
||||
this.access,
|
||||
query.viewerId,
|
||||
query.sprintId,
|
||||
);
|
||||
return this.findIssues.sprintProgress(sprint.id);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintBurndownQuery)
|
||||
export class GetSprintBurndownQueryHandler
|
||||
implements IQueryHandler<GetSprintBurndownQuery, SprintBurndownReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetSprintBurndownQuery,
|
||||
): Promise<SprintBurndownReadModel> {
|
||||
const sprint = await visibleSprint(
|
||||
this.findSprints,
|
||||
this.access,
|
||||
query.viewerId,
|
||||
query.sprintId,
|
||||
);
|
||||
const [issues, timeline] = await Promise.all([
|
||||
this.findIssues.findByProject(sprint.projectId),
|
||||
this.findTimeline.findByProject(sprint.projectId),
|
||||
]);
|
||||
return computeSprintBurndown({ sprint, issues, timeline, now: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
export const SPRINT_QUERY_HANDLERS = [
|
||||
GetSprintsQueryHandler,
|
||||
GetSprintQueryHandler,
|
||||
GetSprintProgressQueryHandler,
|
||||
GetSprintBurndownQueryHandler,
|
||||
];
|
||||
@@ -9,6 +9,7 @@ import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
||||
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
|
||||
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
||||
import { DeleteSprintCommand } from '../commands/sprint.commands';
|
||||
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
|
||||
@@ -17,6 +18,7 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
* with its own "deleted" event):
|
||||
*
|
||||
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
||||
* + DeleteSprint (each sprint, as a cascade)
|
||||
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
||||
* of other projects that pointed to it) + DetachFromParent
|
||||
* (tickets that hung from it go back to the top)
|
||||
@@ -34,11 +36,14 @@ export class ProjectDeletionSaga {
|
||||
ofType(ProjectDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.contentsOf(event.projectId)).pipe(
|
||||
mergeMap(({ issueIds, releaseIds }) => [
|
||||
mergeMap(({ issueIds, releaseIds, sprintIds }) => [
|
||||
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
|
||||
...releaseIds.map(
|
||||
(id) => new DeleteReleaseCommand(event.actorId, id),
|
||||
),
|
||||
...sprintIds.map(
|
||||
(id) => new DeleteSprintCommand(event.actorId, id, true),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -76,11 +81,12 @@ export class ProjectDeletionSaga {
|
||||
);
|
||||
|
||||
private async contentsOf(projectId: string) {
|
||||
const [issueIds, releaseIds] = await Promise.all([
|
||||
const [issueIds, releaseIds, sprintIds] = await Promise.all([
|
||||
this.streams.issueIdsOfProject(projectId),
|
||||
this.streams.releaseIdsOfProject(projectId),
|
||||
this.streams.sprintIdsOfProject(projectId),
|
||||
]);
|
||||
return { issueIds, releaseIds };
|
||||
return { issueIds, releaseIds, sprintIds };
|
||||
}
|
||||
|
||||
private async dependentsOf(issueId: string) {
|
||||
|
||||
@@ -47,6 +47,15 @@ import { LinkIssuesCommand } from '../commands/link-issues.command';
|
||||
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { IssueTree } from '../commands/issue-tree';
|
||||
import {
|
||||
CloseSprintCommand,
|
||||
CreateSprintCommand,
|
||||
DeleteSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
} from '../commands/sprint.commands';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
@@ -73,6 +82,7 @@ async function setup() {
|
||||
ProjectDeletionSaga,
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
SprintCarryOverSaga,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
AggregateRehydrator,
|
||||
@@ -90,11 +100,22 @@ async function setup() {
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
{
|
||||
provide: FindSprintsRepository,
|
||||
useValue: { findActive: async () => null },
|
||||
},
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
findOpenAssignedTo: async (userId: string) =>
|
||||
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
|
||||
sprintProgress: async () => ({
|
||||
total: 2,
|
||||
finished: 1,
|
||||
percent: 50,
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -109,6 +130,7 @@ async function setup() {
|
||||
...Object.values(moduleRef.get(ProjectDeletionSaga)),
|
||||
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
|
||||
...Object.values(moduleRef.get(UserDeactivatedSaga)),
|
||||
...Object.values(moduleRef.get(SprintCarryOverSaga)),
|
||||
].filter((value): value is ISaga => typeof value === 'function');
|
||||
|
||||
/** What the events bridge does: every stored event through every saga, until quiet. */
|
||||
@@ -206,6 +228,41 @@ describe('sagas', () => {
|
||||
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('closing a sprint carries its unfinished tickets over; deleting one empties it', async () => {
|
||||
const { run, drain, rehydrator } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
const sprint = async (name: string) =>
|
||||
(
|
||||
await run(
|
||||
new CreateSprintCommand(
|
||||
'owner',
|
||||
web,
|
||||
name,
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
)
|
||||
).id;
|
||||
const [one, two] = [await sprint('One'), await sprint('Two')];
|
||||
const open = (await run(ticket(web, 'open'))).id;
|
||||
const done = (await run(ticket(web, 'done'))).id;
|
||||
for (const id of [open, done])
|
||||
await run(new SetIssueSprintCommand('owner', id, one));
|
||||
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
|
||||
await run(new ChangeIssueStatusCommand('owner', done, status));
|
||||
|
||||
await run(new StartSprintCommand('owner', one));
|
||||
await run(new CloseSprintCommand('owner', one, two));
|
||||
await drain();
|
||||
|
||||
expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBe(two);
|
||||
expect((await rehydrator.rehydrate(done, Issue)).sprintId).toBe(one);
|
||||
|
||||
await run(new DeleteSprintCommand('owner', two));
|
||||
await drain();
|
||||
expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBeNull();
|
||||
});
|
||||
|
||||
it('only the owner deletes a project', async () => {
|
||||
const { run } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||
import { CommentersFollowSaga } from './commenters-follow.saga';
|
||||
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||
|
||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||
export const SAGAS = [
|
||||
@@ -9,4 +10,5 @@ export const SAGAS = [
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
CommentersFollowSaga,
|
||||
SprintCarryOverSaga,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { CarryOverIssueCommand } from '../commands/sprint.commands';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
|
||||
/**
|
||||
* When a sprint ends, what was left moves on:
|
||||
*
|
||||
* SprintClosed → CarryOverIssue for its unfinished tickets, to the sprint
|
||||
* chosen when closing it (or back to the backlog)
|
||||
* SprintDeleted → CarryOverIssue for all its tickets, back to the backlog
|
||||
*
|
||||
* Who was in the sprint comes from the event store. The moves are
|
||||
* attributed to whoever closed or deleted the sprint.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SprintCarryOverSaga {
|
||||
constructor(private readonly streams: TrackerStreamsRepository) {}
|
||||
|
||||
@Saga()
|
||||
sprintEnded = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(SprintClosedEvent, SprintDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.streams.issueIdsEverInSprint(event.aggregateId)).pipe(
|
||||
mergeMap((issueIds) =>
|
||||
issueIds.map(
|
||||
(issueId) =>
|
||||
new CarryOverIssueCommand(
|
||||
event.actorId,
|
||||
issueId,
|
||||
event.aggregateId,
|
||||
event instanceof SprintClosedEvent ? event.carryOverTo : null,
|
||||
event instanceof SprintClosedEvent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
122
libs/api/tracker/application/src/lib/sprints.service.ts
Normal file
122
libs/api/tracker/application/src/lib/sprints.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintBurndownReadModel,
|
||||
SprintDetails,
|
||||
SprintProgressReadModel,
|
||||
SprintReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import {
|
||||
CloseSprintCommand,
|
||||
CreateSprintCommand,
|
||||
DeleteSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
UpdateSprintCommand,
|
||||
} from './commands/sprint.commands';
|
||||
import {
|
||||
GetSprintBurndownQuery,
|
||||
GetSprintProgressQuery,
|
||||
GetSprintQuery,
|
||||
GetSprintsQuery,
|
||||
} from './queries/sprint.queries';
|
||||
|
||||
export interface CreateSprintInput {
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
/** Sprints: plan them, run them, close them (and what was left moves on). */
|
||||
@Injectable()
|
||||
export class SprintsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
list(viewerId: string | null, projectId: string): Promise<SprintReadModel[]> {
|
||||
return this.queryBus.execute(new GetSprintsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
findOne(viewerId: string | null, sprintId: string): Promise<SprintReadModel> {
|
||||
return this.queryBus.execute(new GetSprintQuery(viewerId, sprintId));
|
||||
}
|
||||
|
||||
progress(
|
||||
viewerId: string | null,
|
||||
sprintId: string,
|
||||
): Promise<SprintProgressReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetSprintProgressQuery(viewerId, sprintId),
|
||||
);
|
||||
}
|
||||
|
||||
burndown(
|
||||
viewerId: string | null,
|
||||
sprintId: string,
|
||||
): Promise<SprintBurndownReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetSprintBurndownQuery(viewerId, sprintId),
|
||||
);
|
||||
}
|
||||
|
||||
create(actorId: string, input: CreateSprintInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateSprintCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
input.name,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
input.goal ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
changes: SprintDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateSprintCommand(actorId, sprintId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
start(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new StartSprintCommand(actorId, sprintId));
|
||||
}
|
||||
|
||||
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
||||
close(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
carryOverTo: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CloseSprintCommand(actorId, sprintId, carryOverTo),
|
||||
);
|
||||
}
|
||||
|
||||
/** Planned sprints only; its tickets go back to the backlog. */
|
||||
delete(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteSprintCommand(actorId, sprintId),
|
||||
) as Promise<CommandResult>;
|
||||
}
|
||||
|
||||
/** Puts a ticket in a sprint (null: back to the backlog). */
|
||||
setIssueSprint(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
sprintId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new SetIssueSprintCommand(actorId, issueId, sprintId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
const NOTIFICATIONS = [
|
||||
@@ -51,6 +52,7 @@ const SERVICES = [
|
||||
WorkflowsService,
|
||||
NotificationsService,
|
||||
SavedFiltersService,
|
||||
SprintsService,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -243,6 +243,20 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async sprintIdsOfProject(projectId: string) {
|
||||
return this.streams(
|
||||
'SprintCreatedEvent',
|
||||
(d) => d['projectId'] === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
async issueIdsEverInSprint(sprintId: string) {
|
||||
return this.streams(
|
||||
'IssueSprintChangedEvent',
|
||||
(d) => d['sprintId'] === sprintId,
|
||||
);
|
||||
}
|
||||
|
||||
async commentIdsOfIssue(issueId: string) {
|
||||
return this.streams(
|
||||
'CommentAddedEvent',
|
||||
|
||||
@@ -11,6 +11,8 @@ export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
export * from './lib/release/release-status';
|
||||
export * from './lib/sprint/sprint';
|
||||
export * from './lib/sprint/sprint-status';
|
||||
export * from './lib/events';
|
||||
export * from './lib/factories';
|
||||
export * from './lib/read-models';
|
||||
|
||||
@@ -31,3 +31,9 @@ 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';
|
||||
export * from './sprint-created.event';
|
||||
export * from './sprint-updated.event';
|
||||
export * from './sprint-started.event';
|
||||
export * from './sprint-closed.event';
|
||||
export * from './sprint-deleted.event';
|
||||
export * from './issue-sprint-changed.event';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket was moved in or out of a sprint. */
|
||||
@AutowiredEvent
|
||||
export class IssueSprintChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly sprintId: string | null,
|
||||
readonly previousSprintId: string | null,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/**
|
||||
* A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back
|
||||
* to the backlog); the SprintCarryOverSaga moves them.
|
||||
*/
|
||||
@AutowiredEvent
|
||||
export class SprintClosedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
projectId: string,
|
||||
readonly carryOverTo: string | null,
|
||||
readonly unfinishedIssues: number,
|
||||
) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { SprintProps } from '../sprint/sprint';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was planned. */
|
||||
@AutowiredEvent
|
||||
export class SprintCreatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
readonly sprint: SprintProps,
|
||||
) {
|
||||
super(actorId, sprint.id, sprint.projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was deleted: its tickets go back to the backlog. */
|
||||
@AutowiredEvent
|
||||
export class SprintDeletedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint got under way. */
|
||||
@AutowiredEvent
|
||||
export class SprintStartedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { SprintDetails } from '../sprint/sprint';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was renamed, re-dated or got another goal (only what changed). */
|
||||
@AutowiredEvent
|
||||
export class SprintUpdatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
projectId: string,
|
||||
readonly changes: SprintDetails,
|
||||
) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
|
||||
import { IssueFactory } from './issue.factory';
|
||||
import { ProjectFactory } from './project.factory';
|
||||
import { ReleaseFactory } from './release.factory';
|
||||
import { SprintFactory } from './sprint.factory';
|
||||
|
||||
export * from './issue.factory';
|
||||
export { CommentFactory, ProjectFactory, ReleaseFactory };
|
||||
export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory };
|
||||
|
||||
export const TRACKER_FACTORIES = [
|
||||
ProjectFactory,
|
||||
IssueFactory,
|
||||
CommentFactory,
|
||||
ReleaseFactory,
|
||||
SprintFactory,
|
||||
];
|
||||
|
||||
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { newId } from '@task-stream/api/shared/domain';
|
||||
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||
import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint';
|
||||
import { SprintStatus } from '../sprint/sprint-status';
|
||||
|
||||
@Injectable()
|
||||
export class SprintFactory {
|
||||
create(
|
||||
actorId: string,
|
||||
props: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
): Sprint {
|
||||
const details = normalizeSprintDetails({
|
||||
name: props.name,
|
||||
goal: props.goal ?? null,
|
||||
startDate: props.startDate,
|
||||
endDate: props.endDate,
|
||||
});
|
||||
const data: SprintProps = {
|
||||
id: newId(),
|
||||
projectId: props.projectId,
|
||||
...details,
|
||||
status: SprintStatus.PLANNED,
|
||||
startedAt: null,
|
||||
closedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const sprint = new Sprint(data.id);
|
||||
Object.assign(sprint, { ...data });
|
||||
sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true });
|
||||
return sprint;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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 { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
@@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public checklist: ChecklistItem[] = [];
|
||||
/** Place in the backlog once someone moved it (see `backlogRank`). */
|
||||
public rank: string | null = null;
|
||||
public sprintId: string | null = null;
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -246,6 +248,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves the ticket in or out of a sprint (null: back to the backlog). */
|
||||
setSprint(actorId: string, sprintId: string | null): void {
|
||||
if (this.sprintId === sprintId) return;
|
||||
this.apply(
|
||||
new IssueSprintChangedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
sprintId,
|
||||
this.sprintId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Records a file stored for this ticket (or one of its comments). */
|
||||
attach(actorId: string, attachment: IssueAttachment): void {
|
||||
const next = normalizeAttachment(attachment);
|
||||
@@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
|
||||
this.sprintId = event.sprintId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
|
||||
this.releaseId = event.releaseId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -12,12 +12,13 @@ export class IssueTimelineEntry {
|
||||
issueId!: string;
|
||||
projectId!: string;
|
||||
at!: string;
|
||||
kind!: 'status' | 'release';
|
||||
kind!: 'status' | 'release' | 'sprint';
|
||||
status?: string;
|
||||
category?: StatusCategory;
|
||||
/** Assignee when the status changed (throughput per person). */
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
sprintId?: string | null;
|
||||
}
|
||||
|
||||
export class DurationStats {
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './project-stats.read-model';
|
||||
export * from './analytics.read-model';
|
||||
export * from './notification.read-model';
|
||||
export * from './saved-filter.read-model';
|
||||
export * from './sprint.read-model';
|
||||
|
||||
@@ -70,6 +70,7 @@ export class IssueReadModel {
|
||||
reporter!: UserRefReadModel;
|
||||
assignee!: UserRefReadModel | null;
|
||||
release!: { id: string; name: string } | null;
|
||||
sprint!: { id: string; name: string } | null;
|
||||
labels!: string[];
|
||||
figmaUrl!: string | null;
|
||||
storyPoints!: number | null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SprintStatus } from '../sprint/sprint-status';
|
||||
|
||||
export class SprintReadModel {
|
||||
id!: string;
|
||||
projectId!: string;
|
||||
name!: string;
|
||||
goal!: string | null;
|
||||
/** `YYYY-MM-DD`, both included. */
|
||||
startDate!: string;
|
||||
endDate!: string;
|
||||
status!: SprintStatus;
|
||||
startedAt!: string | null;
|
||||
closedAt!: string | null;
|
||||
createdAt!: string;
|
||||
version!: number;
|
||||
}
|
||||
|
||||
/** How far a sprint's tickets have got. */
|
||||
export class SprintProgressReadModel {
|
||||
total!: number;
|
||||
/** Done or cancelled. */
|
||||
finished!: number;
|
||||
percent!: number;
|
||||
storyPoints!: number;
|
||||
finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per day of the sprint: tickets in it (cancelled ones aside), how many are
|
||||
* still open and their points. Days still to come are null; `ideal` falls in
|
||||
* a straight line from what was open on the first day to zero on the last.
|
||||
*/
|
||||
export class SprintBurndownReadModel {
|
||||
sprintId!: string;
|
||||
dates!: string[];
|
||||
scope!: (number | null)[];
|
||||
remaining!: (number | null)[];
|
||||
remainingPoints!: (number | null)[];
|
||||
ideal!: number[];
|
||||
}
|
||||
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum SprintStatus {
|
||||
/** Being prepared: tickets come and go. */
|
||||
PLANNED = 'PLANNED',
|
||||
/** Under way (one per project at a time). */
|
||||
ACTIVE = 'ACTIVE',
|
||||
/** Over: what was left went to another sprint or back to the backlog. */
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||
import { SprintFactory } from '../factories/sprint.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Sprint } from './sprint';
|
||||
import { SprintStatus } from './sprint-status';
|
||||
|
||||
const factory = new SprintFactory();
|
||||
const newSprint = (name = 'Sprint 1', projectId = 'p-1') =>
|
||||
factory.create('u-pm', {
|
||||
projectId,
|
||||
name,
|
||||
goal: ' Pagos con tarjeta ',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-23',
|
||||
});
|
||||
|
||||
describe('Sprint', () => {
|
||||
it('is planned with tidy details', () => {
|
||||
const sprint = newSprint();
|
||||
expect(sprint.status).toBe(SprintStatus.PLANNED);
|
||||
expect(sprint.goal).toBe('Pagos con tarjeta');
|
||||
});
|
||||
|
||||
it('refuses dates the wrong way round, too long or malformed', () => {
|
||||
const create = (startDate: string, endDate: string) =>
|
||||
factory.create('u', { projectId: 'p', name: 'S', startDate, endDate });
|
||||
expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError);
|
||||
expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/);
|
||||
expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('starts once, only when nothing else is under way, and closes', () => {
|
||||
const sprint = newSprint();
|
||||
const next = newSprint('Sprint 2');
|
||||
expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
sprint.start('u', null);
|
||||
expect(() => sprint.start('u', null)).toThrow(BusinessRuleError);
|
||||
expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow(
|
||||
/start date/,
|
||||
);
|
||||
sprint.update('u', { endDate: '2026-10-26' });
|
||||
|
||||
sprint.close('u', next, 3);
|
||||
const closed = sprint
|
||||
.getUncommittedEvents()
|
||||
.find((e) => e instanceof SprintClosedEvent);
|
||||
expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 });
|
||||
const { rebuilt } = replay(sprint, Sprint);
|
||||
expect(rebuilt.status).toBe(SprintStatus.CLOSED);
|
||||
expect(rebuilt.endDate).toBe('2026-10-26');
|
||||
expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
it('carries over only to another open sprint of the same project', () => {
|
||||
const sprint = newSprint();
|
||||
sprint.start('u', null);
|
||||
expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError);
|
||||
expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes planned sprints, and any on cascade', () => {
|
||||
const planned = newSprint();
|
||||
planned.delete('u');
|
||||
expect(planned.deleted).toBe(true);
|
||||
const active = newSprint();
|
||||
active.start('u', null);
|
||||
expect(() => active.delete('u')).toThrow(BusinessRuleError);
|
||||
active.delete('u', true);
|
||||
expect(active.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
VersionedAggregateRoot,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||
import { SprintDeletedEvent } from '../events/sprint-deleted.event';
|
||||
import { SprintStartedEvent } from '../events/sprint-started.event';
|
||||
import { SprintUpdatedEvent } from '../events/sprint-updated.event';
|
||||
import { SprintStatus } from './sprint-status';
|
||||
|
||||
/** Data of a sprint (what SprintCreatedEvent carries). */
|
||||
export interface SprintProps {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal: string | null;
|
||||
/** First and last day, `YYYY-MM-DD`. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: SprintStatus;
|
||||
startedAt: string | null;
|
||||
closedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type SprintDetails = Partial<
|
||||
Pick<SprintProps, 'name' | 'goal' | 'startDate' | 'endDate'>
|
||||
>;
|
||||
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
/** Long enough for any real iteration, short enough to catch a typo. */
|
||||
export const MAX_SPRINT_DAYS = 90;
|
||||
|
||||
/** Checks the name and the dates of a sprint; returns them tidied. */
|
||||
export function normalizeSprintDetails(
|
||||
details: Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate'>> &
|
||||
Pick<SprintProps, 'goal'>,
|
||||
): Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate' | 'goal'>> {
|
||||
const name = details.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 80,
|
||||
'A sprint needs a name (up to 80 characters)',
|
||||
);
|
||||
for (const date of [details.startDate, details.endDate])
|
||||
assertThat(
|
||||
DATE.test(date) && !Number.isNaN(Date.parse(date)),
|
||||
'Sprint dates must be YYYY-MM-DD',
|
||||
);
|
||||
const days =
|
||||
(Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000;
|
||||
assertThat(days >= 0, 'A sprint cannot end before it starts');
|
||||
assertThat(
|
||||
days < MAX_SPRINT_DAYS,
|
||||
`A sprint lasts at most ${MAX_SPRINT_DAYS} days`,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
goal: details.goal?.trim() || null,
|
||||
startDate: details.startDate,
|
||||
endDate: details.endDate,
|
||||
};
|
||||
}
|
||||
|
||||
/** A time-boxed iteration: the tickets the team commits to for a few weeks. */
|
||||
export class Sprint extends VersionedAggregateRoot implements SprintProps {
|
||||
public projectId!: string;
|
||||
public name!: string;
|
||||
public goal: string | null = null;
|
||||
public startDate!: string;
|
||||
public endDate!: string;
|
||||
public status: SprintStatus = SprintStatus.PLANNED;
|
||||
public startedAt: string | null = null;
|
||||
public closedAt: string | null = null;
|
||||
public createdAt!: string;
|
||||
public deleted = false;
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Closed sprints keep what they had: their record is fixed. */
|
||||
assertAcceptsIssues(): void {
|
||||
if (this.status === SprintStatus.CLOSED)
|
||||
throw new BusinessRuleError(`Sprint ${this.name} is closed`);
|
||||
}
|
||||
|
||||
update(actorId: string, changes: SprintDetails): void {
|
||||
this.assertAcceptsIssues();
|
||||
const next = normalizeSprintDetails({
|
||||
name: changes.name ?? this.name,
|
||||
goal: changes.goal !== undefined ? changes.goal : this.goal,
|
||||
startDate: changes.startDate ?? this.startDate,
|
||||
endDate: changes.endDate ?? this.endDate,
|
||||
});
|
||||
if (
|
||||
this.status === SprintStatus.ACTIVE &&
|
||||
next.startDate !== this.startDate
|
||||
)
|
||||
throw new BusinessRuleError('A sprint under way keeps its start date');
|
||||
const patch: SprintDetails = {};
|
||||
for (const field of ['name', 'goal', 'startDate', 'endDate'] as const)
|
||||
if (next[field] !== this[field])
|
||||
(patch as Record<string, unknown>)[field] = next[field];
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||
}
|
||||
|
||||
/** `otherActive`: the sprint already under way in the project, if any. */
|
||||
start(actorId: string, otherActive: { name: string } | null): void {
|
||||
if (this.status !== SprintStatus.PLANNED)
|
||||
throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`);
|
||||
if (otherActive)
|
||||
throw new BusinessRuleError(
|
||||
`Sprint ${otherActive.name} is under way; close it first`,
|
||||
);
|
||||
this.apply(new SprintStartedEvent(actorId, this.id, this.projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the sprint. Its unfinished tickets go to `carryOverTo` (another
|
||||
* open sprint of the project) or, with null, back to the backlog.
|
||||
*/
|
||||
close(
|
||||
actorId: string,
|
||||
carryOverTo: Sprint | null,
|
||||
unfinishedIssues: number,
|
||||
): void {
|
||||
if (this.status !== SprintStatus.ACTIVE)
|
||||
throw new BusinessRuleError(
|
||||
`Only a sprint under way can be closed; ${this.name} is ${this.status}`,
|
||||
);
|
||||
if (carryOverTo) {
|
||||
if (
|
||||
carryOverTo.id === this.id ||
|
||||
carryOverTo.projectId !== this.projectId
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
'Unfinished tickets go to another sprint of the same project',
|
||||
);
|
||||
carryOverTo.assertAcceptsIssues();
|
||||
}
|
||||
this.apply(
|
||||
new SprintClosedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
carryOverTo?.id ?? null,
|
||||
unfinishedIssues,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a sprint that never started (its tickets go back to the
|
||||
* backlog), or any sprint when its project is deleted (`cascade`).
|
||||
*/
|
||||
delete(actorId: string, cascade = false): void {
|
||||
if (this.deleted) return;
|
||||
if (!cascade && this.status !== SprintStatus.PLANNED)
|
||||
throw new BusinessRuleError(
|
||||
`Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`,
|
||||
);
|
||||
this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId));
|
||||
}
|
||||
|
||||
/* ---- State transitions (also replayed by loadFromHistory) ---- */
|
||||
|
||||
onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void {
|
||||
Object.assign(this, sprint);
|
||||
}
|
||||
|
||||
onSprintUpdatedEvent(event: SprintUpdatedEvent): void {
|
||||
Object.assign(this, event.changes);
|
||||
}
|
||||
|
||||
onSprintStartedEvent(event: SprintStartedEvent): void {
|
||||
this.status = SprintStatus.ACTIVE;
|
||||
this.startedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onSprintClosedEvent(event: SprintClosedEvent): void {
|
||||
this.status = SprintStatus.CLOSED;
|
||||
this.closedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onSprintDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
SprintCreatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@@ -32,6 +34,22 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
|
||||
);
|
||||
}
|
||||
|
||||
sprintIdsOfProject(projectId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
SprintCreatedEvent.name,
|
||||
`data->>'projectId'`,
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
issueIdsEverInSprint(sprintId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
IssueSprintChangedEvent.name,
|
||||
`data->>'sprintId'`,
|
||||
sprintId,
|
||||
);
|
||||
}
|
||||
|
||||
commentIdsOfIssue(issueId: string): Promise<string[]> {
|
||||
return this.streams(
|
||||
CommentAddedEvent.name,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IssueChange,
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
SprintChange,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
@@ -19,6 +20,7 @@ const Channels = {
|
||||
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
||||
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
||||
release: (projectId: string) => `tracker.release.${projectId}`,
|
||||
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
|
||||
project: (projectId: string) => `tracker.project.${projectId}`,
|
||||
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
|
||||
notifications: (userId: string) => `tracker.notifications.${userId}`,
|
||||
@@ -49,6 +51,10 @@ export class RedisChangeFeed extends ChangeFeed {
|
||||
);
|
||||
}
|
||||
|
||||
publishSprint(change: SprintChange): Promise<void> {
|
||||
return this.pubSub.publish(Channels.sprint(change.projectId), change);
|
||||
}
|
||||
|
||||
async publishProject(
|
||||
change: ProjectChange,
|
||||
notifyUserIds: string[] = [],
|
||||
@@ -88,6 +94,10 @@ export class RedisChangeFeed extends ChangeFeed {
|
||||
return this.pubSub.asyncIterableIterator(Channels.release(projectId));
|
||||
}
|
||||
|
||||
sprints(projectId: string): AsyncIterableIterator<SprintChange> {
|
||||
return this.pubSub.asyncIterableIterator(Channels.sprint(projectId));
|
||||
}
|
||||
|
||||
project(projectId: string): AsyncIterableIterator<ProjectChange> {
|
||||
return this.pubSub.asyncIterableIterator(Channels.project(projectId));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
FindIssuesRepository,
|
||||
FindProjectsRepository,
|
||||
FindReleasesRepository,
|
||||
FindSprintsRepository,
|
||||
UpsertMaterializedSprintRepository,
|
||||
FindTimelineRepository,
|
||||
IssueNumbersRepository,
|
||||
ProjectKeysRepository,
|
||||
@@ -58,6 +60,10 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import {
|
||||
MongoFindSprintsRepository,
|
||||
MongoUpsertMaterializedSprintRepository,
|
||||
} from './mongo/sprints.repositories';
|
||||
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||
|
||||
@@ -79,6 +85,11 @@ const PORTS = [
|
||||
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
|
||||
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
|
||||
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
|
||||
{ provide: FindSprintsRepository, useClass: MongoFindSprintsRepository },
|
||||
{
|
||||
provide: UpsertMaterializedSprintRepository,
|
||||
useClass: MongoUpsertMaterializedSprintRepository,
|
||||
},
|
||||
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
|
||||
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
@@ -23,6 +24,10 @@ registerEnumType(StatusCategory, {
|
||||
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||
registerEnumType(SprintStatus, {
|
||||
name: 'SprintStatus',
|
||||
description: 'PLANNED, ACTIVE (one per project at a time) or CLOSED',
|
||||
});
|
||||
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
||||
registerEnumType(IssueSort, {
|
||||
name: 'IssueSort',
|
||||
|
||||
@@ -181,6 +181,41 @@ export class ChangeIssueStatusInput {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
@InputType()
|
||||
export class CreateSprintInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(80) name!: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
goal?: string | null;
|
||||
@Field({ description: 'YYYY-MM-DD' }) @Matches(DAY) startDate!: string;
|
||||
@Field({ description: 'YYYY-MM-DD, included' })
|
||||
@Matches(DAY)
|
||||
endDate!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateSprintInput {
|
||||
@Field(() => ID) @IsUUID() sprintId!: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
goal?: string | null;
|
||||
@Field({ nullable: true }) @IsOptional() @Matches(DAY) startDate?: string;
|
||||
@Field({ nullable: true }) @IsOptional() @Matches(DAY) endDate?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SavedFilterCriteriaInput {
|
||||
@Field(() => [String], { nullable: true })
|
||||
@@ -320,6 +355,14 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
@Field(() => ID, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sprintId?: string;
|
||||
@Field({ nullable: true, description: 'Only tickets in no sprint' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
noSprint?: boolean;
|
||||
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueSort)
|
||||
|
||||
@@ -125,6 +125,8 @@ export class ProjectResolver {
|
||||
search?: string | null,
|
||||
@Args('unassigned', { type: () => Boolean, nullable: true })
|
||||
unassigned?: boolean | null,
|
||||
@Args('sprintId', { type: () => ID, nullable: true })
|
||||
sprintId?: string | null,
|
||||
) {
|
||||
return this.projects.board(
|
||||
viewerOf(me),
|
||||
@@ -138,6 +140,7 @@ export class ProjectResolver {
|
||||
labels: labels ?? undefined,
|
||||
search: search?.slice(0, 200) ?? undefined,
|
||||
unassigned: unassigned ?? undefined,
|
||||
sprintId: sprintId ?? undefined,
|
||||
},
|
||||
perColumn,
|
||||
);
|
||||
|
||||
170
libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
Normal file
170
libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
CurrentUser,
|
||||
OptionalAuth,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
IssuesService,
|
||||
IssueSort,
|
||||
SprintsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { CreateSprintInput, UpdateSprintInput } from './inputs';
|
||||
import {
|
||||
CommandResultGql,
|
||||
IssuePageGql,
|
||||
SprintBurndownGql,
|
||||
SprintGql,
|
||||
SprintProgressGql,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => SprintGql)
|
||||
export class SprintResolver {
|
||||
constructor(
|
||||
private readonly sprintsService: SprintsService,
|
||||
private readonly issuesService: IssuesService,
|
||||
) {}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [SprintGql], {
|
||||
description:
|
||||
"A project's sprints: the one under way, the planned ones, then the closed ones",
|
||||
})
|
||||
sprints(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.sprintsService.list(viewerOf(me), projectId);
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => SprintGql)
|
||||
sprint(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
) {
|
||||
return this.sprintsService.findOne(viewerOf(me), id);
|
||||
}
|
||||
|
||||
@ResolveField(() => SprintProgressGql)
|
||||
progress(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() sprint: SprintReadModel,
|
||||
) {
|
||||
return this.sprintsService.progress(viewerOf(me), sprint.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => SprintBurndownGql)
|
||||
burndown(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() sprint: SprintReadModel,
|
||||
) {
|
||||
return this.sprintsService.burndown(viewerOf(me), sprint.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => IssuePageGql, {
|
||||
description: 'Tickets in this sprint, in backlog order',
|
||||
})
|
||||
issues(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() sprint: SprintReadModel,
|
||||
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
return this.issuesService.search(viewerOf(me), {
|
||||
projectId: sprint.projectId,
|
||||
sprintId: sprint.id,
|
||||
sort: IssueSort.RANK,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Plan a sprint (owners and admins)',
|
||||
})
|
||||
createSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: CreateSprintInput,
|
||||
) {
|
||||
return this.sprintsService.create(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
updateSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateSprintInput,
|
||||
) {
|
||||
const { sprintId, ...changes } = input;
|
||||
return this.sprintsService.update(
|
||||
me.id,
|
||||
sprintId,
|
||||
Object.fromEntries(
|
||||
Object.entries(changes).filter(
|
||||
([field, value]) => value !== null || field === 'goal',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Start a planned sprint (only one under way per project)',
|
||||
})
|
||||
startSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||
) {
|
||||
return this.sprintsService.start(me.id, sprintId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.',
|
||||
})
|
||||
closeSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||
@Args('carryOverToSprintId', { type: () => ID, nullable: true })
|
||||
carryOverToSprintId: string | null,
|
||||
) {
|
||||
return this.sprintsService.close(
|
||||
me.id,
|
||||
sprintId,
|
||||
carryOverToSprintId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Delete a sprint that never started; its tickets go back to the backlog',
|
||||
})
|
||||
deleteSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||
) {
|
||||
return this.sprintsService.delete(me.id, sprintId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Put a ticket in a sprint (null: back to the backlog)',
|
||||
})
|
||||
setIssueSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('sprintId', { type: () => ID, nullable: true })
|
||||
sprintId: string | null,
|
||||
) {
|
||||
return this.sprintsService.setIssueSprint(me.id, issueId, sprintId ?? null);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NotificationGql,
|
||||
ProjectChangeGql,
|
||||
ReleaseChangeGql,
|
||||
SprintChangeGql,
|
||||
} from './types';
|
||||
|
||||
const asIs = { resolve: <T>(payload: T) => payload };
|
||||
@@ -66,6 +67,14 @@ export class SubscriptionResolver {
|
||||
return this.changes.watchReleases(me.id, projectId);
|
||||
}
|
||||
|
||||
@Subscription(() => SprintChangeGql, asIs)
|
||||
sprintChanged(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.changes.watchSprints(me.id, projectId);
|
||||
}
|
||||
|
||||
@Subscription(() => ProjectChangeGql, asIs)
|
||||
projectChanged(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { LinkDirection } from './enums';
|
||||
@@ -110,6 +111,12 @@ export class AttachmentGql {
|
||||
commentId!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('SprintRef')
|
||||
export class SprintRefGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() name!: string;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseRef')
|
||||
export class ReleaseRefGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -154,6 +161,7 @@ export class IssueGql {
|
||||
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
|
||||
@Field(() => ReleaseRefGql, { nullable: true })
|
||||
release!: ReleaseRefGql | null;
|
||||
@Field(() => SprintRefGql, { nullable: true }) sprint!: SprintRefGql | null;
|
||||
@Field(() => [String]) labels!: string[];
|
||||
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
|
||||
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
|
||||
@@ -270,6 +278,56 @@ export class ReleaseGql {
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('Sprint', {
|
||||
description: 'A time-boxed iteration: the tickets the team commits to',
|
||||
})
|
||||
export class SprintGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => String, { nullable: true }) goal!: string | null;
|
||||
@Field({ description: 'First day, YYYY-MM-DD' }) startDate!: string;
|
||||
@Field({ description: 'Last day (included), YYYY-MM-DD' }) endDate!: string;
|
||||
@Field(() => SprintStatus) status!: SprintStatus;
|
||||
@Field(() => String, { nullable: true }) startedAt!: string | null;
|
||||
@Field(() => String, { nullable: true }) closedAt!: string | null;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
}
|
||||
|
||||
@ObjectType('SprintProgress')
|
||||
export class SprintProgressGql {
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int, { description: 'Done or cancelled' }) finished!: number;
|
||||
@Field(() => Int) percent!: number;
|
||||
@Field(() => Int) storyPoints!: number;
|
||||
@Field(() => Int) finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
@ObjectType('SprintBurndown', {
|
||||
description:
|
||||
'Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.',
|
||||
})
|
||||
export class SprintBurndownGql {
|
||||
@Field(() => ID) sprintId!: string;
|
||||
@Field(() => [String]) dates!: string[];
|
||||
@Field(() => [Int], { nullable: 'items' }) scope!: (number | null)[];
|
||||
@Field(() => [Int], { nullable: 'items' }) remaining!: (number | null)[];
|
||||
@Field(() => [Int], { nullable: 'items' })
|
||||
remainingPoints!: (number | null)[];
|
||||
@Field(() => [Float]) ideal!: number[];
|
||||
}
|
||||
|
||||
@ObjectType('SprintChange')
|
||||
export class SprintChangeGql {
|
||||
@Field() action!: string;
|
||||
@Field(() => Int) version!: number;
|
||||
@Field(() => ID) actorId!: string;
|
||||
@Field(() => ID) sprintId!: string;
|
||||
@Field(() => SprintGql, { nullable: true, description: 'Null: deleted' })
|
||||
sprint!: SprintGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('CountByStatus')
|
||||
export class CountByStatusGql {
|
||||
@Field({ description: 'Status id' }) key!: string;
|
||||
|
||||
@@ -285,6 +285,11 @@ export class BoardQueryDto {
|
||||
@IsBoolean()
|
||||
unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sprintId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -451,6 +456,17 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@IsString()
|
||||
parent?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sprintId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only tickets in no sprint' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
noSprint?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueSort,
|
||||
enumName: 'IssueSort',
|
||||
@@ -819,6 +835,66 @@ export class CreateReleaseDto {
|
||||
|
||||
export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class CreateSprintDto {
|
||||
@ApiProperty({ example: 'Sprint 14', maxLength: 80 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
maxLength: 500,
|
||||
example: 'Pagos con tarjeta en producción',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
goal?: string | null;
|
||||
|
||||
@ApiProperty({ format: 'date', example: '2026-10-12' })
|
||||
@Matches(DAY)
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
format: 'date',
|
||||
example: '2026-10-23',
|
||||
description: 'Last day, included',
|
||||
})
|
||||
@Matches(DAY)
|
||||
endDate!: string;
|
||||
}
|
||||
|
||||
export class UpdateSprintDto extends PartialType(CreateSprintDto) {}
|
||||
|
||||
export class CloseSprintDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
carryOverTo?: string | null;
|
||||
}
|
||||
|
||||
export class SetIssueSprintDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'uuid',
|
||||
description: 'Open sprint of the project, or null for the backlog',
|
||||
})
|
||||
@ValidateIf((dto: SetIssueSprintDto) => dto.sprintId !== null)
|
||||
@IsUUID()
|
||||
sprintId!: string | null;
|
||||
}
|
||||
|
||||
export class PublishReleaseDto {
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
SETTING_COLORS,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
@@ -135,6 +136,11 @@ export class IssueParentDto {
|
||||
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
|
||||
}
|
||||
|
||||
export class SprintRefDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'Sprint 14' }) name!: string;
|
||||
}
|
||||
|
||||
export class IssueDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@@ -163,6 +169,8 @@ export class IssueDto {
|
||||
assignee!: UserRefDto | null;
|
||||
@ApiProperty({ type: ReleaseRefDto, nullable: true })
|
||||
release!: ReleaseRefDto | null;
|
||||
@ApiProperty({ type: SprintRefDto, nullable: true })
|
||||
sprint!: SprintRefDto | null;
|
||||
@ApiProperty({ type: [String] }) labels!: string[];
|
||||
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
|
||||
figmaUrl!: string | null;
|
||||
@@ -255,6 +263,50 @@ export class CommentPageDto {
|
||||
@ApiProperty() total!: number;
|
||||
}
|
||||
|
||||
export class SprintDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty({ example: 'Sprint 14' }) name!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) goal!: string | null;
|
||||
@ApiProperty({ format: 'date' }) startDate!: string;
|
||||
@ApiProperty({ format: 'date', description: 'Last day, included' })
|
||||
endDate!: string;
|
||||
@ApiProperty({ enum: SprintStatus, enumName: 'SprintStatus' })
|
||||
status!: SprintStatus;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
startedAt!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
closedAt!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
export class SprintProgressDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty({ description: 'Done or cancelled' }) finished!: number;
|
||||
@ApiProperty({ minimum: 0, maximum: 100 }) percent!: number;
|
||||
@ApiProperty() storyPoints!: number;
|
||||
@ApiProperty() finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
export class SprintBurndownDto {
|
||||
@ApiProperty({ format: 'uuid' }) sprintId!: string;
|
||||
@ApiProperty({ type: [String], format: 'date' }) dates!: string[];
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
nullable: true,
|
||||
description: 'Tickets in the sprint (null: days to come)',
|
||||
})
|
||||
scope!: (number | null)[];
|
||||
@ApiProperty({ type: [Number], nullable: true }) remaining!: (
|
||||
| number
|
||||
| null
|
||||
)[];
|
||||
@ApiProperty({ type: [Number], nullable: true })
|
||||
remainingPoints!: (number | null)[];
|
||||
@ApiProperty({ type: [Number] }) ideal!: number[];
|
||||
}
|
||||
|
||||
export class ReleaseDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
|
||||
@@ -157,6 +157,8 @@ export class IssuesController {
|
||||
unassigned: query.unassigned,
|
||||
releaseId: query.releaseId,
|
||||
labels: query.label,
|
||||
sprintId: query.sprintId,
|
||||
noSprint: query.noSprint,
|
||||
sort: query.sort,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
|
||||
@@ -187,6 +187,7 @@ export class ProjectsController {
|
||||
labels: query.label,
|
||||
search: query.search,
|
||||
unassigned: query.unassigned,
|
||||
sprintId: query.sprintId,
|
||||
},
|
||||
query.perColumn,
|
||||
);
|
||||
|
||||
249
libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
Normal file
249
libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiOptionalPireonAuth,
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import {
|
||||
IssuesService,
|
||||
IssueSort,
|
||||
SprintsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import {
|
||||
CloseSprintDto,
|
||||
CreateSprintDto,
|
||||
PageQueryDto,
|
||||
SetIssueSprintDto,
|
||||
UpdateSprintDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
CommandResultDto,
|
||||
IssuePageDto,
|
||||
SprintBurndownDto,
|
||||
SprintDto,
|
||||
SprintProgressDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
});
|
||||
const SPRINT = ApiParam({ name: 'sprint', format: 'uuid' });
|
||||
|
||||
/** REST adapter of the sprint use cases: plan, run and close iterations. */
|
||||
@ApiTags('Sprints')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class SprintsController {
|
||||
constructor(
|
||||
private readonly sprints: SprintsService,
|
||||
private readonly issues: IssuesService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/sprints')
|
||||
@ApiOptionalPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: "A project's sprints",
|
||||
description: 'The one under way, the planned ones, then the closed ones.',
|
||||
})
|
||||
@ApiOkResponse({ type: [SprintDto] })
|
||||
@ApiReadErrors()
|
||||
async list(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('project') project: string,
|
||||
) {
|
||||
return this.sprints.list(
|
||||
viewerOf(me),
|
||||
await this.refs.projectId(viewerOf(me), project),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('projects/:project/sprints')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Plan a sprint (owners and admins)' })
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async create(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateSprintDto,
|
||||
) {
|
||||
return this.sprints.create(me.id, {
|
||||
...body,
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({ summary: 'A sprint' })
|
||||
@ApiOkResponse({ type: SprintDto })
|
||||
@ApiReadErrors()
|
||||
findOne(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.findOne(viewerOf(me), sprint);
|
||||
}
|
||||
|
||||
@Patch('sprints/:sprint')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Rename, re-date or change the goal (not once closed)',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
update(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
@Body() body: UpdateSprintDto,
|
||||
) {
|
||||
return this.sprints.update(me.id, sprint, body);
|
||||
}
|
||||
|
||||
@Delete('sprints/:sprint')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Delete a sprint that never started',
|
||||
description: 'Its tickets go back to the backlog.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
delete(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.delete(me.id, sprint);
|
||||
}
|
||||
|
||||
@Post('sprints/:sprint/start')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Start a planned sprint (only one under way per project)',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
start(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.start(me.id, sprint);
|
||||
}
|
||||
|
||||
@Post('sprints/:sprint/close')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Close the sprint under way',
|
||||
description:
|
||||
'Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
close(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
@Body() body: CloseSprintDto,
|
||||
) {
|
||||
return this.sprints.close(me.id, sprint, body.carryOverTo ?? null);
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint/progress')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({ summary: 'Finished tickets and points' })
|
||||
@ApiOkResponse({ type: SprintProgressDto })
|
||||
@ApiReadErrors()
|
||||
progress(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.progress(viewerOf(me), sprint);
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint/burndown')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Open tickets and points, day by day, against the ideal line',
|
||||
})
|
||||
@ApiOkResponse({ type: SprintBurndownDto })
|
||||
@ApiReadErrors()
|
||||
burndown(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.burndown(viewerOf(me), sprint);
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint/issues')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({ summary: 'Tickets in the sprint, in backlog order' })
|
||||
@ApiOkResponse({ type: IssuePageDto })
|
||||
@ApiReadErrors()
|
||||
async issuesOf(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
@Query() query: PageQueryDto,
|
||||
) {
|
||||
const found = await this.sprints.findOne(viewerOf(me), sprint);
|
||||
const page = await this.issues.search(viewerOf(me), {
|
||||
projectId: found.projectId,
|
||||
sprintId: found.id,
|
||||
sort: IssueSort.RANK,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
}
|
||||
|
||||
@Put('issues/:issue/sprint')
|
||||
@ApiParam({
|
||||
name: 'issue',
|
||||
description: 'Ticket id or key',
|
||||
example: 'WEB-12',
|
||||
})
|
||||
@ApiOperation({ summary: 'Put the ticket in a sprint (null: the backlog)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async setIssueSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: SetIssueSprintDto,
|
||||
) {
|
||||
return this.sprints.setIssueSprint(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.sprintId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { ReleasesController } from './http/releases.controller';
|
||||
import { NotificationsController } from './http/notifications.controller';
|
||||
import { SavedFiltersController } from './http/saved-filters.controller';
|
||||
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
import { SprintResolver } from './graphql/sprint.resolver';
|
||||
import { SprintsController } from './http/sprints.controller';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -48,6 +50,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
ExploreController,
|
||||
NotificationsController,
|
||||
SavedFiltersController,
|
||||
SprintsController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -58,6 +61,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
SubscriptionResolver,
|
||||
NotificationResolver,
|
||||
SavedFilterResolver,
|
||||
SprintResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
@@ -26,6 +26,10 @@ export const issuePageEvents = eventGroup({
|
||||
targetKey: string;
|
||||
}>(),
|
||||
unlinkRequested: type<LinkIssuesInput>(),
|
||||
sprintChangeRequested: type<{
|
||||
issueId: string;
|
||||
sprintId: string | null;
|
||||
}>(),
|
||||
releaseChangeRequested: type<{
|
||||
issueId: string;
|
||||
releaseId: string | null;
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
ProjectStats,
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
CreateSprintInput,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
UpdateSprintInput,
|
||||
UserDirectoryEntry,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
@@ -23,6 +27,15 @@ export const projectPageEvents = eventGroup({
|
||||
releaseCreateRequested: type<CreateReleaseInput>(),
|
||||
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
|
||||
releaseArchiveRequested: type<{ releaseId: string }>(),
|
||||
sprintCreateRequested: type<CreateSprintInput>(),
|
||||
sprintUpdateRequested: type<UpdateSprintInput>(),
|
||||
sprintStartRequested: type<{ sprintId: string }>(),
|
||||
sprintCloseRequested: type<{
|
||||
sprintId: string;
|
||||
carryOverTo: string | null;
|
||||
}>(),
|
||||
sprintDeleteRequested: type<{ sprintId: string }>(),
|
||||
burndownRequested: type<{ sprintId: string }>(),
|
||||
userSearchRequested: type<string>(),
|
||||
settingsSaveRequested: type<{
|
||||
projectId: string;
|
||||
@@ -42,6 +55,8 @@ export const projectApiEvents = eventGroup({
|
||||
loadFailed: type<string>(),
|
||||
statsLoaded: type<ProjectStats>(),
|
||||
releasesLoaded: type<Release[]>(),
|
||||
sprintsLoaded: type<Sprint[]>(),
|
||||
burndownLoaded: type<SprintBurndown>(),
|
||||
activityLoaded: type<Activity[]>(),
|
||||
commandSucceeded: type<string>(),
|
||||
commandFailed: type<string>(),
|
||||
@@ -63,5 +78,6 @@ export const projectRealtimeEvents = eventGroup({
|
||||
projectDeleted: type<Project>(),
|
||||
activityAdded: type<Activity>(),
|
||||
releaseChanged: type<Release>(),
|
||||
sprintChanged: type<{ sprintId: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
SavedFiltersGateway,
|
||||
SprintsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -70,6 +71,14 @@ function gateways() {
|
||||
download: vi.fn(() => of(new Blob(['x']))),
|
||||
remove: vi.fn(() => of(ok)),
|
||||
},
|
||||
sprints: {
|
||||
list: vi.fn(() => of([])),
|
||||
burndown: vi.fn(() => NEVER),
|
||||
create: vi.fn(() => of(ok)),
|
||||
start: vi.fn(() => of(ok)),
|
||||
close: vi.fn(() => of(ok)),
|
||||
setIssueSprint: vi.fn(() => of(ok)),
|
||||
},
|
||||
views: {
|
||||
list: vi.fn(() => of([])),
|
||||
save: vi.fn(() => of('v1')),
|
||||
@@ -80,6 +89,7 @@ function gateways() {
|
||||
commentChanges: () => NEVER,
|
||||
activity: () => NEVER,
|
||||
releaseChanges: () => NEVER,
|
||||
sprintChanges: () => NEVER,
|
||||
projectChanges: vi.fn((): unknown => NEVER),
|
||||
},
|
||||
};
|
||||
@@ -113,6 +123,7 @@ function setup() {
|
||||
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
||||
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
||||
{ provide: SavedFiltersGateway, useValue: fakes.views },
|
||||
{ provide: SprintsGateway, useValue: fakes.sprints },
|
||||
{
|
||||
provide: Notifications,
|
||||
useValue: { success: vi.fn(), error: vi.fn() },
|
||||
@@ -219,6 +230,28 @@ describe('ProjectFacade', () => {
|
||||
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
|
||||
});
|
||||
|
||||
it('plans, starts and closes sprints of the open project', () => {
|
||||
const { project, fakes } = setup();
|
||||
project.open('WEB');
|
||||
project.createSprint({
|
||||
name: 'Sprint 1',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-23',
|
||||
});
|
||||
project.startSprint('s1');
|
||||
project.closeSprint('s1', null);
|
||||
|
||||
expect(fakes.sprints.list).toHaveBeenCalledWith('p1');
|
||||
expect(fakes.sprints.create).toHaveBeenCalledWith({
|
||||
projectId: 'p1',
|
||||
name: 'Sprint 1',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-23',
|
||||
});
|
||||
expect(fakes.sprints.start).toHaveBeenCalledWith('s1');
|
||||
expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null);
|
||||
});
|
||||
|
||||
it('makes the project public and deletes it, then goes home', () => {
|
||||
const { project, fakes, navigate, notifications } = setup();
|
||||
project.open('WEB');
|
||||
|
||||
@@ -79,6 +79,13 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** null: back to the backlog. */
|
||||
setSprint(sprintId: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.sprintChangeRequested({ issueId, sprintId }),
|
||||
);
|
||||
}
|
||||
|
||||
setRelease(releaseId: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
|
||||
|
||||
@@ -2,8 +2,11 @@ import { computed, inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
CreateReleaseInput,
|
||||
CreateSprintInput,
|
||||
ProjectRole,
|
||||
ProjectSettings,
|
||||
SprintStatus,
|
||||
UpdateSprintInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { projectPageEvents } from '../events/project.events';
|
||||
import { ProjectStore } from '../stores/project.store';
|
||||
@@ -21,6 +24,20 @@ export class ProjectFacade {
|
||||
readonly members = this.store.members;
|
||||
readonly stats = this.store.stats;
|
||||
readonly releases = this.store.releases;
|
||||
/** Under way, planned, then closed; with their progress. */
|
||||
readonly sprints = this.store.sprints;
|
||||
/** The sprint under way, if any. */
|
||||
readonly activeSprint = computed(
|
||||
() =>
|
||||
this.store.sprints().find((s) => s.status === SprintStatus.ACTIVE) ??
|
||||
null,
|
||||
);
|
||||
/** Sprints that still take tickets (under way or planned). */
|
||||
readonly openSprints = computed(() =>
|
||||
this.store.sprints().filter((s) => s.status !== SprintStatus.CLOSED),
|
||||
);
|
||||
/** Burndown of the sprint asked for with `loadBurndown`. */
|
||||
readonly burndown = this.store.burndown;
|
||||
readonly activity = this.store.activity;
|
||||
/** Pireon directory results for `searchUsers()`. */
|
||||
readonly userResults = this.store.userResults;
|
||||
@@ -80,6 +97,33 @@ export class ProjectFacade {
|
||||
}
|
||||
|
||||
/** `force` publishes even with unfinished tickets. */
|
||||
createSprint(sprint: Omit<CreateSprintInput, 'projectId'>): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId)
|
||||
this.dispatch.sprintCreateRequested({ projectId, ...sprint });
|
||||
}
|
||||
|
||||
updateSprint(changes: UpdateSprintInput): void {
|
||||
this.dispatch.sprintUpdateRequested(changes);
|
||||
}
|
||||
|
||||
startSprint(sprintId: string): void {
|
||||
this.dispatch.sprintStartRequested({ sprintId });
|
||||
}
|
||||
|
||||
/** Unfinished tickets go to `carryOverTo` (null: back to the backlog). */
|
||||
closeSprint(sprintId: string, carryOverTo: string | null): void {
|
||||
this.dispatch.sprintCloseRequested({ sprintId, carryOverTo });
|
||||
}
|
||||
|
||||
deleteSprint(sprintId: string): void {
|
||||
this.dispatch.sprintDeleteRequested({ sprintId });
|
||||
}
|
||||
|
||||
loadBurndown(sprintId: string): void {
|
||||
this.dispatch.burndownRequested({ sprintId });
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string, force = false): void {
|
||||
this.dispatch.releasePublishRequested({ releaseId, force });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Issue,
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
SprintsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
@@ -122,6 +123,7 @@ export const IssueStore = signalStore(
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
attachments = inject(AttachmentsGateway),
|
||||
sprints = inject(SprintsGateway),
|
||||
) => {
|
||||
/** One file after another: the ticket's version moves with each. */
|
||||
const uploadAll = (
|
||||
@@ -410,6 +412,20 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.sprintChangeRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
sprints.setIssueSprint(payload.issueId, payload.sprintId),
|
||||
payload.sprintId
|
||||
? 'Metido en el sprint'
|
||||
: 'De vuelta al backlog',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.releaseChangeRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
ReleasesGateway,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
SprintsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UserDirectoryEntry,
|
||||
UsersGateway,
|
||||
@@ -50,6 +53,10 @@ interface ProjectState {
|
||||
project: Project | null;
|
||||
stats: ProjectStats | null;
|
||||
releases: Release[];
|
||||
/** Under way, planned, then closed; with their progress. */
|
||||
sprints: Sprint[];
|
||||
/** Burndown of the sprint asked for with `burndownRequested`. */
|
||||
burndown: SprintBurndown | null;
|
||||
activity: Activity[];
|
||||
userResults: UserDirectoryEntry[];
|
||||
analytics: ProjectAnalytics | null;
|
||||
@@ -71,6 +78,8 @@ export const ProjectStore = signalStore(
|
||||
project: null,
|
||||
stats: null,
|
||||
releases: [],
|
||||
sprints: [],
|
||||
burndown: null,
|
||||
activity: [],
|
||||
userResults: [],
|
||||
analytics: null,
|
||||
@@ -109,6 +118,8 @@ export const ProjectStore = signalStore(
|
||||
project: null,
|
||||
stats: null,
|
||||
releases: [],
|
||||
sprints: [],
|
||||
burndown: null,
|
||||
activity: [],
|
||||
analytics: null,
|
||||
analyticsError: null,
|
||||
@@ -153,6 +164,10 @@ export const ProjectStore = signalStore(
|
||||
error: payload,
|
||||
})),
|
||||
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
|
||||
on(projectApiEvents.sprintsLoaded, ({ payload }) => ({ sprints: payload })),
|
||||
on(projectApiEvents.burndownLoaded, ({ payload }) => ({
|
||||
burndown: payload,
|
||||
})),
|
||||
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
|
||||
releases: payload,
|
||||
})),
|
||||
@@ -175,6 +190,7 @@ export const ProjectStore = signalStore(
|
||||
events = inject(Events),
|
||||
projects = inject(ProjectsGateway),
|
||||
releases = inject(ReleasesGateway),
|
||||
sprints = inject(SprintsGateway),
|
||||
activity = inject(ActivityGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
@@ -191,6 +207,23 @@ export const ProjectStore = signalStore(
|
||||
}),
|
||||
);
|
||||
|
||||
const reloadSprints = (projectId: string) =>
|
||||
sprints.list(projectId).pipe(
|
||||
mapResponse({
|
||||
next: (list) => projectApiEvents.sprintsLoaded(list),
|
||||
error: (error) =>
|
||||
projectApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
const loadBurndown = (sprintId: string) =>
|
||||
sprints.burndown(sprintId).pipe(
|
||||
mapResponse({
|
||||
next: (burndown) => projectApiEvents.burndownLoaded(burndown),
|
||||
error: (error) =>
|
||||
projectApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
load$: events.on(projectPageEvents.opened).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
@@ -216,6 +249,7 @@ export const ProjectStore = signalStore(
|
||||
.stats(payload.id)
|
||||
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
|
||||
reloadReleases(payload.id),
|
||||
reloadSprints(payload.id),
|
||||
activity
|
||||
.list(payload.id)
|
||||
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
|
||||
@@ -240,6 +274,9 @@ export const ProjectStore = signalStore(
|
||||
realtime
|
||||
.releaseChanges(payload.id)
|
||||
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
|
||||
realtime
|
||||
.sprintChanges(payload.id)
|
||||
.pipe(map((c) => projectRealtimeEvents.sprintChanged(c))),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -267,6 +304,24 @@ export const ProjectStore = signalStore(
|
||||
return id ? reloadReleases(id) : [];
|
||||
}),
|
||||
),
|
||||
/** Progress and burndown follow the tickets: refetch when they or a sprint change. */
|
||||
refreshSprints$: merge(
|
||||
events.on(projectRealtimeEvents.sprintChanged),
|
||||
events
|
||||
.on(projectRealtimeEvents.activityAdded)
|
||||
.pipe(debounceTime(800)),
|
||||
).pipe(
|
||||
switchMap(() => {
|
||||
const id = store.project()?.id;
|
||||
const shown = store.burndown()?.sprintId;
|
||||
return id
|
||||
? merge(reloadSprints(id), shown ? loadBurndown(shown) : [])
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
burndown$: events
|
||||
.on(projectPageEvents.burndownRequested)
|
||||
.pipe(switchMap(({ payload }) => loadBurndown(payload.sprintId))),
|
||||
analytics$: events.on(projectPageEvents.analyticsRequested).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
projects.analytics(payload.projectId, payload.days).pipe(
|
||||
@@ -376,6 +431,68 @@ export const ProjectStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.sprintCreateRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
sprints.create(payload),
|
||||
`Sprint ${payload.name} planificado`,
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.sprintUpdateRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
sprints.update(payload),
|
||||
'Sprint actualizado',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.sprintStartRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
sprints.start(payload.sprintId),
|
||||
'Sprint en marcha 🏁',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.sprintCloseRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
sprints.close(payload.sprintId, payload.carryOverTo),
|
||||
payload.carryOverTo
|
||||
? 'Sprint cerrado: lo pendiente pasa al siguiente'
|
||||
: 'Sprint cerrado: lo pendiente vuelve al backlog',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.sprintDeleteRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
sprints.remove(payload.sprintId),
|
||||
'Sprint eliminado',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.releasePublishRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -39,6 +39,13 @@ export enum ReleaseStatus {
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
}
|
||||
|
||||
export enum SprintStatus {
|
||||
PLANNED = 'PLANNED',
|
||||
/** Under way: one per project at a time. */
|
||||
ACTIVE = 'ACTIVE',
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
|
||||
/** Why someone is told. */
|
||||
export enum NotificationKind {
|
||||
ASSIGNED = 'ASSIGNED',
|
||||
|
||||
@@ -86,4 +86,22 @@ export interface BoardFilter {
|
||||
labels?: string[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
sprintId?: string;
|
||||
}
|
||||
|
||||
export interface CreateSprintInput {
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
/** `YYYY-MM-DD`. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface UpdateSprintInput {
|
||||
sprintId: string;
|
||||
name?: string;
|
||||
goal?: string | null;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from './enums';
|
||||
import { ProjectSettings } from './workflow';
|
||||
@@ -107,6 +108,7 @@ export interface IssueSummary {
|
||||
priority: IssuePriority;
|
||||
assignee: UserRef | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
labels: string[];
|
||||
/** The epic or ticket this one breaks down. */
|
||||
parent: IssueParent | null;
|
||||
@@ -159,6 +161,40 @@ export interface Comment {
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SprintProgress {
|
||||
total: number;
|
||||
/** Done or cancelled. */
|
||||
finished: number;
|
||||
percent: number;
|
||||
storyPoints: number;
|
||||
finishedStoryPoints: number;
|
||||
}
|
||||
|
||||
/** A time-boxed iteration of a project. */
|
||||
export interface Sprint {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal: string | null;
|
||||
/** `YYYY-MM-DD`, both included. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: SprintStatus;
|
||||
startedAt: string | null;
|
||||
closedAt: string | null;
|
||||
progress: SprintProgress;
|
||||
}
|
||||
|
||||
/** Per day of a sprint; null for the days to come. */
|
||||
export interface SprintBurndown {
|
||||
sprintId: string;
|
||||
dates: string[];
|
||||
scope: (number | null)[];
|
||||
remaining: (number | null)[];
|
||||
remainingPoints: (number | null)[];
|
||||
ideal: number[];
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Comment,
|
||||
Epic,
|
||||
SavedFilter,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
SavedFilterCriteria,
|
||||
CommandResult,
|
||||
Issue,
|
||||
@@ -32,6 +34,8 @@ import {
|
||||
MyIssuesFilter,
|
||||
UpdateIssueInput,
|
||||
UpdateProjectInput,
|
||||
CreateSprintInput,
|
||||
UpdateSprintInput,
|
||||
} from '../models/inputs';
|
||||
|
||||
/*
|
||||
@@ -173,6 +177,25 @@ export abstract class ReleasesGateway {
|
||||
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
|
||||
}
|
||||
|
||||
export abstract class SprintsGateway {
|
||||
/** With their progress: under way, planned, then closed. */
|
||||
abstract list(projectId: string): Observable<Sprint[]>;
|
||||
abstract burndown(sprintId: string): Observable<SprintBurndown>;
|
||||
abstract create(input: CreateSprintInput): Observable<CommandResult>;
|
||||
abstract update(input: UpdateSprintInput): Observable<CommandResult>;
|
||||
abstract start(sprintId: string): Observable<CommandResult>;
|
||||
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
||||
abstract close(
|
||||
sprintId: string,
|
||||
carryOverTo: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract remove(sprintId: string): Observable<CommandResult>;
|
||||
abstract setIssueSprint(
|
||||
issueId: string,
|
||||
sprintId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class ActivityGateway {
|
||||
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
||||
}
|
||||
@@ -219,6 +242,8 @@ export abstract class TrackerRealtimeGateway {
|
||||
abstract commentChanges(issueId: string): Observable<CommentChanged>;
|
||||
abstract activity(projectId: string): Observable<Activity>;
|
||||
abstract releaseChanges(projectId: string): Observable<Release>;
|
||||
/** Something about a sprint of the project changed (or it was deleted). */
|
||||
abstract sprintChanges(projectId: string): Observable<{ sprintId: string }>;
|
||||
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
||||
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
||||
abstract notifications(): Observable<Notification>;
|
||||
|
||||
@@ -59,6 +59,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
labels: filter.labels?.length ? filter.labels : null,
|
||||
search: filter.search?.trim() || null,
|
||||
unassigned: filter.unassigned ?? null,
|
||||
sprintId: filter.sprintId ?? null,
|
||||
})
|
||||
.pipe(
|
||||
map((d) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
NotificationAddedDocument,
|
||||
ProjectChangedDocument,
|
||||
ReleaseChangedDocument,
|
||||
SprintChangedDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
import {
|
||||
@@ -85,6 +86,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
|
||||
);
|
||||
}
|
||||
|
||||
sprintChanges(projectId: string): Observable<{ sprintId: string }> {
|
||||
return this.signedIn(() =>
|
||||
this.gql
|
||||
.subscribe(SprintChangedDocument, { projectId })
|
||||
.pipe(map((d) => ({ sprintId: d.sprintChanged.sprintId }))),
|
||||
);
|
||||
}
|
||||
|
||||
projectChanges(projectId: string): Observable<ProjectChanged> {
|
||||
return this.signedIn(() =>
|
||||
this.gql
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
CommandResult,
|
||||
CreateSprintInput,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
SprintsGateway,
|
||||
UpdateSprintInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
CloseSprintDocument,
|
||||
CreateSprintDocument,
|
||||
DeleteSprintDocument,
|
||||
SetIssueSprintDocument,
|
||||
SprintBurndownDocument,
|
||||
SprintsDocument,
|
||||
StartSprintDocument,
|
||||
UpdateSprintDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
import { toCommandResult } from './mappers';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlSprintsGateway extends SprintsGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
list(projectId: string): Observable<Sprint[]> {
|
||||
return this.gql
|
||||
.query(SprintsDocument, { projectId })
|
||||
.pipe(map((d) => d.sprints as Sprint[]));
|
||||
}
|
||||
|
||||
burndown(sprintId: string): Observable<SprintBurndown> {
|
||||
return this.gql
|
||||
.query(SprintBurndownDocument, { id: sprintId })
|
||||
.pipe(map((d) => d.sprint.burndown));
|
||||
}
|
||||
|
||||
create(input: CreateSprintInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CreateSprintDocument, { input })
|
||||
.pipe(map((d) => toCommandResult(d.createSprint)));
|
||||
}
|
||||
|
||||
update(input: UpdateSprintInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(UpdateSprintDocument, { input })
|
||||
.pipe(map((d) => toCommandResult(d.updateSprint)));
|
||||
}
|
||||
|
||||
start(sprintId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(StartSprintDocument, { sprintId })
|
||||
.pipe(map((d) => toCommandResult(d.startSprint)));
|
||||
}
|
||||
|
||||
close(
|
||||
sprintId: string,
|
||||
carryOverTo: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CloseSprintDocument, {
|
||||
sprintId,
|
||||
carryOverToSprintId: carryOverTo,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.closeSprint)));
|
||||
}
|
||||
|
||||
remove(sprintId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(DeleteSprintDocument, { sprintId })
|
||||
.pipe(map((d) => toCommandResult(d.deleteSprint)));
|
||||
}
|
||||
|
||||
setIssueSprint(
|
||||
issueId: string,
|
||||
sprintId: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(SetIssueSprintDocument, { issueId, sprintId })
|
||||
.pipe(map((d) => toCommandResult(d.setIssueSprint)));
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ fragment IssueSummaryFields on Issue {
|
||||
id
|
||||
name
|
||||
}
|
||||
sprint {
|
||||
id
|
||||
name
|
||||
}
|
||||
parent {
|
||||
id
|
||||
key
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ query Board(
|
||||
$labels: [String!]
|
||||
$search: String
|
||||
$unassigned: Boolean
|
||||
$sprintId: ID
|
||||
) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
@@ -20,6 +21,7 @@ query Board(
|
||||
labels: $labels
|
||||
search: $search
|
||||
unassigned: $unassigned
|
||||
sprintId: $sprintId
|
||||
perColumn: 100
|
||||
) {
|
||||
status
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
fragment SprintFields on Sprint {
|
||||
id
|
||||
projectId
|
||||
name
|
||||
goal
|
||||
startDate
|
||||
endDate
|
||||
status
|
||||
startedAt
|
||||
closedAt
|
||||
progress {
|
||||
total
|
||||
finished
|
||||
percent
|
||||
storyPoints
|
||||
finishedStoryPoints
|
||||
}
|
||||
}
|
||||
|
||||
query Sprints($projectId: ID!) {
|
||||
sprints(projectId: $projectId) {
|
||||
...SprintFields
|
||||
}
|
||||
}
|
||||
|
||||
query SprintBurndown($id: ID!) {
|
||||
sprint(id: $id) {
|
||||
id
|
||||
burndown {
|
||||
sprintId
|
||||
dates
|
||||
scope
|
||||
remaining
|
||||
remainingPoints
|
||||
ideal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateSprint($input: CreateSprintInput!) {
|
||||
createSprint(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UpdateSprint($input: UpdateSprintInput!) {
|
||||
updateSprint(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation StartSprint($sprintId: ID!) {
|
||||
startSprint(sprintId: $sprintId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation CloseSprint($sprintId: ID!, $carryOverToSprintId: ID) {
|
||||
closeSprint(sprintId: $sprintId, carryOverToSprintId: $carryOverToSprintId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation DeleteSprint($sprintId: ID!) {
|
||||
deleteSprint(sprintId: $sprintId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation SetIssueSprint($issueId: ID!, $sprintId: ID) {
|
||||
setIssueSprint(issueId: $issueId, sprintId: $sprintId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
subscription SprintChanged($projectId: ID!) {
|
||||
sprintChanged(projectId: $projectId) {
|
||||
action
|
||||
sprintId
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
SavedFiltersGateway,
|
||||
SprintsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -26,6 +27,7 @@ import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
||||
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||
|
||||
/** Ports → GraphQL adapters (and REST for files). */
|
||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
@@ -40,5 +42,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,12 @@ const NOBODY = '__nobody__';
|
||||
[options]="priorityFilterOptions"
|
||||
[(value)]="priorityFilter"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Sprint"
|
||||
[options]="sprintFilterOptions()"
|
||||
[(value)]="sprintFilter"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Release"
|
||||
@@ -335,6 +341,8 @@ export class BoardPage {
|
||||
readonly release = input<string | undefined>();
|
||||
/** `?assignee=` (from the tracking page's team list), bound by the router. */
|
||||
readonly assignee = input<string | undefined>();
|
||||
/** `?sprint=` (from the sprints page), bound by the router. */
|
||||
readonly sprint = input<string | undefined>();
|
||||
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
@@ -353,6 +361,16 @@ export class BoardPage {
|
||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||
protected readonly area = signal<string | null>(ALL);
|
||||
protected readonly typeFilter = signal<string | null>(ALL);
|
||||
protected readonly sprintFilter = signal<string | null>(ALL);
|
||||
protected readonly sprintFilterOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() => [
|
||||
{ value: ALL, label: 'Todos los sprints' },
|
||||
...this.project.openSprints().map((s) => ({
|
||||
value: s.id,
|
||||
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
|
||||
})),
|
||||
]);
|
||||
protected readonly priorityFilter = signal<string | null>(ALL);
|
||||
/** What is typed; `search` follows it once typing pauses. */
|
||||
protected readonly searchInput = signal('');
|
||||
@@ -364,6 +382,7 @@ export class BoardPage {
|
||||
!!(
|
||||
this.assigneeFilter() ||
|
||||
this.releaseFilter() ||
|
||||
this.sprintFilter() ||
|
||||
this.area() ||
|
||||
this.typeFilter() ||
|
||||
this.priorityFilter() ||
|
||||
@@ -485,6 +504,10 @@ export class BoardPage {
|
||||
const assignee = this.assignee();
|
||||
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
|
||||
});
|
||||
effect(() => {
|
||||
const sprint = this.sprint();
|
||||
if (sprint) untracked(() => this.sprintFilter.set(sprint));
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const text = this.searchInput().trim();
|
||||
const timer = setTimeout(() => this.search.set(text), 300);
|
||||
@@ -505,6 +528,7 @@ export class BoardPage {
|
||||
priorities: priority ? [priority] : undefined,
|
||||
labels: this.labels().length ? this.labels() : undefined,
|
||||
search: this.search() || undefined,
|
||||
sprintId: this.sprintFilter() || undefined,
|
||||
};
|
||||
if (!projectId) return;
|
||||
untracked(() =>
|
||||
@@ -569,6 +593,7 @@ export class BoardPage {
|
||||
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
|
||||
);
|
||||
this.releaseFilter.set(filter.releaseId ?? ALL);
|
||||
this.sprintFilter.set(ALL);
|
||||
this.area.set(filter.area ?? ALL);
|
||||
this.typeFilter.set(filter.types?.[0] ?? ALL);
|
||||
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
|
||||
@@ -581,6 +606,7 @@ export class BoardPage {
|
||||
for (const filter of [
|
||||
this.assigneeFilter,
|
||||
this.releaseFilter,
|
||||
this.sprintFilter,
|
||||
this.area,
|
||||
this.typeFilter,
|
||||
this.priorityFilter,
|
||||
|
||||
@@ -594,6 +594,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
<pi-label>Release</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="sprintOptions()"
|
||||
[disabled]="!project.canContribute() || sprintLocked()"
|
||||
[value]="issue.sprint?.id ?? ''"
|
||||
(valueChange)="facade.setSprint($event || null)"
|
||||
>
|
||||
<pi-label>Sprint</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="typeOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
@@ -768,6 +776,30 @@ export class IssuePage {
|
||||
.map((r) => ({ value: r.id, label: r.name })),
|
||||
],
|
||||
);
|
||||
/** Open sprints, plus the ticket's own even if it closed since. */
|
||||
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => {
|
||||
const current = this.facade.issue()?.sprint;
|
||||
const open = this.project.openSprints();
|
||||
return [
|
||||
{ value: NONE, label: 'Backlog (sin sprint)' },
|
||||
...open.map((s) => ({
|
||||
value: s.id,
|
||||
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
|
||||
})),
|
||||
...(current && !open.some((s) => s.id === current.id)
|
||||
? [{ value: current.id, label: `${current.name} · cerrado` }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
);
|
||||
/** A closed sprint keeps its tickets. */
|
||||
protected readonly sprintLocked = computed(() => {
|
||||
const current = this.facade.issue()?.sprint;
|
||||
return (
|
||||
!!current && !this.project.openSprints().some((s) => s.id === current.id)
|
||||
);
|
||||
});
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
/** Active values, plus the ticket's own even if archived since. */
|
||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
|
||||
@@ -86,6 +86,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
|
||||
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||
|
||||
571
libs/web/tracker/presenters/src/lib/pages/sprints.page.ts
Normal file
571
libs/web/tracker/presenters/src/lib/pages/sprints.page.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiDateRangeFieldComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiProgressBarComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { Sprint, SprintStatus } from '@task-stream/web/tracker/domain';
|
||||
import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const BACKLOG = '';
|
||||
|
||||
/** `YYYY-MM-DD` of a local date (what the date picker gives). */
|
||||
const isoDay = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const localDate = (day: string) => new Date(`${day}T00:00:00`);
|
||||
const label = (day: string, month: 'short' | 'long' = 'short') =>
|
||||
localDate(day).toLocaleDateString('es', { day: 'numeric', month });
|
||||
|
||||
const STATUS: Record<SprintStatus, string> = {
|
||||
[SprintStatus.ACTIVE]: 'En curso',
|
||||
[SprintStatus.PLANNED]: 'Planificado',
|
||||
[SprintStatus.CLOSED]: 'Cerrado',
|
||||
};
|
||||
|
||||
/** /p/:key/sprints — plan the iterations, follow the one under way, close it. */
|
||||
@Component({
|
||||
selector: 'ts-sprints-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
SeriesChartComponent,
|
||||
PiActionButtonComponent,
|
||||
PiDateRangeFieldComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiProgressBarComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<p class="ts-muted">
|
||||
Iteraciones de unas semanas. Mete tickets desde su ficha; al cerrar un
|
||||
sprint, lo que quede pasa al siguiente o vuelve al backlog.
|
||||
</p>
|
||||
@if (project.canManage()) {
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Planificar sprint"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (focus(); as sprint) {
|
||||
<section
|
||||
class="ts-sprint ts-sprint--focus ts-panel"
|
||||
[attr.data-status]="sprint.status"
|
||||
aria-labelledby="ts-sprint-focus"
|
||||
>
|
||||
<header class="ts-sprint__head">
|
||||
<h2 id="ts-sprint-focus">{{ sprint.name }}</h2>
|
||||
<span class="ts-pill" [attr.data-status]="sprint.status">{{
|
||||
statusLabel(sprint)
|
||||
}}</span>
|
||||
<span class="ts-muted">{{ when(sprint) }}</span>
|
||||
</header>
|
||||
@if (sprint.goal) {
|
||||
<p class="ts-sprint__goal">🎯 {{ sprint.goal }}</p>
|
||||
}
|
||||
<pi-progress-bar
|
||||
[label]="progressLabel(sprint)"
|
||||
[value]="sprint.progress.percent"
|
||||
/>
|
||||
@if (project.burndown()?.sprintId === sprint.id) {
|
||||
<div class="ts-sprint__chart">
|
||||
<pi-segmented-control
|
||||
ariaLabel="Medir en"
|
||||
[options]="units"
|
||||
[(value)]="unit"
|
||||
/>
|
||||
<ts-series-chart
|
||||
axisLabel="Día"
|
||||
[labels]="burndownLabels()"
|
||||
[longLabels]="burndownLongLabels()"
|
||||
[series]="burndownSeries()"
|
||||
[marker]="todayMarker()"
|
||||
[summary]="burndownSummary()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="ts-sprint__actions">
|
||||
<a
|
||||
class="ts-plain-link"
|
||||
routerLink="../board"
|
||||
[queryParams]="{ sprint: sprint.id }"
|
||||
>Ver en el tablero →</a
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (project.canManage()) {
|
||||
@switch (sprint.status) {
|
||||
@case ('ACTIVE') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Editar"
|
||||
(execute)="openEdit(sprint)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Cerrar sprint"
|
||||
(execute)="openClose(sprint)"
|
||||
/>
|
||||
}
|
||||
@case ('PLANNED') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Editar"
|
||||
(execute)="openEdit(sprint)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Empezar"
|
||||
[disabled]="!!project.activeSprint()"
|
||||
(execute)="project.startSprint(sprint.id)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (planned().length) {
|
||||
<h2 class="ts-sprints__title">Planificados</h2>
|
||||
<ul class="ts-sprints">
|
||||
@for (sprint of planned(); track sprint.id) {
|
||||
<li class="ts-sprint ts-panel" [class.is-focus]="isFocus(sprint)">
|
||||
<button
|
||||
type="button"
|
||||
class="ts-sprint__pick"
|
||||
(click)="show(sprint)"
|
||||
>
|
||||
<strong>{{ sprint.name }}</strong>
|
||||
<span class="ts-muted">{{ when(sprint) }}</span>
|
||||
<span class="ts-mono"
|
||||
>{{ sprint.progress.total }} tickets ·
|
||||
{{ sprint.progress.storyPoints }} pts</span
|
||||
>
|
||||
</button>
|
||||
@if (project.canManage()) {
|
||||
<span class="ts-sprint__row-actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Borrar"
|
||||
(execute)="project.deleteSprint(sprint.id)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
appearance="outline"
|
||||
text="Empezar"
|
||||
[disabled]="!!project.activeSprint()"
|
||||
(execute)="project.startSprint(sprint.id)"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (closed().length) {
|
||||
<h2 class="ts-sprints__title">Cerrados</h2>
|
||||
<ul class="ts-sprints">
|
||||
@for (sprint of closed(); track sprint.id) {
|
||||
<li class="ts-sprint ts-panel" [class.is-focus]="isFocus(sprint)">
|
||||
<button
|
||||
type="button"
|
||||
class="ts-sprint__pick"
|
||||
(click)="show(sprint)"
|
||||
>
|
||||
<strong>{{ sprint.name }}</strong>
|
||||
<span class="ts-muted">{{ when(sprint) }}</span>
|
||||
<span class="ts-mono"
|
||||
>{{ sprint.progress.finished }}/{{
|
||||
sprint.progress.total
|
||||
}}
|
||||
terminados</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!project.sprints().length) {
|
||||
<pi-empty-state
|
||||
icon="flag"
|
||||
title="Sin sprints"
|
||||
description="Planifica el primero: un nombre, unas fechas y un objetivo. Luego mete en él los tickets."
|
||||
/>
|
||||
}
|
||||
|
||||
<ng-template #sprintForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); save()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field placeholder="Sprint 14" [(value)]="name">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-date-range-field
|
||||
startPlaceholder="Empieza"
|
||||
endPlaceholder="Acaba"
|
||||
[(value)]="range"
|
||||
>
|
||||
<pi-label [isRequired]="true">Fechas</pi-label>
|
||||
</pi-date-range-field>
|
||||
<pi-textarea
|
||||
placeholder="Qué tiene que quedar hecho al final"
|
||||
[(value)]="goal"
|
||||
>
|
||||
<pi-label>Objetivo</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="editing() ? 'Guardar' : 'Planificar'"
|
||||
[disabled]="!canSave()"
|
||||
(execute)="save()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #closeForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); close()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
|
||||
este sprint como registro de lo hecho.
|
||||
</p>
|
||||
<pi-select-field [options]="carryOverOptions()" [(value)]="carryOverTo">
|
||||
<pi-label>Lo pendiente va a</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Cerrar sprint"
|
||||
(execute)="close()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.ts-sprint {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border-left: 3px solid var(--ts-line-strong);
|
||||
&[data-status='ACTIVE'] {
|
||||
border-left-color: var(--ts-brand);
|
||||
}
|
||||
&.is-focus {
|
||||
border-left-color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-sprint--focus {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.ts-sprint__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
.ts-sprint__goal {
|
||||
margin: 0;
|
||||
}
|
||||
.ts-sprint__chart {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
pi-segmented-control {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
.ts-sprint__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ts-sprints__title {
|
||||
margin: 1.25rem 0 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ts-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ts-sprints {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
.ts-sprint {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
padding: 0.625rem 0.875rem;
|
||||
}
|
||||
}
|
||||
.ts-sprint__pick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.875rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
&:hover strong {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-sprint__row-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.ts-pill[data-status='ACTIVE'] {
|
||||
--tone: var(--ts-brand);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SprintsPage {
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly sprintForm =
|
||||
viewChild.required<TemplateRef<unknown>>('sprintForm');
|
||||
private readonly closeForm =
|
||||
viewChild.required<TemplateRef<unknown>>('closeForm');
|
||||
|
||||
/** The sprint picked from the lists; by default the one under way. */
|
||||
private readonly picked = signal<string | null>(null);
|
||||
protected readonly focus = computed(() => {
|
||||
const sprints = this.project.sprints();
|
||||
return (
|
||||
sprints.find((s) => s.id === this.picked()) ??
|
||||
this.project.activeSprint() ??
|
||||
sprints.find((s) => s.status === SprintStatus.PLANNED) ??
|
||||
null
|
||||
);
|
||||
});
|
||||
protected readonly planned = computed(() =>
|
||||
this.project.sprints().filter((s) => s.status === SprintStatus.PLANNED),
|
||||
);
|
||||
protected readonly closed = computed(() =>
|
||||
this.project.sprints().filter((s) => s.status === SprintStatus.CLOSED),
|
||||
);
|
||||
|
||||
/* Burndown */
|
||||
protected readonly units = [
|
||||
{ value: 'tickets', label: 'Tickets' },
|
||||
{ value: 'points', label: 'Puntos' },
|
||||
];
|
||||
protected readonly unit = signal<string>('tickets');
|
||||
protected readonly burndownLabels = computed(
|
||||
() => this.project.burndown()?.dates.map((d) => label(d)) ?? [],
|
||||
);
|
||||
protected readonly burndownLongLabels = computed(
|
||||
() => this.project.burndown()?.dates.map((d) => label(d, 'long')) ?? [],
|
||||
);
|
||||
protected readonly burndownSeries = computed<ChartSeries[]>(() => {
|
||||
const burndown = this.project.burndown();
|
||||
if (!burndown) return [];
|
||||
const points = this.unit() === 'points';
|
||||
const remaining = points ? burndown.remainingPoints : burndown.remaining;
|
||||
// The ideal line starts from what was open on the first day, in the unit shown.
|
||||
const first = remaining[0] ?? 0;
|
||||
const span = Math.max(burndown.dates.length - 1, 1);
|
||||
return [
|
||||
{
|
||||
label: 'Ideal',
|
||||
values: burndown.dates.map(
|
||||
(_, i) => Math.round((first * (span - i) * 10) / span) / 10,
|
||||
),
|
||||
color: 'var(--ts-muted)',
|
||||
dashed: true,
|
||||
},
|
||||
{
|
||||
label: points ? 'Puntos pendientes' : 'Tickets pendientes',
|
||||
values: remaining,
|
||||
color: 'var(--ts-viz-1)',
|
||||
},
|
||||
];
|
||||
});
|
||||
protected readonly todayMarker = computed(() => {
|
||||
const index = this.project.burndown()?.dates.indexOf(isoDay(new Date()));
|
||||
return index === undefined || index === -1 ? null : { index, label: 'Hoy' };
|
||||
});
|
||||
protected readonly burndownSummary = computed(() => {
|
||||
const burndown = this.project.burndown();
|
||||
const open = last(burndown?.remaining.filter((v) => v !== null) ?? []);
|
||||
return open === undefined
|
||||
? 'Burndown del sprint'
|
||||
: `Burndown del sprint: quedan ${open} tickets por terminar`;
|
||||
});
|
||||
|
||||
/* Plan / edit */
|
||||
protected readonly editing = signal<Sprint | null>(null);
|
||||
protected readonly name = signal('');
|
||||
protected readonly goal = signal('');
|
||||
protected readonly range = signal<{ start: Date | null; end: Date | null }>({
|
||||
start: null,
|
||||
end: null,
|
||||
});
|
||||
protected readonly canSave = computed(
|
||||
() => !!this.name().trim() && !!this.range().start && !!this.range().end,
|
||||
);
|
||||
|
||||
/* Close */
|
||||
private closing: Sprint | null = null;
|
||||
protected readonly unfinished = signal(0);
|
||||
protected readonly carryOverTo = signal<string | null>(BACKLOG);
|
||||
protected readonly carryOverOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: BACKLOG, label: 'El backlog' },
|
||||
...this.planned().map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${label(s.startDate)})`,
|
||||
})),
|
||||
],
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Load the burndown of whatever sprint is in focus.
|
||||
effect(() => {
|
||||
const sprint = this.focus();
|
||||
if (sprint && sprint.status !== SprintStatus.PLANNED)
|
||||
untracked(() => this.project.loadBurndown(sprint.id));
|
||||
});
|
||||
}
|
||||
|
||||
protected statusLabel = (sprint: Sprint) => STATUS[sprint.status];
|
||||
protected isFocus = (sprint: Sprint) => this.focus()?.id === sprint.id;
|
||||
|
||||
/** "12 oct – 23 oct · quedan 5 días". */
|
||||
protected when(sprint: Sprint): string {
|
||||
const dates = `${label(sprint.startDate)} – ${label(sprint.endDate)}`;
|
||||
if (sprint.status !== SprintStatus.ACTIVE) return dates;
|
||||
const left = Math.ceil(
|
||||
(localDate(sprint.endDate).getTime() + DAY - Date.now()) / DAY,
|
||||
);
|
||||
if (left < 0) return `${dates} · pasado de fecha`;
|
||||
return `${dates} · ${left === 1 ? 'queda 1 día' : `quedan ${left} días`}`;
|
||||
}
|
||||
|
||||
protected progressLabel(sprint: Sprint): string {
|
||||
const { finished, total, finishedStoryPoints, storyPoints } =
|
||||
sprint.progress;
|
||||
const points = storyPoints
|
||||
? ` · ${finishedStoryPoints} de ${storyPoints} puntos`
|
||||
: '';
|
||||
return `${finished} de ${total} tickets terminados${points}`;
|
||||
}
|
||||
|
||||
protected show(sprint: Sprint): void {
|
||||
this.picked.set(sprint.id);
|
||||
}
|
||||
|
||||
protected openCreate(): void {
|
||||
this.editing.set(null);
|
||||
const lastEnd = last(
|
||||
this.project
|
||||
.sprints()
|
||||
.map((s) => s.endDate)
|
||||
.sort(),
|
||||
);
|
||||
// Right after the last one, two weeks long.
|
||||
const start = lastEnd
|
||||
? new Date(localDate(lastEnd).getTime() + DAY)
|
||||
: new Date();
|
||||
this.name.set(`Sprint ${this.project.sprints().length + 1}`);
|
||||
this.goal.set('');
|
||||
this.range.set({
|
||||
start,
|
||||
end: new Date(start.getTime() + 13 * DAY),
|
||||
});
|
||||
this.drawer.open('Planificar sprint', this.sprintForm());
|
||||
}
|
||||
|
||||
protected openEdit(sprint: Sprint): void {
|
||||
this.editing.set(sprint);
|
||||
this.name.set(sprint.name);
|
||||
this.goal.set(sprint.goal ?? '');
|
||||
this.range.set({
|
||||
start: localDate(sprint.startDate),
|
||||
end: localDate(sprint.endDate),
|
||||
});
|
||||
this.drawer.open(`Editar ${sprint.name}`, this.sprintForm());
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
const { start, end } = this.range();
|
||||
if (!this.canSave() || !start || !end) return;
|
||||
const details = {
|
||||
name: this.name().trim(),
|
||||
goal: this.goal().trim() || null,
|
||||
startDate: isoDay(start),
|
||||
endDate: isoDay(end),
|
||||
};
|
||||
const sprint = this.editing();
|
||||
if (sprint) this.project.updateSprint({ sprintId: sprint.id, ...details });
|
||||
else this.project.createSprint(details);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected openClose(sprint: Sprint): void {
|
||||
this.closing = sprint;
|
||||
this.unfinished.set(sprint.progress.total - sprint.progress.finished);
|
||||
this.carryOverTo.set(this.planned()[0]?.id ?? BACKLOG);
|
||||
this.drawer.open(`Cerrar ${sprint.name}`, this.closeForm());
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
if (!this.closing) return;
|
||||
this.project.closeSprint(this.closing.id, this.carryOverTo() || null);
|
||||
this.closing = null;
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/backlog.page').then((m) => m.BacklogPage),
|
||||
},
|
||||
{
|
||||
path: 'sprints',
|
||||
title: 'Sprints · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/sprints.page').then((m) => m.SprintsPage),
|
||||
},
|
||||
{
|
||||
path: 'epics',
|
||||
title: 'Épicas · TaskStream',
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string;
|
||||
values: number[];
|
||||
/** null: no value there (the line breaks; e.g. days still to come). */
|
||||
values: (number | null)[];
|
||||
/** CSS colour (a `--ts-viz-*` token). */
|
||||
color: string;
|
||||
/** Wash the area under a line (~10%); ignored when stacked. */
|
||||
area?: boolean;
|
||||
/** Dashed line, for references such as an ideal pace. */
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
export interface ChartMarker {
|
||||
@@ -136,6 +139,7 @@ function niceTicks(max: number): number[] {
|
||||
@if (shape.line) {
|
||||
<path
|
||||
[class]="stacked() ? 'ts-chart__gap' : 'ts-chart__line'"
|
||||
[class.ts-chart__line--dashed]="shape.dashed"
|
||||
[attr.d]="shape.line"
|
||||
[style.stroke]="stacked() ? null : shape.color"
|
||||
/>
|
||||
@@ -183,7 +187,7 @@ function niceTicks(max: number): number[] {
|
||||
class="ts-chart__tooltip-key"
|
||||
[style.--key]="row.color"
|
||||
></span>
|
||||
<strong>{{ row.value }}</strong> {{ row.label }}
|
||||
<strong>{{ row.value ?? '—' }}</strong> {{ row.label }}
|
||||
</p>
|
||||
}
|
||||
@if (tip.total !== null) {
|
||||
@@ -211,7 +215,7 @@ function niceTicks(max: number): number[] {
|
||||
<tr>
|
||||
<th scope="row">{{ label }}</th>
|
||||
@for (s of series(); track s.label) {
|
||||
<td>{{ s.values[i] }}</td>
|
||||
<td>{{ s.values[i] ?? '—' }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
@@ -282,6 +286,10 @@ function niceTicks(max: number): number[] {
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.ts-chart__line--dashed {
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.ts-chart__wash {
|
||||
fill-opacity: 0.1;
|
||||
}
|
||||
@@ -431,13 +439,25 @@ export class SeriesChartComponent {
|
||||
let base = new Array(this.count()).fill(0) as number[];
|
||||
return this.series().map((s) => {
|
||||
const lower = this.stacked() ? base : base.map(() => 0);
|
||||
const upper = s.values.map((v, i) => (this.stacked() ? lower[i] : 0) + v);
|
||||
if (this.stacked()) base = upper;
|
||||
// Stacked bands have no gaps: a missing value adds nothing.
|
||||
const upper: (number | null)[] = s.values.map((v, i) =>
|
||||
v === null && !this.stacked()
|
||||
? null
|
||||
: (this.stacked() ? lower[i] : 0) + (v ?? 0),
|
||||
);
|
||||
if (this.stacked()) base = upper as number[];
|
||||
return { ...s, lower, upper };
|
||||
});
|
||||
});
|
||||
protected readonly ticks = computed(() =>
|
||||
niceTicks(Math.max(0, ...this.bands().flatMap((b) => b.upper))),
|
||||
niceTicks(
|
||||
Math.max(
|
||||
0,
|
||||
...this.bands().flatMap((b) =>
|
||||
b.upper.filter((v): v is number => v !== null),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
private readonly max = computed(() => last(this.ticks()) || 1);
|
||||
|
||||
@@ -482,10 +502,19 @@ export class SeriesChartComponent {
|
||||
|
||||
protected readonly shapes = computed(() =>
|
||||
this.bands().map((band) => {
|
||||
const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v)}`);
|
||||
const line = points.length ? `M${points.join('L')}` : '';
|
||||
const gaps = band.upper.some((v) => v === null);
|
||||
// A gap starts a new stroke ("M") where the values come back.
|
||||
const line = band.upper
|
||||
.map((v, i) =>
|
||||
v === null
|
||||
? null
|
||||
: `${i === 0 || band.upper[i - 1] === null ? 'M' : 'L'}${this.x(i)},${this.y(v)}`,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v ?? 0)}`);
|
||||
let fill: string | null = null;
|
||||
if (this.stacked() || band.area) {
|
||||
if (!gaps && (this.stacked() || band.area)) {
|
||||
const floor = band.lower
|
||||
.map((v, i) => `${this.x(i)},${this.y(v)}`)
|
||||
.reverse();
|
||||
@@ -493,7 +522,13 @@ export class SeriesChartComponent {
|
||||
? `M${points.join('L')}L${floor.join('L')}Z`
|
||||
: null;
|
||||
}
|
||||
return { label: band.label, color: band.color, line, fill };
|
||||
return {
|
||||
label: band.label,
|
||||
color: band.color,
|
||||
dashed: !!band.dashed,
|
||||
line,
|
||||
fill,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -501,10 +536,10 @@ export class SeriesChartComponent {
|
||||
protected readonly endLabels = computed(() => {
|
||||
if (this.stacked() || this.count() === 0) return [];
|
||||
const labels = this.bands()
|
||||
.map((band) => ({
|
||||
text: String(last(band.upper) ?? 0),
|
||||
y: this.y(last(band.upper) ?? 0),
|
||||
}))
|
||||
.map((band) => {
|
||||
const value = last(band.upper.filter((v) => v !== null)) ?? 0;
|
||||
return { text: String(value), y: this.y(value) };
|
||||
})
|
||||
.sort((a, b) => a.y - b.y);
|
||||
const apart = labels.every(
|
||||
(l, i) => i === 0 || l.y - labels[i - 1].y >= 14,
|
||||
@@ -515,11 +550,13 @@ export class SeriesChartComponent {
|
||||
protected readonly dots = computed(() => {
|
||||
const index = this.hover();
|
||||
if (index === null) return [];
|
||||
return this.bands().map((band) => ({
|
||||
label: band.label,
|
||||
color: band.color,
|
||||
y: this.y(band.upper[index] ?? 0),
|
||||
}));
|
||||
return this.bands()
|
||||
.filter((band) => band.upper[index] !== null)
|
||||
.map((band) => ({
|
||||
label: band.label,
|
||||
color: band.color,
|
||||
y: this.y(band.upper[index] ?? 0),
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly tooltip = computed(() => {
|
||||
@@ -534,7 +571,9 @@ export class SeriesChartComponent {
|
||||
return {
|
||||
title: this.fullLabels()[index],
|
||||
rows,
|
||||
total: this.stacked() ? rows.reduce((sum, r) => sum + r.value, 0) : null,
|
||||
total: this.stacked()
|
||||
? rows.reduce((sum, r) => sum + (r.value ?? 0), 0)
|
||||
: null,
|
||||
x,
|
||||
left: x > this.width() * 0.6,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user