feat: portfolio of all my projects
/cartera (and GraphQL portfolio, REST /api/me/portfolio, MCP get_portfolio) shows every active project I belong to (not archived, not as a guest) at a glance: open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and the open epics with their progress. It reuses the dashboard's computations. A release with no tickets now reads «Sin tickets» instead of «Todo hecho», here and on the dashboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
PortfolioProjectReadModel,
|
||||
ProjectReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights } from '../analytics/insights';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { GetPortfolioQuery } from './get-portfolio.query';
|
||||
import { guestOnly } from './read-access';
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
/** Epics shown per project. */
|
||||
const EPICS = 6;
|
||||
|
||||
@QueryHandler(GetPortfolioQuery)
|
||||
export class GetPortfolioQueryHandler
|
||||
implements IQueryHandler<GetPortfolioQuery, PortfolioProjectReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetPortfolioQuery,
|
||||
): Promise<PortfolioProjectReadModel[]> {
|
||||
const projects = (await this.findProjects.findByMember(query.viewerId))
|
||||
.filter((p) => !p.archivedAt && !guestOnly(p, query.viewerId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
||||
return Promise.all(projects.map((p) => this.summary(p)));
|
||||
}
|
||||
|
||||
private async summary(
|
||||
project: ProjectReadModel,
|
||||
): Promise<PortfolioProjectReadModel> {
|
||||
const [issues, timeline, sprints, releases, epics] = await Promise.all([
|
||||
this.findIssues.findByProject(project.id),
|
||||
this.findTimeline.findByProject(project.id),
|
||||
this.findSprints.findByProject(project.id),
|
||||
this.findReleases.findByProject(project.id),
|
||||
this.findIssues.epics(project.id),
|
||||
]);
|
||||
const reading = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
settings: project.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
activeSprint:
|
||||
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||
sprints,
|
||||
releases,
|
||||
members: project.members,
|
||||
since: null,
|
||||
mentions: 0,
|
||||
now: new Date(),
|
||||
});
|
||||
const live = issues.filter((i) => !i.trashedAt);
|
||||
return {
|
||||
projectId: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
isPublic: project.isPublic,
|
||||
total: live.length,
|
||||
open: live.filter((i) => !FINISHED.includes(i.statusCategory)).length,
|
||||
overdue: reading.risks.overdueCount,
|
||||
blocked: reading.risks.blockedCount,
|
||||
stuck: reading.risks.stuckCount,
|
||||
sprint: reading.sprint,
|
||||
releases: reading.releases,
|
||||
epics: epics
|
||||
.filter(
|
||||
(e) =>
|
||||
!FINISHED.includes(e.issue.statusCategory) && !e.issue.trashedAt,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(a.issue.dueDate ?? '9999').localeCompare(b.issue.dueDate ?? '9999'),
|
||||
)
|
||||
.slice(0, EPICS),
|
||||
updatedAt: project.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { PortfolioProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* Every active project I take full part in (not archived, not as a guest),
|
||||
* at a glance: open work, risks, sprint, releases and epics.
|
||||
*/
|
||||
export class GetPortfolioQuery extends Query<PortfolioProjectReadModel[]> {
|
||||
constructor(public readonly viewerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from './get-my-projects.query';
|
||||
export * from './get-project-analytics.query';
|
||||
export * from './get-project-insights.query';
|
||||
export * from './get-ticket-scope.query';
|
||||
export * from './get-portfolio.query';
|
||||
export * from './get-project-stats.query';
|
||||
export * from './get-release-burnup.query';
|
||||
export * from './get-project.query';
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GetProjectQueryHandler } from './get-project.query-handler';
|
||||
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
||||
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
||||
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
|
||||
import { GetPortfolioQueryHandler } from './get-portfolio.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';
|
||||
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
|
||||
GetProjectAnalyticsQueryHandler,
|
||||
GetProjectInsightsQueryHandler,
|
||||
GetTicketScopeQueryHandler,
|
||||
GetPortfolioQueryHandler,
|
||||
GetProjectStatsQueryHandler,
|
||||
GetReleaseBurnupQueryHandler,
|
||||
GetReleaseQueryHandler,
|
||||
|
||||
Reference in New Issue
Block a user