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

@@ -6,7 +6,6 @@ import {
CommandResult,
CreateIssueInput,
IssueChanged,
IssueStatus,
} from '@task-stream/web/tracker/domain';
export const boardPageEvents = eventGroup({
@@ -17,7 +16,7 @@ export const boardPageEvents = eventGroup({
/** Optimistic: the card moves immediately and comes back if the API refuses. */
issueMoved: type<{
issueId: string;
to: IssueStatus;
to: string;
reason?: string | null;
}>(),
issueCreateRequested: type<CreateIssueInput>(),

View File

@@ -0,0 +1,23 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { IssuePage, Release } from '@task-stream/web/tracker/domain';
export const homePageEvents = eventGroup({
source: 'Home Page',
events: {
/** Loads (or reloads) "my work": also used to refresh when coming back. */
opened: type<void>(),
},
});
export const homeApiEvents = eventGroup({
source: 'Home API',
events: {
loaded: type<{
mine: IssuePage;
awaitingQa: IssuePage;
releases: Release[];
}>(),
loadFailed: type<string>(),
},
});

View File

@@ -5,8 +5,11 @@ import {
CreateReleaseInput,
MemberInput,
Project,
ProjectAnalytics,
ProjectSettings,
ProjectStats,
Release,
ReleaseBurnup,
UserDirectoryEntry,
} from '@task-stream/web/tracker/domain';
@@ -21,6 +24,12 @@ export const projectPageEvents = eventGroup({
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
userSearchRequested: type<string>(),
settingsSaveRequested: type<{
projectId: string;
settings: ProjectSettings;
}>(),
analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(),
},
});
@@ -35,6 +44,10 @@ export const projectApiEvents = eventGroup({
commandSucceeded: type<string>(),
commandFailed: type<string>(),
usersFound: type<UserDirectoryEntry[]>(),
analyticsLoaded: type<ProjectAnalytics>(),
analyticsFailed: type<string>(),
burnupLoaded: type<ReleaseBurnup>(),
settingsSaved: type<string>(),
},
});

View File

@@ -1,10 +1,6 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
BoardFilter,
CreateIssueInput,
IssueStatus,
} from '@task-stream/web/tracker/domain';
import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events';
import { BoardStore } from '../stores/board.store';
@@ -30,7 +26,7 @@ export class BoardFacade {
}
/** Optimistic: the card moves at once and comes back if the API refuses. */
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
moveIssue(issueId: string, to: string, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}

View File

@@ -6,7 +6,6 @@ import {
ActivityGateway,
CommentsGateway,
IssuesGateway,
IssueStatus,
Project,
ProjectRole,
ProjectsGateway,
@@ -19,12 +18,14 @@ import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
import { BoardFacade, provideBoardFacade } from './board.facade';
import { HomeFacade } from './home.facade';
import { IssueFacade, provideIssueFacade } from './issue.facade';
import { ProjectFacade, provideProjectFacade } from './project.facade';
const ok = { id: 'x', key: null, aggregateVersion: 1 };
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
const issue = { id: 'i1', key: 'WEB-1', links: [] };
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
/** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() {
@@ -33,6 +34,8 @@ function gateways() {
project: vi.fn(() => of(project)),
stats: vi.fn(() => NEVER),
addMember: vi.fn(() => of(ok)),
updateSettings: vi.fn(() => of(ok)),
analytics: vi.fn(() => of(analytics)),
},
issues: {
board: vi.fn(() => of([])),
@@ -127,6 +130,28 @@ describe('ProjectFacade', () => {
});
});
it('saves settings and loads statistics of the open project', () => {
const { project, fakes } = setup();
project.open('WEB');
const settings = {
statuses: [],
transitions: [],
issueTypes: [],
areas: [],
};
project.saveSettings(settings);
expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings);
expect(project.savingSettings()).toBe(false);
project.loadAnalytics(30);
expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30);
expect(project.analyticsDays()).toBe(30);
expect(project.analytics()).toBe(analytics);
// Unknown ids still get a readable name.
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
it('searches the directory from two characters', () => {
const { project } = setup();
const seen: string[] = [];
@@ -145,15 +170,71 @@ describe('IssueFacade', () => {
it('acts on the open ticket', () => {
const { issue, fakes } = setup();
issue.changeStatus(IssueStatus.IN_PROGRESS);
issue.changeStatus('IN_PROGRESS');
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
issue.open('WEB-1');
issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari');
issue.changeStatus('QA_FAILED', 'Falla en Safari');
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
issueId: 'i1',
status: IssueStatus.QA_FAILED,
status: 'QA_FAILED',
reason: 'Falla en Safari',
});
});
});
describe('HomeFacade', () => {
const issue = (key: string, status: string, statusCategory: string) =>
({ id: key, key, status, statusCategory, title: key }) as never;
it('loads my work and groups it by category, QA failures first', () => {
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
of(
filter.assignedToMe
? {
items: [
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
issue('WEB-4', 'BACKLOG', 'TODO'),
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
],
total: 3,
}
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
),
);
TestBed.configureTestingModule({
providers: [
{ provide: IssuesGateway, useValue: { mine } },
{
provide: ReleasesGateway,
useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) },
},
],
});
const home = TestBed.inject(HomeFacade);
home.load();
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({ assignedToMe: true }),
);
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({
assignedToMe: false,
categories: ['QA'],
}),
);
expect(home.loaded()).toBe(true);
expect(
home
.mineByCategory()
.map((g) => [g.category, g.issues.map((i) => i.key)]),
).toEqual([
['IN_PROGRESS', ['WEB-2', 'WEB-1']],
['TODO', ['WEB-4']],
]);
expect(home.qaFailed()).toBe(1);
expect(home.awaitingQaTotal()).toBe(1);
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
});
});

View File

@@ -0,0 +1,32 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { homePageEvents } from '../events/home.events';
import { HomeStore } from '../stores/home.store';
/** "My work" on the home page: my tickets, waiting for QA, upcoming releases. */
@Injectable({ providedIn: 'root' })
export class HomeFacade {
private readonly store = inject(HomeStore);
private readonly dispatch = injectDispatch(homePageEvents);
/** My open tickets (most recently updated first) and how many there are. */
readonly mine = this.store.mine;
readonly mineTotal = this.store.mineTotal;
/** The same, grouped by category (in progress, QA, pending), QA failures first. */
readonly mineByCategory = this.store.mineByCategory;
readonly qaFailed = this.store.qaFailed;
/** Tickets of my projects in a QA-category status (anyone's). */
readonly awaitingQa = this.store.awaitingQa;
readonly awaitingQaTotal = this.store.awaitingQaTotal;
/** Planned releases of my projects, soonest first. */
readonly releases = this.store.releases;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly loading = this.store.loading;
readonly error = this.store.error;
/** Loads my work; call again to refresh (e.g. when the tab regains focus). */
load(): void {
this.dispatch.opened();
}
}

View File

@@ -3,7 +3,6 @@ import { injectDispatch } from '@ngrx/signals/events';
import {
IssueLink,
IssueLinkType,
IssueStatus,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { issuePageEvents } from '../events/issue.events';
@@ -34,7 +33,7 @@ export class IssueFacade {
}
/** QA_FAILED and CANCELLED need a reason. */
changeStatus(status: IssueStatus, reason?: string | null): void {
changeStatus(status: string, reason?: string | null): void {
this.withIssue((issueId) =>
this.dispatch.statusChangeRequested({ issueId, status, reason }),
);

View File

@@ -3,6 +3,7 @@ import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
ProjectRole,
ProjectSettings,
} from '@task-stream/web/tracker/domain';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
@@ -30,6 +31,16 @@ export class ProjectFacade {
readonly canManage = this.store.canManage;
/** Any role but viewer. */
readonly canContribute = this.store.canContribute;
/** Statuses, ticket types and areas of this project (names, colours, lookups). */
readonly workflow = this.store.workflow;
readonly savingSettings = this.store.savingSettings;
/** Statistics of the last `analyticsDays()` days (see `loadAnalytics`). */
readonly analytics = this.store.analytics;
readonly analyticsDays = this.store.analyticsDays;
readonly analyticsLoading = this.store.analyticsLoading;
readonly analyticsError = this.store.analyticsError;
/** Burnup of the release asked for with `loadBurnup`. */
readonly burnup = this.store.burnup;
/** Loads the project (by id or key) and follows its changes. */
open(idOrKey: string): void {
@@ -74,6 +85,21 @@ export class ProjectFacade {
this.dispatch.releaseArchiveRequested({ releaseId });
}
/** Replaces statuses, transitions, types and areas (owners and admins). */
saveSettings(settings: ProjectSettings): void {
const projectId = this.projectId();
if (projectId) this.dispatch.settingsSaveRequested({ projectId, settings });
}
loadAnalytics(days = this.store.analyticsDays()): void {
const projectId = this.projectId();
if (projectId) this.dispatch.analyticsRequested({ projectId, days });
}
loadBurnup(releaseId: string): void {
this.dispatch.burnupRequested({ releaseId });
}
private projectId(): string | null {
return this.store.project()?.id ?? null;
}

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(