Files
task-stream/libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts
Josep Piguillem 45a3dc1e16 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>
2026-10-08 15:14:32 +02:00

374 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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