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,41 +1,49 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueStatus } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { BoardColumn, GetBoardQuery } from './get-board.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
const BOARD_COLUMNS: IssueStatus[] = [
|
||||
IssueStatus.BACKLOG,
|
||||
IssueStatus.TODO,
|
||||
IssueStatus.IN_PROGRESS,
|
||||
IssueStatus.IN_REVIEW,
|
||||
IssueStatus.READY_FOR_QA,
|
||||
IssueStatus.IN_QA,
|
||||
IssueStatus.QA_FAILED,
|
||||
IssueStatus.DONE,
|
||||
];
|
||||
|
||||
@QueryHandler(GetBoardQuery)
|
||||
export class GetBoardQueryHandler
|
||||
implements IQueryHandler<GetBoardQuery, BoardColumn[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Columns follow the project's statuses in order. Cancelled ones stay off the
|
||||
* board; archived ones only show while they still hold tickets.
|
||||
*/
|
||||
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
|
||||
await this.access.assertCanView(query.filter.projectId, query.viewerId);
|
||||
return Promise.all(
|
||||
BOARD_COLUMNS.map(async (status) => {
|
||||
const project = await this.findProjects.findById(query.filter.projectId);
|
||||
const statuses = (
|
||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS
|
||||
).statuses.filter((status) => status.category !== StatusCategory.CANCELLED);
|
||||
const columns = await Promise.all(
|
||||
statuses.map(async (status) => {
|
||||
const page = await this.findIssues.search({
|
||||
...query.filter,
|
||||
statuses: [status],
|
||||
statuses: [status.id],
|
||||
limit: Math.min(query.perColumn, 200),
|
||||
offset: 0,
|
||||
});
|
||||
return { status, issues: page.items, total: page.total };
|
||||
return {
|
||||
column: { status: status.id, issues: page.items, total: page.total },
|
||||
archived: status.archived,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return columns
|
||||
.filter(({ column, archived }) => !archived || column.total > 0)
|
||||
.map(({ column }) => column);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { IssueFilter } from '../ports/find-issues.repository';
|
||||
|
||||
export interface BoardColumn {
|
||||
status: IssueStatus;
|
||||
/** Status id of the project's workflow. */
|
||||
status: string;
|
||||
issues: IssueReadModel[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Kanban board: one column per status. */
|
||||
/** Kanban board: one column per status of the project (cancelled ones aside). */
|
||||
export class GetBoardQuery extends Query<BoardColumn[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
ProjectAnalyticsReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeProjectAnalytics } from '../analytics/analytics';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { GetProjectAnalyticsQuery } from './get-project-analytics.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
@QueryHandler(GetProjectAnalyticsQuery)
|
||||
export class GetProjectAnalyticsQueryHandler
|
||||
implements IQueryHandler<GetProjectAnalyticsQuery, ProjectAnalyticsReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetProjectAnalyticsQuery,
|
||||
): Promise<ProjectAnalyticsReadModel> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline] = await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
this.findTimeline.findByProject(query.projectId),
|
||||
]);
|
||||
const days = Math.min(Math.max(Math.round(query.days), 7), 365);
|
||||
const to = new Date();
|
||||
return computeProjectAnalytics({
|
||||
issues,
|
||||
timeline,
|
||||
statusOrder: (project?.settings ?? DEFAULT_PROJECT_SETTINGS).statuses.map(
|
||||
(status) => status.id,
|
||||
),
|
||||
from: new Date(to.getTime() - (days - 1) * DAY),
|
||||
to,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ProjectAnalyticsReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Statistics of a project over the last `days` days. */
|
||||
export class GetProjectAnalyticsQuery extends Query<ProjectAnalyticsReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly days: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { computeReleaseBurnup } from '../analytics/analytics';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { GetReleaseBurnupQuery } from './get-release-burnup.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
@QueryHandler(GetReleaseBurnupQuery)
|
||||
export class GetReleaseBurnupQueryHandler
|
||||
implements IQueryHandler<GetReleaseBurnupQuery, ReleaseBurnupReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReleaseBurnupQuery): Promise<ReleaseBurnupReadModel> {
|
||||
const release = this.access.assertFound(
|
||||
await this.findReleases.findById(query.releaseId),
|
||||
'Release',
|
||||
query.releaseId,
|
||||
);
|
||||
await this.access.assertCanView(release.projectId, query.viewerId);
|
||||
const [issues, timeline] = await Promise.all([
|
||||
this.findIssues.findByProject(release.projectId),
|
||||
this.findTimeline.findByProject(release.projectId),
|
||||
]);
|
||||
return computeReleaseBurnup({ release, issues, timeline, now: new Date() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Scope vs finished tickets of a release, day by day. */
|
||||
export class GetReleaseBurnupQuery extends Query<ReleaseBurnupReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { GetUpcomingReleasesQuery } from './get-upcoming-releases.query';
|
||||
|
||||
@QueryHandler(GetUpcomingReleasesQuery)
|
||||
export class GetUpcomingReleasesQueryHandler
|
||||
implements IQueryHandler<GetUpcomingReleasesQuery, ReleaseReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
|
||||
const projects = await this.findProjects.findByMember(query.viewerId);
|
||||
return this.findReleases.findUpcoming(
|
||||
projects.map((project) => project.id),
|
||||
Math.min(query.limit, 50),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Planned releases of the viewer's projects, soonest first. */
|
||||
export class GetUpcomingReleasesQuery extends Query<ReleaseReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly limit: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,13 @@ export * from './get-comments.query';
|
||||
export * from './get-issue.query';
|
||||
export * from './get-issues-by-ids.query';
|
||||
export * from './get-my-projects.query';
|
||||
export * from './get-project-analytics.query';
|
||||
export * from './get-project-stats.query';
|
||||
export * from './get-release-burnup.query';
|
||||
export * from './get-project.query';
|
||||
export * from './get-release-progress.query';
|
||||
export * from './get-release.query';
|
||||
export * from './get-releases.query';
|
||||
export * from './get-upcoming-releases.query';
|
||||
export * from './search-issues.query';
|
||||
export * from './search-my-issues.query';
|
||||
|
||||
@@ -6,11 +6,15 @@ import { GetIssueQueryHandler } from './get-issue.query-handler';
|
||||
import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
|
||||
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
|
||||
import { GetProjectQueryHandler } from './get-project.query-handler';
|
||||
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
||||
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
||||
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
||||
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
||||
import { GetReleaseProgressQueryHandler } from './get-release-progress.query-handler';
|
||||
import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
||||
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
||||
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
CanViewProjectQueryHandler,
|
||||
@@ -21,9 +25,13 @@ export const QUERY_HANDLERS = [
|
||||
GetIssuesByIdsQueryHandler,
|
||||
GetMyProjectsQueryHandler,
|
||||
GetProjectQueryHandler,
|
||||
GetProjectAnalyticsQueryHandler,
|
||||
GetProjectStatsQueryHandler,
|
||||
GetReleaseBurnupQueryHandler,
|
||||
GetReleaseQueryHandler,
|
||||
GetReleaseProgressQueryHandler,
|
||||
GetReleasesQueryHandler,
|
||||
GetUpcomingReleasesQueryHandler,
|
||||
SearchIssuesQueryHandler,
|
||||
SearchMyIssuesQueryHandler,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { SearchMyIssuesQuery } from './search-my-issues.query';
|
||||
|
||||
@QueryHandler(SearchMyIssuesQuery)
|
||||
export class SearchMyIssuesQueryHandler
|
||||
implements IQueryHandler<SearchMyIssuesQuery, Page<IssueReadModel>>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
/** Membership is the access rule: only projects the viewer belongs to. */
|
||||
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> {
|
||||
const projects = await this.findProjects.findByMember(query.viewerId);
|
||||
return this.findIssues.searchAcross({
|
||||
projectIds: projects.map((project) => project.id),
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: query.filter.categories,
|
||||
limit: Math.min(query.filter.limit, 200),
|
||||
offset: query.filter.offset,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Page } from '../ports/find-issues.repository';
|
||||
|
||||
export interface MyIssuesFilter {
|
||||
/** true: assigned to the viewer. false: any ticket of the viewer's projects. */
|
||||
assignedToMe: boolean;
|
||||
statuses?: string[];
|
||||
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
||||
categories?: StatusCategory[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Tickets across every project the viewer is a member of ("my work"). */
|
||||
export class SearchMyIssuesQuery extends Query<Page<IssueReadModel>> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly filter: MyIssuesFilter,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user