feat: follow an epic with everything under it

«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).

API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:59:04 +02:00
parent 179d6a5e94
commit d79ef418d3
31 changed files with 1072 additions and 62 deletions

View File

@@ -1,10 +1,13 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import {
PiEmptyStateComponent,
@@ -17,7 +20,8 @@ import {
ProjectFacade,
provideEpicsFacade,
} from '@task-stream/web/tracker/application';
import { Epic } from '@task-stream/web/tracker/domain';
import { ToastService } from '@task-stream/web/shared/ui';
import { Epic, IssuesGateway } from '@task-stream/web/tracker/domain';
import { statusLook, typeLook } from '../ui/copy';
/** /p/:key/epics — the big pieces of work and how far each has got. */
@@ -69,6 +73,22 @@ import { statusLook, typeLook } from '../ui/copy';
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
statusLabel(epic)
}}</span>
@if (project.myRole()) {
<button
type="button"
class="ts-epic__follow"
[class.is-on]="following(epic)"
[attr.aria-pressed]="following(epic)"
[attr.title]="
following(epic)
? 'Dejar de seguir esta épica y sus tickets'
: 'Avisarme de esta épica y de todos sus tickets, también los que se añadan'
"
(click)="toggleFollow(epic)"
>
{{ following(epic) ? 'Siguiendo todo' : 'Seguir todo' }}
</button>
}
</header>
<pi-progress-bar
[label]="summary(epic)"
@@ -86,6 +106,31 @@ import { statusLook, typeLook } from '../ui/copy';
}
`,
styles: `
/* A small pill, not @pi/ui's sized button. */
.ts-epic__follow {
flex: none;
height: auto !important;
min-width: 0 !important;
padding: 0.1875rem 0.625rem !important;
border: 1px solid var(--ts-line-strong);
border-radius: 999px;
background: transparent;
color: var(--ts-ink-soft);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ts-brand);
color: var(--ts-ink);
}
&.is-on {
border-color: transparent;
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
color: var(--ts-brand);
}
}
.ts-epics {
display: grid;
gap: 0.75rem;
@@ -120,7 +165,12 @@ import { statusLook, typeLook } from '../ui/copy';
})
export class EpicsPage {
protected readonly facade = inject(EpicsFacade);
private readonly project = inject(ProjectFacade);
protected readonly project = inject(ProjectFacade);
private readonly issues = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
/** Changed here since they loaded: epic id → following. */
private readonly changed = signal<ReadonlyMap<string, boolean>>(new Map());
constructor() {
effect(() => {
@@ -129,6 +179,32 @@ export class EpicsPage {
});
}
protected following(epic: Epic): boolean {
return this.changed().get(epic.issue.id) ?? epic.followingTree;
}
protected toggleFollow(epic: Epic): void {
const on = !this.following(epic);
const set = (value: boolean) =>
this.changed.update((m) => new Map(m).set(epic.issue.id, value));
set(on);
this.issues
.followTree(epic.issue.id, on)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () =>
this.toast.success(
on
? `Sigues ${epic.issue.key} y todo lo que cuelga de ella`
: `Ya no sigues ${epic.issue.key}`,
),
error: () => {
set(!on);
this.toast.error('No se pudo cambiar');
},
});
}
protected summary(epic: Epic): string {
const { finished, total, percent, storyPoints, finishedStoryPoints } =
epic.progress;

View File

@@ -206,6 +206,28 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (
project.myRole() &&
(facade.children().length || issue.followingTree)
) {
<pi-icon-button
icon="kanban"
color="grey"
appearance="outline"
[text]="
issue.followingTree
? 'Siguiendo todo · ' + facade.children().length
: 'Seguir todo'
"
[attr.aria-pressed]="issue.followingTree"
[attr.title]="
issue.followingTree
? 'Dejar de seguir lo que cuelga de este ticket'
: 'Avisarme de este ticket y de todo lo que cuelga de él, también lo que se añada'
"
(execute)="facade.setFollowingTree(!issue.followingTree)"
/>
}
@if (project.myRole()) {
<pi-icon-button
icon="pin"