feat: epics, subtasks and checklists

Tickets can hang from another one of the same project, three levels deep
at most (epic → ticket → subtask). The ticket page lists its subtasks with
their progress, creates new ones and moves the ticket under an epic; a new
Épicas tab shows every epic and how far it has got. Each ticket also gets a
checklist for the small steps that don't deserve a ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View File

@@ -0,0 +1,66 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
Epic,
IssuesGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { debounceTime, startWith, switchMap } from 'rxjs';
import { epicsApiEvents, epicsPageEvents } from '../events/epics.events';
import { describeError } from '../notifications';
interface EpicsState {
epics: Epic[];
loaded: boolean;
error: string | null;
}
/** A project's epics and their progress, live. Scoped to the epics view. */
export const EpicsStore = signalStore(
withState<EpicsState>({ epics: [], loaded: false, error: null }),
withReducer(
on(epicsPageEvents.opened, () => ({ loaded: false, error: null })),
on(epicsApiEvents.loaded, ({ payload }) => ({
epics: payload,
loaded: true,
})),
on(epicsApiEvents.loadFailed, ({ payload }) => ({
loaded: true,
error: payload,
})),
),
withEventHandlers(
(
_store,
events = inject(Events),
issues = inject(IssuesGateway),
realtime = inject(TrackerRealtimeGateway),
) => ({
// Any ticket of the project may move an epic's progress: reload, calmly.
load$: events.on(epicsPageEvents.opened).pipe(
switchMap(({ payload }) =>
realtime.issueChanges(payload.projectId).pipe(
debounceTime(500),
startWith(null),
switchMap(() =>
issues.epics(payload.projectId).pipe(
mapResponse({
next: (list) => epicsApiEvents.loaded(list),
error: (error) =>
epicsApiEvents.loadFailed(describeError(error)),
}),
),
),
),
),
),
}),
),
);

View File

@@ -14,8 +14,11 @@ import {
AttachmentsGateway,
Comment,
CommentsGateway,
checklistProgress,
childProgress,
Issue,
IssuesGateway,
IssueSummary,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
@@ -45,6 +48,8 @@ interface IssueState {
issue: Issue | null;
comments: Comment[];
history: Activity[];
/** Tickets hanging from this one. */
children: IssueSummary[];
loading: boolean;
error: string | null;
}
@@ -55,10 +60,15 @@ export const IssueStore = signalStore(
issue: null,
comments: [],
history: [],
children: [],
loading: false,
error: null,
}),
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
withComputed(({ issue, children }, auth = inject(AuthFacade)) => ({
childProgress: computed(() => childProgress(children())),
checklistProgress: computed(() =>
checklistProgress(issue()?.checklist ?? []),
),
me: computed(() => auth.user()?.id ?? null),
watching: computed(
() =>
@@ -79,6 +89,7 @@ export const IssueStore = signalStore(
issue: null,
comments: [],
history: [],
children: [],
})),
on(issueApiEvents.loaded, ({ payload }) => ({
issue: payload,
@@ -90,6 +101,7 @@ export const IssueStore = signalStore(
})),
on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })),
on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })),
on(issueApiEvents.childrenLoaded, ({ payload }) => ({ children: payload })),
on(issueRealtimeEvents.commentChanged, ({ payload }, state) => {
const others = state.comments.filter((c) => c.id !== payload.commentId);
if (!payload.comment) return { comments: others };
@@ -181,6 +193,44 @@ export const IssueStore = signalStore(
),
),
),
children$: merge(
loaded$.pipe(
map(({ payload }) => payload),
filter(
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
),
),
events.on(issueRealtimeEvents.childrenTouched).pipe(
debounceTime(200),
map(() => store.issue()),
filter((issue): issue is Issue => !!issue),
),
).pipe(
switchMap((issue) =>
issues
.children(issue.projectId, issue.id)
.pipe(map((list) => issueApiEvents.childrenLoaded(list))),
),
),
/** A ticket under this one changed, or one moved in or out. */
liveChildren$: events.on(issuePageEvents.opened).pipe(
switchMap(() => loaded$.pipe(map(({ payload }) => payload))),
filter(
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
),
switchMap((issue) =>
realtime.issueChanges(issue.projectId).pipe(
filter(
(change) =>
change.issue.parent?.id === issue.id ||
store.children().some((c) => c.id === change.issue.id),
),
map(() =>
issueRealtimeEvents.childrenTouched({ issueId: issue.id }),
),
),
),
),
refetch$: events.on(issueRealtimeEvents.issueTouched).pipe(
debounceTime(150),
switchMap(({ payload }) => fetchIssue(payload.issueId)),
@@ -242,6 +292,82 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.parentRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
payload.parentKey
? issues
.issue(payload.parentKey.trim().toUpperCase())
.pipe(
switchMap((parent) =>
issues.setParent(payload.issueId, parent.id),
),
)
: issues.setParent(payload.issueId, null),
payload.parentKey
? `Ahora cuelga de ${payload.parentKey.trim().toUpperCase()}`
: 'Ya no cuelga de ningún ticket',
done,
failed,
),
),
),
events
.on(issuePageEvents.subtaskCreateRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.create(payload),
'Subtarea creada',
done,
failed,
),
),
),
events
.on(issuePageEvents.checklistAddRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.addChecklistItem(payload.issueId, payload.text),
'Paso añadido',
done,
failed,
),
),
),
events.on(issuePageEvents.checklistUpdateRequested).pipe(
mergeMap(({ payload }) =>
issues
.updateChecklistItem(
payload.issueId,
payload.itemId,
payload.changes,
)
.pipe(
// Ticking a step is not worth a toast; only failures are.
mapResponse({
next: () => issueApiEvents.checklistSaved(),
error: (error) =>
issueApiEvents.commandFailed(describeError(error)),
}),
),
),
),
events
.on(issuePageEvents.checklistRemoveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.removeChecklistItem(payload.issueId, payload.itemId),
'Paso eliminado',
done,
failed,
),
),
),
events
.on(issuePageEvents.linkRequested)
.pipe(