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:
@@ -1852,4 +1852,56 @@ describe('task-stream (e2e)', () => {
|
|||||||
(r) => r.status === 200,
|
(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),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": {
|
"/api/issues/{issue}/presence": {
|
||||||
"put": {
|
"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.",
|
"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"
|
"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": {
|
"PresenceBodyDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13573,51 +13766,6 @@
|
|||||||
"title"
|
"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": {
|
"UpdateIssueDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1082,6 +1082,27 @@ type OptionCount {
|
|||||||
value: String!
|
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)"""
|
"""Someone on a ticket right now (heartbeat in the last 30 s)"""
|
||||||
type Presence {
|
type Presence {
|
||||||
"""Last heartbeat"""
|
"""Last heartbeat"""
|
||||||
@@ -1364,6 +1385,11 @@ type Query {
|
|||||||
myViews: [MyView!]!
|
myViews: [MyView!]!
|
||||||
notificationSettings: NotificationSettings!
|
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)"""
|
"""A project of mine, or any public one (no sign-in needed)"""
|
||||||
project(
|
project(
|
||||||
"""Project id or key (e.g. "TS")"""
|
"""Project id or key (e.g. "TS")"""
|
||||||
|
|||||||
@@ -397,6 +397,26 @@ export interface paths {
|
|||||||
patch: operations["ProjectsController_changeMemberRole"];
|
patch: operations["ProjectsController_changeMemberRole"];
|
||||||
trace?: never;
|
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": {
|
"/api/issues/{issue}/presence": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2440,6 +2460,36 @@ export interface components {
|
|||||||
ChangeMemberRoleDto: {
|
ChangeMemberRoleDto: {
|
||||||
role: components["schemas"]["ProjectRole"];
|
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: {
|
PresenceBodyDto: {
|
||||||
/**
|
/**
|
||||||
* @description Editing the ticket or writing a comment
|
* @description Editing the ticket or writing a comment
|
||||||
@@ -2514,18 +2564,6 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
parent?: string | null;
|
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: {
|
UpdateIssueDto: {
|
||||||
description?: string | null;
|
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: {
|
IssuesController_touchPresence: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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<string, never>, 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({
|
@Tool({
|
||||||
name: 'get_project_insights',
|
name: 'get_project_insights',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ describe('appRoutes', () => {
|
|||||||
const children = app?.children ?? [];
|
const children = app?.children ?? [];
|
||||||
expect(children.map((route) => route.path)).toEqual([
|
expect(children.map((route) => route.path)).toEqual([
|
||||||
'',
|
'',
|
||||||
|
'cartera',
|
||||||
'explorar',
|
'explorar',
|
||||||
'p/:key',
|
'p/:key',
|
||||||
]);
|
]);
|
||||||
expect(children[0].canActivate).toContain(authGuard);
|
expect(children[0].canActivate).toContain(authGuard);
|
||||||
expect(children[1].canActivate).toBeUndefined();
|
expect(children[1].canActivate).toContain(authGuard);
|
||||||
expect(children[2].canActivate).toBeUndefined();
|
expect(children[2].canActivate).toBeUndefined();
|
||||||
|
expect(children[3].canActivate).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes every project section', () => {
|
it('exposes every project section', () => {
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ import {
|
|||||||
Proyectos
|
Proyectos
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@if (auth.isAuthenticated()) {
|
||||||
|
<a
|
||||||
|
shellNav
|
||||||
|
class="ts-nav-link"
|
||||||
|
routerLink="/cartera"
|
||||||
|
routerLinkActive="is-active"
|
||||||
|
>
|
||||||
|
Cartera
|
||||||
|
</a>
|
||||||
|
}
|
||||||
<a
|
<a
|
||||||
shellNav
|
shellNav
|
||||||
class="ts-nav-link"
|
class="ts-nav-link"
|
||||||
|
|||||||
@@ -418,6 +418,11 @@ puras (`application/src/lib/analytics`):
|
|||||||
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
||||||
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||||
|
|
||||||
|
La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cada proyecto activo de quien pregunta
|
||||||
|
(no archivado y en el que no es invitado), los tickets abiertos, vencidos, bloqueados y atascados, el sprint en curso,
|
||||||
|
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
|
||||||
|
panel.
|
||||||
|
|
||||||
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
||||||
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
||||||
|
|
||||||
@@ -673,23 +678,23 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
|
|
||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
||||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ visualización de la plataforma:
|
|||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos |
|
| `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||||
|
| `/cartera` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas |
|
||||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
|
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ flowchart LR
|
|||||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||||
| | `get_project_stats` | Cifras de seguimiento |
|
| | `get_project_stats` | Cifras de seguimiento |
|
||||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
|
| | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas |
|
||||||
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios |
|
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios |
|
||||||
| | `get_project_activity` | Actividad reciente |
|
| | `get_project_activity` | Actividad reciente |
|
||||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
|
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ Nada: la tanda está hecha (ver 10.5).
|
|||||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||||
integración continua.
|
integración continua.
|
||||||
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
||||||
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
|
|
||||||
|
|
||||||
### Otras ideas apuntadas, sin decidir
|
### Otras ideas apuntadas, sin decidir
|
||||||
|
|
||||||
@@ -122,3 +121,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
|
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
|
||||||
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
|
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
|
||||||
abre tickets y comenta en los suyos.
|
abre tickets y comenta en los suyos.
|
||||||
|
- Vista de cartera (`/cartera`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,7 @@ import {
|
|||||||
IssuePriority,
|
IssuePriority,
|
||||||
ProjectAnalyticsReadModel,
|
ProjectAnalyticsReadModel,
|
||||||
ProjectInsightsReadModel,
|
ProjectInsightsReadModel,
|
||||||
|
PortfolioProjectReadModel,
|
||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
@@ -38,6 +39,7 @@ import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
|
|||||||
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
||||||
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
||||||
import { GetProjectInsightsQuery } from './queries/get-project-insights.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 { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||||
import { GetProjectQuery } from './queries/get-project.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). */
|
/** 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. */
|
/** The dashboard's reading; with `since`, what changed after it too. */
|
||||||
insights(
|
insights(
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
|
PortfolioProjectReadModel,
|
||||||
|
ProjectReadModel,
|
||||||
|
SprintStatus,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeInsights } from '../analytics/insights';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
|
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||||
|
import { GetPortfolioQuery } from './get-portfolio.query';
|
||||||
|
import { guestOnly } from './read-access';
|
||||||
|
|
||||||
|
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||||
|
/** Epics shown per project. */
|
||||||
|
const EPICS = 6;
|
||||||
|
|
||||||
|
@QueryHandler(GetPortfolioQuery)
|
||||||
|
export class GetPortfolioQueryHandler
|
||||||
|
implements IQueryHandler<GetPortfolioQuery, PortfolioProjectReadModel[]>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findProjects: FindProjectsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly findTimeline: FindTimelineRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly findReleases: FindReleasesRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetPortfolioQuery,
|
||||||
|
): Promise<PortfolioProjectReadModel[]> {
|
||||||
|
const projects = (await this.findProjects.findByMember(query.viewerId))
|
||||||
|
.filter((p) => !p.archivedAt && !guestOnly(p, query.viewerId))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
||||||
|
return Promise.all(projects.map((p) => this.summary(p)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async summary(
|
||||||
|
project: ProjectReadModel,
|
||||||
|
): Promise<PortfolioProjectReadModel> {
|
||||||
|
const [issues, timeline, sprints, releases, epics] = await Promise.all([
|
||||||
|
this.findIssues.findByProject(project.id),
|
||||||
|
this.findTimeline.findByProject(project.id),
|
||||||
|
this.findSprints.findByProject(project.id),
|
||||||
|
this.findReleases.findByProject(project.id),
|
||||||
|
this.findIssues.epics(project.id),
|
||||||
|
]);
|
||||||
|
const reading = computeInsights({
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
settings: project.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||||
|
activeSprint:
|
||||||
|
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||||
|
sprints,
|
||||||
|
releases,
|
||||||
|
members: project.members,
|
||||||
|
since: null,
|
||||||
|
mentions: 0,
|
||||||
|
now: new Date(),
|
||||||
|
});
|
||||||
|
const live = issues.filter((i) => !i.trashedAt);
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
key: project.key,
|
||||||
|
name: project.name,
|
||||||
|
isPublic: project.isPublic,
|
||||||
|
total: live.length,
|
||||||
|
open: live.filter((i) => !FINISHED.includes(i.statusCategory)).length,
|
||||||
|
overdue: reading.risks.overdueCount,
|
||||||
|
blocked: reading.risks.blockedCount,
|
||||||
|
stuck: reading.risks.stuckCount,
|
||||||
|
sprint: reading.sprint,
|
||||||
|
releases: reading.releases,
|
||||||
|
epics: epics
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
!FINISHED.includes(e.issue.statusCategory) && !e.issue.trashedAt,
|
||||||
|
)
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.issue.dueDate ?? '9999').localeCompare(b.issue.dueDate ?? '9999'),
|
||||||
|
)
|
||||||
|
.slice(0, EPICS),
|
||||||
|
updatedAt: project.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Query } from '@nestjs/cqrs';
|
||||||
|
import { PortfolioProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every active project I take full part in (not archived, not as a guest),
|
||||||
|
* at a glance: open work, risks, sprint, releases and epics.
|
||||||
|
*/
|
||||||
|
export class GetPortfolioQuery extends Query<PortfolioProjectReadModel[]> {
|
||||||
|
constructor(public readonly viewerId: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export * from './get-my-projects.query';
|
|||||||
export * from './get-project-analytics.query';
|
export * from './get-project-analytics.query';
|
||||||
export * from './get-project-insights.query';
|
export * from './get-project-insights.query';
|
||||||
export * from './get-ticket-scope.query';
|
export * from './get-ticket-scope.query';
|
||||||
|
export * from './get-portfolio.query';
|
||||||
export * from './get-project-stats.query';
|
export * from './get-project-stats.query';
|
||||||
export * from './get-release-burnup.query';
|
export * from './get-release-burnup.query';
|
||||||
export * from './get-project.query';
|
export * from './get-project.query';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GetProjectQueryHandler } from './get-project.query-handler';
|
|||||||
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
||||||
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
||||||
import { GetTicketScopeQueryHandler } from './get-ticket-scope.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 { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
||||||
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
||||||
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
||||||
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
|
|||||||
GetProjectAnalyticsQueryHandler,
|
GetProjectAnalyticsQueryHandler,
|
||||||
GetProjectInsightsQueryHandler,
|
GetProjectInsightsQueryHandler,
|
||||||
GetTicketScopeQueryHandler,
|
GetTicketScopeQueryHandler,
|
||||||
|
GetPortfolioQueryHandler,
|
||||||
GetProjectStatsQueryHandler,
|
GetProjectStatsQueryHandler,
|
||||||
GetReleaseBurnupQueryHandler,
|
GetReleaseBurnupQueryHandler,
|
||||||
GetReleaseQueryHandler,
|
GetReleaseQueryHandler,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EpicReadModel } from './issue.read-model';
|
||||||
import { UserRefReadModel } from './user-ref.read-model';
|
import { UserRefReadModel } from './user-ref.read-model';
|
||||||
|
|
||||||
/** A ticket named by the dashboard, with the number that put it there. */
|
/** 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. */
|
/** One per active list (SELECT) custom field. */
|
||||||
fields!: FieldBreakdownReadModel[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
CommandResultGql,
|
CommandResultGql,
|
||||||
ProjectAnalyticsGql,
|
ProjectAnalyticsGql,
|
||||||
ProjectInsightsGql,
|
ProjectInsightsGql,
|
||||||
|
PortfolioProjectGql,
|
||||||
ProjectGql,
|
ProjectGql,
|
||||||
ProjectSettingsGql,
|
ProjectSettingsGql,
|
||||||
ProjectStatsGql,
|
ProjectStatsGql,
|
||||||
@@ -47,6 +48,14 @@ export class ProjectResolver {
|
|||||||
return this.projects.findMine(me.id);
|
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()
|
@OptionalAuth()
|
||||||
@Query(() => ProjectGql, {
|
@Query(() => ProjectGql, {
|
||||||
description: 'A project of mine, or any public one (no sign-in needed)',
|
description: 'A project of mine, or any public one (no sign-in needed)',
|
||||||
|
|||||||
@@ -1073,3 +1073,27 @@ export class PresenceGql {
|
|||||||
editing!: boolean;
|
editing!: boolean;
|
||||||
@Field({ description: 'Last heartbeat' }) at!: string;
|
@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[];
|
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,
|
CommentsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
|
ProjectsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||||
import { withoutNulls } from '../common/issue-input';
|
import { withoutNulls } from '../common/issue-input';
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
EpicDto,
|
EpicDto,
|
||||||
IssueDto,
|
IssueDto,
|
||||||
IssuePageDto,
|
IssuePageDto,
|
||||||
|
PortfolioProjectDto,
|
||||||
PresenceDto,
|
PresenceDto,
|
||||||
} from './dto/responses.dto';
|
} from './dto/responses.dto';
|
||||||
import { viewerOf } from '../common/viewer';
|
import { viewerOf } from '../common/viewer';
|
||||||
@@ -90,8 +92,33 @@ export class IssuesController {
|
|||||||
private readonly comments: CommentsService,
|
private readonly comments: CommentsService,
|
||||||
private readonly refs: Refs,
|
private readonly refs: Refs,
|
||||||
private readonly presence: PresenceService,
|
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')
|
@Put('issues/:issue/presence')
|
||||||
@ISSUE
|
@ISSUE
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from './lib/facades/epics.facade';
|
|||||||
export * from './lib/facades/insights.facade';
|
export * from './lib/facades/insights.facade';
|
||||||
export * from './lib/facades/my-views.facade';
|
export * from './lib/facades/my-views.facade';
|
||||||
export * from './lib/facades/presence.facade';
|
export * from './lib/facades/presence.facade';
|
||||||
|
export * from './lib/facades/portfolio.facade';
|
||||||
export * from './lib/facades/backlog.facade';
|
export * from './lib/facades/backlog.facade';
|
||||||
export * from './lib/facades/list.facade';
|
export * from './lib/facades/list.facade';
|
||||||
export * from './lib/facades/propose.facade';
|
export * from './lib/facades/propose.facade';
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IssuePriority } from './enums';
|
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. */
|
/** A ticket the dashboard names, with the number that put it there. */
|
||||||
export interface InsightIssue {
|
export interface InsightIssue {
|
||||||
@@ -141,3 +141,21 @@ export interface ProjectInsights {
|
|||||||
quality: Quality;
|
quality: Quality;
|
||||||
fields: FieldBreakdown[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProjectInsights } from '../models/insights';
|
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -67,6 +67,8 @@ export abstract class ProjectsGateway {
|
|||||||
abstract publicProjects(): Observable<Project[]>;
|
abstract publicProjects(): Observable<Project[]>;
|
||||||
abstract project(idOrKey: string): Observable<Project>;
|
abstract project(idOrKey: string): Observable<Project>;
|
||||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
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. */
|
/** The dashboard; with `since`, also what changed after it. */
|
||||||
abstract insights(
|
abstract insights(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Project,
|
Project,
|
||||||
ProjectAnalytics,
|
ProjectAnalytics,
|
||||||
ProjectInsights,
|
ProjectInsights,
|
||||||
|
PortfolioProject,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ProjectStats,
|
ProjectStats,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
MyProjectsDocument,
|
MyProjectsDocument,
|
||||||
ProjectAnalyticsDocument,
|
ProjectAnalyticsDocument,
|
||||||
ProjectInsightsDocument,
|
ProjectInsightsDocument,
|
||||||
|
PortfolioDocument,
|
||||||
ProjectDocument,
|
ProjectDocument,
|
||||||
ProjectStatsDocument,
|
ProjectStatsDocument,
|
||||||
PublicProjectsDocument,
|
PublicProjectsDocument,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
import {
|
import {
|
||||||
toCommandResult,
|
toCommandResult,
|
||||||
|
toIssueSummary,
|
||||||
toProject,
|
toProject,
|
||||||
toSettingsInput,
|
toSettingsInput,
|
||||||
toStats,
|
toStats,
|
||||||
@@ -65,6 +68,21 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
|||||||
.pipe(map((d) => toStats(d.project.stats)));
|
.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(
|
insights(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
since: string | null,
|
since: string | null,
|
||||||
|
|||||||
@@ -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<{
|
export type ReleasesQueryVariables = Exact<{
|
||||||
idOrKey: string;
|
idOrKey: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -12307,6 +12387,309 @@ export const ProjectInsightsDocument = {
|
|||||||
ProjectInsightsQuery,
|
ProjectInsightsQuery,
|
||||||
ProjectInsightsQueryVariables
|
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 = {
|
export const ReleasesDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
345
libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts
Normal file
345
libs/web/tracker/presenters/src/lib/pages/portfolio.page.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ export const trackerRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/projects.page').then((m) => m.ProjectsPage),
|
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',
|
path: 'explorar',
|
||||||
title: 'Proyectos públicos · TaskStream',
|
title: 'Proyectos públicos · TaskStream',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const short = (day: string | null) =>
|
|||||||
})
|
})
|
||||||
: '—';
|
: '—';
|
||||||
|
|
||||||
const VERDICT: Record<
|
export const VERDICT: Record<
|
||||||
ReleaseForecast['verdict'],
|
ReleaseForecast['verdict'],
|
||||||
{ label: string; tone: string }
|
{ label: string; tone: string }
|
||||||
> = {
|
> = {
|
||||||
@@ -28,6 +28,16 @@ const VERDICT: Record<
|
|||||||
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
|
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
|
* Planned releases with the date they would likely be finished at the
|
||||||
* team's real pace (Monte Carlo over the last six weeks).
|
* team's real pace (Monte Carlo over the last six weeks).
|
||||||
@@ -104,7 +114,7 @@ export class ReleaseForecastComponent {
|
|||||||
protected readonly rows = computed(() =>
|
protected readonly rows = computed(() =>
|
||||||
this.releases().map((r) => ({
|
this.releases().map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
...VERDICT[r.verdict],
|
...verdictOf(r),
|
||||||
share: r.total ? (r.finished / r.total) * 100 : 0,
|
share: r.total ? (r.finished / r.total) * 100 : 0,
|
||||||
target: short(r.targetDate),
|
target: short(r.targetDate),
|
||||||
likely: short(r.likelyDate),
|
likely: short(r.likelyDate),
|
||||||
|
|||||||
Reference in New Issue
Block a user