feat: estimate tickets and see the team's velocity
Story points can be set on the ticket page and when creating a ticket, and show on cards, column headers, subtasks and the backlog. The Sprints tab charts the points finished per closed sprint and warns when a planned sprint holds more than the recent average. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
|
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from './lib/ports/gateways';
|
|||||||
export * from './lib/models/mentions';
|
export * from './lib/models/mentions';
|
||||||
export * from './lib/models/hierarchy';
|
export * from './lib/models/hierarchy';
|
||||||
export * from './lib/models/views';
|
export * from './lib/models/views';
|
||||||
|
export * from './lib/models/estimation';
|
||||||
|
|||||||
37
libs/web/tracker/domain/src/lib/models/estimation.spec.ts
Normal file
37
libs/web/tracker/domain/src/lib/models/estimation.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { SprintStatus } from './enums';
|
||||||
|
import { velocity } from './estimation';
|
||||||
|
import { sumPoints } from './hierarchy';
|
||||||
|
import { Sprint } from './models';
|
||||||
|
|
||||||
|
const sprint = (
|
||||||
|
id: string,
|
||||||
|
startDate: string,
|
||||||
|
done: number,
|
||||||
|
status = SprintStatus.CLOSED,
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
startDate,
|
||||||
|
status,
|
||||||
|
progress: { storyPoints: done + 2, finishedStoryPoints: done },
|
||||||
|
}) as Sprint;
|
||||||
|
|
||||||
|
describe('estimation', () => {
|
||||||
|
it('averages the points finished in the last closed sprints', () => {
|
||||||
|
const { history, average } = velocity([
|
||||||
|
sprint('s4', '2026-10-26', 30, SprintStatus.ACTIVE),
|
||||||
|
sprint('s3', '2026-10-12', 21),
|
||||||
|
sprint('s1', '2026-09-14', 8),
|
||||||
|
sprint('s2', '2026-09-28', 13),
|
||||||
|
sprint('s0', '2026-09-01', 99),
|
||||||
|
]);
|
||||||
|
expect(history.map((h) => h.sprintId)).toEqual(['s0', 's1', 's2', 's3']);
|
||||||
|
expect(average).toBe(14);
|
||||||
|
expect(velocity([]).average).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts unestimated tickets as zero', () => {
|
||||||
|
expect(sumPoints([{ storyPoints: 3 }, { storyPoints: null }, {}])).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
libs/web/tracker/domain/src/lib/models/estimation.ts
Normal file
40
libs/web/tracker/domain/src/lib/models/estimation.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { SprintStatus } from './enums';
|
||||||
|
import { Sprint } from './models';
|
||||||
|
|
||||||
|
/** The usual scale, so estimates stay comparable (the API takes 0-100). */
|
||||||
|
export const POINT_SCALE = [0, 1, 2, 3, 5, 8, 13, 21] as const;
|
||||||
|
|
||||||
|
export interface SprintVelocity {
|
||||||
|
sprintId: string;
|
||||||
|
name: string;
|
||||||
|
/** Points in it when it closed. */
|
||||||
|
committed: number;
|
||||||
|
/** Of those, finished (done or cancelled). */
|
||||||
|
done: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points finished per closed sprint (oldest first) and the average of the
|
||||||
|
* last `window` of them: a team's pace, to plan the next one.
|
||||||
|
*/
|
||||||
|
export function velocity(
|
||||||
|
sprints: Sprint[],
|
||||||
|
window = 3,
|
||||||
|
): { history: SprintVelocity[]; average: number | null } {
|
||||||
|
const history = sprints
|
||||||
|
.filter((s) => s.status === SprintStatus.CLOSED)
|
||||||
|
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||||
|
.map((s) => ({
|
||||||
|
sprintId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
committed: s.progress.storyPoints,
|
||||||
|
done: s.progress.finishedStoryPoints,
|
||||||
|
}));
|
||||||
|
const recent = history.slice(-window);
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
average: recent.length
|
||||||
|
? Math.round(recent.reduce((sum, s) => sum + s.done, 0) / recent.length)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,23 +6,26 @@ const FINISHED: StatusCategory[] = [
|
|||||||
StatusCategory.CANCELLED,
|
StatusCategory.CANCELLED,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Progress of the tickets hanging from one (story points are not loaded here). */
|
/** Progress of the tickets hanging from one. */
|
||||||
export function childProgress(children: IssueSummary[]): ChildProgress {
|
export function childProgress(children: IssueSummary[]): ChildProgress {
|
||||||
const finished = children.filter((c) =>
|
const done = children.filter((c) => FINISHED.includes(c.statusCategory));
|
||||||
FINISHED.includes(c.statusCategory),
|
|
||||||
).length;
|
|
||||||
return {
|
return {
|
||||||
total: children.length,
|
total: children.length,
|
||||||
finished,
|
finished: done.length,
|
||||||
percent:
|
percent:
|
||||||
children.length === 0
|
children.length === 0
|
||||||
? 0
|
? 0
|
||||||
: Math.round((finished / children.length) * 100),
|
: Math.round((done.length / children.length) * 100),
|
||||||
storyPoints: 0,
|
storyPoints: sumPoints(children),
|
||||||
finishedStoryPoints: 0,
|
finishedStoryPoints: sumPoints(done),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Estimated points of some tickets (unestimated ones count 0). */
|
||||||
|
export function sumPoints(issues: { storyPoints?: number | null }[]): number {
|
||||||
|
return issues.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
/** "3/5": ticked steps of a checklist. */
|
/** "3/5": ticked steps of a checklist. */
|
||||||
export function checklistProgress(items: ChecklistItem[]): {
|
export function checklistProgress(items: ChecklistItem[]): {
|
||||||
done: number;
|
done: number;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface CreateIssueInput {
|
|||||||
priority?: IssuePriority;
|
priority?: IssuePriority;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
figmaUrl?: string | null;
|
figmaUrl?: string | null;
|
||||||
|
storyPoints?: number | null;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
/** Epic or ticket the new one breaks down. */
|
/** Epic or ticket the new one breaks down. */
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export interface IssueSummary {
|
|||||||
parent: IssueParent | null;
|
parent: IssueParent | null;
|
||||||
/** Place in the backlog and in its board column (ascending, as text). */
|
/** Place in the backlog and in its board column (ascending, as text). */
|
||||||
rank: string;
|
rank: string;
|
||||||
|
/** Estimate; null when nobody estimated it. */
|
||||||
|
storyPoints: number | null;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -141,7 +143,6 @@ export interface Issue extends IssueSummary {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
reporter: UserRef;
|
reporter: UserRef;
|
||||||
figmaUrl: string | null;
|
figmaUrl: string | null;
|
||||||
storyPoints: number | null;
|
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
links: IssueLink[];
|
links: IssueLink[];
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
|
|||||||
@@ -79,13 +79,13 @@ fragment IssueSummaryFields on Issue {
|
|||||||
title
|
title
|
||||||
}
|
}
|
||||||
rank
|
rank
|
||||||
|
storyPoints
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment IssueFields on Issue {
|
fragment IssueFields on Issue {
|
||||||
...IssueSummaryFields
|
...IssueSummaryFields
|
||||||
description
|
description
|
||||||
figmaUrl
|
figmaUrl
|
||||||
storyPoints
|
|
||||||
dueDate
|
dueDate
|
||||||
createdAt
|
createdAt
|
||||||
allowedTransitions
|
allowedTransitions
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export type IssueSummaryFieldsFragment = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -298,7 +299,6 @@ export type IssueSummaryFieldsFragment = {
|
|||||||
export type IssueFieldsFragment = {
|
export type IssueFieldsFragment = {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
figmaUrl: string | null;
|
figmaUrl: string | null;
|
||||||
storyPoints: number | null;
|
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
allowedTransitions: Array<string>;
|
allowedTransitions: Array<string>;
|
||||||
@@ -316,6 +316,7 @@ export type IssueFieldsFragment = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
reporter: { id: string; name: string };
|
reporter: { id: string; name: string };
|
||||||
links: Array<{
|
links: Array<{
|
||||||
type: IssueLinkType;
|
type: IssueLinkType;
|
||||||
@@ -409,6 +410,7 @@ export type BoardQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -426,7 +428,6 @@ export type IssueQuery = {
|
|||||||
issue: {
|
issue: {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
figmaUrl: string | null;
|
figmaUrl: string | null;
|
||||||
storyPoints: number | null;
|
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
allowedTransitions: Array<string>;
|
allowedTransitions: Array<string>;
|
||||||
@@ -444,6 +445,7 @@ export type IssueQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
reporter: { id: string; name: string };
|
reporter: { id: string; name: string };
|
||||||
links: Array<{
|
links: Array<{
|
||||||
type: IssueLinkType;
|
type: IssueLinkType;
|
||||||
@@ -490,6 +492,7 @@ export type SearchIssuesQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -642,6 +645,7 @@ export type MyIssuesQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -671,6 +675,7 @@ export type IssueChildrenQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -700,6 +705,7 @@ export type EpicsQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -771,6 +777,7 @@ export type BacklogQuery = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -1396,6 +1403,7 @@ export type IssueChangedSubscription = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
rank: string;
|
rank: string;
|
||||||
|
storyPoints: number | null;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
@@ -1783,6 +1791,7 @@ export const IssueSummaryFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1822,7 +1831,6 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
{
|
{
|
||||||
@@ -1997,6 +2005,7 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2658,6 +2667,7 @@ export const BoardDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2801,6 +2811,7 @@ export const IssueDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2820,7 +2831,6 @@ export const IssueDocument = {
|
|||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
{
|
{
|
||||||
@@ -3063,6 +3073,7 @@ export const SearchIssuesDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4290,6 +4301,7 @@ export const MyIssuesDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4447,6 +4459,7 @@ export const IssueChildrenDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4622,6 +4635,7 @@ export const EpicsDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5101,6 +5115,7 @@ export const BacklogDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -8748,6 +8763,7 @@ export const IssueChangedDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
untracked,
|
untracked,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
ProjectFacade,
|
ProjectFacade,
|
||||||
provideBacklogFacade,
|
provideBacklogFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import { IssueSummary } from '@task-stream/web/tracker/domain';
|
import { IssueSummary, sumPoints } from '@task-stream/web/tracker/domain';
|
||||||
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||||
|
|
||||||
/** /p/:key/backlog — what is still to start, in the order the team takes it. */
|
/** /p/:key/backlog — what is still to start, in the order the team takes it. */
|
||||||
@@ -43,6 +44,11 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra
|
Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra
|
||||||
para reordenar; las columnas del tablero siguen este mismo orden.
|
para reordenar; las columnas del tablero siguen este mismo orden.
|
||||||
</p>
|
</p>
|
||||||
|
@if (facade.loaded() && facade.issues().length) {
|
||||||
|
<span class="ts-muted ts-mono"
|
||||||
|
>{{ points() }} pts estimados · {{ unestimated() }} sin estimar</span
|
||||||
|
>
|
||||||
|
}
|
||||||
@if (facade.loaded() && facade.total() > facade.issues().length) {
|
@if (facade.loaded() && facade.total() > facade.issues().length) {
|
||||||
<span class="ts-muted ts-mono"
|
<span class="ts-muted ts-mono"
|
||||||
>{{ facade.issues().length }} de {{ facade.total() }}</span
|
>{{ facade.issues().length }} de {{ facade.total() }}</span
|
||||||
@@ -92,6 +98,9 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
<span class="ts-backlog__priority ts-muted">{{
|
<span class="ts-backlog__priority ts-muted">{{
|
||||||
priorityLabel(issue)
|
priorityLabel(issue)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<span class="ts-backlog__points ts-mono" title="Estimación">{{
|
||||||
|
issue.storyPoints ?? '—'
|
||||||
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="ts-backlog__who"
|
class="ts-backlog__who"
|
||||||
[title]="issue.assignee?.name ?? 'Sin asignar'"
|
[title]="issue.assignee?.name ?? 'Sin asignar'"
|
||||||
@@ -138,7 +147,10 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
}
|
}
|
||||||
.ts-backlog__row {
|
.ts-backlog__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem auto auto minmax(0, 1fr) auto auto auto auto;
|
grid-template-columns: 2rem auto auto minmax(
|
||||||
|
0,
|
||||||
|
1fr
|
||||||
|
) auto auto 2rem auto auto;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.375rem 0.875rem;
|
padding: 0.375rem 0.875rem;
|
||||||
@@ -151,6 +163,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
grid-template-columns: 2rem auto minmax(0, 1fr) auto;
|
grid-template-columns: 2rem auto minmax(0, 1fr) auto;
|
||||||
.ts-backlog__type,
|
.ts-backlog__type,
|
||||||
|
.ts-backlog__points,
|
||||||
.ts-pill,
|
.ts-pill,
|
||||||
.ts-backlog__priority,
|
.ts-backlog__priority,
|
||||||
.ts-backlog__who {
|
.ts-backlog__who {
|
||||||
@@ -180,6 +193,10 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
.ts-backlog__priority {
|
.ts-backlog__priority {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
.ts-backlog__points {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.ts-backlog__who {
|
.ts-backlog__who {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -205,6 +222,10 @@ export class BacklogPage {
|
|||||||
protected readonly facade = inject(BacklogFacade);
|
protected readonly facade = inject(BacklogFacade);
|
||||||
protected readonly project = inject(ProjectFacade);
|
protected readonly project = inject(ProjectFacade);
|
||||||
protected readonly initialsOf = initials;
|
protected readonly initialsOf = initials;
|
||||||
|
protected readonly points = computed(() => sumPoints(this.facade.issues()));
|
||||||
|
protected readonly unestimated = computed(
|
||||||
|
() => this.facade.issues().filter((i) => i.storyPoints === null).length,
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
|
|||||||
@@ -131,3 +131,8 @@
|
|||||||
outline-offset: -4px;
|
outline-offset: -4px;
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-column__points {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ import {
|
|||||||
boardFilterOf,
|
boardFilterOf,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
SavedFilter,
|
SavedFilter,
|
||||||
|
sumPoints,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { IssueCardComponent } from '../ui/issue-card';
|
import { IssueCardComponent } from '../ui/issue-card';
|
||||||
import { options, PRIORITY, statusLook } from '../ui/copy';
|
import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
|
||||||
|
|
||||||
const ALL = '';
|
const ALL = '';
|
||||||
/** "Sin asignar" in the person filter. */
|
/** "Sin asignar" in the person filter. */
|
||||||
@@ -186,7 +188,14 @@ const NOBODY = '__nobody__';
|
|||||||
<span class="ts-column__name">{{
|
<span class="ts-column__name">{{
|
||||||
statusLabel(column.status)
|
statusLabel(column.status)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="ts-column__count">{{ column.total }}</span>
|
<span class="ts-column__count"
|
||||||
|
>{{ column.total }}
|
||||||
|
@if (pointsOf(column); as points) {
|
||||||
|
<span class="ts-column__points" title="Puntos estimados"
|
||||||
|
>· {{ points }} pts</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
class="ts-column__list"
|
class="ts-column__list"
|
||||||
@@ -254,6 +263,12 @@ const NOBODY = '__nobody__';
|
|||||||
<pi-select-field [formField]="f.area" [options]="areaOptions()">
|
<pi-select-field [formField]="f.area" [options]="areaOptions()">
|
||||||
<pi-label>Área</pi-label>
|
<pi-label>Área</pi-label>
|
||||||
</pi-select-field>
|
</pi-select-field>
|
||||||
|
<pi-select-field
|
||||||
|
[formField]="f.storyPoints"
|
||||||
|
[options]="pointsOptions"
|
||||||
|
>
|
||||||
|
<pi-label>Estimación</pi-label>
|
||||||
|
</pi-select-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="ts-form__row">
|
<div class="ts-form__row">
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
@@ -461,6 +476,9 @@ export class BoardPage {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
protected readonly priorityOptions = options(PRIORITY);
|
protected readonly priorityOptions = options(PRIORITY);
|
||||||
|
protected readonly pointsOptions = pointOptions(POINT_SCALE);
|
||||||
|
/** Estimated points of the cards loaded in a column. */
|
||||||
|
protected pointsOf = (column: BoardColumn) => sumPoints(column.issues);
|
||||||
protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
|
protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
|
||||||
() =>
|
() =>
|
||||||
this.project
|
this.project
|
||||||
@@ -477,6 +495,7 @@ export class BoardPage {
|
|||||||
figmaUrl: '',
|
figmaUrl: '',
|
||||||
priority: IssuePriority.MEDIUM as string,
|
priority: IssuePriority.MEDIUM as string,
|
||||||
area: ALL as string,
|
area: ALL as string,
|
||||||
|
storyPoints: '' as string,
|
||||||
assigneeId: ALL as string,
|
assigneeId: ALL as string,
|
||||||
releaseId: ALL as string,
|
releaseId: ALL as string,
|
||||||
});
|
});
|
||||||
@@ -648,6 +667,7 @@ export class BoardPage {
|
|||||||
figmaUrl: '',
|
figmaUrl: '',
|
||||||
priority: IssuePriority.MEDIUM,
|
priority: IssuePriority.MEDIUM,
|
||||||
area: ALL,
|
area: ALL,
|
||||||
|
storyPoints: '',
|
||||||
assigneeId: ALL,
|
assigneeId: ALL,
|
||||||
releaseId: this.releaseFilter() || ALL,
|
releaseId: this.releaseFilter() || ALL,
|
||||||
});
|
});
|
||||||
@@ -669,6 +689,7 @@ export class BoardPage {
|
|||||||
: null,
|
: null,
|
||||||
priority: v.priority as IssuePriority,
|
priority: v.priority as IssuePriority,
|
||||||
area: v.area || null,
|
area: v.area || null,
|
||||||
|
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
|
||||||
assigneeId: v.assigneeId || null,
|
assigneeId: v.assigneeId || null,
|
||||||
releaseId: v.releaseId || null,
|
releaseId: v.releaseId || null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
matches,
|
matches,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
mentionSegments,
|
mentionSegments,
|
||||||
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
initials,
|
initials,
|
||||||
LINK,
|
LINK,
|
||||||
options,
|
options,
|
||||||
|
pointOptions,
|
||||||
PRIORITY,
|
PRIORITY,
|
||||||
statusLook,
|
statusLook,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
@@ -248,7 +250,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
</h2>
|
</h2>
|
||||||
@if (facade.children().length) {
|
@if (facade.children().length) {
|
||||||
<pi-progress-bar
|
<pi-progress-bar
|
||||||
[label]="facade.childProgress().percent + '% terminado'"
|
[label]="
|
||||||
|
facade.childProgress().percent +
|
||||||
|
'% terminado' +
|
||||||
|
(facade.childProgress().storyPoints
|
||||||
|
? ' · ' +
|
||||||
|
facade.childProgress().finishedStoryPoints +
|
||||||
|
' de ' +
|
||||||
|
facade.childProgress().storyPoints +
|
||||||
|
' puntos'
|
||||||
|
: '')
|
||||||
|
"
|
||||||
[value]="facade.childProgress().percent"
|
[value]="facade.childProgress().percent"
|
||||||
/>
|
/>
|
||||||
<ul class="ts-children">
|
<ul class="ts-children">
|
||||||
@@ -618,6 +630,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
>
|
>
|
||||||
<pi-label>Prioridad</pi-label>
|
<pi-label>Prioridad</pi-label>
|
||||||
</pi-select-field>
|
</pi-select-field>
|
||||||
|
<pi-select-field
|
||||||
|
[options]="pointsOptions()"
|
||||||
|
[disabled]="!project.canContribute()"
|
||||||
|
[value]="issue.storyPoints === null ? '' : '' + issue.storyPoints"
|
||||||
|
(valueChange)="update({ storyPoints: $event ? +$event : null })"
|
||||||
|
>
|
||||||
|
<pi-label>Estimación</pi-label>
|
||||||
|
</pi-select-field>
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
[options]="areaOptions()"
|
[options]="areaOptions()"
|
||||||
[disabled]="!project.canContribute()"
|
[disabled]="!project.canContribute()"
|
||||||
@@ -801,6 +821,9 @@ export class IssuePage {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
protected readonly priorityOptions = options(PRIORITY);
|
protected readonly priorityOptions = options(PRIORITY);
|
||||||
|
protected readonly pointsOptions = computed(() =>
|
||||||
|
pointOptions(POINT_SCALE, this.facade.issue()?.storyPoints ?? null),
|
||||||
|
);
|
||||||
/** Active values, plus the ticket's own even if archived since. */
|
/** Active values, plus the ticket's own even if archived since. */
|
||||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
() => {
|
() => {
|
||||||
@@ -964,6 +987,7 @@ export class IssuePage {
|
|||||||
type?: string;
|
type?: string;
|
||||||
priority?: IssuePriority;
|
priority?: IssuePriority;
|
||||||
area?: string | null;
|
area?: string | null;
|
||||||
|
storyPoints?: number | null;
|
||||||
}): void {
|
}): void {
|
||||||
this.facade.update(changes);
|
this.facade.update(changes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import {
|
|||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import { Sprint, SprintStatus } from '@task-stream/web/tracker/domain';
|
import {
|
||||||
|
Sprint,
|
||||||
|
SprintStatus,
|
||||||
|
velocity,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
|
import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
|
||||||
|
|
||||||
const DAY = 86_400_000;
|
const DAY = 86_400_000;
|
||||||
@@ -175,6 +179,14 @@ const STATUS: Record<SprintStatus, string> = {
|
|||||||
>{{ sprint.progress.total }} tickets ·
|
>{{ sprint.progress.total }} tickets ·
|
||||||
{{ sprint.progress.storyPoints }} pts</span
|
{{ sprint.progress.storyPoints }} pts</span
|
||||||
>
|
>
|
||||||
|
@if (overCommitted(sprint)) {
|
||||||
|
<span class="ts-sprint__warn"
|
||||||
|
>Por encima de la velocidad media ({{
|
||||||
|
pace().average
|
||||||
|
}}
|
||||||
|
pts)</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (project.canManage()) {
|
@if (project.canManage()) {
|
||||||
<span class="ts-sprint__row-actions">
|
<span class="ts-sprint__row-actions">
|
||||||
@@ -224,6 +236,26 @@ const STATUS: Record<SprintStatus, string> = {
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (pace().history.length) {
|
||||||
|
<h2 class="ts-sprints__title">Velocidad</h2>
|
||||||
|
<section class="ts-panel ts-velocity" aria-label="Velocidad">
|
||||||
|
<p class="ts-muted">
|
||||||
|
Puntos terminados en cada sprint cerrado.
|
||||||
|
@if (pace().average !== null) {
|
||||||
|
Media de los últimos {{ paceWindow }}:
|
||||||
|
<strong>{{ pace().average }} puntos</strong> por sprint.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<ts-series-chart
|
||||||
|
axisLabel="Sprint"
|
||||||
|
[labels]="velocityLabels()"
|
||||||
|
[series]="velocitySeries()"
|
||||||
|
[summary]="velocitySummary()"
|
||||||
|
[height]="200"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!project.sprints().length) {
|
@if (!project.sprints().length) {
|
||||||
<pi-empty-state
|
<pi-empty-state
|
||||||
icon="flag"
|
icon="flag"
|
||||||
@@ -367,6 +399,18 @@ const STATUS: Record<SprintStatus, string> = {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.ts-velocity {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-sprint__warn {
|
||||||
|
color: var(--ts-c-amber, var(--ts-muted));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
.ts-pill[data-status='ACTIVE'] {
|
.ts-pill[data-status='ACTIVE'] {
|
||||||
--tone: var(--ts-brand);
|
--tone: var(--ts-brand);
|
||||||
}
|
}
|
||||||
@@ -446,6 +490,40 @@ export class SprintsPage {
|
|||||||
: `Burndown del sprint: quedan ${open} tickets por terminar`;
|
: `Burndown del sprint: quedan ${open} tickets por terminar`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Velocity */
|
||||||
|
protected readonly paceWindow = 3;
|
||||||
|
protected readonly pace = computed(() =>
|
||||||
|
velocity(this.project.sprints(), this.paceWindow),
|
||||||
|
);
|
||||||
|
protected readonly velocityLabels = computed(() =>
|
||||||
|
this.pace().history.map((h) => h.name),
|
||||||
|
);
|
||||||
|
protected readonly velocitySeries = computed<ChartSeries[]>(() => [
|
||||||
|
{
|
||||||
|
label: 'Comprometidos',
|
||||||
|
values: this.pace().history.map((h) => h.committed),
|
||||||
|
color: 'var(--ts-muted)',
|
||||||
|
dashed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Terminados',
|
||||||
|
values: this.pace().history.map((h) => h.done),
|
||||||
|
color: 'var(--ts-viz-1)',
|
||||||
|
area: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
protected readonly velocitySummary = computed(() => {
|
||||||
|
const { history, average } = this.pace();
|
||||||
|
return `Velocidad de ${history.length} sprints cerrados${
|
||||||
|
average === null ? '' : `: media de ${average} puntos`
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
/** A planned sprint holding more than the team usually finishes. */
|
||||||
|
protected overCommitted = (sprint: Sprint) => {
|
||||||
|
const average = this.pace().average;
|
||||||
|
return average !== null && sprint.progress.storyPoints > average;
|
||||||
|
};
|
||||||
|
|
||||||
/* Plan / edit */
|
/* Plan / edit */
|
||||||
protected readonly editing = signal<Sprint | null>(null);
|
protected readonly editing = signal<Sprint | null>(null);
|
||||||
protected readonly name = signal('');
|
protected readonly name = signal('');
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ export const RELEASE_STATUS: Record<
|
|||||||
[ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' },
|
[ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** "Sin estimar" + the usual scale (+ `current` if it is off the scale). */
|
||||||
|
export function pointOptions(
|
||||||
|
scale: readonly number[],
|
||||||
|
current: number | null = null,
|
||||||
|
): { value: string; label: string }[] {
|
||||||
|
const values = [...scale];
|
||||||
|
if (current !== null && !values.includes(current)) values.push(current);
|
||||||
|
return [
|
||||||
|
{ value: '', label: 'Sin estimar' },
|
||||||
|
...values
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((v) => ({
|
||||||
|
value: String(v),
|
||||||
|
label: v === 1 ? '1 punto' : `${v} puntos`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const options = <T extends string>(
|
export const options = <T extends string>(
|
||||||
record: Record<T, string | { label: string }>,
|
record: Record<T, string | { label: string }>,
|
||||||
) =>
|
) =>
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
|||||||
>↑ {{ parent.key }}</span
|
>↑ {{ parent.key }}</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
@if (issue().storyPoints !== null) {
|
||||||
|
<span class="ts-card__points" title="Estimación">{{
|
||||||
|
issue().storyPoints
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
@if (issue().release; as release) {
|
@if (issue().release; as release) {
|
||||||
<span class="ts-card__meta" title="Release"
|
<span class="ts-card__meta" title="Release"
|
||||||
>◆ {{ release.name }}</span
|
>◆ {{ release.name }}</span
|
||||||
@@ -73,6 +78,18 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.ts-card__points {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ts-line);
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.ts-card {
|
.ts-card {
|
||||||
--type: var(--ts-muted);
|
--type: var(--ts-muted);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user