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>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
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;
|
||
}
|