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:
@@ -7,9 +7,11 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades.
|
||||
|
||||
## Contenido
|
||||
|
||||
- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
|
||||
- Facades (lo que se exporta): `ProjectsFacade` y `HomeFacade` («Mi trabajo») (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
|
||||
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
|
||||
- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen
|
||||
- `ProjectFacade` también da la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas
|
||||
(`loadAnalytics`, `loadBurnup`).
|
||||
- Interno: stores `ProjectsStore`, `ProjectStore`, `HomeStore`, `BoardStore`, `IssueStore` y eventos por origen
|
||||
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
|
||||
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
|
||||
- Puerto `Notifications`.
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './lib/facades/projects.facade';
|
||||
export * from './lib/facades/project.facade';
|
||||
export * from './lib/facades/board.facade';
|
||||
export * from './lib/facades/issue.facade';
|
||||
export * from './lib/facades/home.facade';
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
23
libs/web/tracker/application/src/lib/events/home.events.ts
Normal file
23
libs/web/tracker/application/src/lib/events/home.events.ts
Normal 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>(),
|
||||
},
|
||||
});
|
||||
@@ -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>(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
32
libs/web/tracker/application/src/lib/facades/home.facade.ts
Normal file
32
libs/web/tracker/application/src/lib/facades/home.facade.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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