feat: sprints with burndown and carry-over

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

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

View File

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