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

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