feat: move unfinished tickets between releases

- Bulk editing gets «Release…» (open releases or none), and the list
  view a release filter, also reachable from each release («Ver en la
  lista», ?release=).
- Archiving a release asks where its unfinished tickets go, as closing
  a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release
  of the same project, or none) and the saga moves them there; finished
  ones stay. In GraphQL, REST and the MCP's archive_release.
- Chapter 10 lists the next features and the ideas to weigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:55:45 +02:00
parent 0a73eda9b2
commit 993f80b4b9
34 changed files with 408 additions and 48 deletions

View File

@@ -4,6 +4,7 @@ import {
computed,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
@@ -105,6 +106,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
[options]="assigneeFilterOptions()"
[(value)]="assignee"
/>
<pi-select-field
[options]="releaseFilterOptions()"
[(value)]="releaseFilter"
/>
@if (facade.loaded()) {
<span class="ts-muted ts-mono ts-list__count">
{{ facade.total() }} tickets{{
@@ -430,6 +435,8 @@ const numberOf = (key: string) => Number(key.split('-').pop());
`,
})
export class ListPage {
/** `?release=<id>`: opens already filtered by that release. */
readonly release = input<string | undefined>(undefined);
protected readonly facade = inject(ListFacade);
protected readonly project = inject(ProjectFacade);
protected readonly NOBODY = NOBODY;
@@ -455,6 +462,11 @@ export class ListPage {
protected readonly type = signal<string | null>(ALL);
protected readonly priority = signal<string | null>(ALL);
protected readonly assignee = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL);
private readonly releaseFromUrl = effect(() => {
const id = this.release();
if (id) untracked(() => this.releaseFilter.set(id));
});
protected readonly scopeOptions: PiSelectFieldOption<string>[] = [
{ value: 'open', label: 'Abiertos' },
{ value: 'closed', label: 'Terminados o cancelados' },
@@ -483,6 +495,24 @@ export class ListPage {
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
]);
/** Planned ones first: those are the ones tickets move between. */
protected readonly releaseFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todas las releases' },
...[...this.project.releases()]
.sort(
(a, b) =>
Number(b.status === 'PLANNED') - Number(a.status === 'PLANNED'),
)
.map((r) => ({
value: r.id,
label:
r.status === 'PLANNED'
? r.name
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
})),
]);
private readonly criteria = computed<ListCriteria>(() => {
const scope = this.scope();
const assignee = this.assignee();
@@ -496,6 +526,7 @@ export class ListPage {
: undefined,
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined,
};
});

View File

@@ -14,6 +14,8 @@ import {
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSelectFieldComponent,
PiSelectFieldOption,
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
@@ -23,6 +25,9 @@ import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
/** The «no release» choice of a select (an empty value). */
const NO_RELEASE = '';
@Component({
selector: 'ts-releases-page',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -33,6 +38,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
PiActionButtonComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiSelectFieldComponent,
PiTextFieldComponent,
PiTextareaComponent,
],
@@ -103,6 +109,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
[queryParams]="{ release: release.id }"
>Ver en el tablero →</a
>
<a
class="ts-release__link"
[routerLink]="['../list']"
[queryParams]="{ release: release.id }"
>Ver en la lista →</a
>
<span class="ts-spacer"></span>
@if (release.notes) {
<pi-action-button
@@ -179,6 +191,28 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
</form>
</ng-template>
<ng-template #archiveForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); confirmArchive()"
novalidate
>
<p class="ts-muted">
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
esta release como registro de lo hecho.
</p>
<pi-select-field [options]="moveToOptions()" [(value)]="moveTo">
<pi-label>Lo pendiente va a</pi-label>
</pi-select-field>
<pi-action-button
size="w100"
color="primary"
text="Archivar release"
(execute)="confirmArchive()"
/>
</form>
</ng-template>
<ng-template #createForm>
<form
class="ts-form"
@@ -353,6 +387,8 @@ export class ReleasesPage {
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('notesForm');
private readonly archiveForm =
viewChild.required<TemplateRef<unknown>>('archiveForm');
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
@@ -457,7 +493,44 @@ export class ReleasesPage {
.catch(() => this.toast.error('No se pudieron copiar'));
}
/* Archive: where its unfinished tickets go, as when closing a sprint. */
private archiving: Release | null = null;
protected readonly unfinished = signal(0);
protected readonly moveTo = signal<string | null>(NO_RELEASE);
protected readonly moveToOptions = signal<PiSelectFieldOption<string>[]>([]);
protected archive(release: Release): void {
this.facade.archiveRelease(release.id);
this.archiving = release;
// The next one first: the nearest target date, then the oldest planned
// (the list comes newest first).
const open = this.facade
.releases()
.filter((r) => r.status === ReleaseStatus.PLANNED && r.id !== release.id)
.map((r, i) => ({ r, i }))
.sort(
(a, b) =>
(a.r.targetDate ?? '9999').localeCompare(b.r.targetDate ?? '9999') ||
b.i - a.i,
)
.map(({ r }) => r);
this.unfinished.set(release.progress.total - release.progress.finished);
this.moveToOptions.set([
{ value: NO_RELEASE, label: 'Ninguna release' },
...open.map((r) => ({ value: r.id, label: r.name })),
]);
this.moveTo.set(open[0]?.id ?? NO_RELEASE);
// Nothing to place: no need to ask.
if (this.unfinished() === 0) {
this.confirmArchive();
return;
}
this.drawer.open(`Archivar ${release.name}`, this.archiveForm());
}
protected confirmArchive(): void {
if (!this.archiving) return;
this.facade.archiveRelease(this.archiving.id, this.moveTo() || null);
this.archiving = null;
this.drawer.closeAll();
}
}