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: `

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»).

@if (epics.error(); as error) { } @else if (!epics.loaded()) { } @else {
@for (month of months(); track month.day) { {{ monthLabel(month.day) }} }
@if (sprintBands().length) {
Sprints
@for (band of sprintBands(); track band.id) { {{ band.name }} }
} @if (releaseMarks().length) {
Releases
@for (mark of releaseMarks(); track mark.id) { {{ mark.name }} }
} @for (row of epicRows(); track row.epic.issue.id) { }
@if (undated().length) {

Épicas sin fechas

} @else if (!epicRows().length) {

Aún no hay épicas: un ticket con subtareas aparece aquí en cuanto tenga fechas.

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