feat: release notes

Each release can carry notes in Markdown, written before or after it
ships. A draft is generated from its finished tickets grouped by type, to
edit and copy from the releases page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:19:23 +02:00
parent 45a3dc1e16
commit 24b37b6628
40 changed files with 1251 additions and 31 deletions

View File

@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
TemplateRef,
@@ -15,7 +16,8 @@ import {
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
@@ -83,6 +85,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
{{ release.progress.percent }}%</span
>
</div>
@if (release.notes) {
<details class="ts-release__notes">
<summary>Notas de la versión</summary>
<pre class="ts-release__notes-text">{{ release.notes }}</pre>
</details>
}
<div class="ts-release__actions">
<a
class="ts-release__link"
@@ -91,6 +99,24 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
>Ver en el tablero →</a
>
<span class="ts-spacer"></span>
@if (release.notes) {
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Copiar notas"
(execute)="copyNotes(release)"
/>
}
@if (facade.canManage() && release.status !== 'ARCHIVED') {
<pi-action-button
size="small"
appearance="outline"
color="grey"
[text]="release.notes ? 'Editar notas' : 'Escribir notas'"
(execute)="openNotes(release)"
/>
}
@if (facade.canManage() && release.status === 'PLANNED') {
<pi-action-button
size="small"
@@ -118,6 +144,39 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
}
</ol>
<ng-template #notesForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveNotes()"
novalidate
>
<p class="ts-muted">
Lo que trae la versión, contado para quien la usa (Markdown). El
borrador sale de los tickets terminados, agrupados por tipo.
</p>
<pi-action-button
size="small"
appearance="outline"
color="grey"
[text]="drafting() ? 'Generando…' : 'Generar borrador'"
[disabled]="drafting()"
(execute)="draftNotes()"
/>
<pi-textarea
placeholder="## Novedades&#10;- …"
[(value)]="notes"
>
<pi-label>Notas</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
text="Guardar notas"
(execute)="saveNotes()"
/>
</form>
</ng-template>
<ng-template #createForm>
<form
class="ts-form"
@@ -144,6 +203,20 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
</ng-template>
`,
styles: `
.ts-release__notes {
margin-top: 0.25rem;
summary {
cursor: pointer;
font-size: 0.875rem;
color: var(--ts-muted);
}
}
.ts-release__notes-text {
margin: 0.5rem 0 0;
white-space: pre-wrap;
font: inherit;
font-size: 0.875rem;
}
/* A timeline: one node per version on a vertical rail. */
.ts-releases {
position: relative;
@@ -276,6 +349,15 @@ export class ReleasesPage {
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('notesForm');
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
/* Release notes */
private writing: Release | null = null;
protected readonly notes = signal('');
protected readonly drafting = signal(false);
private readonly model = signal({
name: '',
@@ -327,6 +409,52 @@ export class ReleasesPage {
this.facade.publishRelease(release.id);
}
protected openNotes(release: Release): void {
this.writing = release;
this.notes.set(release.notes ?? '');
this.drawer.open(`Notas de ${release.name}`, this.notesForm(), '40rem');
if (!release.notes) this.draftNotes();
}
protected draftNotes(): void {
if (!this.writing) return;
// Replacing what someone wrote needs a yes.
if (
this.notes().trim() &&
this.notes() !== this.writing.notes &&
!confirm('¿Sustituir lo escrito por el borrador?')
)
return;
this.drafting.set(true);
this.facade
.releaseNotesDraft(this.writing.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (draft) => {
this.notes.set(draft);
this.drafting.set(false);
},
error: () => {
this.drafting.set(false);
this.toast.error('No se pudo generar el borrador');
},
});
}
protected saveNotes(): void {
if (!this.writing) return;
this.facade.writeReleaseNotes(this.writing.id, this.notes());
this.writing = null;
this.drawer.closeAll();
}
protected copyNotes(release: Release): void {
navigator.clipboard
?.writeText(release.notes ?? '')
.then(() => this.toast.success('Notas copiadas'))
.catch(() => this.toast.error('No se pudieron copiar'));
}
protected archive(release: Release): void {
this.facade.archiveRelease(release.id);
}