feat: per-project workflow settings, statistics and "my work" home

Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

@@ -1,13 +1,9 @@
import {
BoardColumn,
IssueStatus,
IssueSummary,
} from '@task-stream/web/tracker/domain';
import { BoardColumn, IssueSummary } from '@task-stream/web/tracker/domain';
import { applyIssueChange, moveIssue } from './board.reducers';
const issue = (
id: string,
status: IssueStatus,
status: string,
version = 1,
assignee: string | null = null,
): IssueSummary =>
@@ -27,55 +23,42 @@ const issue = (
const board = (): BoardColumn[] => [
{
status: IssueStatus.TODO,
status: 'TODO',
total: 1,
issues: [issue('a', IssueStatus.TODO)],
issues: [issue('a', 'TODO')],
},
{ status: IssueStatus.IN_PROGRESS, total: 0, issues: [] },
{ status: 'IN_PROGRESS', total: 0, issues: [] },
];
describe('board reducers', () => {
it('moves a card optimistically', () => {
const next = moveIssue(board(), 'a', IssueStatus.IN_PROGRESS);
const next = moveIssue(board(), 'a', 'IN_PROGRESS');
expect(next[0]).toMatchObject({ total: 0, issues: [] });
expect(next[1].issues[0]).toMatchObject({
id: 'a',
status: IssueStatus.IN_PROGRESS,
status: 'IN_PROGRESS',
});
expect(next[1].total).toBe(1);
});
it('applies newer realtime versions and ignores stale ones', () => {
const moved = applyIssueChange(
board(),
issue('a', IssueStatus.IN_PROGRESS, 2),
null,
);
const moved = applyIssueChange(board(), issue('a', 'IN_PROGRESS', 2), null);
expect(moved[1].issues.map((i) => i.id)).toEqual(['a']);
const stale = applyIssueChange(
moved,
issue('a', IssueStatus.TODO, 1),
null,
);
const stale = applyIssueChange(moved, issue('a', 'TODO', 1), null);
expect(stale).toBe(moved);
});
it('drops issues that no longer match the filter', () => {
const next = applyIssueChange(
board(),
issue('a', IssueStatus.TODO, 2, 'bob'),
{ projectId: 'p', assigneeId: 'ana' },
);
const next = applyIssueChange(board(), issue('a', 'TODO', 2, 'bob'), {
projectId: 'p',
assigneeId: 'ana',
});
expect(next[0].issues).toEqual([]);
});
it('adds new issues from other users', () => {
const next = applyIssueChange(
board(),
issue('b', IssueStatus.TODO, 1),
null,
);
const next = applyIssueChange(board(), issue('b', 'TODO', 1), null);
expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']);
});
});

View File

@@ -1,7 +1,6 @@
import {
BoardColumn,
BoardFilter,
IssueStatus,
IssueSummary,
} from '@task-stream/web/tracker/domain';
@@ -56,7 +55,7 @@ export function placeIssue(
export function moveIssue(
columns: BoardColumn[],
issueId: string,
to: IssueStatus,
to: string,
): BoardColumn[] {
const issue = findIssue(columns, issueId);
return issue

View File

@@ -0,0 +1,125 @@
import { computed, inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
IssuesGateway,
IssueSummary,
OPEN_CATEGORIES,
QA_FAILED,
Release,
ReleasesGateway,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { forkJoin, switchMap } from 'rxjs';
import { homeApiEvents, homePageEvents } from '../events/home.events';
import { describeError } from '../notifications';
/** Groups of "my work": what is moving first, then QA, then what is pending. */
const GROUP_ORDER = [
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
StatusCategory.TODO,
];
interface HomeState {
mine: IssueSummary[];
mineTotal: number;
awaitingQa: IssueSummary[];
awaitingQaTotal: number;
releases: Release[];
loaded: boolean;
loading: boolean;
error: string | null;
}
/**
* "My work" across every project I belong to: my open tickets, tickets
* waiting for QA and upcoming releases. Root-scoped, so coming back to the
* home page shows the last data at once while it refreshes.
*/
export const HomeStore = signalStore(
{ providedIn: 'root' },
withState<HomeState>({
mine: [],
mineTotal: 0,
awaitingQa: [],
awaitingQaTotal: 0,
releases: [],
loaded: false,
loading: false,
error: null,
}),
withComputed(({ mine }) => ({
/**
* My open tickets grouped by category (statuses are per project), QA
* failures first within their group.
*/
mineByCategory: computed(() =>
GROUP_ORDER.map((category) => ({
category,
issues: mine()
.filter((issue) => issue.statusCategory === category)
.sort(
(a, b) =>
Number(b.status === QA_FAILED) - Number(a.status === QA_FAILED),
),
})).filter((group) => group.issues.length > 0),
),
qaFailed: computed(
() => mine().filter((issue) => issue.status === QA_FAILED).length,
),
})),
withReducer(
on(homePageEvents.opened, () => ({ loading: true, error: null })),
on(homeApiEvents.loaded, ({ payload }) => ({
mine: payload.mine.items,
mineTotal: payload.mine.total,
awaitingQa: payload.awaitingQa.items,
awaitingQaTotal: payload.awaitingQa.total,
releases: payload.releases,
loaded: true,
loading: false,
})),
on(homeApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
),
withEventHandlers(
(
_store,
events = inject(Events),
issues = inject(IssuesGateway),
releases = inject(ReleasesGateway),
) => ({
load$: events.on(homePageEvents.opened).pipe(
switchMap(() =>
forkJoin({
mine: issues.mine({
assignedToMe: true,
categories: [...OPEN_CATEGORIES],
limit: 50,
}),
awaitingQa: issues.mine({
assignedToMe: false,
categories: [StatusCategory.QA],
limit: 20,
}),
releases: releases.upcoming(5),
}).pipe(
mapResponse({
next: (data) => homeApiEvents.loaded(data),
error: (error) => homeApiEvents.loadFailed(describeError(error)),
}),
),
),
),
}),
),
);

View File

@@ -12,14 +12,17 @@ import {
Activity,
ActivityGateway,
Project,
ProjectAnalytics,
ProjectRole,
ProjectsGateway,
ProjectStats,
Release,
ReleaseBurnup,
ReleasesGateway,
TrackerRealtimeGateway,
UserDirectoryEntry,
UsersGateway,
Workflow,
} from '@task-stream/web/tracker/domain';
import {
debounceTime,
@@ -46,6 +49,13 @@ interface ProjectState {
releases: Release[];
activity: Activity[];
userResults: UserDirectoryEntry[];
analytics: ProjectAnalytics | null;
analyticsDays: number;
analyticsLoading: boolean;
analyticsError: string | null;
burnup: ReleaseBurnup | null;
/** True while settings are being saved (until the project comes back changed). */
savingSettings: boolean;
loading: boolean;
error: string | null;
}
@@ -58,6 +68,12 @@ export const ProjectStore = signalStore(
releases: [],
activity: [],
userResults: [],
analytics: null,
analyticsDays: 90,
analyticsLoading: false,
analyticsError: null,
burnup: null,
savingSettings: false,
loading: false,
error: null,
}),
@@ -76,6 +92,8 @@ export const ProjectStore = signalStore(
() => !!myRole() && myRole() !== ProjectRole.VIEWER,
),
members: computed(() => project()?.members ?? []),
/** Statuses, types and areas of the project, with lookups. */
workflow: computed(() => new Workflow(project()?.settings)),
};
}),
withReducer(
@@ -86,12 +104,42 @@ export const ProjectStore = signalStore(
stats: null,
releases: [],
activity: [],
analytics: null,
analyticsError: null,
burnup: null,
})),
on(projectApiEvents.loaded, ({ payload }) => ({
project: payload,
loading: false,
})),
on(projectRealtimeEvents.projectChanged, ({ payload }) => ({
project: payload,
loading: false,
savingSettings: false,
})),
on(projectPageEvents.settingsSaveRequested, () => ({
savingSettings: true,
})),
on(
projectApiEvents.loaded,
projectRealtimeEvents.projectChanged,
({ payload }) => ({ project: payload, loading: false }),
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
() => ({ savingSettings: false }),
),
on(projectPageEvents.analyticsRequested, ({ payload }) => ({
analyticsDays: payload.days,
analyticsLoading: true,
analyticsError: null,
})),
on(projectApiEvents.analyticsLoaded, ({ payload }) => ({
analytics: payload,
analyticsLoading: false,
})),
on(projectApiEvents.analyticsFailed, ({ payload }) => ({
analyticsLoading: false,
analyticsError: payload,
})),
on(projectPageEvents.burnupRequested, () => ({ burnup: null })),
on(projectApiEvents.burnupLoaded, ({ payload }) => ({ burnup: payload })),
on(projectApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
@@ -204,6 +252,28 @@ export const ProjectStore = signalStore(
return id ? reloadReleases(id) : [];
}),
),
analytics$: events.on(projectPageEvents.analyticsRequested).pipe(
switchMap(({ payload }) =>
projects.analytics(payload.projectId, payload.days).pipe(
mapResponse({
next: (stats) => projectApiEvents.analyticsLoaded(stats),
error: (error) =>
projectApiEvents.analyticsFailed(describeError(error)),
}),
),
),
),
burnup$: events.on(projectPageEvents.burnupRequested).pipe(
switchMap(({ payload }) =>
releases.burnup(payload.releaseId).pipe(
mapResponse({
next: (burnup) => projectApiEvents.burnupLoaded(burnup),
error: (error) =>
projectApiEvents.commandFailed(describeError(error)),
}),
),
),
),
/** Pireon directory search (to add members). */
searchUsers$: events.on(projectPageEvents.userSearchRequested).pipe(
debounceTime(250),
@@ -253,6 +323,18 @@ export const ProjectStore = signalStore(
),
),
),
events
.on(projectPageEvents.settingsSaveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.updateSettings(payload.projectId, payload.settings),
'Configuración guardada',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releaseCreateRequested)
.pipe(