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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
125
libs/web/tracker/application/src/lib/stores/home.store.ts
Normal file
125
libs/web/tracker/application/src/lib/stores/home.store.ts
Normal 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)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user