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:
2026-10-08 15:13:58 +02:00
parent 09bd8c19a3
commit 45a3dc1e16
28 changed files with 698 additions and 10 deletions

View File

@@ -8503,6 +8503,12 @@
"type": "number", "type": "number",
"nullable": true "nullable": true
}, },
"startDate": {
"type": "string",
"nullable": true,
"format": "date",
"description": "When work should start (roadmap)"
},
"dueDate": { "dueDate": {
"type": "string", "type": "string",
"nullable": true, "nullable": true,
@@ -8583,6 +8589,7 @@
"labels", "labels",
"figmaUrl", "figmaUrl",
"storyPoints", "storyPoints",
"startDate",
"dueDate", "dueDate",
"links", "links",
"attachments", "attachments",
@@ -9070,6 +9077,12 @@
"minimum": 0, "minimum": 0,
"maximum": 100 "maximum": 100
}, },
"startDate": {
"type": "string",
"nullable": true,
"format": "date",
"description": "When work should start (roadmap)"
},
"dueDate": { "dueDate": {
"type": "string", "type": "string",
"nullable": true, "nullable": true,
@@ -9191,6 +9204,12 @@
"minimum": 0, "minimum": 0,
"maximum": 100 "maximum": 100
}, },
"startDate": {
"type": "string",
"nullable": true,
"format": "date",
"description": "When work should start (roadmap)"
},
"dueDate": { "dueDate": {
"type": "string", "type": "string",
"nullable": true, "nullable": true,

View File

@@ -149,6 +149,9 @@ input CreateIssueInput {
priority: IssuePriority priority: IssuePriority
projectId: ID! projectId: ID!
releaseId: ID releaseId: ID
"""When work should start (roadmap), YYYY-MM-DD"""
startDate: String
storyPoints: Int storyPoints: Int
title: String! title: String!
@@ -262,6 +265,9 @@ type Issue {
reporter: UserRef! reporter: UserRef!
sprint: SprintRef sprint: SprintRef
"""When work should start (roadmap)"""
startDate: String
"""A status id of the project's settings""" """A status id of the project's settings"""
status: String! status: String!
statusCategory: StatusCategory! statusCategory: StatusCategory!
@@ -955,6 +961,9 @@ input UpdateIssueInput {
issueId: ID! issueId: ID!
labels: [String!] labels: [String!]
priority: IssuePriority priority: IssuePriority
"""When work should start (roadmap), YYYY-MM-DD"""
startDate: String
storyPoints: Int storyPoints: Int
title: String title: String
type: String type: String

View File

@@ -1417,6 +1417,11 @@ export interface components {
/** @description Figma frame */ /** @description Figma frame */
figmaUrl: string | null; figmaUrl: string | null;
storyPoints: number | null; storyPoints: number | null;
/**
* Format: date
* @description When work should start (roadmap)
*/
startDate: string | null;
/** Format: date */ /** Format: date */
dueDate: string | null; dueDate: string | null;
links: components["schemas"]["IssueLinkDto"][]; links: components["schemas"]["IssueLinkDto"][];
@@ -1567,6 +1572,11 @@ export interface components {
/** @description Figma frame (https) for UI changes */ /** @description Figma frame (https) for UI changes */
figmaUrl?: string | null; figmaUrl?: string | null;
storyPoints?: number | null; storyPoints?: number | null;
/**
* Format: date
* @description When work should start (roadmap)
*/
startDate?: string | null;
/** Format: date */ /** Format: date */
dueDate?: string | null; dueDate?: string | null;
/** /**
@@ -1610,6 +1620,11 @@ export interface components {
/** @description Figma frame (https) for UI changes */ /** @description Figma frame (https) for UI changes */
figmaUrl?: string | null; figmaUrl?: string | null;
storyPoints?: number | null; storyPoints?: number | null;
/**
* Format: date
* @description When work should start (roadmap)
*/
startDate?: string | null;
/** Format: date */ /** Format: date */
dueDate?: string | null; dueDate?: string | null;
/** /**

View File

@@ -30,6 +30,7 @@ describe('appRoutes', () => {
'issues/:issueKey', 'issues/:issueKey',
'backlog', 'backlog',
'sprints', 'sprints',
'roadmap',
'epics', 'epics',
'releases', 'releases',
'dashboard', 'dashboard',

View File

@@ -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 | | 🎲 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 |
| 🗺️ 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 | | 🧩 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 | | 👀 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 | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |

View File

@@ -84,6 +84,7 @@ const FIELD: Record<string, string> = {
labels: 'las etiquetas', labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma', figmaUrl: 'el enlace de Figma',
storyPoints: 'los puntos', storyPoints: 'los puntos',
startDate: 'la fecha de inicio',
dueDate: 'la fecha límite', dueDate: 'la fecha límite',
}; };

View File

@@ -55,6 +55,7 @@ export class IssueCreatedEventHandler
labels: issue.labels, labels: issue.labels,
figmaUrl: issue.figmaUrl, figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints, storyPoints: issue.storyPoints,
startDate: issue.startDate ?? null,
dueDate: issue.dueDate, dueDate: issue.dueDate,
createdAt: issue.createdAt, createdAt: issue.createdAt,
updatedAt: issue.updatedAt, updatedAt: issue.updatedAt,

View File

@@ -35,6 +35,11 @@ export class IssueFactory {
workflow.assertActiveArea(props.area); workflow.assertActiveArea(props.area);
const title = details.title ?? ''; const title = details.title ?? '';
assertThat(title.length > 0, 'Issue title is required'); 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 now = new Date().toISOString();
const data: IssueProps = { const data: IssueProps = {
@@ -54,6 +59,7 @@ export class IssueFactory {
labels: details.labels ?? [], labels: details.labels ?? [],
figmaUrl: details.figmaUrl ?? null, figmaUrl: details.figmaUrl ?? null,
storyPoints: details.storyPoints ?? null, storyPoints: details.storyPoints ?? null,
startDate: details.startDate ?? null,
dueDate: details.dueDate ?? null, dueDate: details.dueDate ?? null,
links: [], links: [],
createdAt: now, createdAt: now,

View File

@@ -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', () => { describe('backlog order', () => {
it('moves between two tickets, to the top, and survives a replay', () => { it('moves between two tickets, to the top, and survives a replay', () => {
const issue = newIssue(); const issue = newIssue();

View File

@@ -55,6 +55,8 @@ export interface IssueDetails {
labels?: string[]; labels?: string[];
figmaUrl?: string | null; figmaUrl?: string | null;
storyPoints?: number | null; storyPoints?: number | null;
/** When work on it should start (roadmap); `dueDate` is when it should end. */
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
} }
@@ -77,6 +79,7 @@ export interface IssueProps {
labels: string[]; labels: string[];
figmaUrl: string | null; figmaUrl: string | null;
storyPoints: number | null; storyPoints: number | null;
startDate?: string | null;
dueDate: string | null; dueDate: string | null;
links: IssueLink[]; links: IssueLink[];
createdAt: string; createdAt: string;
@@ -103,6 +106,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public labels: string[] = []; public labels: string[] = [];
public figmaUrl: string | null = null; public figmaUrl: string | null = null;
public storyPoints: number | null = null; public storyPoints: number | null = null;
public startDate: string | null = null;
public dueDate: string | null = null; public dueDate: string | null = null;
public links: IssueLink[] = []; public links: IssueLink[] = [];
public attachments: IssueAttachment[] = []; public attachments: IssueAttachment[] = [];
@@ -137,6 +141,18 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
if (JSON.stringify(value) === JSON.stringify(this[field])) continue; if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
(patch as Record<string, unknown>)[field] = value; (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) if (patch.title !== undefined)
assertThat(patch.title.length > 0, 'Issue title is required'); assertThat(patch.title.length > 0, 'Issue title is required');
if (patch.type !== undefined) workflow.assertActiveType(patch.type); 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', '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) { if (details.dueDate !== undefined && details.dueDate !== null) {
assertThat( assertThat(
!Number.isNaN(Date.parse(details.dueDate)), !Number.isNaN(Date.parse(details.dueDate)),

View File

@@ -74,6 +74,7 @@ export class IssueReadModel {
labels!: string[]; labels!: string[];
figmaUrl!: string | null; figmaUrl!: string | null;
storyPoints!: number | null; storyPoints!: number | null;
startDate!: string | null;
dueDate!: string | null; dueDate!: string | null;
links!: IssueLinkReadModel[]; links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[]; attachments!: AttachmentReadModel[];

View File

@@ -223,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
watchers: rest.watchers ?? [], watchers: rest.watchers ?? [],
parent: rest.parent?.id ? rest.parent : null, parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number), rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null,
sprint: rest.sprint?.id ? rest.sprint : null, sprint: rest.sprint?.id ? rest.sprint : null,
checklist: rest.checklist ?? [], checklist: rest.checklist ?? [],
}; };

View File

@@ -123,6 +123,14 @@ abstract class IssueFieldsInput {
@Max(100) @Max(100)
storyPoints?: number | null; 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?: @Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
| string | string
| null; | null;

View File

@@ -171,6 +171,11 @@ export class IssueGql {
@Field(() => [String]) labels!: string[]; @Field(() => [String]) labels!: string[];
@Field(() => String, { nullable: true }) figmaUrl!: string | null; @Field(() => String, { nullable: true }) figmaUrl!: string | null;
@Field(() => Int, { nullable: true }) storyPoints!: number | 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(() => String, { nullable: true }) dueDate!: string | null;
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[]; @Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; @Field(() => [AttachmentGql]) attachments!: AttachmentGql[];

View File

@@ -545,6 +545,16 @@ class IssueFieldsDto {
@Max(100) @Max(100)
storyPoints?: number | null; 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' }) @ApiPropertyOptional({ type: String, nullable: true, format: 'date' })
@IsOptional() @IsOptional()
@IsISO8601() @IsISO8601()

View File

@@ -181,6 +181,13 @@ export class IssueDto {
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' }) @ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
figmaUrl!: string | null; figmaUrl!: string | null;
@ApiProperty({ type: Number, nullable: true }) storyPoints!: number | 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' }) @ApiProperty({ type: String, nullable: true, format: 'date' })
dueDate!: string | null; dueDate!: string | null;
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[]; @ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];

View File

@@ -8,3 +8,4 @@ export * from './lib/models/hierarchy';
export * from './lib/models/views'; export * from './lib/models/views';
export * from './lib/models/estimation'; export * from './lib/models/estimation';
export * from './lib/models/swimlanes'; export * from './lib/models/swimlanes';
export * from './lib/models/roadmap';

View File

@@ -29,6 +29,8 @@ export interface CreateIssueInput {
labels?: string[]; labels?: string[];
figmaUrl?: string | null; figmaUrl?: string | null;
storyPoints?: number | null; storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | 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. */

View File

@@ -116,6 +116,9 @@ export interface IssueSummary {
rank: string; rank: string;
/** Estimate; null when nobody estimated it. */ /** Estimate; null when nobody estimated it. */
storyPoints: number | null; storyPoints: number | null;
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
startDate: string | null;
dueDate: string | null;
commentCount: number; commentCount: number;
updatedAt: string; updatedAt: string;
version: number; version: number;
@@ -143,7 +146,6 @@ export interface Issue extends IssueSummary {
description: string | null; description: string | null;
reporter: UserRef; reporter: UserRef;
figmaUrl: string | null; figmaUrl: string | null;
dueDate: string | null;
links: IssueLink[]; links: IssueLink[];
attachments: Attachment[]; attachments: Attachment[];
/** People following the ticket. */ /** People following the ticket. */

View 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);
});
});

View 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;
}

View File

@@ -81,13 +81,14 @@ fragment IssueSummaryFields on Issue {
} }
rank rank
storyPoints storyPoints
startDate
dueDate
} }
fragment IssueFields on Issue { fragment IssueFields on Issue {
...IssueSummaryFields ...IssueSummaryFields
description description
figmaUrl figmaUrl
dueDate
createdAt createdAt
allowedTransitions allowedTransitions
reporter { reporter {

View File

@@ -40,6 +40,8 @@ export type CreateIssueInput = {
priority?: IssuePriority | null | undefined; priority?: IssuePriority | null | undefined;
projectId: string; projectId: string;
releaseId?: string | null | undefined; releaseId?: string | null | undefined;
/** When work should start (roadmap), YYYY-MM-DD */
startDate?: string | null | undefined;
storyPoints?: number | null | undefined; storyPoints?: number | null | undefined;
title: string; title: string;
/** A ticket type id */ /** A ticket type id */
@@ -212,6 +214,8 @@ export type UpdateIssueInput = {
issueId: string; issueId: string;
labels?: Array<string> | null | undefined; labels?: Array<string> | null | undefined;
priority?: IssuePriority | null | undefined; priority?: IssuePriority | null | undefined;
/** When work should start (roadmap), YYYY-MM-DD */
startDate?: string | null | undefined;
storyPoints?: number | null | undefined; storyPoints?: number | null | undefined;
title?: string | null | undefined; title?: string | null | undefined;
type?: string | null | undefined; type?: string | null | undefined;
@@ -293,6 +297,8 @@ export type IssueSummaryFieldsFragment = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -302,7 +308,6 @@ export type IssueSummaryFieldsFragment = {
export type IssueFieldsFragment = { export type IssueFieldsFragment = {
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
dueDate: string | null;
createdAt: string; createdAt: string;
allowedTransitions: Array<string>; allowedTransitions: Array<string>;
id: string; id: string;
@@ -320,6 +325,8 @@ export type IssueFieldsFragment = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
reporter: { id: string; name: string }; reporter: { id: string; name: string };
links: Array<{ links: Array<{
type: IssueLinkType; type: IssueLinkType;
@@ -414,6 +421,8 @@ export type BoardQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -431,7 +440,6 @@ export type IssueQuery = {
issue: { issue: {
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
dueDate: string | null;
createdAt: string; createdAt: string;
allowedTransitions: Array<string>; allowedTransitions: Array<string>;
id: string; id: string;
@@ -449,6 +457,8 @@ export type IssueQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
reporter: { id: string; name: string }; reporter: { id: string; name: string };
links: Array<{ links: Array<{
type: IssueLinkType; type: IssueLinkType;
@@ -496,6 +506,8 @@ export type SearchIssuesQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -649,6 +661,8 @@ export type MyIssuesQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -679,6 +693,8 @@ export type IssueChildrenQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -709,6 +725,8 @@ export type EpicsQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -781,6 +799,8 @@ export type BacklogQuery = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -1410,6 +1430,8 @@ export type IssueChangedSubscription = {
version: number; version: number;
rank: string; rank: string;
storyPoints: number | null; storyPoints: number | null;
startDate: string | null;
dueDate: string | 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;
@@ -1804,6 +1826,8 @@ export const IssueSummaryFieldsFragmentDoc = {
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ {
kind: 'Field', kind: 'Field',
@@ -2018,6 +2041,8 @@ export const IssueFieldsFragmentDoc = {
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{ {
kind: 'Field', kind: 'Field',
@@ -3086,6 +3114,8 @@ export const SearchIssuesDocument = {
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } }, { kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { 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: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
], ],
}, },
}, },

View File

@@ -15,6 +15,7 @@ import { RouterLink } from '@angular/router';
import { import {
PiActionButtonComponent, PiActionButtonComponent,
PiCheckboxComponent, PiCheckboxComponent,
PiDateFieldComponent,
PiFileUploadComponent, PiFileUploadComponent,
PiIconButtonComponent, PiIconButtonComponent,
PiIconComponent, PiIconComponent,
@@ -73,6 +74,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
AttachmentItem, AttachmentItem,
PiActionButtonComponent, PiActionButtonComponent,
PiCheckboxComponent, PiCheckboxComponent,
PiDateFieldComponent,
PiFileUploadComponent, PiFileUploadComponent,
PiIconComponent, PiIconComponent,
PiIconButtonComponent, PiIconButtonComponent,
@@ -638,6 +640,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
> >
<pi-label>Estimación</pi-label> <pi-label>Estimación</pi-label>
</pi-select-field> </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 <pi-select-field
[options]="areaOptions()" [options]="areaOptions()"
[disabled]="!project.canContribute()" [disabled]="!project.canContribute()"
@@ -983,11 +999,26 @@ export class IssuePage {
this.facade.setRelease(releaseId || null); 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: { protected update(changes: {
type?: string; type?: string;
priority?: IssuePriority; priority?: IssuePriority;
area?: string | null; area?: string | null;
storyPoints?: number | null; storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
}): void { }): void {
this.facade.update(changes); this.facade.update(changes);
} }

View File

@@ -88,6 +88,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a> <a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a> <a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
<a routerLink="epics" routerLinkActive="is-active">Épicas</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="releases" routerLinkActive="is-active">Releases</a>
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a> <a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a> <a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>

View 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;
}

View File

@@ -709,10 +709,9 @@ function idFrom(name: string): string {
} }
.ts-status-grid { .ts-status-grid {
display: grid; display: grid;
grid-template-columns: 1.25rem minmax( grid-template-columns:
10rem, 1.25rem minmax(10rem, 1fr)
1fr 9rem 8rem 5.5rem 5.5rem 6.5rem;
) 9rem 8rem 5.5rem 5.5rem 6.5rem;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }

View File

@@ -50,6 +50,12 @@ export const trackerRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./pages/sprints.page').then((m) => m.SprintsPage), 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', path: 'epics',
title: 'Épicas · TaskStream', title: 'Épicas · TaskStream',