feat: a roadmap of epics over time
Tickets get a start date next to their due date, both editable on the ticket page. The Hoja de ruta tab lays the epics out over the months as bars filled with their progress, with sprints and releases as landmarks and a line for today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8503,6 +8503,12 @@
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"startDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date",
|
||||
"description": "When work should start (roadmap)"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
@@ -8583,6 +8589,7 @@
|
||||
"labels",
|
||||
"figmaUrl",
|
||||
"storyPoints",
|
||||
"startDate",
|
||||
"dueDate",
|
||||
"links",
|
||||
"attachments",
|
||||
@@ -9070,6 +9077,12 @@
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"startDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date",
|
||||
"description": "When work should start (roadmap)"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
@@ -9191,6 +9204,12 @@
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"startDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date",
|
||||
"description": "When work should start (roadmap)"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
|
||||
@@ -149,6 +149,9 @@ input CreateIssueInput {
|
||||
priority: IssuePriority
|
||||
projectId: ID!
|
||||
releaseId: ID
|
||||
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
title: String!
|
||||
|
||||
@@ -262,6 +265,9 @@ type Issue {
|
||||
reporter: UserRef!
|
||||
sprint: SprintRef
|
||||
|
||||
"""When work should start (roadmap)"""
|
||||
startDate: String
|
||||
|
||||
"""A status id of the project's settings"""
|
||||
status: String!
|
||||
statusCategory: StatusCategory!
|
||||
@@ -955,6 +961,9 @@ input UpdateIssueInput {
|
||||
issueId: ID!
|
||||
labels: [String!]
|
||||
priority: IssuePriority
|
||||
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
title: String
|
||||
type: String
|
||||
|
||||
@@ -1417,6 +1417,11 @@ export interface components {
|
||||
/** @description Figma frame */
|
||||
figmaUrl: string | null;
|
||||
storyPoints: number | null;
|
||||
/**
|
||||
* Format: date
|
||||
* @description When work should start (roadmap)
|
||||
*/
|
||||
startDate: string | null;
|
||||
/** Format: date */
|
||||
dueDate: string | null;
|
||||
links: components["schemas"]["IssueLinkDto"][];
|
||||
@@ -1567,6 +1572,11 @@ export interface components {
|
||||
/** @description Figma frame (https) for UI changes */
|
||||
figmaUrl?: string | null;
|
||||
storyPoints?: number | null;
|
||||
/**
|
||||
* Format: date
|
||||
* @description When work should start (roadmap)
|
||||
*/
|
||||
startDate?: string | null;
|
||||
/** Format: date */
|
||||
dueDate?: string | null;
|
||||
/**
|
||||
@@ -1610,6 +1620,11 @@ export interface components {
|
||||
/** @description Figma frame (https) for UI changes */
|
||||
figmaUrl?: string | null;
|
||||
storyPoints?: number | null;
|
||||
/**
|
||||
* Format: date
|
||||
* @description When work should start (roadmap)
|
||||
*/
|
||||
startDate?: string | null;
|
||||
/** Format: date */
|
||||
dueDate?: string | null;
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('appRoutes', () => {
|
||||
'issues/:issueKey',
|
||||
'backlog',
|
||||
'sprints',
|
||||
'roadmap',
|
||||
'epics',
|
||||
'releases',
|
||||
'dashboard',
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| 🎲 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 |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||
|
||||
@@ -84,6 +84,7 @@ const FIELD: Record<string, string> = {
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
storyPoints: 'los puntos',
|
||||
startDate: 'la fecha de inicio',
|
||||
dueDate: 'la fecha límite',
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export class IssueCreatedEventHandler
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
startDate: issue.startDate ?? null,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
|
||||
@@ -35,6 +35,11 @@ export class IssueFactory {
|
||||
workflow.assertActiveArea(props.area);
|
||||
const title = details.title ?? '';
|
||||
assertThat(title.length > 0, 'Issue title is required');
|
||||
if (details.startDate && details.dueDate)
|
||||
assertThat(
|
||||
Date.parse(details.startDate) <= Date.parse(details.dueDate),
|
||||
'A ticket cannot be due before it starts',
|
||||
);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const data: IssueProps = {
|
||||
@@ -54,6 +59,7 @@ export class IssueFactory {
|
||||
labels: details.labels ?? [],
|
||||
figmaUrl: details.figmaUrl ?? null,
|
||||
storyPoints: details.storyPoints ?? null,
|
||||
startDate: details.startDate ?? null,
|
||||
dueDate: details.dueDate ?? null,
|
||||
links: [],
|
||||
createdAt: now,
|
||||
|
||||
@@ -279,6 +279,26 @@ describe('Issue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dates', () => {
|
||||
it('plans a start and a due date, never the wrong way round', () => {
|
||||
const issue = newIssue();
|
||||
issue.updateDetails('u', {
|
||||
startDate: '2026-11-02',
|
||||
dueDate: '2026-11-20',
|
||||
});
|
||||
expect([issue.startDate, issue.dueDate]).toEqual([
|
||||
'2026-11-02',
|
||||
'2026-11-20',
|
||||
]);
|
||||
expect(() =>
|
||||
issue.updateDetails('u', { startDate: '2026-12-01' }),
|
||||
).toThrow(ValidationError);
|
||||
issue.updateDetails('u', { dueDate: null });
|
||||
issue.updateDetails('u', { startDate: '2026-12-01' });
|
||||
expect(replay(issue, Issue).rebuilt.startDate).toBe('2026-12-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlog order', () => {
|
||||
it('moves between two tickets, to the top, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface IssueDetails {
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
storyPoints?: number | null;
|
||||
/** When work on it should start (roadmap); `dueDate` is when it should end. */
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ export interface IssueProps {
|
||||
labels: string[];
|
||||
figmaUrl: string | null;
|
||||
storyPoints: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate: string | null;
|
||||
links: IssueLink[];
|
||||
createdAt: string;
|
||||
@@ -103,6 +106,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public labels: string[] = [];
|
||||
public figmaUrl: string | null = null;
|
||||
public storyPoints: number | null = null;
|
||||
public startDate: string | null = null;
|
||||
public dueDate: string | null = null;
|
||||
public links: IssueLink[] = [];
|
||||
public attachments: IssueAttachment[] = [];
|
||||
@@ -137,6 +141,18 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
|
||||
(patch as Record<string, unknown>)[field] = value;
|
||||
}
|
||||
const start =
|
||||
patch.startDate !== undefined ? patch.startDate : this.startDate;
|
||||
const due = patch.dueDate !== undefined ? patch.dueDate : this.dueDate;
|
||||
if (
|
||||
(patch.startDate !== undefined || patch.dueDate !== undefined) &&
|
||||
start &&
|
||||
due
|
||||
)
|
||||
assertThat(
|
||||
Date.parse(start) <= Date.parse(due),
|
||||
'A ticket cannot be due before it starts',
|
||||
);
|
||||
if (patch.title !== undefined)
|
||||
assertThat(patch.title.length > 0, 'Issue title is required');
|
||||
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
|
||||
@@ -575,6 +591,12 @@ export function normalizeIssueDetails(details: IssueDetails): IssueDetails {
|
||||
'storyPoints must be an integer between 0 and 100',
|
||||
);
|
||||
}
|
||||
if (details.startDate !== undefined && details.startDate !== null) {
|
||||
assertThat(
|
||||
!Number.isNaN(Date.parse(details.startDate)),
|
||||
'startDate must be an ISO date',
|
||||
);
|
||||
}
|
||||
if (details.dueDate !== undefined && details.dueDate !== null) {
|
||||
assertThat(
|
||||
!Number.isNaN(Date.parse(details.dueDate)),
|
||||
|
||||
@@ -74,6 +74,7 @@ export class IssueReadModel {
|
||||
labels!: string[];
|
||||
figmaUrl!: string | null;
|
||||
storyPoints!: number | null;
|
||||
startDate!: string | null;
|
||||
dueDate!: string | null;
|
||||
links!: IssueLinkReadModel[];
|
||||
attachments!: AttachmentReadModel[];
|
||||
|
||||
@@ -223,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
watchers: rest.watchers ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
startDate: rest.startDate ?? null,
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
checklist: rest.checklist ?? [],
|
||||
};
|
||||
|
||||
@@ -123,6 +123,14 @@ abstract class IssueFieldsInput {
|
||||
@Max(100)
|
||||
storyPoints?: number | null;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'When work should start (roadmap), YYYY-MM-DD',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
startDate?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
|
||||
| string
|
||||
| null;
|
||||
|
||||
@@ -171,6 +171,11 @@ export class IssueGql {
|
||||
@Field(() => [String]) labels!: string[];
|
||||
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
|
||||
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'When work should start (roadmap)',
|
||||
})
|
||||
startDate!: string | null;
|
||||
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
|
||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||
|
||||
@@ -545,6 +545,16 @@ class IssueFieldsDto {
|
||||
@Max(100)
|
||||
storyPoints?: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'date',
|
||||
description: 'When work should start (roadmap)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
startDate?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'date' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
|
||||
@@ -181,6 +181,13 @@ export class IssueDto {
|
||||
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
|
||||
figmaUrl!: string | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) storyPoints!: number | null;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'date',
|
||||
description: 'When work should start (roadmap)',
|
||||
})
|
||||
startDate!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
dueDate!: string | null;
|
||||
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './lib/models/hierarchy';
|
||||
export * from './lib/models/views';
|
||||
export * from './lib/models/estimation';
|
||||
export * from './lib/models/swimlanes';
|
||||
export * from './lib/models/roadmap';
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface CreateIssueInput {
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
storyPoints?: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
|
||||
@@ -116,6 +116,9 @@ export interface IssueSummary {
|
||||
rank: string;
|
||||
/** Estimate; null when nobody estimated it. */
|
||||
storyPoints: number | null;
|
||||
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
@@ -143,7 +146,6 @@ export interface Issue extends IssueSummary {
|
||||
description: string | null;
|
||||
reporter: UserRef;
|
||||
figmaUrl: string | null;
|
||||
dueDate: string | null;
|
||||
links: IssueLink[];
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
|
||||
33
libs/web/tracker/domain/src/lib/models/roadmap.spec.ts
Normal file
33
libs/web/tracker/domain/src/lib/models/roadmap.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
roadmapBar,
|
||||
roadmapMonths,
|
||||
roadmapOffset,
|
||||
roadmapRange,
|
||||
} from './roadmap';
|
||||
|
||||
const today = new Date('2026-10-08T12:00:00Z');
|
||||
|
||||
describe('roadmap', () => {
|
||||
it('spans whole months around today and the dates it shows', () => {
|
||||
const range = roadmapRange(['2026-11-20', null, '2027-01-15'], today);
|
||||
expect(new Date(range.from).toISOString().slice(0, 10)).toBe('2026-09-01');
|
||||
expect(new Date(range.to).toISOString().slice(0, 10)).toBe('2027-03-01');
|
||||
expect(roadmapMonths(range).map((m) => m.day)).toEqual([
|
||||
'2026-09-01',
|
||||
'2026-10-01',
|
||||
'2026-11-01',
|
||||
'2026-12-01',
|
||||
'2027-01-01',
|
||||
'2027-02-01',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places bars and clamps what falls outside', () => {
|
||||
const range = roadmapRange([], today);
|
||||
const bar = roadmapBar('2026-10-01', '2026-10-31', range);
|
||||
expect(bar.left).toBeGreaterThan(0);
|
||||
expect(bar.left + bar.width).toBeLessThan(100);
|
||||
expect(roadmapOffset('2020-01-01', range)).toBe(0);
|
||||
expect(roadmapOffset('2030-01-01', range)).toBe(100);
|
||||
});
|
||||
});
|
||||
72
libs/web/tracker/domain/src/lib/models/roadmap.ts
Normal file
72
libs/web/tracker/domain/src/lib/models/roadmap.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** Time span a roadmap shows (UTC midnights). */
|
||||
export interface RoadmapRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const at = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
|
||||
|
||||
/**
|
||||
* From two weeks before the earliest date (or today) to a month after the
|
||||
* latest (or two months from today), whole months, a year at most.
|
||||
*/
|
||||
export function roadmapRange(
|
||||
days: (string | null)[],
|
||||
today: Date,
|
||||
): RoadmapRange {
|
||||
const now = Date.UTC(
|
||||
today.getUTCFullYear(),
|
||||
today.getUTCMonth(),
|
||||
today.getUTCDate(),
|
||||
);
|
||||
const known = days.filter((d): d is string => !!d).map(at);
|
||||
const start = Math.min(now - 14 * DAY, ...known);
|
||||
const end = Math.max(now + 60 * DAY, ...known.map((t) => t + 30 * DAY));
|
||||
const first = new Date(start);
|
||||
const from = Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1);
|
||||
const last = new Date(Math.min(end, from + 366 * DAY));
|
||||
const to = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() + 1, 1);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/** Where a day falls, as a percentage of the range (clamped to 0-100). */
|
||||
export function roadmapOffset(day: string, range: RoadmapRange): number {
|
||||
const share = (at(day) - range.from) / (range.to - range.from);
|
||||
return Math.round(Math.min(1, Math.max(0, share)) * 10000) / 100;
|
||||
}
|
||||
|
||||
/** Left and width (%) of a bar from `start` to `end`, both days included. */
|
||||
export function roadmapBar(
|
||||
start: string,
|
||||
end: string,
|
||||
range: RoadmapRange,
|
||||
): { left: number; width: number } {
|
||||
const left = roadmapOffset(start, range);
|
||||
const right = roadmapOffset(
|
||||
new Date(at(end) + DAY).toISOString().slice(0, 10),
|
||||
range,
|
||||
);
|
||||
return { left, width: Math.max(right - left, 0.5) };
|
||||
}
|
||||
|
||||
/** The first day of each month in the range, with its share of the width. */
|
||||
export function roadmapMonths(
|
||||
range: RoadmapRange,
|
||||
): { day: string; left: number; width: number }[] {
|
||||
const months: { day: string; left: number; width: number }[] = [];
|
||||
const cursor = new Date(range.from);
|
||||
while (cursor.getTime() < range.to) {
|
||||
const start = cursor.getTime();
|
||||
const next = Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1);
|
||||
const total = range.to - range.from;
|
||||
months.push({
|
||||
day: new Date(start).toISOString().slice(0, 10),
|
||||
left: ((start - range.from) / total) * 100,
|
||||
width: ((Math.min(next, range.to) - start) / total) * 100,
|
||||
});
|
||||
cursor.setTime(next);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
@@ -81,13 +81,14 @@ fragment IssueSummaryFields on Issue {
|
||||
}
|
||||
rank
|
||||
storyPoints
|
||||
startDate
|
||||
dueDate
|
||||
}
|
||||
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
description
|
||||
figmaUrl
|
||||
dueDate
|
||||
createdAt
|
||||
allowedTransitions
|
||||
reporter {
|
||||
|
||||
@@ -40,6 +40,8 @@ export type CreateIssueInput = {
|
||||
priority?: IssuePriority | null | undefined;
|
||||
projectId: string;
|
||||
releaseId?: string | null | undefined;
|
||||
/** When work should start (roadmap), YYYY-MM-DD */
|
||||
startDate?: string | null | undefined;
|
||||
storyPoints?: number | null | undefined;
|
||||
title: string;
|
||||
/** A ticket type id */
|
||||
@@ -212,6 +214,8 @@ export type UpdateIssueInput = {
|
||||
issueId: string;
|
||||
labels?: Array<string> | null | undefined;
|
||||
priority?: IssuePriority | null | undefined;
|
||||
/** When work should start (roadmap), YYYY-MM-DD */
|
||||
startDate?: string | null | undefined;
|
||||
storyPoints?: number | null | undefined;
|
||||
title?: string | null | undefined;
|
||||
type?: string | null | undefined;
|
||||
@@ -293,6 +297,8 @@ export type IssueSummaryFieldsFragment = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -302,7 +308,6 @@ export type IssueSummaryFieldsFragment = {
|
||||
export type IssueFieldsFragment = {
|
||||
description: string | null;
|
||||
figmaUrl: string | null;
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
allowedTransitions: Array<string>;
|
||||
id: string;
|
||||
@@ -320,6 +325,8 @@ export type IssueFieldsFragment = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -414,6 +421,8 @@ export type BoardQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -431,7 +440,6 @@ export type IssueQuery = {
|
||||
issue: {
|
||||
description: string | null;
|
||||
figmaUrl: string | null;
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
allowedTransitions: Array<string>;
|
||||
id: string;
|
||||
@@ -449,6 +457,8 @@ export type IssueQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -496,6 +506,8 @@ export type SearchIssuesQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -649,6 +661,8 @@ export type MyIssuesQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -679,6 +693,8 @@ export type IssueChildrenQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -709,6 +725,8 @@ export type EpicsQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -781,6 +799,8 @@ export type BacklogQuery = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -1410,6 +1430,8 @@ export type IssueChangedSubscription = {
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -1804,6 +1826,8 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1843,7 +1867,6 @@ export const IssueFieldsFragmentDoc = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -2018,6 +2041,8 @@ export const IssueFieldsFragmentDoc = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2680,6 +2705,8 @@ export const BoardDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2824,6 +2851,8 @@ export const IssueDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2843,7 +2872,6 @@ export const IssueDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -3086,6 +3114,8 @@ export const SearchIssuesDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -4314,6 +4344,8 @@ export const MyIssuesDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -4472,6 +4504,8 @@ export const IssueChildrenDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -4648,6 +4682,8 @@ export const EpicsDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -5128,6 +5164,8 @@ export const BacklogDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -8788,6 +8826,8 @@ export const IssueChangedDocument = {
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiDateFieldComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
@@ -73,6 +74,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
AttachmentItem,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiDateFieldComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
@@ -638,6 +640,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
<pi-label>Estimación</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-date-field
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="dateOf(issue.startDate)"
|
||||
(valueChange)="setDate('startDate', $event)"
|
||||
>
|
||||
<pi-label>Empieza</pi-label>
|
||||
</pi-date-field>
|
||||
<pi-date-field
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="dateOf(issue.dueDate)"
|
||||
(valueChange)="setDate('dueDate', $event)"
|
||||
>
|
||||
<pi-label>Fecha límite</pi-label>
|
||||
</pi-date-field>
|
||||
<pi-select-field
|
||||
[options]="areaOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
@@ -983,11 +999,26 @@ export class IssuePage {
|
||||
this.facade.setRelease(releaseId || null);
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → the local date the picker shows. */
|
||||
protected dateOf(day: string | null): Date | null {
|
||||
return day ? new Date(`${day.slice(0, 10)}T00:00:00`) : null;
|
||||
}
|
||||
|
||||
protected setDate(field: 'startDate' | 'dueDate', date: Date | null): void {
|
||||
const day = date
|
||||
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
: null;
|
||||
if (day !== (this.facade.issue()?.[field]?.slice(0, 10) ?? null))
|
||||
this.update({ [field]: day });
|
||||
}
|
||||
|
||||
protected update(changes: {
|
||||
type?: string;
|
||||
priority?: IssuePriority;
|
||||
area?: string | null;
|
||||
storyPoints?: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
}): void {
|
||||
this.facade.update(changes);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
|
||||
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
||||
|
||||
373
libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts
Normal file
373
libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
|
||||
import {
|
||||
EpicsFacade,
|
||||
ProjectFacade,
|
||||
provideEpicsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Epic,
|
||||
ReleaseStatus,
|
||||
roadmapBar,
|
||||
roadmapMonths,
|
||||
roadmapOffset,
|
||||
roadmapRange,
|
||||
SprintStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { statusLook } from '../ui/copy';
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const monthName = (day: string) =>
|
||||
new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
||||
month: 'short',
|
||||
year: day.endsWith('-01-01') ? '2-digit' : undefined,
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const shortDay = (day: string) =>
|
||||
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/** /p/:key/roadmap — epics over time, with sprints and releases as landmarks. */
|
||||
@Component({
|
||||
selector: 'ts-roadmap-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiNoteComponent, PiSkeletonComponent],
|
||||
providers: [provideEpicsFacade()],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<p class="ts-muted">
|
||||
Las épicas en el tiempo, con los sprints y las releases como
|
||||
referencias. Las fechas de una épica se ponen en su ficha («Empieza» y
|
||||
«Fecha límite»).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (epics.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudo cargar la hoja de ruta"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else if (!epics.loaded()) {
|
||||
<pi-skeleton shape="block" height="16rem" />
|
||||
} @else {
|
||||
<div class="ts-roadmap ts-panel" role="table" aria-label="Hoja de ruta">
|
||||
<div class="ts-roadmap__row ts-roadmap__months" role="row">
|
||||
<span class="ts-roadmap__label" role="columnheader"></span>
|
||||
<div class="ts-roadmap__track" role="columnheader">
|
||||
@for (month of months(); track month.day) {
|
||||
<span
|
||||
class="ts-roadmap__month"
|
||||
[style.left.%]="month.left"
|
||||
[style.width.%]="month.width"
|
||||
>{{ monthLabel(month.day) }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (sprintBands().length) {
|
||||
<div class="ts-roadmap__row" role="row">
|
||||
<span class="ts-roadmap__label ts-muted" role="rowheader"
|
||||
>Sprints</span
|
||||
>
|
||||
<div class="ts-roadmap__track" role="cell">
|
||||
@for (band of sprintBands(); track band.id) {
|
||||
<span
|
||||
class="ts-roadmap__sprint"
|
||||
[class.is-active]="band.active"
|
||||
[style.left.%]="band.left"
|
||||
[style.width.%]="band.width"
|
||||
[title]="band.title"
|
||||
>{{ band.name }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (releaseMarks().length) {
|
||||
<div class="ts-roadmap__row" role="row">
|
||||
<span class="ts-roadmap__label ts-muted" role="rowheader"
|
||||
>Releases</span
|
||||
>
|
||||
<div class="ts-roadmap__track" role="cell">
|
||||
@for (mark of releaseMarks(); track mark.id) {
|
||||
<span
|
||||
class="ts-roadmap__release"
|
||||
[class.is-shipped]="mark.shipped"
|
||||
[style.left.%]="mark.left"
|
||||
[title]="mark.title"
|
||||
><span aria-hidden="true">◆</span> {{ mark.name }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (row of epicRows(); track row.epic.issue.id) {
|
||||
<div class="ts-roadmap__row" role="row">
|
||||
<a
|
||||
class="ts-roadmap__label"
|
||||
role="rowheader"
|
||||
[routerLink]="['..', 'issues', row.epic.issue.key]"
|
||||
>
|
||||
<span class="ts-key">{{ row.epic.issue.key }}</span>
|
||||
{{ row.epic.issue.title }}
|
||||
</a>
|
||||
<div class="ts-roadmap__track" role="cell">
|
||||
<a
|
||||
class="ts-roadmap__bar"
|
||||
[style.left.%]="row.left"
|
||||
[style.width.%]="row.width"
|
||||
[style.--tone]="statusTone(row.epic)"
|
||||
[style.--done.%]="row.epic.progress.percent"
|
||||
[routerLink]="['..', 'issues', row.epic.issue.key]"
|
||||
[title]="row.title"
|
||||
><span class="visually-hidden">{{ row.title }}</span></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<span
|
||||
class="ts-roadmap__today"
|
||||
aria-hidden="true"
|
||||
[style.--at.%]="todayAt()"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
@if (undated().length) {
|
||||
<h2 class="ts-roadmap__title">Épicas sin fechas</h2>
|
||||
<ul class="ts-roadmap__undated">
|
||||
@for (epic of undated(); track epic.issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-key"
|
||||
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||
>{{ epic.issue.key }}</a
|
||||
>
|
||||
{{ epic.issue.title }}
|
||||
<span class="ts-muted ts-mono">{{ epic.progress.percent }}%</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!epicRows().length) {
|
||||
<p class="ts-muted">
|
||||
Aún no hay épicas: un ticket con subtareas aparece aquí en cuanto
|
||||
tenga fechas.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-roadmap {
|
||||
--label: 14rem;
|
||||
position: relative;
|
||||
display: grid;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.ts-roadmap__row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--label) minmax(36rem, 1fr);
|
||||
align-items: center;
|
||||
min-height: 2.25rem;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
&:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
.ts-roadmap__label {
|
||||
overflow: hidden;
|
||||
padding-right: 0.75rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
a.ts-roadmap__label:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
.ts-roadmap__track {
|
||||
position: relative;
|
||||
height: 1.75rem;
|
||||
}
|
||||
.ts-roadmap__months .ts-roadmap__track {
|
||||
height: 1.5rem;
|
||||
}
|
||||
.ts-roadmap__month {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
padding-left: 0.375rem;
|
||||
border-left: 1px solid var(--ts-line);
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ts-roadmap__sprint,
|
||||
.ts-roadmap__bar {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.ts-roadmap__sprint {
|
||||
overflow: hidden;
|
||||
padding: 0 0.375rem;
|
||||
background: var(--ts-line);
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.15rem;
|
||||
white-space: nowrap;
|
||||
&.is-active {
|
||||
background: color-mix(in srgb, var(--ts-brand) 22%, transparent);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
.ts-roadmap__release {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
transform: translateX(-0.375rem);
|
||||
color: var(--ts-c-violet, var(--ts-brand));
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
&.is-shipped {
|
||||
color: var(--ts-c-green, var(--ts-muted));
|
||||
}
|
||||
}
|
||||
.ts-roadmap__bar {
|
||||
--tone: var(--ts-brand);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--tone) var(--done),
|
||||
color-mix(in srgb, var(--tone) 30%, transparent) var(--done)
|
||||
);
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--tone);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
.ts-roadmap__today {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
bottom: 0.75rem;
|
||||
left: calc(
|
||||
0.75rem + var(--label) + (100% - 1.5rem - var(--label)) * var(--at) /
|
||||
100%
|
||||
);
|
||||
width: 2px;
|
||||
background: var(--ts-c-red, var(--ts-brand));
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ts-roadmap__title {
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-roadmap__undated {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ts-roadmap {
|
||||
--label: 8rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RoadmapPage {
|
||||
protected readonly epics = inject(EpicsFacade);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
|
||||
private readonly dated = computed(() =>
|
||||
this.epics.epics().filter((e) => e.issue.startDate || e.issue.dueDate),
|
||||
);
|
||||
protected readonly undated = computed(() =>
|
||||
this.epics.epics().filter((e) => !e.issue.startDate && !e.issue.dueDate),
|
||||
);
|
||||
private readonly range = computed(() =>
|
||||
roadmapRange(
|
||||
[
|
||||
...this.dated().flatMap((e) => [e.issue.startDate, e.issue.dueDate]),
|
||||
...this.project.sprints().flatMap((s) => [s.startDate, s.endDate]),
|
||||
...this.project.releases().map((r) => r.targetDate),
|
||||
],
|
||||
new Date(),
|
||||
),
|
||||
);
|
||||
protected readonly months = computed(() => roadmapMonths(this.range()));
|
||||
protected readonly todayAt = computed(() =>
|
||||
roadmapOffset(today(), this.range()),
|
||||
);
|
||||
protected readonly epicRows = computed(() =>
|
||||
this.dated()
|
||||
.map((epic) => {
|
||||
// One date only: a single-day bar on it.
|
||||
const start = (epic.issue.startDate ?? epic.issue.dueDate) as string;
|
||||
const end = (epic.issue.dueDate ?? epic.issue.startDate) as string;
|
||||
return {
|
||||
epic,
|
||||
start,
|
||||
...roadmapBar(start, end, this.range()),
|
||||
title: `${epic.issue.key} · ${shortDay(start)} – ${shortDay(end)} · ${epic.progress.percent}% terminado`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.start.localeCompare(b.start)),
|
||||
);
|
||||
protected readonly sprintBands = computed(() =>
|
||||
this.project.sprints().map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
active: s.status === SprintStatus.ACTIVE,
|
||||
title: `${s.name} · ${shortDay(s.startDate)} – ${shortDay(s.endDate)}`,
|
||||
...roadmapBar(s.startDate, s.endDate, this.range()),
|
||||
})),
|
||||
);
|
||||
protected readonly releaseMarks = computed(() =>
|
||||
this.project
|
||||
.releases()
|
||||
.filter((r) => r.targetDate && r.status !== ReleaseStatus.ARCHIVED)
|
||||
.map((r) => {
|
||||
const day = (r.releasedAt ?? r.targetDate) as string;
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
shipped: r.status === ReleaseStatus.RELEASED,
|
||||
left: roadmapOffset(day, this.range()),
|
||||
title: `${r.name} · ${shortDay(day)}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
if (projectId) untracked(() => this.epics.open(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected monthLabel = monthName;
|
||||
protected statusTone = (epic: Epic) =>
|
||||
statusLook(this.project.workflow(), epic.issue.status).tone;
|
||||
}
|
||||
@@ -709,10 +709,9 @@ function idFrom(name: string): string {
|
||||
}
|
||||
.ts-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(
|
||||
10rem,
|
||||
1fr
|
||||
) 9rem 8rem 5.5rem 5.5rem 6.5rem;
|
||||
grid-template-columns:
|
||||
1.25rem minmax(10rem, 1fr)
|
||||
9rem 8rem 5.5rem 5.5rem 6.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/sprints.page').then((m) => m.SprintsPage),
|
||||
},
|
||||
{
|
||||
path: 'roadmap',
|
||||
title: 'Hoja de ruta · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/roadmap.page').then((m) => m.RoadmapPage),
|
||||
},
|
||||
{
|
||||
path: 'epics',
|
||||
title: 'Épicas · TaskStream',
|
||||
|
||||
Reference in New Issue
Block a user