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:
@@ -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)',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user