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:
@@ -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