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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
373
libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts
Normal file
373
libs/web/tracker/presenters/src/lib/pages/roadmap.page.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user