feat: dashboard forecasts releases, tells what changed and weighs the load
The insights reading grows (GraphQL insights(since:), REST ?since=, MCP): - planned releases with a likely (p50) and safe (p85) finish date from a Monte Carlo over the last six weeks of finished work, one after another, and a verdict against their target; - with `since` (the viewer's last visit, kept in the browser): tickets created, finished and sent back from QA, and their mentions; - each person's open points against what they closed per sprint lately; - open tickets without estimate, area or assignee, or untouched for 90 days, each count opening the list filtered (new noEstimate, noArea and updatedBefore filters). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1414,5 +1414,28 @@ describe('task-stream (e2e)', () => {
|
||||
{ k: key },
|
||||
);
|
||||
expect(project.insights.risks.overdueCount).toBe(1);
|
||||
|
||||
// What changed since a moment, and what the backlog lacks.
|
||||
const since = new Date(Date.now() - 60_000).toISOString();
|
||||
const later = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/insights?since=${encodeURIComponent(since)}`,
|
||||
);
|
||||
expect(later.body['changes']).toMatchObject({ since, createdCount: 2 });
|
||||
expect(later.body['backlog']).toMatchObject({
|
||||
open: 2,
|
||||
unestimated: 2,
|
||||
unassigned: 2,
|
||||
});
|
||||
expect(
|
||||
later.body['team'].map((t: { user: { id: string } }) => t.user.id),
|
||||
).toContain('101');
|
||||
const unestimated = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/issues?noEstimate=true&noArea=true`,
|
||||
);
|
||||
expect(unestimated.body['total']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1449,6 +1449,16 @@
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "since",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "ISO instant (e.g. your last visit): the answer also tells what changed after it",
|
||||
"schema": {
|
||||
"example": "2026-10-14T08:00:00Z",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2507,6 +2517,33 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "noEstimate",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Only tickets nobody estimated",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "noArea",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Only tickets with no area",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "updatedBefore",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Only tickets not updated since this instant (ISO)",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "field",
|
||||
"required": false,
|
||||
@@ -12109,6 +12146,176 @@
|
||||
"overLimit"
|
||||
]
|
||||
},
|
||||
"ReleaseForecastDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"releaseId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"targetDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"finished": {
|
||||
"type": "number"
|
||||
},
|
||||
"likelyDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date",
|
||||
"description": "Half of the simulated futures finish by then"
|
||||
},
|
||||
"safeDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date",
|
||||
"description": "85% finish by then"
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"DONE",
|
||||
"NO_DATE",
|
||||
"UNKNOWN",
|
||||
"ON_TRACK",
|
||||
"AT_RISK",
|
||||
"LATE"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"releaseId",
|
||||
"name",
|
||||
"targetDate",
|
||||
"total",
|
||||
"finished",
|
||||
"likelyDate",
|
||||
"safeDate",
|
||||
"verdict"
|
||||
]
|
||||
},
|
||||
"ChangesSinceDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"since": {
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"createdCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"finished": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"finishedCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"returned": {
|
||||
"description": "Sent back from QA",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"returnedCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"mentions": {
|
||||
"type": "number",
|
||||
"description": "Times you were mentioned here"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"since",
|
||||
"created",
|
||||
"createdCount",
|
||||
"finished",
|
||||
"finishedCount",
|
||||
"returned",
|
||||
"returnedCount",
|
||||
"mentions"
|
||||
]
|
||||
},
|
||||
"TeamLoadDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/UserRefDto"
|
||||
},
|
||||
"openTickets": {
|
||||
"type": "number"
|
||||
},
|
||||
"openPoints": {
|
||||
"type": "number"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "number"
|
||||
},
|
||||
"pointsPerSprint": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Points closed per sprint, last three closed sprints"
|
||||
},
|
||||
"overloaded": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"openTickets",
|
||||
"openPoints",
|
||||
"blocked",
|
||||
"pointsPerSprint",
|
||||
"overloaded"
|
||||
]
|
||||
},
|
||||
"BacklogHealthDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"open": {
|
||||
"type": "number"
|
||||
},
|
||||
"unestimated": {
|
||||
"type": "number"
|
||||
},
|
||||
"noArea": {
|
||||
"type": "number"
|
||||
},
|
||||
"unassigned": {
|
||||
"type": "number"
|
||||
},
|
||||
"stale": {
|
||||
"type": "number"
|
||||
},
|
||||
"staleDays": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"open",
|
||||
"unestimated",
|
||||
"noArea",
|
||||
"unassigned",
|
||||
"stale",
|
||||
"staleDays"
|
||||
]
|
||||
},
|
||||
"ProjectInsightsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12135,6 +12342,31 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StatusTimeDto"
|
||||
}
|
||||
},
|
||||
"releases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ReleaseForecastDto"
|
||||
}
|
||||
},
|
||||
"changes": {
|
||||
"nullable": true,
|
||||
"description": "Only when asked with `since`",
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ChangesSinceDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"team": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TeamLoadDto"
|
||||
}
|
||||
},
|
||||
"backlog": {
|
||||
"$ref": "#/components/schemas/BacklogHealthDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -12142,7 +12374,11 @@
|
||||
"sprint",
|
||||
"risks",
|
||||
"aging",
|
||||
"bottlenecks"
|
||||
"bottlenecks",
|
||||
"releases",
|
||||
"changes",
|
||||
"team",
|
||||
"backlog"
|
||||
]
|
||||
},
|
||||
"ActivityDto": {
|
||||
|
||||
@@ -129,6 +129,15 @@ type AutomationTriggerSpec {
|
||||
status: String
|
||||
}
|
||||
|
||||
type BacklogHealth {
|
||||
noArea: Int!
|
||||
open: Int!
|
||||
stale: Int!
|
||||
staleDays: Int!
|
||||
unassigned: Int!
|
||||
unestimated: Int!
|
||||
}
|
||||
|
||||
type BoardColumn {
|
||||
issues: [Issue!]!
|
||||
status: String!
|
||||
@@ -184,6 +193,22 @@ input ChangeIssueStatusInput {
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""What happened after a moment"""
|
||||
type ChangesSince {
|
||||
created: [InsightIssue!]!
|
||||
createdCount: Int!
|
||||
finished: [InsightIssue!]!
|
||||
finishedCount: Int!
|
||||
|
||||
"""Times I was mentioned here"""
|
||||
mentions: Int!
|
||||
|
||||
"""Sent back from QA"""
|
||||
returned: [InsightIssue!]!
|
||||
returnedCount: Int!
|
||||
since: String!
|
||||
}
|
||||
|
||||
"""A step inside a ticket that does not deserve a ticket of its own"""
|
||||
type ChecklistItem {
|
||||
done: Boolean!
|
||||
@@ -598,6 +623,12 @@ input IssueFilterInput {
|
||||
dueTo: String
|
||||
labels: [String!]
|
||||
|
||||
"""Only tickets with no area"""
|
||||
noArea: Boolean
|
||||
|
||||
"""Only tickets nobody estimated"""
|
||||
noEstimate: Boolean
|
||||
|
||||
"""Only tickets in no sprint"""
|
||||
noSprint: Boolean
|
||||
|
||||
@@ -615,6 +646,9 @@ input IssueFilterInput {
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
|
||||
"""Only tickets not updated since this instant (ISO)"""
|
||||
updatedBefore: String
|
||||
|
||||
"""Only the tickets I follow (none when anonymous)"""
|
||||
watching: Boolean
|
||||
}
|
||||
@@ -978,7 +1012,10 @@ type Project {
|
||||
"""
|
||||
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
|
||||
"""
|
||||
insights: ProjectInsights!
|
||||
insights(
|
||||
"""ISO instant (e.g. my last visit): also tell what changed after it"""
|
||||
since: String
|
||||
): ProjectInsights!
|
||||
|
||||
"""Anyone can read it, also without signing in"""
|
||||
isPublic: Boolean!
|
||||
@@ -1021,13 +1058,21 @@ type ProjectChange {
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""The dashboard: sprint under way, risks, aging and bottlenecks"""
|
||||
"""
|
||||
The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes
|
||||
"""
|
||||
type ProjectInsights {
|
||||
aging: Aging!
|
||||
backlog: BacklogHealth!
|
||||
bottlenecks: [StatusTime!]!
|
||||
|
||||
"""Only when asked with `since`"""
|
||||
changes: ChangesSince
|
||||
generatedAt: String!
|
||||
releases: [ReleaseForecast!]!
|
||||
risks: Risks!
|
||||
sprint: SprintPulse
|
||||
team: [TeamLoad!]!
|
||||
}
|
||||
|
||||
type ProjectMember {
|
||||
@@ -1237,6 +1282,24 @@ type ReleaseChange {
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""When a planned release would be finished, at the real pace"""
|
||||
type ReleaseForecast {
|
||||
finished: Int!
|
||||
|
||||
"""Half of the simulated futures finish by then"""
|
||||
likelyDate: String
|
||||
name: String!
|
||||
releaseId: ID!
|
||||
|
||||
"""85% finish by then"""
|
||||
safeDate: String
|
||||
targetDate: String
|
||||
total: Int!
|
||||
|
||||
"""DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE"""
|
||||
verdict: String!
|
||||
}
|
||||
|
||||
type ReleaseProgress {
|
||||
byStatus: [CountByStatus!]!
|
||||
finished: Int!
|
||||
@@ -1450,6 +1513,17 @@ type Subscription {
|
||||
sprintChanged(projectId: ID!): SprintChange!
|
||||
}
|
||||
|
||||
type TeamLoad {
|
||||
blocked: Int!
|
||||
openPoints: Int!
|
||||
openTickets: Int!
|
||||
overloaded: Boolean!
|
||||
|
||||
"""Points closed per sprint, last three closed sprints"""
|
||||
pointsPerSprint: Float
|
||||
user: UserRef!
|
||||
}
|
||||
|
||||
type Throughput {
|
||||
count: Int!
|
||||
user: UserRef
|
||||
|
||||
@@ -2179,12 +2179,67 @@ export interface components {
|
||||
wipLimit: number | null;
|
||||
overLimit: boolean;
|
||||
};
|
||||
ReleaseForecastDto: {
|
||||
/** Format: uuid */
|
||||
releaseId: string;
|
||||
name: string;
|
||||
/** Format: date */
|
||||
targetDate: string | null;
|
||||
total: number;
|
||||
finished: number;
|
||||
/**
|
||||
* Format: date
|
||||
* @description Half of the simulated futures finish by then
|
||||
*/
|
||||
likelyDate: string | null;
|
||||
/**
|
||||
* Format: date
|
||||
* @description 85% finish by then
|
||||
*/
|
||||
safeDate: string | null;
|
||||
/** @enum {string} */
|
||||
verdict: "DONE" | "NO_DATE" | "UNKNOWN" | "ON_TRACK" | "AT_RISK" | "LATE";
|
||||
};
|
||||
ChangesSinceDto: {
|
||||
since: string;
|
||||
created: components["schemas"]["InsightIssueDto"][];
|
||||
createdCount: number;
|
||||
finished: components["schemas"]["InsightIssueDto"][];
|
||||
finishedCount: number;
|
||||
/** @description Sent back from QA */
|
||||
returned: components["schemas"]["InsightIssueDto"][];
|
||||
returnedCount: number;
|
||||
/** @description Times you were mentioned here */
|
||||
mentions: number;
|
||||
};
|
||||
TeamLoadDto: {
|
||||
user: components["schemas"]["UserRefDto"];
|
||||
openTickets: number;
|
||||
openPoints: number;
|
||||
blocked: number;
|
||||
/** @description Points closed per sprint, last three closed sprints */
|
||||
pointsPerSprint: number | null;
|
||||
overloaded: boolean;
|
||||
};
|
||||
BacklogHealthDto: {
|
||||
open: number;
|
||||
unestimated: number;
|
||||
noArea: number;
|
||||
unassigned: number;
|
||||
stale: number;
|
||||
staleDays: number;
|
||||
};
|
||||
ProjectInsightsDto: {
|
||||
generatedAt: string;
|
||||
sprint: components["schemas"]["SprintPulseDto"] | null;
|
||||
risks: components["schemas"]["RisksDto"];
|
||||
aging: components["schemas"]["AgingDto"];
|
||||
bottlenecks: components["schemas"]["StatusTimeDto"][];
|
||||
releases: components["schemas"]["ReleaseForecastDto"][];
|
||||
/** @description Only when asked with `since` */
|
||||
changes: components["schemas"]["ChangesSinceDto"] | null;
|
||||
team: components["schemas"]["TeamLoadDto"][];
|
||||
backlog: components["schemas"]["BacklogHealthDto"];
|
||||
};
|
||||
ActivityDto: {
|
||||
id: string;
|
||||
@@ -3970,7 +4025,10 @@ export interface operations {
|
||||
};
|
||||
ProjectsController_insights: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description ISO instant (e.g. your last visit): the answer also tells what changed after it */
|
||||
since?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project id or key */
|
||||
@@ -4579,6 +4637,12 @@ export interface operations {
|
||||
dueFrom?: string;
|
||||
/** @description Due on or before this day */
|
||||
dueTo?: string;
|
||||
/** @description Only tickets nobody estimated */
|
||||
noEstimate?: boolean;
|
||||
/** @description Only tickets with no area */
|
||||
noArea?: boolean;
|
||||
/** @description Only tickets not updated since this instant (ISO) */
|
||||
updatedBefore?: string;
|
||||
/** @description Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several */
|
||||
field?: string[];
|
||||
/** @description RECENT (default): last updated first. RANK: backlog order. */
|
||||
|
||||
@@ -207,15 +207,26 @@ export class ProjectsResolver {
|
||||
@Tool({
|
||||
name: 'get_project_insights',
|
||||
description:
|
||||
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time) and the average days in each in-progress and QA status, with WIP limits.",
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, and open tickets missing an estimate, an area or an assignee. With since, also what changed after it.",
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
since: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'ISO instant: also tell what was created, finished or sent back from QA after it',
|
||||
),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
insights({ project }: { project: string }, ctx: McpContext) {
|
||||
insights(
|
||||
{ project, since }: { project: string; since?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/insights', {
|
||||
params: { path: { project } },
|
||||
params: { path: { project }, query: { since } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -380,14 +380,18 @@ Los tickets anteriores a esta proyección solo tienen su creación y su estado a
|
||||
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||
|
||||
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
||||
decir qué mirar ahora (`analytics/insights.ts`):
|
||||
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
||||
|
||||
| Bloque | Qué da |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
|
||||
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
|
||||
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
|
||||
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
|
||||
| Bloque | Qué da |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
|
||||
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
|
||||
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
|
||||
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
|
||||
| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. `ON_TRACK`, `AT_RISK`, `LATE`, `NO_DATE`, `UNKNOWN` (sin ritmo aún) o `DONE` |
|
||||
| Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
|
||||
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar |
|
||||
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
|
||||
|
||||
## 3.2 GraphQL
|
||||
|
||||
|
||||
@@ -280,22 +280,22 @@ visualización de la plataforma:
|
||||
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, sprint en curso (¿llega?), tickets en riesgo, antigüedad de lo que está en curso, cuellos de botella, equipo (cada persona enlaza a su tablero) y tipos. Se recarga solo con cada cambio |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
|
||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||
|
||||
@@ -34,54 +34,54 @@ flowchart LR
|
||||
|
||||
57 herramientas, agrupadas por ámbito:
|
||||
|
||||
| Ámbito | Herramienta | Qué hace |
|
||||
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| Usuario | `whoami` | El usuario del token |
|
||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
||||
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
||||
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||
| | `list_public_projects` | Proyectos públicos |
|
||||
| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) |
|
||||
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||
| | `get_project_stats` | Cifras de seguimiento |
|
||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||
| | `get_project_insights` | El panel: sprint en curso, tickets en riesgo, antigüedad y cuellos de botella |
|
||||
| | `get_project_activity` | Actividad reciente |
|
||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||
| | `assign_issue` | Asignar o desasignar |
|
||||
| | `set_issue_release` | Meter en una release o sacar |
|
||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||
| | `rank_issue` | Mover un ticket en el backlog |
|
||||
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||
| | `get_sprint` | Progreso, tickets y burndown |
|
||||
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||
| Releases | `list_releases` | Releases de un proyecto |
|
||||
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||
| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
|
||||
| Ámbito | Herramienta | Qué hace |
|
||||
| ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuario | `whoami` | El usuario del token |
|
||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
||||
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
||||
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||
| | `list_public_projects` | Proyectos públicos |
|
||||
| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) |
|
||||
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||
| | `get_project_stats` | Cifras de seguimiento |
|
||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog y, con `since`, los cambios |
|
||||
| | `get_project_activity` | Actividad reciente |
|
||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||
| | `assign_issue` | Asignar o desasignar |
|
||||
| | `set_issue_release` | Meter en una release o sacar |
|
||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||
| | `rank_issue` | Mover un ticket en el backlog |
|
||||
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||
| | `get_sprint` | Progreso, tickets y burndown |
|
||||
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||
| Releases | `list_releases` | Releases de un proyecto |
|
||||
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||
| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
|
||||
|
||||
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
|
||||
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
|
||||
|
||||
@@ -35,15 +35,9 @@ cuando hay pistas, por dónde empezar.
|
||||
|
||||
En el panel del proyecto:
|
||||
|
||||
1. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
||||
con semáforo.
|
||||
2. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
||||
3. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
||||
sobrecarga y sus bloqueados.
|
||||
4. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
||||
5. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
||||
1. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
||||
siguientes a cada release.
|
||||
6. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
||||
2. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
||||
navegador»).
|
||||
|
||||
### Aceptadas, para después de esta tanda
|
||||
@@ -127,3 +121,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
|
||||
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
|
||||
y MCP (`get_project_insights`).
|
||||
- Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al
|
||||
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
@@ -7,7 +9,13 @@ import {
|
||||
SprintStatus,
|
||||
StatusCategory as C,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights, STUCK_DAYS } from './insights';
|
||||
import {
|
||||
computeInsights,
|
||||
InsightsInput,
|
||||
simulateDays,
|
||||
STALE_DAYS,
|
||||
STUCK_DAYS,
|
||||
} from './insights';
|
||||
|
||||
const NOW = new Date('2026-10-15T12:00:00Z');
|
||||
const bob = { id: 'bob', name: 'Bob' };
|
||||
@@ -68,6 +76,24 @@ const sprint: SprintReadModel = {
|
||||
};
|
||||
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
|
||||
|
||||
const BASE: Omit<InsightsInput, 'issues' | 'timeline'> = {
|
||||
settings: defaultProjectSettings(),
|
||||
activeSprint: null,
|
||||
sprints: [],
|
||||
releases: [],
|
||||
members: [],
|
||||
since: null,
|
||||
mentions: 0,
|
||||
now: NOW,
|
||||
};
|
||||
const release = (id: string, targetDate: string | null): ReleaseReadModel =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
targetDate,
|
||||
status: ReleaseStatus.PLANNED,
|
||||
}) as ReleaseReadModel;
|
||||
|
||||
describe('computeInsights', () => {
|
||||
it('names what is late, due soon, blocked and stuck', () => {
|
||||
const issues = [
|
||||
@@ -95,9 +121,8 @@ describe('computeInsights', () => {
|
||||
const { risks, aging } = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
settings: defaultProjectSettings(),
|
||||
activeSprint: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]);
|
||||
expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]);
|
||||
@@ -121,9 +146,9 @@ describe('computeInsights', () => {
|
||||
const { sprint: pulse } = computeInsights({
|
||||
issues,
|
||||
timeline: [],
|
||||
...BASE,
|
||||
settings: defaultProjectSettings(),
|
||||
activeSprint: sprint,
|
||||
now: NOW,
|
||||
});
|
||||
expect(pulse).toMatchObject({
|
||||
totalDays: 14,
|
||||
@@ -155,9 +180,8 @@ describe('computeInsights', () => {
|
||||
const rows = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
settings,
|
||||
activeSprint: null,
|
||||
now: NOW,
|
||||
}).bottlenecks;
|
||||
expect(rows.map((r) => r.status)).toEqual([
|
||||
'IN_PROGRESS',
|
||||
@@ -174,4 +198,108 @@ describe('computeInsights', () => {
|
||||
overLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forecasts planned releases at the pace of the last weeks', () => {
|
||||
// Two tickets finished a day, every day of the last six weeks.
|
||||
expect(simulateDays(10, [2, 2, 2])).toEqual({ likely: 5, safe: 5 });
|
||||
expect(simulateDays(3, [0, 0])).toBeNull();
|
||||
expect(simulateDays(0, [])).toEqual({ likely: 0, safe: 0 });
|
||||
|
||||
const done = Array.from({ length: 12 }, (_, n) => `D${n}`);
|
||||
const issues = [
|
||||
...done.map((id) => issue(id, 'DONE', C.DONE)),
|
||||
...['A1', 'A2', 'A3'].map((id) =>
|
||||
issue(id, 'TODO', C.TODO, { release: { id: 'a', name: 'a' } }),
|
||||
),
|
||||
...['B1', 'B2', 'B3'].map((id) =>
|
||||
issue(id, 'TODO', C.TODO, { release: { id: 'b', name: 'b' } }),
|
||||
),
|
||||
];
|
||||
// One finished every 3.5 days or so: 12 in the last 42 days.
|
||||
const timeline = done.map((id, n) =>
|
||||
step(
|
||||
id,
|
||||
new Date(NOW.getTime() - (n * 3 + 1) * 86_400_000).toISOString(),
|
||||
'DONE',
|
||||
C.DONE,
|
||||
),
|
||||
);
|
||||
const { releases } = computeInsights({
|
||||
...BASE,
|
||||
issues,
|
||||
timeline,
|
||||
releases: [release('b', '2026-10-20'), release('a', '2026-12-31')],
|
||||
});
|
||||
// Nearer target first; the later one waits for it.
|
||||
expect(releases.map((r) => [r.releaseId, r.verdict])).toEqual([
|
||||
['b', 'LATE'],
|
||||
['a', 'ON_TRACK'],
|
||||
]);
|
||||
expect(releases[1].likelyDate! > releases[0].likelyDate!).toBe(true);
|
||||
});
|
||||
|
||||
it('tells what changed since a moment, and who carries what', () => {
|
||||
const ana = { id: 'ana', name: 'Ana' };
|
||||
const issues = [
|
||||
issue('NEW', 'TODO', C.TODO, {
|
||||
createdAt: '2026-10-15T08:00:00Z',
|
||||
assignee: ana,
|
||||
storyPoints: 8,
|
||||
}),
|
||||
issue('CLOSED', 'DONE', C.DONE, {
|
||||
sprint: { id: 'old', name: 'Old' },
|
||||
storyPoints: 5,
|
||||
}),
|
||||
issue('BACK', 'QA_FAILED', C.IN_PROGRESS, { storyPoints: 2 }),
|
||||
issue('STALE', 'TODO', C.TODO, {
|
||||
assignee: null,
|
||||
area: 'FRONTEND',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
];
|
||||
const timeline = [
|
||||
step('CLOSED', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('CLOSED', '2026-10-15T09:00:00Z', 'DONE', C.DONE),
|
||||
step('BACK', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('BACK', '2026-10-15T10:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
|
||||
];
|
||||
const reading = computeInsights({
|
||||
...BASE,
|
||||
issues,
|
||||
timeline,
|
||||
since: new Date('2026-10-15T07:00:00Z'),
|
||||
mentions: 2,
|
||||
members: [{ user: ana, role: 'MEMBER' as never }],
|
||||
sprints: [
|
||||
{ id: 'old', closedAt: '2026-10-11T00:00:00Z' } as SprintReadModel,
|
||||
],
|
||||
});
|
||||
expect(reading.changes).toMatchObject({
|
||||
createdCount: 1,
|
||||
finishedCount: 1,
|
||||
returnedCount: 1,
|
||||
mentions: 2,
|
||||
});
|
||||
expect(reading.changes?.returned[0].key).toBe('BACK');
|
||||
// Bob closed 5 points in the one closed sprint and holds 2 open.
|
||||
expect(
|
||||
reading.team.map((t) => [
|
||||
t.user.id,
|
||||
t.openPoints,
|
||||
t.pointsPerSprint,
|
||||
t.overloaded,
|
||||
]),
|
||||
).toEqual([
|
||||
['ana', 8, 0, false],
|
||||
['bob', 2, 5, false],
|
||||
]);
|
||||
expect(reading.backlog).toEqual({
|
||||
open: 3,
|
||||
unestimated: 1,
|
||||
noArea: 2,
|
||||
unassigned: 1,
|
||||
stale: 1,
|
||||
staleDays: STALE_DAYS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import {
|
||||
BacklogHealthReadModel,
|
||||
ChangesSinceReadModel,
|
||||
InsightIssueReadModel,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
ProjectInsightsReadModel,
|
||||
ProjectMemberReadModel,
|
||||
ProjectSettings,
|
||||
ReleaseForecastReadModel,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
SprintPulseReadModel,
|
||||
SprintReadModel,
|
||||
StatusCategory,
|
||||
StatusTimeReadModel,
|
||||
TeamLoadReadModel,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeProjectAnalytics, Step, stepsByIssue } from './analytics';
|
||||
|
||||
@@ -24,6 +32,10 @@ const DAY = 86_400_000;
|
||||
export const STUCK_DAYS = 7;
|
||||
/** How many tickets each list names. */
|
||||
const LIST = 8;
|
||||
/** Days not updated that make an open ticket «stale». */
|
||||
export const STALE_DAYS = 90;
|
||||
/** Days of finished work the forecasts draw their pace from. */
|
||||
export const PACE_DAYS = 42;
|
||||
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
||||
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
@@ -32,6 +44,14 @@ export interface InsightsInput {
|
||||
timeline: IssueTimelineEntry[];
|
||||
settings: ProjectSettings;
|
||||
activeSprint: SprintReadModel | null;
|
||||
/** Every sprint of the project (closed ones give each person's pace). */
|
||||
sprints: SprintReadModel[];
|
||||
releases: ReleaseReadModel[];
|
||||
members: ProjectMemberReadModel[];
|
||||
/** The viewer's last visit, to tell what changed since. */
|
||||
since: Date | null;
|
||||
/** Times the viewer was mentioned in the project since then. */
|
||||
mentions: number;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
@@ -137,6 +157,245 @@ export function computeInsights(
|
||||
thresholdDays: analytics.cycleTime.p85Days,
|
||||
},
|
||||
bottlenecks: bottlenecks(input.settings, open, steps, now),
|
||||
releases: forecasts(input.releases, live, steps, now),
|
||||
changes: input.since
|
||||
? changesSince(input.since.getTime(), live, steps, line, input.mentions)
|
||||
: null,
|
||||
team: teamLoad(
|
||||
input.members,
|
||||
open,
|
||||
live,
|
||||
input.sprints,
|
||||
new Set(blocked.map((b) => b.id)),
|
||||
),
|
||||
backlog: backlogHealth(open, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */
|
||||
function dailyThroughput(steps: Map<string, Step[]>, now: number): number[] {
|
||||
const today = utcDay(now);
|
||||
const counts = new Array<number>(PACE_DAYS).fill(0);
|
||||
for (const list of steps.values())
|
||||
list.forEach((step, i) => {
|
||||
const before = list[i - 1];
|
||||
if (step.category !== StatusCategory.DONE) return;
|
||||
if (before && before.category === StatusCategory.DONE) return;
|
||||
const ago = Math.floor((today - utcDay(step.at)) / DAY);
|
||||
if (ago >= 0 && ago < PACE_DAYS) counts[ago]++;
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Small seeded generator: the same data always gives the same forecast. */
|
||||
function random(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Days to finish `remaining` tickets, drawing each day's throughput from
|
||||
* the days measured (Monte Carlo): the 50th and 85th percentiles.
|
||||
*/
|
||||
export function simulateDays(
|
||||
remaining: number,
|
||||
samples: number[],
|
||||
runs = 500,
|
||||
): { likely: number; safe: number } | null {
|
||||
if (remaining <= 0) return { likely: 0, safe: 0 };
|
||||
if (!samples.some((n) => n > 0)) return null;
|
||||
const next = random(remaining * 7919 + samples.length);
|
||||
const results: number[] = [];
|
||||
for (let run = 0; run < runs; run++) {
|
||||
let done = 0;
|
||||
let days = 0;
|
||||
while (done < remaining && days < 3 * 365) {
|
||||
done += samples[Math.floor(next() * samples.length)];
|
||||
days++;
|
||||
}
|
||||
results.push(days);
|
||||
}
|
||||
results.sort((a, b) => a - b);
|
||||
return {
|
||||
likely: results[Math.floor(0.5 * runs)],
|
||||
safe: results[Math.ceil(0.85 * runs) - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Planned releases in target-date order. The team finishes them one after
|
||||
* another, so each one's forecast counts what is left in the earlier ones.
|
||||
*/
|
||||
function forecasts(
|
||||
releases: ReleaseReadModel[],
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): ReleaseForecastReadModel[] {
|
||||
const pace = dailyThroughput(steps, now);
|
||||
const today = utcDay(now);
|
||||
const dayAfter = (days: number) =>
|
||||
new Date(today + days * DAY).toISOString().slice(0, 10);
|
||||
let ahead = 0;
|
||||
return releases
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.map((release) => {
|
||||
const own = issues.filter(
|
||||
(i) =>
|
||||
i.release?.id === release.id &&
|
||||
i.statusCategory !== StatusCategory.CANCELLED,
|
||||
);
|
||||
const finished = own.filter(
|
||||
(i) => i.statusCategory === StatusCategory.DONE,
|
||||
).length;
|
||||
const left = own.length - finished;
|
||||
ahead += left;
|
||||
const days = left > 0 ? simulateDays(ahead, pace) : null;
|
||||
const likelyDate = days ? dayAfter(days.likely) : null;
|
||||
const safeDate = days ? dayAfter(days.safe) : null;
|
||||
const verdict: ReleaseForecastReadModel['verdict'] =
|
||||
left === 0
|
||||
? 'DONE'
|
||||
: !days
|
||||
? 'UNKNOWN'
|
||||
: !release.targetDate
|
||||
? 'NO_DATE'
|
||||
: (safeDate as string) <= release.targetDate
|
||||
? 'ON_TRACK'
|
||||
: (likelyDate as string) <= release.targetDate
|
||||
? 'AT_RISK'
|
||||
: 'LATE';
|
||||
return {
|
||||
releaseId: release.id,
|
||||
name: release.name,
|
||||
targetDate: release.targetDate,
|
||||
total: own.length,
|
||||
finished,
|
||||
likelyDate,
|
||||
safeDate,
|
||||
verdict,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Created, finished and sent back from QA after `since`. */
|
||||
function changesSince(
|
||||
since: number,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
line: (issue: IssueReadModel, days: number) => InsightIssueReadModel,
|
||||
mentions: number,
|
||||
): ChangesSinceReadModel {
|
||||
const created = issues
|
||||
.filter((i) => Date.parse(i.createdAt) > since)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const finished: IssueReadModel[] = [];
|
||||
const returned: IssueReadModel[] = [];
|
||||
for (const issue of issues) {
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
const recent = list.filter((s, i) => s.at > since && i > 0);
|
||||
if (
|
||||
issue.statusCategory === StatusCategory.DONE &&
|
||||
recent.some((s) => s.category === StatusCategory.DONE)
|
||||
)
|
||||
finished.push(issue);
|
||||
if (
|
||||
list.some(
|
||||
(s, i) =>
|
||||
s.at > since &&
|
||||
i > 0 &&
|
||||
list[i - 1].category === StatusCategory.QA &&
|
||||
!CLOSED.includes(s.category) &&
|
||||
s.category !== StatusCategory.QA,
|
||||
)
|
||||
)
|
||||
returned.push(issue);
|
||||
}
|
||||
return {
|
||||
since: new Date(since).toISOString(),
|
||||
created: created.slice(0, LIST).map((i) => line(i, 0)),
|
||||
createdCount: created.length,
|
||||
finished: finished.slice(0, LIST).map((i) => line(i, 0)),
|
||||
finishedCount: finished.length,
|
||||
returned: returned.slice(0, LIST).map((i) => line(i, 0)),
|
||||
returnedCount: returned.length,
|
||||
mentions,
|
||||
};
|
||||
}
|
||||
|
||||
const pointsOf = (list: IssueReadModel[]) =>
|
||||
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
|
||||
|
||||
/** Open work per person against the points they closed per sprint lately. */
|
||||
function teamLoad(
|
||||
members: ProjectMemberReadModel[],
|
||||
open: IssueReadModel[],
|
||||
issues: IssueReadModel[],
|
||||
sprints: SprintReadModel[],
|
||||
blockedIds: Set<string>,
|
||||
): TeamLoadReadModel[] {
|
||||
const people = new Map<string, UserRefReadModel>();
|
||||
for (const m of members) people.set(m.user.id, m.user);
|
||||
for (const i of open) if (i.assignee) people.set(i.assignee.id, i.assignee);
|
||||
const lastClosed = sprints
|
||||
.filter((s) => s.closedAt)
|
||||
.sort((a, b) => (b.closedAt as string).localeCompare(a.closedAt as string))
|
||||
.slice(0, 3)
|
||||
.map((s) => s.id);
|
||||
return [...people.values()]
|
||||
.map((user) => {
|
||||
const mine = open.filter((i) => i.assignee?.id === user.id);
|
||||
const closed = issues.filter(
|
||||
(i) =>
|
||||
i.assignee?.id === user.id &&
|
||||
i.statusCategory === StatusCategory.DONE &&
|
||||
!!i.sprint &&
|
||||
lastClosed.includes(i.sprint.id),
|
||||
);
|
||||
const perSprint = lastClosed.length
|
||||
? round1(pointsOf(closed) / lastClosed.length)
|
||||
: null;
|
||||
const openPoints = pointsOf(mine);
|
||||
return {
|
||||
user,
|
||||
openTickets: mine.length,
|
||||
openPoints,
|
||||
blocked: mine.filter((i) => blockedIds.has(i.id)).length,
|
||||
pointsPerSprint: perSprint,
|
||||
overloaded: !!perSprint && openPoints > perSprint,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.openPoints - a.openPoints ||
|
||||
b.openTickets - a.openTickets ||
|
||||
a.user.name.localeCompare(b.user.name, 'es'),
|
||||
);
|
||||
}
|
||||
|
||||
function backlogHealth(
|
||||
open: IssueReadModel[],
|
||||
now: number,
|
||||
): BacklogHealthReadModel {
|
||||
const staleBefore = now - STALE_DAYS * DAY;
|
||||
return {
|
||||
open: open.length,
|
||||
unestimated: open.filter((i) => i.storyPoints === null).length,
|
||||
noArea: open.filter((i) => !i.area).length,
|
||||
unassigned: open.filter((i) => !i.assignee).length,
|
||||
stale: open.filter((i) => Date.parse(i.updatedAt) < staleBefore).length,
|
||||
staleDays: STALE_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export interface IssueFilter {
|
||||
dueTo?: string;
|
||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||
noSprint?: boolean;
|
||||
/** Only tickets nobody estimated. */
|
||||
noEstimate?: boolean;
|
||||
/** Only tickets with no area. */
|
||||
noArea?: boolean;
|
||||
/** Only tickets not updated since this instant (ISO). */
|
||||
updatedBefore?: string;
|
||||
/** Custom field id → the value it must hold. */
|
||||
customFields?: Record<string, string>;
|
||||
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
|
||||
|
||||
@@ -323,13 +323,14 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||
/** Sprint under way, risks, aging and bottlenecks, for the dashboard. */
|
||||
/** The dashboard's reading; with `since`, what changed after it too. */
|
||||
insights(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
since: Date | null = null,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetProjectInsightsQuery(viewerId, projectId),
|
||||
new GetProjectInsightsQuery(viewerId, projectId, since),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
NotificationKind,
|
||||
ProjectInsightsReadModel,
|
||||
SprintStatus,
|
||||
} 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 { NotificationsRepository } from '../ports/notifications.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { GetProjectInsightsQuery } from './get-project-insights.query';
|
||||
@@ -21,24 +25,51 @@ export class GetProjectInsightsQueryHandler
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly notifications: NotificationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetProjectInsightsQuery,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline, activeSprint] = await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
this.findTimeline.findByProject(query.projectId),
|
||||
this.findSprints.findActive(query.projectId),
|
||||
]);
|
||||
const [project, issues, timeline, sprints, releases, mentions] =
|
||||
await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
this.findTimeline.findByProject(query.projectId),
|
||||
this.findSprints.findByProject(query.projectId),
|
||||
this.findReleases.findByProject(query.projectId),
|
||||
this.mentionsSince(query),
|
||||
]);
|
||||
return computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
activeSprint,
|
||||
activeSprint:
|
||||
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||
sprints,
|
||||
releases,
|
||||
members: project?.members ?? [],
|
||||
since: query.since,
|
||||
mentions,
|
||||
now: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/** The viewer's mentions in this project after `since` (latest 200). */
|
||||
private async mentionsSince(query: GetProjectInsightsQuery): Promise<number> {
|
||||
if (!query.since || !query.viewerId) return 0;
|
||||
const since = query.since.toISOString();
|
||||
const latest = await this.notifications.findForUser(query.viewerId, {
|
||||
unreadOnly: false,
|
||||
limit: 200,
|
||||
});
|
||||
return latest.filter(
|
||||
(n) =>
|
||||
n.projectId === query.projectId &&
|
||||
n.kind === NotificationKind.MENTIONED &&
|
||||
n.createdAt > since,
|
||||
).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** The dashboard's reading of a project: sprint, risks, aging, bottlenecks. */
|
||||
/**
|
||||
* The dashboard's reading of a project: sprint, risks, aging, bottlenecks,
|
||||
* release forecasts, team load, backlog health and, with `since`, what
|
||||
* changed after it.
|
||||
*/
|
||||
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
|
||||
constructor(
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
public readonly since: Date | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -70,6 +70,64 @@ export class StatusTimeReadModel {
|
||||
overLimit!: boolean;
|
||||
}
|
||||
|
||||
/** When a planned release would be finished, at the team's real pace. */
|
||||
export class ReleaseForecastReadModel {
|
||||
releaseId!: string;
|
||||
name!: string;
|
||||
targetDate!: string | null;
|
||||
/** Tickets in it (cancelled ones aside). */
|
||||
total!: number;
|
||||
finished!: number;
|
||||
/** Half of the simulated futures finish by then (null: no pace yet). */
|
||||
likelyDate!: string | null;
|
||||
/** 85% of them do. */
|
||||
safeDate!: string | null;
|
||||
/**
|
||||
* DONE: nothing left. NO_DATE: no target to compare. UNKNOWN: nothing
|
||||
* finished lately to measure a pace. ON_TRACK: 85% in time. AT_RISK: half
|
||||
* in time. LATE: less than half.
|
||||
*/
|
||||
verdict!: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE';
|
||||
}
|
||||
|
||||
/** What has happened since a moment (the viewer's last visit). */
|
||||
export class ChangesSinceReadModel {
|
||||
since!: string;
|
||||
created!: InsightIssueReadModel[];
|
||||
createdCount!: number;
|
||||
finished!: InsightIssueReadModel[];
|
||||
finishedCount!: number;
|
||||
/** Sent back from QA. */
|
||||
returned!: InsightIssueReadModel[];
|
||||
returnedCount!: number;
|
||||
/** Times the viewer was mentioned in this project. */
|
||||
mentions!: number;
|
||||
}
|
||||
|
||||
/** One person's open work against what they usually close. */
|
||||
export class TeamLoadReadModel {
|
||||
user!: UserRefReadModel;
|
||||
openTickets!: number;
|
||||
openPoints!: number;
|
||||
/** Their open tickets waiting for another open one. */
|
||||
blocked!: number;
|
||||
/** Points they closed per sprint, over the last three closed sprints. */
|
||||
pointsPerSprint!: number | null;
|
||||
/** More open points than they usually close in a sprint. */
|
||||
overloaded!: boolean;
|
||||
}
|
||||
|
||||
/** Open tickets missing what planning needs. */
|
||||
export class BacklogHealthReadModel {
|
||||
open!: number;
|
||||
unestimated!: number;
|
||||
noArea!: number;
|
||||
unassigned!: number;
|
||||
/** Not updated in `staleDays` days. */
|
||||
stale!: number;
|
||||
staleDays!: number;
|
||||
}
|
||||
|
||||
/** What the project dashboard reads beyond the counts. */
|
||||
export class ProjectInsightsReadModel {
|
||||
generatedAt!: string;
|
||||
@@ -78,4 +136,11 @@ export class ProjectInsightsReadModel {
|
||||
aging!: AgingReadModel;
|
||||
/** In-progress and QA statuses, in board order. */
|
||||
bottlenecks!: StatusTimeReadModel[];
|
||||
/** Planned releases, by target date. */
|
||||
releases!: ReleaseForecastReadModel[];
|
||||
/** Null unless asked with a `since`. */
|
||||
changes!: ChangesSinceReadModel | null;
|
||||
/** Members (and others with open tickets), busiest first. */
|
||||
team!: TeamLoadReadModel[];
|
||||
backlog!: BacklogHealthReadModel;
|
||||
}
|
||||
|
||||
@@ -450,6 +450,9 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}),
|
||||
};
|
||||
// Only field ids: a key never becomes an operator.
|
||||
if (filter.noEstimate) query['storyPoints'] = null;
|
||||
if (filter.noArea) query['area'] = null;
|
||||
if (filter.updatedBefore) query['updatedAt'] = { $lt: filter.updatedBefore };
|
||||
for (const [field, value] of Object.entries(filter.customFields ?? {}))
|
||||
if (/^[A-Z][A-Z0-9_]{0,29}$/.test(field) && typeof value === 'string')
|
||||
query[`customFields.${field}`] = value;
|
||||
|
||||
@@ -568,6 +568,21 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
dueTo?: string;
|
||||
@Field({ nullable: true, description: 'Only tickets nobody estimated' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
noEstimate?: boolean;
|
||||
@Field({ nullable: true, description: 'Only tickets with no area' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
noArea?: boolean;
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Only tickets not updated since this instant (ISO)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
updatedBefore?: string;
|
||||
@Field(() => [CustomFieldValueInput], {
|
||||
nullable: true,
|
||||
description: 'Only tickets holding these custom values',
|
||||
|
||||
@@ -91,8 +91,20 @@ export class ProjectResolver {
|
||||
insights(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() project: ProjectReadModel,
|
||||
@Args('since', {
|
||||
type: () => String,
|
||||
nullable: true,
|
||||
description:
|
||||
'ISO instant (e.g. my last visit): also tell what changed after it',
|
||||
})
|
||||
since?: string | null,
|
||||
) {
|
||||
return this.projects.insights(viewerOf(me), project.id);
|
||||
const after = since ? new Date(since) : null;
|
||||
return this.projects.insights(
|
||||
viewerOf(me),
|
||||
project.id,
|
||||
after && !Number.isNaN(after.getTime()) ? after : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => [ReleaseGql])
|
||||
|
||||
@@ -867,8 +867,70 @@ export class StatusTimeGql {
|
||||
@Field() overLimit!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseForecast', {
|
||||
description: 'When a planned release would be finished, at the real pace',
|
||||
})
|
||||
export class ReleaseForecastGql {
|
||||
@Field(() => ID) releaseId!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => String, { nullable: true }) targetDate!: string | null;
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int) finished!: number;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Half of the simulated futures finish by then',
|
||||
})
|
||||
likelyDate!: string | null;
|
||||
@Field(() => String, { nullable: true, description: '85% finish by then' })
|
||||
safeDate!: string | null;
|
||||
@Field({
|
||||
description:
|
||||
'DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE',
|
||||
})
|
||||
verdict!: string;
|
||||
}
|
||||
|
||||
@ObjectType('ChangesSince', { description: 'What happened after a moment' })
|
||||
export class ChangesSinceGql {
|
||||
@Field() since!: string;
|
||||
@Field(() => [InsightIssueGql]) created!: InsightIssueGql[];
|
||||
@Field(() => Int) createdCount!: number;
|
||||
@Field(() => [InsightIssueGql]) finished!: InsightIssueGql[];
|
||||
@Field(() => Int) finishedCount!: number;
|
||||
@Field(() => [InsightIssueGql], { description: 'Sent back from QA' })
|
||||
returned!: InsightIssueGql[];
|
||||
@Field(() => Int) returnedCount!: number;
|
||||
@Field(() => Int, { description: 'Times I was mentioned here' })
|
||||
mentions!: number;
|
||||
}
|
||||
|
||||
@ObjectType('TeamLoad')
|
||||
export class TeamLoadGql {
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field(() => Int) openTickets!: number;
|
||||
@Field(() => Int) openPoints!: number;
|
||||
@Field(() => Int) blocked!: number;
|
||||
@Field(() => Float, {
|
||||
nullable: true,
|
||||
description: 'Points closed per sprint, last three closed sprints',
|
||||
})
|
||||
pointsPerSprint!: number | null;
|
||||
@Field() overloaded!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('BacklogHealth')
|
||||
export class BacklogHealthGql {
|
||||
@Field(() => Int) open!: number;
|
||||
@Field(() => Int) unestimated!: number;
|
||||
@Field(() => Int) noArea!: number;
|
||||
@Field(() => Int) unassigned!: number;
|
||||
@Field(() => Int) stale!: number;
|
||||
@Field(() => Int) staleDays!: number;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectInsights', {
|
||||
description: 'The dashboard: sprint under way, risks, aging and bottlenecks',
|
||||
description:
|
||||
'The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes',
|
||||
})
|
||||
export class ProjectInsightsGql {
|
||||
@Field() generatedAt!: string;
|
||||
@@ -877,4 +939,12 @@ export class ProjectInsightsGql {
|
||||
@Field(() => RiskGql) risks!: RiskGql;
|
||||
@Field(() => AgingGql) aging!: AgingGql;
|
||||
@Field(() => [StatusTimeGql]) bottlenecks!: StatusTimeGql[];
|
||||
@Field(() => [ReleaseForecastGql]) releases!: ReleaseForecastGql[];
|
||||
@Field(() => ChangesSinceGql, {
|
||||
nullable: true,
|
||||
description: 'Only when asked with `since`',
|
||||
})
|
||||
changes!: ChangesSinceGql | null;
|
||||
@Field(() => [TeamLoadGql]) team!: TeamLoadGql[];
|
||||
@Field(() => BacklogHealthGql) backlog!: BacklogHealthGql;
|
||||
}
|
||||
|
||||
@@ -330,6 +330,17 @@ export class AnalyticsQueryDto {
|
||||
days = 90;
|
||||
}
|
||||
|
||||
export class InsightsQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'ISO instant (e.g. your last visit): the answer also tells what changed after it',
|
||||
example: '2026-10-14T08:00:00Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
since?: string;
|
||||
}
|
||||
|
||||
export class ChangeMemberRoleDto {
|
||||
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
|
||||
@IsEnum(ProjectRole)
|
||||
@@ -609,6 +620,25 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
dueTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only tickets nobody estimated' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
noEstimate?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only tickets with no area' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
noArea?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only tickets not updated since this instant (ISO)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
updatedBefore?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
example: ['BROWSER:Safari'],
|
||||
|
||||
@@ -943,6 +943,69 @@ export class StatusTimeDto {
|
||||
@ApiProperty() overLimit!: boolean;
|
||||
}
|
||||
|
||||
export class ReleaseForecastDto {
|
||||
@ApiProperty({ format: 'uuid' }) releaseId!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
targetDate!: string | null;
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() finished!: number;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'date',
|
||||
description: 'Half of the simulated futures finish by then',
|
||||
})
|
||||
likelyDate!: string | null;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'date',
|
||||
description: '85% finish by then',
|
||||
})
|
||||
safeDate!: string | null;
|
||||
@ApiProperty({
|
||||
enum: ['DONE', 'NO_DATE', 'UNKNOWN', 'ON_TRACK', 'AT_RISK', 'LATE'],
|
||||
})
|
||||
verdict!: string;
|
||||
}
|
||||
|
||||
export class ChangesSinceDto {
|
||||
@ApiProperty() since!: string;
|
||||
@ApiProperty({ type: [InsightIssueDto] }) created!: InsightIssueDto[];
|
||||
@ApiProperty() createdCount!: number;
|
||||
@ApiProperty({ type: [InsightIssueDto] }) finished!: InsightIssueDto[];
|
||||
@ApiProperty() finishedCount!: number;
|
||||
@ApiProperty({ type: [InsightIssueDto], description: 'Sent back from QA' })
|
||||
returned!: InsightIssueDto[];
|
||||
@ApiProperty() returnedCount!: number;
|
||||
@ApiProperty({ description: 'Times you were mentioned here' })
|
||||
mentions!: number;
|
||||
}
|
||||
|
||||
export class TeamLoadDto {
|
||||
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
|
||||
@ApiProperty() openTickets!: number;
|
||||
@ApiProperty() openPoints!: number;
|
||||
@ApiProperty() blocked!: number;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Points closed per sprint, last three closed sprints',
|
||||
})
|
||||
pointsPerSprint!: number | null;
|
||||
@ApiProperty() overloaded!: boolean;
|
||||
}
|
||||
|
||||
export class BacklogHealthDto {
|
||||
@ApiProperty() open!: number;
|
||||
@ApiProperty() unestimated!: number;
|
||||
@ApiProperty() noArea!: number;
|
||||
@ApiProperty() unassigned!: number;
|
||||
@ApiProperty() stale!: number;
|
||||
@ApiProperty() staleDays!: number;
|
||||
}
|
||||
|
||||
export class ProjectInsightsDto {
|
||||
@ApiProperty() generatedAt!: string;
|
||||
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||
@@ -950,4 +1013,13 @@ export class ProjectInsightsDto {
|
||||
@ApiProperty({ type: RisksDto }) risks!: RisksDto;
|
||||
@ApiProperty({ type: AgingDto }) aging!: AgingDto;
|
||||
@ApiProperty({ type: [StatusTimeDto] }) bottlenecks!: StatusTimeDto[];
|
||||
@ApiProperty({ type: [ReleaseForecastDto] }) releases!: ReleaseForecastDto[];
|
||||
@ApiProperty({
|
||||
type: ChangesSinceDto,
|
||||
nullable: true,
|
||||
description: 'Only when asked with `since`',
|
||||
})
|
||||
changes!: ChangesSinceDto | null;
|
||||
@ApiProperty({ type: [TeamLoadDto] }) team!: TeamLoadDto[];
|
||||
@ApiProperty({ type: BacklogHealthDto }) backlog!: BacklogHealthDto;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,9 @@ export class IssuesController {
|
||||
watching: query.watching,
|
||||
dueFrom: query.dueFrom,
|
||||
dueTo: query.dueTo,
|
||||
noEstimate: query.noEstimate,
|
||||
noArea: query.noArea,
|
||||
updatedBefore: query.updatedBefore,
|
||||
customFields: query.field?.length
|
||||
? Object.fromEntries(
|
||||
query.field.map((f) => [
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ActivityQueryDto,
|
||||
AddMemberDto,
|
||||
AnalyticsQueryDto,
|
||||
InsightsQueryDto,
|
||||
BoardQueryDto,
|
||||
ChangeMemberRoleDto,
|
||||
CreateProjectDto,
|
||||
@@ -257,10 +258,12 @@ export class ProjectsController {
|
||||
async insights(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('project') project: string,
|
||||
@Query() query: InsightsQueryDto,
|
||||
) {
|
||||
return this.projects.insights(
|
||||
viewerOf(me),
|
||||
await this.refs.projectId(viewerOf(me), project),
|
||||
query.since ? new Date(query.since) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { ProjectInsights } from '@task-stream/web/tracker/domain';
|
||||
export const insightsPageEvents = eventGroup({
|
||||
source: 'Dashboard Page',
|
||||
events: {
|
||||
opened: type<{ projectId: string }>(),
|
||||
/** `since`: my last visit, to tell what changed after it. */
|
||||
opened: type<{ projectId: string; since: string | null }>(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ export class InsightsFacade {
|
||||
readonly loaded = this.store.loaded;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads the project's reading and keeps it up to date. */
|
||||
open(projectId: string): void {
|
||||
this.dispatch.opened({ projectId });
|
||||
/**
|
||||
* Loads the project's reading and keeps it up to date; with `since` (my
|
||||
* last visit), it also tells what changed after it.
|
||||
*/
|
||||
open(projectId: string, since: string | null = null): void {
|
||||
this.dispatch.opened({ projectId, since });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const InsightsStore = signalStore(
|
||||
debounceTime(800),
|
||||
startWith(null),
|
||||
switchMap(() =>
|
||||
projects.insights(payload.projectId).pipe(
|
||||
projects.insights(payload.projectId, payload.since).pipe(
|
||||
mapResponse({
|
||||
next: (insights) => insightsApiEvents.loaded(insights),
|
||||
error: (error) =>
|
||||
|
||||
@@ -103,6 +103,11 @@ export interface IssueListFilter {
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
sprintId?: string;
|
||||
/** Only tickets nobody estimated / with no area. */
|
||||
noEstimate?: boolean;
|
||||
noArea?: boolean;
|
||||
/** Only tickets not updated since this instant (ISO). */
|
||||
updatedBefore?: string;
|
||||
/** Only tickets holding these custom values. */
|
||||
customFields?: CustomFieldValue[];
|
||||
limit: number;
|
||||
|
||||
@@ -55,6 +55,53 @@ export interface StatusTime {
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
/** When a planned release would be finished, at the team's real pace. */
|
||||
export interface ReleaseForecast {
|
||||
releaseId: string;
|
||||
name: string;
|
||||
targetDate: string | null;
|
||||
total: number;
|
||||
finished: number;
|
||||
/** Half of the simulated futures finish by then. */
|
||||
likelyDate: string | null;
|
||||
/** 85% of them do. */
|
||||
safeDate: string | null;
|
||||
verdict: 'DONE' | 'NO_DATE' | 'UNKNOWN' | 'ON_TRACK' | 'AT_RISK' | 'LATE';
|
||||
}
|
||||
|
||||
/** What happened after a moment (my last visit). */
|
||||
export interface ChangesSince {
|
||||
since: string;
|
||||
created: InsightIssue[];
|
||||
createdCount: number;
|
||||
finished: InsightIssue[];
|
||||
finishedCount: number;
|
||||
/** Sent back from QA. */
|
||||
returned: InsightIssue[];
|
||||
returnedCount: number;
|
||||
mentions: number;
|
||||
}
|
||||
|
||||
/** One person's open work against what they usually close per sprint. */
|
||||
export interface TeamLoad {
|
||||
user: UserRef;
|
||||
openTickets: number;
|
||||
openPoints: number;
|
||||
blocked: number;
|
||||
pointsPerSprint: number | null;
|
||||
overloaded: boolean;
|
||||
}
|
||||
|
||||
/** Open tickets missing what planning needs. */
|
||||
export interface BacklogHealth {
|
||||
open: number;
|
||||
unestimated: number;
|
||||
noArea: number;
|
||||
unassigned: number;
|
||||
stale: number;
|
||||
staleDays: number;
|
||||
}
|
||||
|
||||
/** The dashboard's reading of a project beyond the counts. */
|
||||
export interface ProjectInsights {
|
||||
generatedAt: string;
|
||||
@@ -62,4 +109,9 @@ export interface ProjectInsights {
|
||||
risks: Risks;
|
||||
aging: { issues: InsightIssue[]; thresholdDays: number | null };
|
||||
bottlenecks: StatusTime[];
|
||||
releases: ReleaseForecast[];
|
||||
/** Only when asked with a `since`. */
|
||||
changes: ChangesSince | null;
|
||||
team: TeamLoad[];
|
||||
backlog: BacklogHealth;
|
||||
}
|
||||
|
||||
@@ -64,8 +64,11 @@ export abstract class ProjectsGateway {
|
||||
abstract publicProjects(): Observable<Project[]>;
|
||||
abstract project(idOrKey: string): Observable<Project>;
|
||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
||||
/** Sprint under way, risks, aging and bottlenecks (the dashboard). */
|
||||
abstract insights(projectId: string): Observable<ProjectInsights>;
|
||||
/** The dashboard; with `since`, also what changed after it. */
|
||||
abstract insights(
|
||||
projectId: string,
|
||||
since: string | null,
|
||||
): Observable<ProjectInsights>;
|
||||
/** Statistics of the last `days` days. */
|
||||
abstract analytics(
|
||||
projectId: string,
|
||||
|
||||
@@ -63,10 +63,13 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
.pipe(map((d) => toStats(d.project.stats)));
|
||||
}
|
||||
|
||||
insights(projectId: string): Observable<ProjectInsights> {
|
||||
insights(
|
||||
projectId: string,
|
||||
since: string | null,
|
||||
): Observable<ProjectInsights> {
|
||||
return this.gql
|
||||
.query(ProjectInsightsDocument, { idOrKey: projectId })
|
||||
.pipe(map((d) => d.project.insights));
|
||||
.query(ProjectInsightsDocument, { idOrKey: projectId, since })
|
||||
.pipe(map((d) => d.project.insights as ProjectInsights));
|
||||
}
|
||||
|
||||
analytics(projectId: string, days: number): Observable<ProjectAnalytics> {
|
||||
|
||||
@@ -178,6 +178,10 @@ export type IssueFilterInput = {
|
||||
/** Due on or before this day (YYYY-MM-DD) */
|
||||
dueTo?: string | null | undefined;
|
||||
labels?: Array<string> | null | undefined;
|
||||
/** Only tickets with no area */
|
||||
noArea?: boolean | null | undefined;
|
||||
/** Only tickets nobody estimated */
|
||||
noEstimate?: boolean | null | undefined;
|
||||
/** Only tickets in no sprint */
|
||||
noSprint?: boolean | null | undefined;
|
||||
/** Only the tickets hanging from this one */
|
||||
@@ -192,6 +196,8 @@ export type IssueFilterInput = {
|
||||
statuses?: Array<string> | null | undefined;
|
||||
types?: Array<string> | null | undefined;
|
||||
unassigned?: boolean | null | undefined;
|
||||
/** Only tickets not updated since this instant (ISO) */
|
||||
updatedBefore?: string | null | undefined;
|
||||
/** Only the tickets I follow (none when anonymous) */
|
||||
watching?: boolean | null | undefined;
|
||||
};
|
||||
@@ -1769,6 +1775,7 @@ export type InsightIssueFieldsFragment = {
|
||||
|
||||
export type ProjectInsightsQueryVariables = Exact<{
|
||||
idOrKey: string;
|
||||
since?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type ProjectInsightsQuery = {
|
||||
@@ -1854,6 +1861,66 @@ export type ProjectInsightsQuery = {
|
||||
wipLimit: number | null;
|
||||
overLimit: boolean;
|
||||
}>;
|
||||
releases: Array<{
|
||||
releaseId: string;
|
||||
name: string;
|
||||
targetDate: string | null;
|
||||
total: number;
|
||||
finished: number;
|
||||
likelyDate: string | null;
|
||||
safeDate: string | null;
|
||||
verdict: string;
|
||||
}>;
|
||||
changes: {
|
||||
since: string;
|
||||
createdCount: number;
|
||||
finishedCount: number;
|
||||
returnedCount: number;
|
||||
mentions: number;
|
||||
created: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
finished: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
returned: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
status: string;
|
||||
days: number;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
}>;
|
||||
} | null;
|
||||
team: Array<{
|
||||
openTickets: number;
|
||||
openPoints: number;
|
||||
blocked: number;
|
||||
pointsPerSprint: number | null;
|
||||
overloaded: boolean;
|
||||
user: { id: string; name: string };
|
||||
}>;
|
||||
backlog: {
|
||||
open: number;
|
||||
unestimated: number;
|
||||
noArea: number;
|
||||
unassigned: number;
|
||||
stale: number;
|
||||
staleDays: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -10928,6 +10995,14 @@ export const ProjectInsightsDocument = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -10952,6 +11027,16 @@ export const ProjectInsightsDocument = {
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'insights' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
@@ -11175,6 +11260,202 @@ export const ProjectInsightsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'changes' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'since' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'created' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createdCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finished' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finishedCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'returned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'InsightIssueFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'returnedCount' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'mentions' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'team' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'user' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: {
|
||||
kind: 'Name',
|
||||
value: 'UserRefFields',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'openTickets' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'openPoints' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'blocked' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'pointsPerSprint' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overloaded' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'backlog' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'open' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unestimated' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'noArea' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unassigned' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'stale' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'staleDays' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -157,10 +157,10 @@ fragment InsightIssueFields on InsightIssue {
|
||||
dueDate
|
||||
}
|
||||
|
||||
query ProjectInsights($idOrKey: String!) {
|
||||
query ProjectInsights($idOrKey: String!, $since: String) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
insights {
|
||||
insights(since: $since) {
|
||||
generatedAt
|
||||
sprint {
|
||||
sprintId
|
||||
@@ -210,6 +210,50 @@ query ProjectInsights($idOrKey: String!) {
|
||||
wipLimit
|
||||
overLimit
|
||||
}
|
||||
releases {
|
||||
releaseId
|
||||
name
|
||||
targetDate
|
||||
total
|
||||
finished
|
||||
likelyDate
|
||||
safeDate
|
||||
verdict
|
||||
}
|
||||
changes {
|
||||
since
|
||||
created {
|
||||
...InsightIssueFields
|
||||
}
|
||||
createdCount
|
||||
finished {
|
||||
...InsightIssueFields
|
||||
}
|
||||
finishedCount
|
||||
returned {
|
||||
...InsightIssueFields
|
||||
}
|
||||
returnedCount
|
||||
mentions
|
||||
}
|
||||
team {
|
||||
user {
|
||||
...UserRefFields
|
||||
}
|
||||
openTickets
|
||||
openPoints
|
||||
blocked
|
||||
pointsPerSprint
|
||||
overloaded
|
||||
}
|
||||
backlog {
|
||||
open
|
||||
unestimated
|
||||
noArea
|
||||
unassigned
|
||||
stale
|
||||
staleDays
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,17 @@ import {
|
||||
ProjectRole,
|
||||
QA_FAILED,
|
||||
StatusCategory,
|
||||
TeamLoad,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
||||
import { AgingComponent } from '../ui/insights/aging';
|
||||
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
|
||||
import { RisksComponent } from '../ui/insights/risks';
|
||||
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
||||
import { ChangesSinceComponent } from '../ui/insights/changes-since';
|
||||
import { BacklogHealthComponent } from '../ui/insights/backlog-health';
|
||||
import { takeLastVisit } from '../ui/last-visit';
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
@@ -41,6 +46,9 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
RisksComponent,
|
||||
AgingComponent,
|
||||
BottlenecksComponent,
|
||||
ReleaseForecastComponent,
|
||||
ChangesSinceComponent,
|
||||
BacklogHealthComponent,
|
||||
],
|
||||
providers: [provideInsightsFacade()],
|
||||
template: `
|
||||
@@ -102,10 +110,16 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
/>
|
||||
} @else if (insights.insights(); as reading) {
|
||||
<div class="ts-dashboard ts-insights">
|
||||
@if (reading.changes; as changes) {
|
||||
<ts-changes-since [changes]="changes" />
|
||||
}
|
||||
@if (reading.sprint; as sprint) {
|
||||
<ts-sprint-pulse [sprint]="sprint" />
|
||||
}
|
||||
<ts-risks [risks]="reading.risks" />
|
||||
@if (reading.releases.length) {
|
||||
<ts-release-forecast [releases]="reading.releases" />
|
||||
}
|
||||
<ts-aging
|
||||
[issues]="reading.aging.issues"
|
||||
[thresholdDays]="reading.aging.thresholdDays"
|
||||
@@ -114,6 +128,7 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
[statuses]="reading.bottlenecks"
|
||||
[workflow]="facade.workflow()"
|
||||
/>
|
||||
<ts-backlog-health [health]="reading.backlog" />
|
||||
</div>
|
||||
} @else {
|
||||
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
|
||||
@@ -155,6 +170,12 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
} @else {
|
||||
<span class="ts-pill">Ya no es miembro</span>
|
||||
}
|
||||
@if (loadOf().get(person.id); as load) {
|
||||
@if (load.overloaded) {
|
||||
<span class="ts-pill ts-pill--warn">Sobrecarga</span>
|
||||
}
|
||||
<span class="ts-team__load">{{ loadText(load) }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="ts-bar" aria-hidden="true"
|
||||
><span
|
||||
@@ -382,6 +403,14 @@ import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-team__load {
|
||||
flex-basis: 100%;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-pill--warn {
|
||||
--tone: var(--ts-c-red, #d14343);
|
||||
}
|
||||
.ts-team__go {
|
||||
color: var(--ts-brand);
|
||||
opacity: 0;
|
||||
@@ -424,10 +453,15 @@ export class DashboardPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly insights = inject(InsightsFacade);
|
||||
|
||||
/** Opened once per project: the project itself changes on every update. */
|
||||
private openedFor: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.facade.project()?.id;
|
||||
if (projectId) untracked(() => this.insights.open(projectId));
|
||||
if (!projectId || projectId === this.openedFor) return;
|
||||
this.openedFor = projectId;
|
||||
untracked(() => this.insights.open(projectId, takeLastVisit(projectId)));
|
||||
});
|
||||
}
|
||||
protected readonly initialsOf = initials;
|
||||
@@ -557,6 +591,25 @@ export class DashboardPage {
|
||||
(a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'),
|
||||
);
|
||||
});
|
||||
/** Open points and usual pace per person (from the insights). */
|
||||
protected readonly loadOf = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(this.insights.insights()?.team ?? []).map((t) => [t.user.id, t]),
|
||||
),
|
||||
);
|
||||
protected loadText(load: TeamLoad): string {
|
||||
const parts = [`${load.openPoints} pts abiertos`];
|
||||
if (load.pointsPerSprint !== null)
|
||||
parts.push(
|
||||
`cierra ${load.pointsPerSprint.toLocaleString('es')} por sprint`,
|
||||
);
|
||||
if (load.blocked)
|
||||
parts.push(
|
||||
`${load.blocked} ${load.blocked === 1 ? 'bloqueado' : 'bloqueados'}`,
|
||||
);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
protected readonly unassigned = computed(
|
||||
() => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -130,6 +131,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (onlyLabel(); as only) {
|
||||
<p class="ts-list__only">
|
||||
Solo {{ only }}
|
||||
<button type="button" class="ts-plain-link" (click)="clearOnly()">
|
||||
Quitar
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (facade.error(); as error) {
|
||||
<pi-note
|
||||
@@ -344,6 +353,14 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
.ts-list__count {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-list__only {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-list {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
@@ -452,6 +469,38 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
export class ListPage {
|
||||
/** `?release=<id>`: opens already filtered by that release. */
|
||||
readonly release = input<string | undefined>(undefined);
|
||||
/** `?missing=estimate|area|assignee` (from the dashboard's backlog health). */
|
||||
readonly missing = input<string | undefined>(undefined);
|
||||
/** `?staleDays=90`: only tickets not updated in that many days. */
|
||||
readonly staleDays = input<string | undefined>(undefined);
|
||||
/** What the URL asked for, until cleared here. */
|
||||
protected readonly gap = linkedSignal(() =>
|
||||
this.missing() === 'estimate' || this.missing() === 'area'
|
||||
? this.missing()
|
||||
: null,
|
||||
);
|
||||
protected readonly stale = linkedSignal(() => {
|
||||
const days = Math.round(Number(this.staleDays()));
|
||||
return days > 0 ? days : null;
|
||||
});
|
||||
private readonly unassignedFromUrl = effect(() => {
|
||||
if (this.missing() === 'assignee')
|
||||
untracked(() => this.assignee.set(NOBODY));
|
||||
});
|
||||
protected readonly onlyLabel = computed(() =>
|
||||
[
|
||||
this.gap() === 'estimate' ? 'sin estimar' : null,
|
||||
this.gap() === 'area' ? 'sin área' : null,
|
||||
this.stale() ? `sin tocar en ${this.stale()} días` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' y '),
|
||||
);
|
||||
|
||||
protected clearOnly(): void {
|
||||
this.gap.set(null);
|
||||
this.stale.set(null);
|
||||
}
|
||||
protected readonly facade = inject(ListFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
protected readonly NOBODY = NOBODY;
|
||||
@@ -577,6 +626,13 @@ export class ListPage {
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
noEstimate: this.gap() === 'estimate' || undefined,
|
||||
noArea: this.gap() === 'area' || undefined,
|
||||
updatedBefore: this.stale()
|
||||
? new Date(
|
||||
Date.now() - (this.stale() as number) * 86_400_000,
|
||||
).toISOString()
|
||||
: undefined,
|
||||
customFields: fields.length
|
||||
? fields.map(([fieldId, value]) => ({ fieldId, value }))
|
||||
: undefined,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { BacklogHealth } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** Open tickets missing what planning needs, each count opening the list. */
|
||||
@Component({
|
||||
selector: 'ts-backlog-health',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'health-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="health-title">Salud del backlog</h2>
|
||||
<span class="ts-muted">De {{ health().open }} tickets abiertos</span>
|
||||
</header>
|
||||
<ul class="ts-issues ts-health">
|
||||
@for (row of rows(); track row.label) {
|
||||
<li>
|
||||
<a class="ts-title" routerLink="../list" [queryParams]="row.query">{{
|
||||
row.label
|
||||
}}</a>
|
||||
<span class="ts-bar"><span [style.width.%]="row.share"></span></span>
|
||||
<span class="ts-days" [class.ts-warn]="row.value > 0">{{
|
||||
row.value
|
||||
}}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
`,
|
||||
styles: `
|
||||
.ts-health {
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
grid-template-columns: minmax(8rem, 12rem) 1fr 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-days {
|
||||
text-align: right;
|
||||
}
|
||||
.ts-warn {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BacklogHealthComponent {
|
||||
readonly health = input.required<BacklogHealth>();
|
||||
|
||||
protected readonly rows = computed(() => {
|
||||
const h = this.health();
|
||||
const share = (n: number) => (h.open ? (n / h.open) * 100 : 0);
|
||||
return [
|
||||
{
|
||||
label: 'Sin estimar',
|
||||
value: h.unestimated,
|
||||
query: { missing: 'estimate' },
|
||||
},
|
||||
{ label: 'Sin área', value: h.noArea, query: { missing: 'area' } },
|
||||
{
|
||||
label: 'Sin asignar',
|
||||
value: h.unassigned,
|
||||
query: { missing: 'assignee' },
|
||||
},
|
||||
{
|
||||
label: `Sin tocar en ${h.staleDays} días`,
|
||||
value: h.stale,
|
||||
query: { staleDays: h.staleDays },
|
||||
},
|
||||
].map((row) => ({ ...row, share: share(row.value) }));
|
||||
});
|
||||
}
|
||||
105
libs/web/tracker/presenters/src/lib/ui/insights/changes-since.ts
Normal file
105
libs/web/tracker/presenters/src/lib/ui/insights/changes-since.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ChangesSince } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from '../copy';
|
||||
|
||||
/** What happened in the project since my last visit. */
|
||||
@Component({
|
||||
selector: 'ts-changes-since',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'changes-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="changes-title">Desde tu última visita</h2>
|
||||
<span class="ts-muted">{{ ago() }}</span>
|
||||
</header>
|
||||
<dl class="ts-changes__counts">
|
||||
@for (count of counts(); track count.label) {
|
||||
<div>
|
||||
<dt>{{ count.label }}</dt>
|
||||
<dd>{{ count.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@for (group of groups(); track group.title) {
|
||||
@if (group.issues.length) {
|
||||
<h3>{{ group.title }}</h3>
|
||||
<ul class="ts-issues">
|
||||
@for (issue of group.issues; track issue.id) {
|
||||
<li>
|
||||
<a class="ts-key" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<a class="ts-title" [routerLink]="['../issues', issue.key]">{{
|
||||
issue.title
|
||||
}}</a>
|
||||
<span></span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
@if (quiet()) {
|
||||
<p class="ts-empty">Nada nuevo desde entonces.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-changes__counts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.75rem;
|
||||
dt {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ChangesSinceComponent {
|
||||
readonly changes = input.required<ChangesSince>();
|
||||
|
||||
protected readonly ago = computed(() => timeAgo(this.changes().since));
|
||||
protected readonly counts = computed(() => {
|
||||
const c = this.changes();
|
||||
return [
|
||||
{ label: 'Nuevos', value: c.createdCount },
|
||||
{ label: 'Terminados', value: c.finishedCount },
|
||||
{ label: 'Vueltos de QA', value: c.returnedCount },
|
||||
{ label: 'Menciones', value: c.mentions },
|
||||
];
|
||||
});
|
||||
protected readonly groups = computed(() => {
|
||||
const c = this.changes();
|
||||
return [
|
||||
{ title: 'Vueltos de QA', issues: c.returned },
|
||||
{ title: 'Nuevos', issues: c.created },
|
||||
{ title: 'Terminados', issues: c.finished },
|
||||
];
|
||||
});
|
||||
protected readonly quiet = computed(() =>
|
||||
this.counts().every((c) => !c.value),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ReleaseForecast } from '@task-stream/web/tracker/domain';
|
||||
|
||||
const short = (day: string | null) =>
|
||||
day
|
||||
? new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
: '—';
|
||||
|
||||
const VERDICT: Record<
|
||||
ReleaseForecast['verdict'],
|
||||
{ label: string; tone: string }
|
||||
> = {
|
||||
ON_TRACK: { label: 'Llega', tone: 'var(--ts-c-green, #2e9e6a)' },
|
||||
AT_RISK: { label: 'Justa', tone: 'var(--ts-c-amber, #c98a12)' },
|
||||
LATE: { label: 'No llega', tone: 'var(--ts-c-red, #d14343)' },
|
||||
DONE: { label: 'Todo hecho', tone: 'var(--ts-c-green, #2e9e6a)' },
|
||||
NO_DATE: { label: 'Sin fecha', tone: 'var(--ts-muted)' },
|
||||
UNKNOWN: { label: 'Sin ritmo aún', tone: 'var(--ts-muted)' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Planned releases with the date they would likely be finished at the
|
||||
* team's real pace (Monte Carlo over the last six weeks).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-release-forecast',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
host: {
|
||||
class: 'ts-panel',
|
||||
'aria-labelledby': 'forecast-title',
|
||||
role: 'region',
|
||||
},
|
||||
styleUrl: './insight-panel.scss',
|
||||
template: `
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="forecast-title">Próximas releases</h2>
|
||||
<span class="ts-muted">Previsión al ritmo de las últimas 6 semanas</span>
|
||||
</header>
|
||||
@if (rows().length) {
|
||||
<ul class="ts-issues ts-forecast">
|
||||
@for (row of rows(); track row.releaseId) {
|
||||
<li>
|
||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||
<span class="ts-verdict" [style.--tone]="row.tone">{{
|
||||
row.label
|
||||
}}</span>
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="row.share"></span
|
||||
></span>
|
||||
<span class="ts-days">{{ row.finished }}/{{ row.total }}</span>
|
||||
<span class="ts-forecast__dates">
|
||||
Objetivo {{ row.target }} · probable {{ row.likely }} · segura
|
||||
{{ row.safe }}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-empty">No hay releases planificadas.</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-forecast {
|
||||
gap: 0.875rem;
|
||||
li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
row-gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-bar {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
.ts-verdict {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--tone) 14%, transparent);
|
||||
color: var(--tone);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-forecast__dates {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ReleaseForecastComponent {
|
||||
readonly releases = input.required<ReleaseForecast[]>();
|
||||
|
||||
protected readonly rows = computed(() =>
|
||||
this.releases().map((r) => ({
|
||||
...r,
|
||||
...VERDICT[r.verdict],
|
||||
share: r.total ? (r.finished / r.total) * 100 : 0,
|
||||
target: short(r.targetDate),
|
||||
likely: short(r.likelyDate),
|
||||
safe: short(r.safeDate),
|
||||
})),
|
||||
);
|
||||
}
|
||||
19
libs/web/tracker/presenters/src/lib/ui/last-visit.ts
Normal file
19
libs/web/tracker/presenters/src/lib/ui/last-visit.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* When I last opened a project's dashboard, kept in this browser only: it
|
||||
* is a convenience ("what changed since"), so storage may fail or be empty.
|
||||
*/
|
||||
const key = (projectId: string) => `task-stream.lastVisit.${projectId}`;
|
||||
|
||||
/** The previous visit (ISO), and from now on this one. */
|
||||
export function takeLastVisit(
|
||||
projectId: string,
|
||||
now = new Date(),
|
||||
): string | null {
|
||||
try {
|
||||
const previous = localStorage.getItem(key(projectId));
|
||||
localStorage.setItem(key(projectId), now.toISOString());
|
||||
return previous && !Number.isNaN(Date.parse(previous)) ? previous : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user