diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 8c90e1f..17ceccf 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1852,4 +1852,56 @@ describe('task-stream (e2e)', () => { (r) => r.status === 200, ); }); + + it('shows my active projects at a glance (the portfolio)', async () => { + const key = 'F' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { + key, + name: `Cartera ${key}`, + }); + await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + const epic = ( + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'FEATURE', + title: 'Pagos', + dueDate: '2030-01-01', + }) + ).body['key']; + await eventually( + () => rest('token-ana', 'GET', `/issues/${epic}`), + (r) => r.status === 200, + ); + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Parte', + parent: epic, + dueDate: '2020-01-01', + }); + await rest('token-ana', 'POST', `/projects/${key}/releases`, { + name: '1.0', + targetDate: '2030-06-01', + }); + const mine = await eventually( + () => rest('token-ana', 'GET', '/me/portfolio'), + (r) => + r.body?.find?.((p: { key: string }) => p.key === key)?.epics?.length === + 1 && + r.body.find((p: { key: string }) => p.key === key).releases.length === + 1, + ); + const row = mine.body.find((p: { key: string }) => p.key === key); + expect(row).toMatchObject({ total: 2, open: 2, overdue: 1, sprint: null }); + expect(row.releases[0]).toMatchObject({ name: '1.0', total: 0 }); + expect(row.epics[0].issue.key).toBe(epic); + + // Archived projects leave it. + await rest('token-ana', 'POST', `/projects/${key}/archive`); + await eventually( + () => gql('token-ana', `query { portfolio { key } }`), + (d) => !d['portfolio'].some((p: { key: string }) => p.key === key), + ); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 2e35072..c0ad9da 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2185,6 +2185,80 @@ ] } }, + "/api/me/portfolio": { + "get": { + "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.", + "operationId": "IssuesController_portfolio", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PortfolioProjectDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "My projects at a glance", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/presence": { "put": { "description": "Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.", @@ -13416,6 +13490,125 @@ "role" ] }, + "ChildProgressDto": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "finished": { + "type": "number", + "description": "Done or cancelled" + }, + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "storyPoints": { + "type": "number" + }, + "finishedStoryPoints": { + "type": "number" + } + }, + "required": [ + "total", + "finished", + "percent", + "storyPoints", + "finishedStoryPoints" + ] + }, + "EpicDto": { + "type": "object", + "properties": { + "issue": { + "$ref": "#/components/schemas/IssueDto" + }, + "progress": { + "$ref": "#/components/schemas/ChildProgressDto" + } + }, + "required": [ + "issue", + "progress" + ] + }, + "PortfolioProjectDto": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string", + "example": "WEB" + }, + "name": { + "type": "string" + }, + "isPublic": { + "type": "boolean" + }, + "total": { + "type": "number" + }, + "open": { + "type": "number" + }, + "overdue": { + "type": "number" + }, + "blocked": { + "type": "number" + }, + "stuck": { + "type": "number" + }, + "sprint": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/SprintPulseDto" + } + ] + }, + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseForecastDto" + } + }, + "epics": { + "description": "Open epics, nearest due date first (six at most)", + "type": "array", + "items": { + "$ref": "#/components/schemas/EpicDto" + } + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "projectId", + "key", + "name", + "isPublic", + "total", + "open", + "overdue", + "blocked", + "stuck", + "sprint", + "releases", + "epics", + "updatedAt" + ] + }, "PresenceBodyDto": { "type": "object", "properties": { @@ -13573,51 +13766,6 @@ "title" ] }, - "ChildProgressDto": { - "type": "object", - "properties": { - "total": { - "type": "number" - }, - "finished": { - "type": "number", - "description": "Done or cancelled" - }, - "percent": { - "type": "number", - "minimum": 0, - "maximum": 100 - }, - "storyPoints": { - "type": "number" - }, - "finishedStoryPoints": { - "type": "number" - } - }, - "required": [ - "total", - "finished", - "percent", - "storyPoints", - "finishedStoryPoints" - ] - }, - "EpicDto": { - "type": "object", - "properties": { - "issue": { - "$ref": "#/components/schemas/IssueDto" - }, - "progress": { - "$ref": "#/components/schemas/ChildProgressDto" - } - }, - "required": [ - "issue", - "progress" - ] - }, "UpdateIssueDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index a2b17a4..1060278 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -1082,6 +1082,27 @@ type OptionCount { value: String! } +""" +One of my active projects at a glance: open work, risks, sprint, releases and epics +""" +type PortfolioProject { + blocked: Int! + + """Open epics, nearest due date first (six at most)""" + epics: [Epic!]! + isPublic: Boolean! + key: String! + name: String! + open: Int! + overdue: Int! + projectId: ID! + releases: [ReleaseForecast!]! + sprint: SprintPulse + stuck: Int! + total: Int! + updatedAt: String! +} + """Someone on a ticket right now (heartbeat in the last 30 s)""" type Presence { """Last heartbeat""" @@ -1364,6 +1385,11 @@ type Query { myViews: [MyView!]! notificationSettings: NotificationSettings! + """ + My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics + """ + portfolio: [PortfolioProject!]! + """A project of mine, or any public one (no sign-in needed)""" project( """Project id or key (e.g. "TS")""" diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 1572893..7bc5a91 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -397,6 +397,26 @@ export interface paths { patch: operations["ProjectsController_changeMemberRole"]; trace?: never; }; + "/api/me/portfolio": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: operations["IssuesController_portfolio"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/presence": { parameters: { query?: never; @@ -2440,6 +2460,36 @@ export interface components { ChangeMemberRoleDto: { role: components["schemas"]["ProjectRole"]; }; + ChildProgressDto: { + total: number; + /** @description Done or cancelled */ + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; + }; + EpicDto: { + issue: components["schemas"]["IssueDto"]; + progress: components["schemas"]["ChildProgressDto"]; + }; + PortfolioProjectDto: { + /** Format: uuid */ + projectId: string; + /** @example WEB */ + key: string; + name: string; + isPublic: boolean; + total: number; + open: number; + overdue: number; + blocked: number; + stuck: number; + sprint: components["schemas"]["SprintPulseDto"] | null; + releases: components["schemas"]["ReleaseForecastDto"][]; + /** @description Open epics, nearest due date first (six at most) */ + epics: components["schemas"]["EpicDto"][]; + updatedAt: string; + }; PresenceBodyDto: { /** * @description Editing the ticket or writing a comment @@ -2514,18 +2564,6 @@ export interface components { */ parent?: string | null; }; - ChildProgressDto: { - total: number; - /** @description Done or cancelled */ - finished: number; - percent: number; - storyPoints: number; - finishedStoryPoints: number; - }; - EpicDto: { - issue: components["schemas"]["IssueDto"]; - progress: components["schemas"]["ChildProgressDto"]; - }; UpdateIssueDto: { description?: string | null; /** @@ -4778,6 +4816,61 @@ export interface operations { }; }; }; + IssuesController_portfolio: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PortfolioProjectDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; IssuesController_touchPresence: { parameters: { query?: never; diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index 19e3667..09fd3f9 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -204,6 +204,31 @@ export class ProjectsResolver { ); } + @Tool({ + name: 'get_portfolio', + description: + "All the user's active projects at a glance (not archived, not as a guest): open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and open epics with their progress.", + paramsSchema: z.object({}), + annotations: READ, + }) + portfolio(_params: Record, ctx: McpContext) { + return toolResult(async () => { + const projects = await this.api.data( + this.api.as(ctx).GET('/api/me/portfolio'), + ); + // Compact: epics as key, title and progress. + return projects.map(({ epics, ...p }) => ({ + ...p, + epics: epics.map((e) => ({ + key: e.issue.key, + title: e.issue.title, + dueDate: e.issue.dueDate, + percent: e.progress.percent, + })), + })); + }); + } + @Tool({ name: 'get_project_insights', description: diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index c5bc855..5c37eb6 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -12,12 +12,14 @@ describe('appRoutes', () => { const children = app?.children ?? []; expect(children.map((route) => route.path)).toEqual([ '', + 'cartera', 'explorar', 'p/:key', ]); expect(children[0].canActivate).toContain(authGuard); - expect(children[1].canActivate).toBeUndefined(); + expect(children[1].canActivate).toContain(authGuard); expect(children[2].canActivate).toBeUndefined(); + expect(children[3].canActivate).toBeUndefined(); }); it('exposes every project section', () => { diff --git a/apps/web/src/app/app.ts b/apps/web/src/app/app.ts index ad1d2bc..229c7bb 100644 --- a/apps/web/src/app/app.ts +++ b/apps/web/src/app/app.ts @@ -41,6 +41,16 @@ import { Proyectos } + @if (auth.isAuthenticated()) { + + Cartera + + } { + return this.queryBus.execute(new GetPortfolioQuery(viewerId)); + } + /** The dashboard's reading; with `since`, what changed after it too. */ insights( viewerId: string | null, diff --git a/libs/api/tracker/application/src/lib/queries/get-portfolio.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-portfolio.query-handler.ts new file mode 100644 index 0000000..6727ef6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-portfolio.query-handler.ts @@ -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 +{ + 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 { + 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 { + 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, + }; + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-portfolio.query.ts b/libs/api/tracker/application/src/lib/queries/get-portfolio.query.ts new file mode 100644 index 0000000..9e1ba98 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-portfolio.query.ts @@ -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 { + constructor(public readonly viewerId: string) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index 7a133f4..5e08236 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -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'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index 059bee2..cd07236 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -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, diff --git a/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts index 427eafa..14cc876 100644 --- a/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/insights.read-model.ts @@ -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; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index a4b8e32..14fa29a 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -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)', diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 329ae46..88435e9 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -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; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index b3da795..2b19853 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -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; +} diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 56da023..e1bb8c3 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -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({ diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index c98ec80..2f678db 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -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'; diff --git a/libs/web/tracker/application/src/lib/events/portfolio.events.ts b/libs/web/tracker/application/src/lib/events/portfolio.events.ts new file mode 100644 index 0000000..f7482f2 --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/portfolio.events.ts @@ -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(), + }, +}); + +export const portfolioApiEvents = eventGroup({ + source: 'Portfolio API', + events: { + loaded: type(), + loadFailed: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/portfolio.facade.ts b/libs/web/tracker/application/src/lib/facades/portfolio.facade.ts new file mode 100644 index 0000000..982e100 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/portfolio.facade.ts @@ -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]; +} diff --git a/libs/web/tracker/application/src/lib/stores/portfolio.store.ts b/libs/web/tracker/application/src/lib/stores/portfolio.store.ts new file mode 100644 index 0000000..bd449d3 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/portfolio.store.ts @@ -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({ + 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)), + }), + ), + ), + ), + }), + ), +); diff --git a/libs/web/tracker/domain/src/lib/models/insights.ts b/libs/web/tracker/domain/src/lib/models/insights.ts index 3c9f8f4..70ed9bc 100644 --- a/libs/web/tracker/domain/src/lib/models/insights.ts +++ b/libs/web/tracker/domain/src/lib/models/insights.ts @@ -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; +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index cc3a156..77160af 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -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; abstract project(idOrKey: string): Observable; abstract stats(projectId: string): Observable; + /** My active projects at a glance. */ + abstract portfolio(): Observable; /** The dashboard; with `since`, also what changed after it. */ abstract insights( projectId: string, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts index 0053a91..340c8ee 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts @@ -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 { + 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, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 0e3bdc6..0b2d3bd 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -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; + 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; export const ReleasesDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index 393039b..5b9788d 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -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 + } + } + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts b/libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts new file mode 100644 index 0000000..9443020 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts @@ -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: ` +
+
+

Pireon · TaskStream

+

Cartera

+

+ Tus proyectos activos de un vistazo: lo abierto, lo que está en + riesgo, el sprint en curso, las próximas releases y las épicas. +

+
+ +
+ + @if (portfolio.error(); as error) { + + } @else if (!portfolio.loaded()) { +
+ + +
+ } @else if (!portfolio.projects().length) { + + } @else { +
+ @for (p of portfolio.projects(); track p.projectId) { +
+
+ {{ p.key }} +

+ {{ p.name }} +

+
+

+ {{ p.open }} abiertos de {{ p.total }} + {{ p.overdue }} vencidos + {{ p.blocked }} bloqueados + {{ p.stuck }} atascados +

+ +
+

Sprint

+ @if (p.sprint; as s) { +
+ {{ s.name }} + @if (s.behind) { + No llega + } + + {{ s.donePoints }}/{{ s.points }} pts +
+

{{ sprintWhen(s) }}

+ } @else { +

Sin sprint en curso.

+ } +
+ +
+

Próximas releases

+ @for (r of p.releases; track r.releaseId) { +
+ {{ r.name }} + {{ + verdict(r).label + }} + + {{ r.finished }}/{{ r.total }} +
+

+ Objetivo {{ day(r.targetDate) }} · probable + {{ day(r.likelyDate) }} +

+ } @empty { +

Ninguna planificada.

+ } +
+ +
+

Épicas

+ @for (e of p.epics; track e.issue.id) { +
+ {{ e.issue.key }} + {{ e.issue.title }} + + {{ e.progress.percent }}% +
+ @if (e.issue.dueDate) { +

+ Fecha límite {{ day(e.issue.dueDate) }} +

+ } + } @empty { +

Ninguna épica abierta.

+ } +
+
+ } +
+ } + `, + 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}`; + } +} diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts index 3a332df..6630cc2 100644 --- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts +++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts @@ -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', diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts b/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts index a48f49e..44f4bee 100644 --- a/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts +++ b/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts @@ -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),