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

@@ -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';

View File

@@ -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. */

View File

@@ -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. */

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
storyPoints
startDate
dueDate
}
fragment IssueFields on Issue {
...IssueSummaryFields
description
figmaUrl
dueDate
createdAt
allowedTransitions
reporter {

View File

@@ -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' } },
],
},
},

View File

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

View File

@@ -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>

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

View File

@@ -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',