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({

View File

@@ -12,6 +12,7 @@ export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade';
export * from './lib/facades/my-views.facade';
export * from './lib/facades/presence.facade';
export * from './lib/facades/portfolio.facade';
export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,18 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { PortfolioProject } from '@task-stream/web/tracker/domain';
export const portfolioPageEvents = eventGroup({
source: 'Portfolio Page',
events: {
opened: type<void>(),
},
});
export const portfolioApiEvents = eventGroup({
source: 'Portfolio API',
events: {
loaded: type<PortfolioProject[]>(),
loadFailed: type<string>(),
},
});

View File

@@ -0,0 +1,27 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { portfolioPageEvents } from '../events/portfolio.events';
import { PortfolioStore } from '../stores/portfolio.store';
/** «Cartera»: every active project of mine at a glance. */
@Injectable()
export class PortfolioFacade {
private readonly store = inject(PortfolioStore);
private readonly dispatch = injectDispatch(portfolioPageEvents);
readonly projects = this.store.projects;
/** 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 it (again, to refresh). */
load(): void {
this.dispatch.opened();
}
}
/** Store + facade of the portfolio page. */
export function providePortfolioFacade(): Provider[] {
return [PortfolioStore, PortfolioFacade];
}

View File

@@ -0,0 +1,64 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
PortfolioProject,
ProjectsGateway,
} from '@task-stream/web/tracker/domain';
import { switchMap } from 'rxjs';
import {
portfolioApiEvents,
portfolioPageEvents,
} from '../events/portfolio.events';
import { describeError } from '../notifications';
interface PortfolioState {
projects: PortfolioProject[];
loaded: boolean;
loading: boolean;
error: string | null;
}
/** All my active projects at a glance. Scoped to the portfolio page. */
export const PortfolioStore = signalStore(
withState<PortfolioState>({
projects: [],
loaded: false,
loading: false,
error: null,
}),
withReducer(
on(portfolioPageEvents.opened, () => ({ loading: true, error: null })),
on(portfolioApiEvents.loaded, ({ payload }) => ({
projects: payload,
loaded: true,
loading: false,
})),
on(portfolioApiEvents.loadFailed, ({ payload }) => ({
loaded: true,
loading: false,
error: payload,
})),
),
withEventHandlers(
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
load$: events.on(portfolioPageEvents.opened).pipe(
switchMap(() =>
projects.portfolio().pipe(
mapResponse({
next: (list) => portfolioApiEvents.loaded(list),
error: (error) =>
portfolioApiEvents.loadFailed(describeError(error)),
}),
),
),
),
}),
),
);

View File

@@ -1,5 +1,5 @@
import { IssuePriority } from './enums';
import { UserRef } from './models';
import { Epic, UserRef } from './models';
/** A ticket the dashboard names, with the number that put it there. */
export interface InsightIssue {
@@ -141,3 +141,21 @@ export interface ProjectInsights {
quality: Quality;
fields: FieldBreakdown[];
}
/** One of my active projects at a glance. */
export interface PortfolioProject {
projectId: string;
key: string;
name: string;
isPublic: boolean;
total: number;
open: number;
overdue: number;
blocked: number;
stuck: number;
sprint: SprintPulse | null;
releases: ReleaseForecast[];
/** Open epics, nearest due date first. */
epics: Epic[];
updatedAt: string;
}

View File

@@ -1,4 +1,4 @@
import { ProjectInsights } from '../models/insights';
import { PortfolioProject, ProjectInsights } from '../models/insights';
import { Observable } from 'rxjs';
import {
Activity,
@@ -67,6 +67,8 @@ export abstract class ProjectsGateway {
abstract publicProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
/** My active projects at a glance. */
abstract portfolio(): Observable<PortfolioProject[]>;
/** The dashboard; with `since`, also what changed after it. */
abstract insights(
projectId: string,

View File

@@ -6,6 +6,7 @@ import {
Project,
ProjectAnalytics,
ProjectInsights,
PortfolioProject,
ProjectSettings,
ProjectsGateway,
ProjectStats,
@@ -22,6 +23,7 @@ import {
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectInsightsDocument,
PortfolioDocument,
ProjectDocument,
ProjectStatsDocument,
PublicProjectsDocument,
@@ -32,6 +34,7 @@ import {
import { GraphqlOperations } from './graphql-operations';
import {
toCommandResult,
toIssueSummary,
toProject,
toSettingsInput,
toStats,
@@ -65,6 +68,21 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
portfolio(): Observable<PortfolioProject[]> {
return this.gql.query(PortfolioDocument, {}).pipe(
map((d) =>
d.portfolio.map((p) => ({
...(p as unknown as PortfolioProject),
epics: p.epics.map((e) => ({
issue: toIssueSummary(e.issue),
progress: { ...e.progress },
links: [],
})),
})),
),
);
}
insights(
projectId: string,
since: string | null,

View File

@@ -2062,6 +2062,86 @@ export type ProjectInsightsQuery = {
};
};
export type PortfolioQueryVariables = Exact<{ [key: string]: never }>;
export type PortfolioQuery = {
portfolio: Array<{
projectId: string;
key: string;
name: string;
isPublic: boolean;
total: number;
open: number;
overdue: number;
blocked: number;
stuck: number;
updatedAt: string;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
} | null;
releases: Array<{
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
likelyDate: string | null;
safeDate: string | null;
verdict: string;
}>;
epics: Array<{
issue: {
id: string;
projectId: string;
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
labels: Array<string>;
commentCount: number;
updatedAt: string;
version: number;
votes: number;
voted: boolean;
rank: string;
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
progress: {
total: number;
finished: number;
percent: number;
storyPoints: number;
finishedStoryPoints: number;
};
}>;
}>;
};
export type ReleasesQueryVariables = Exact<{
idOrKey: string;
}>;
@@ -12307,6 +12387,309 @@ export const ProjectInsightsDocument = {
ProjectInsightsQuery,
ProjectInsightsQueryVariables
>;
export const PortfolioDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Portfolio' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'portfolio' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{ kind: 'Field', name: { kind: 'Name', value: 'open' } },
{ kind: 'Field', name: { kind: 'Name', value: 'overdue' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocked' } },
{ kind: 'Field', name: { kind: 'Name', value: 'stuck' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releases' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'targetDate' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'likelyDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'safeDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'verdict' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'epics' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issue' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'IssueSummaryFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'progress' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'total' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'percent' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'storyPoints' },
},
{
kind: 'Field',
name: {
kind: 'Name',
value: 'finishedStoryPoints',
},
},
],
},
},
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueSummaryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Issue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blockedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'votes' } },
{ kind: 'Field', name: { kind: 'Name', value: 'voted' } },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
],
} as unknown as DocumentNode<PortfolioQuery, PortfolioQueryVariables>;
export const ReleasesDocument = {
kind: 'Document',
definitions: [

View File

@@ -299,3 +299,55 @@ query ProjectInsights($idOrKey: String!, $since: String) {
}
}
}
query Portfolio {
portfolio {
projectId
key
name
isPublic
total
open
overdue
blocked
stuck
updatedAt
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
releases {
releaseId
name
targetDate
total
finished
likelyDate
safeDate
verdict
}
epics {
issue {
...IssueSummaryFields
}
progress {
total
finished
percent
storyPoints
finishedStoryPoints
}
}
}
}

View File

@@ -0,0 +1,345 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiEmptyStateComponent,
PiNoteComponent,
PiSkeletonComponent,
} from '@pi/ui';
import {
PortfolioFacade,
providePortfolioFacade,
} from '@task-stream/web/tracker/application';
import { ReleaseForecast, SprintPulse } from '@task-stream/web/tracker/domain';
import { projectTone } from '../ui/copy';
import { verdictOf } from '../ui/insights/release-forecast';
const short = (day: string | null) =>
day
? new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
})
: '—';
/** «Cartera»: every active project of mine at a glance. */
@Component({
selector: 'ts-portfolio-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
PiEmptyStateComponent,
PiNoteComponent,
PiSkeletonComponent,
],
providers: [providePortfolioFacade()],
template: `
<header class="ts-page-header">
<div>
<p class="ts-eyebrow">Pireon · TaskStream</p>
<h1>Cartera</h1>
<p>
Tus proyectos activos de un vistazo: lo abierto, lo que está en
riesgo, el sprint en curso, las próximas releases y las épicas.
</p>
</div>
<button
type="button"
class="ts-secondary"
[disabled]="portfolio.loading()"
(click)="portfolio.load()"
>
{{ portfolio.loading() ? 'Actualizando…' : 'Actualizar' }}
</button>
</header>
@if (portfolio.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo cargar la cartera"
[text]="error"
/>
} @else if (!portfolio.loaded()) {
<div class="ts-portfolio">
<pi-skeleton shape="block" height="16rem" />
<pi-skeleton shape="block" height="16rem" />
</div>
} @else if (!portfolio.projects().length) {
<pi-empty-state
icon="folder"
title="Ningún proyecto activo"
description="Aquí salen los proyectos de los que eres miembro (no los archivados ni en los que eres invitado)."
/>
} @else {
<div class="ts-portfolio">
@for (p of portfolio.projects(); track p.projectId) {
<article
class="ts-panel ts-portfolio__card"
[style.--tone]="tone(p.key)"
[attr.aria-labelledby]="'pf-' + p.projectId"
>
<header class="ts-portfolio__head">
<a
class="ts-portfolio__key"
[routerLink]="['/p', p.key, 'dashboard']"
>{{ p.key }}</a
>
<h2 [id]="'pf-' + p.projectId">
<a [routerLink]="['/p', p.key, 'dashboard']">{{ p.name }}</a>
</h2>
</header>
<p class="ts-portfolio__counts">
<span
><strong>{{ p.open }}</strong> abiertos de {{ p.total }}</span
>
<span [class.is-warn]="p.overdue"
><strong>{{ p.overdue }}</strong> vencidos</span
>
<span [class.is-warn]="p.blocked"
><strong>{{ p.blocked }}</strong> bloqueados</span
>
<span [class.is-warn]="p.stuck"
><strong>{{ p.stuck }}</strong> atascados</span
>
</p>
<section>
<h3>Sprint</h3>
@if (p.sprint; as s) {
<div class="ts-portfolio__row">
<a [routerLink]="['/p', p.key, 'sprints']">{{ s.name }}</a>
@if (s.behind) {
<span
class="ts-chip"
style="--chip: var(--ts-c-red, #d14343)"
>No llega</span
>
}
<span class="ts-bar"
><span
[style.width.%]="share(s.donePoints, s.points)"
></span
></span>
<span class="ts-mono"
>{{ s.donePoints }}/{{ s.points }} pts</span
>
</div>
<p class="ts-portfolio__sub">{{ sprintWhen(s) }}</p>
} @else {
<p class="ts-portfolio__sub">Sin sprint en curso.</p>
}
</section>
<section>
<h3>Próximas releases</h3>
@for (r of p.releases; track r.releaseId) {
<div class="ts-portfolio__row">
<a [routerLink]="['/p', p.key, 'releases']">{{ r.name }}</a>
<span class="ts-chip" [style.--chip]="verdict(r).tone">{{
verdict(r).label
}}</span>
<span class="ts-bar"
><span [style.width.%]="share(r.finished, r.total)"></span
></span>
<span class="ts-mono">{{ r.finished }}/{{ r.total }}</span>
</div>
<p class="ts-portfolio__sub">
Objetivo {{ day(r.targetDate) }} · probable
{{ day(r.likelyDate) }}
</p>
} @empty {
<p class="ts-portfolio__sub">Ninguna planificada.</p>
}
</section>
<section>
<h3>Épicas</h3>
@for (e of p.epics; track e.issue.id) {
<div class="ts-portfolio__row">
<a
class="ts-portfolio__epic"
[routerLink]="['/p', p.key, 'issues', e.issue.key]"
><span class="ts-key">{{ e.issue.key }}</span>
{{ e.issue.title }}</a
>
<span class="ts-bar"
><span [style.width.%]="e.progress.percent"></span
></span>
<span class="ts-mono">{{ e.progress.percent }}%</span>
</div>
@if (e.issue.dueDate) {
<p class="ts-portfolio__sub">
Fecha límite {{ day(e.issue.dueDate) }}
</p>
}
} @empty {
<p class="ts-portfolio__sub">Ninguna épica abierta.</p>
}
</section>
</article>
}
</div>
}
`,
styles: `
:host {
display: grid;
gap: 1.5rem;
}
.ts-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.ts-portfolio {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
gap: 1.25rem;
}
.ts-portfolio__card {
display: grid;
gap: 1rem;
align-content: start;
padding: 1.25rem 1.5rem;
border-top: 3px solid var(--tone);
}
.ts-portfolio__head {
display: flex;
align-items: baseline;
gap: 0.625rem;
h2 {
margin: 0;
font-size: 1.125rem;
}
a {
color: inherit;
text-decoration: none;
&:hover {
color: var(--ts-brand);
}
}
}
.ts-portfolio__key {
color: var(--tone) !important;
font-family: var(--ts-font-mono);
font-weight: 700;
}
.ts-portfolio__counts {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
margin: 0;
color: var(--ts-ink-soft);
font-size: 0.8125rem;
.is-warn strong {
color: var(--ts-c-red, #d14343);
}
}
section {
display: grid;
gap: 0.25rem;
}
h3 {
margin: 0 0 0.125rem;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ts-portfolio__row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 5rem auto;
align-items: center;
gap: 0.625rem;
font-size: 0.875rem;
> a {
overflow: hidden;
color: inherit;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--ts-brand);
}
}
.ts-mono {
color: var(--ts-ink-soft);
font-size: 0.75rem;
text-align: right;
}
}
.ts-portfolio__row:not(:has(.ts-chip)) {
grid-template-columns: minmax(0, 1fr) 5rem auto;
}
.ts-portfolio__epic .ts-key {
margin-right: 0.375rem;
font-family: var(--ts-font-mono);
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-portfolio__sub {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
}
.ts-chip {
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--chip) 14%, transparent);
color: var(--chip);
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
.ts-bar {
height: 0.5rem;
border-radius: 0.25rem;
background: var(--ts-sunken);
overflow: hidden;
span {
display: block;
height: 100%;
background: var(--tone, var(--ts-brand));
}
}
`,
})
export class PortfolioPage implements OnInit {
protected readonly portfolio = inject(PortfolioFacade);
protected readonly tone = projectTone;
protected readonly day = short;
ngOnInit(): void {
this.portfolio.load();
}
protected share(done: number, total: number): number {
return total ? Math.round((done / total) * 100) : 0;
}
protected verdict(release: ReleaseForecast) {
return verdictOf(release);
}
protected sprintWhen(sprint: SprintPulse): string {
const left = sprint.daysLeft;
const when =
left < 0
? 'terminó'
: left === 0
? 'último día'
: `quedan ${left} ${left === 1 ? 'día' : 'días'}`;
return `${short(sprint.startDate)} – ${short(sprint.endDate)} · ${when}`;
}
}

View File

@@ -14,6 +14,13 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
import('./pages/projects.page').then((m) => m.ProjectsPage),
},
{
path: 'cartera',
canActivate: [authGuard],
title: 'Cartera · TaskStream',
loadComponent: () =>
import('./pages/portfolio.page').then((m) => m.PortfolioPage),
},
{
path: 'explorar',
title: 'Proyectos públicos · TaskStream',

View File

@@ -16,7 +16,7 @@ const short = (day: string | null) =>
})
: '—';
const VERDICT: Record<
export const VERDICT: Record<
ReleaseForecast['verdict'],
{ label: string; tone: string }
> = {
@@ -28,6 +28,16 @@ const VERDICT: Record<
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
};
/** Its verdict; a release with no tickets yet has nothing to forecast. */
export function verdictOf(release: ReleaseForecast): {
label: string;
tone: string;
} {
return release.total === 0
? { label: 'Sin tickets', tone: 'var(--ts-muted)' }
: VERDICT[release.verdict];
}
/**
* Planned releases with the date they would likely be finished at the
* team's real pace (Monte Carlo over the last six weeks).
@@ -104,7 +114,7 @@ export class ReleaseForecastComponent {
protected readonly rows = computed(() =>
this.releases().map((r) => ({
...r,
...VERDICT[r.verdict],
...verdictOf(r),
share: r.total ? (r.finished / r.total) * 100 : 0,
target: short(r.targetDate),
likely: short(r.likelyDate),