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:
2026-10-15 18:27:46 +02:00
parent 8bf7d47b2e
commit affce615a7
35 changed files with 1603 additions and 80 deletions

View File

@@ -13,6 +13,7 @@ import {
IssuePriority,
ProjectAnalyticsReadModel,
ProjectInsightsReadModel,
PortfolioProjectReadModel,
ProjectReadModel,
ProjectRole,
ProjectSettings,
@@ -38,6 +39,7 @@ import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
import { GetPortfolioQuery } from './queries/get-portfolio.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
import { GetProjectQuery } from './queries/get-project.query';
@@ -337,6 +339,11 @@ export class ProjectsService {
}
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
/** All my active projects at a glance (not archived, not as a guest). */
portfolio(viewerId: string): Promise<PortfolioProjectReadModel[]> {
return this.queryBus.execute(new GetPortfolioQuery(viewerId));
}
/** The dashboard's reading; with `since`, what changed after it too. */
insights(
viewerId: string | null,

View File

@@ -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,
};
}
}

View File

@@ -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();
}
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
import { EpicReadModel } from './issue.read-model';
import { UserRefReadModel } from './user-ref.read-model';
/** A ticket named by the dashboard, with the number that put it there. */
@@ -189,3 +190,23 @@ export class ProjectInsightsReadModel {
/** One per active list (SELECT) custom field. */
fields!: FieldBreakdownReadModel[];
}
/** One of my projects at a glance, for the portfolio. */
export class PortfolioProjectReadModel {
projectId!: string;
key!: string;
name!: string;
isPublic!: boolean;
total!: number;
open!: number;
overdue!: number;
blocked!: number;
stuck!: number;
/** The sprint under way, if any. */
sprint!: SprintPulseReadModel | null;
/** Planned releases with their forecast. */
releases!: ReleaseForecastReadModel[];
/** Open epics (nearest due date first), at most six. */
epics!: EpicReadModel[];
updatedAt!: string;
}

View File

@@ -32,6 +32,7 @@ import {
CommandResultGql,
ProjectAnalyticsGql,
ProjectInsightsGql,
PortfolioProjectGql,
ProjectGql,
ProjectSettingsGql,
ProjectStatsGql,
@@ -47,6 +48,14 @@ export class ProjectResolver {
return this.projects.findMine(me.id);
}
@Query(() => [PortfolioProjectGql], {
description:
'My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics',
})
portfolio(@CurrentUser() me: User) {
return this.projects.portfolio(me.id);
}
@OptionalAuth()
@Query(() => ProjectGql, {
description: 'A project of mine, or any public one (no sign-in needed)',

View File

@@ -1073,3 +1073,27 @@ export class PresenceGql {
editing!: boolean;
@Field({ description: 'Last heartbeat' }) at!: string;
}
@ObjectType('PortfolioProject', {
description:
'One of my active projects at a glance: open work, risks, sprint, releases and epics',
})
export class PortfolioProjectGql {
@Field(() => ID) projectId!: string;
@Field() key!: string;
@Field() name!: string;
@Field() isPublic!: boolean;
@Field(() => Int) total!: number;
@Field(() => Int) open!: number;
@Field(() => Int) overdue!: number;
@Field(() => Int) blocked!: number;
@Field(() => Int) stuck!: number;
@Field(() => SprintPulseGql, { nullable: true })
sprint!: SprintPulseGql | null;
@Field(() => [ReleaseForecastGql]) releases!: ReleaseForecastGql[];
@Field(() => [EpicGql], {
description: 'Open epics, nearest due date first (six at most)',
})
epics!: EpicGql[];
@Field() updatedAt!: string;
}

View File

@@ -1140,3 +1140,24 @@ export class ProjectInsightsDto {
})
fields!: FieldBreakdownDto[];
}
export class PortfolioProjectDto {
@ApiProperty({ format: 'uuid' }) projectId!: string;
@ApiProperty({ example: 'WEB' }) key!: string;
@ApiProperty() name!: string;
@ApiProperty() isPublic!: boolean;
@ApiProperty() total!: number;
@ApiProperty() open!: number;
@ApiProperty() overdue!: number;
@ApiProperty() blocked!: number;
@ApiProperty() stuck!: number;
@ApiProperty({ type: SprintPulseDto, nullable: true })
sprint!: SprintPulseDto | null;
@ApiProperty({ type: [ReleaseForecastDto] }) releases!: ReleaseForecastDto[];
@ApiProperty({
type: [EpicDto],
description: 'Open epics, nearest due date first (six at most)',
})
epics!: EpicDto[];
@ApiProperty() updatedAt!: string;
}

View File

@@ -33,6 +33,7 @@ import {
CommentsService,
IssuesService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { withoutNulls } from '../common/issue-input';
@@ -63,6 +64,7 @@ import {
EpicDto,
IssueDto,
IssuePageDto,
PortfolioProjectDto,
PresenceDto,
} from './dto/responses.dto';
import { viewerOf } from '../common/viewer';
@@ -90,8 +92,33 @@ export class IssuesController {
private readonly comments: CommentsService,
private readonly refs: Refs,
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
) {}
@Get('me/portfolio')
@ApiOperation({
summary: 'My projects at a glance',
description:
'Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.',
})
@ApiOkResponse({ type: [PortfolioProjectDto] })
@ApiReadErrors()
async portfolio(@CurrentUser() me: User) {
const projects = await this.projects.portfolio(me.id);
// Epics as the REST API shows tickets (what blocks them, my vote…).
return Promise.all(
projects.map(async (p) => ({
...p,
epics: await Promise.all(
p.epics.map(async (e) => ({
...e,
issue: await this.refs.issueDto(e.issue, me.id),
})),
),
})),
);
}
@Put('issues/:issue/presence')
@ISSUE
@ApiOperation({