import { ChangeDetectionStrategy, Component, computed, effect, inject, input, untracked, } from '@angular/core'; import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui'; import { provideSharedDashboardFacade, SharedDashboardFacade, } from '@task-stream/web/tracker/application'; import { tone } from '../ui/copy'; import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap'; import { ReleaseForecastComponent } from '../ui/insights/release-forecast'; import { SprintPulseComponent } from '../ui/insights/sprint-pulse'; /** * /share/:token — a project's dashboard for its clients, without an * account: only the blocks its owners chose, read again every minute. */ @Component({ selector: 'ts-shared-dashboard-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ PiEmptyStateComponent, PiSkeletonComponent, SprintPulseComponent, ReleaseForecastComponent, ActivityHeatmapComponent, ], template: ` @if (shared.missing()) { } @else if (shared.dashboard(); as d) {

{{ d.projectKey }}

{{ d.projectName }}

Estado del proyecto · actualizado a las {{ time(d.generatedAt) }} · se actualiza solo

@if (d.flow; as flow) {

Flujo

{{ total() }} tickets
@if (total()) {
    @for (s of flow; track s.status) {
  • {{ s.count }} {{ s.name }}
  • }
} @else {

Todavía no hay tickets.

}
} @if (d.blocks.includes('sprint')) { @if (d.sprint; as sprint) { } @else {

Sprint

No hay un sprint en curso.

} } @if (d.releases; as releases) { @if (releases.length) { } @else {

Próximas releases

Ninguna planificada.

} } @if (d.epics; as epics) {

Épicas

Abiertas, por fecha
@if (epics.length) {
    @for (e of epics; track e.key) {
  • {{ e.title }} {{ e.done }}/{{ e.total }} @if (e.dueDate) { · {{ date(e.dueDate) }} }
  • }
} @else {

Ninguna épica abierta.

}
} @if (d.activity; as activity) { }
} @else if (shared.error(); as error) { } @else { } `, styles: ` :host { display: grid; gap: 1.5rem; } .ts-shared { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr)); gap: 1.25rem; align-items: start; } .ts-shared__wide { grid-column: 1 / -1; } .ts-panel { padding: 1.5rem; } .ts-panel__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; h2 { margin: 0; font-size: 1.125rem; font-weight: 700; } .ts-muted { font-size: 0.8125rem; } } .ts-shared__title { margin: 0 0 0.5rem; font-size: 1.125rem; font-weight: 700; } .ts-flow__bar { display: flex; gap: 3px; height: 2rem; span { flex-basis: 0; border-radius: 0.375rem; background: var(--tone); } } .ts-flow__legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 0.875rem 0 0; padding: 0; list-style: none; font-size: 0.875rem; li { display: inline-flex; align-items: center; gap: 0.375rem; &::before { content: ''; width: 0.625rem; height: 0.625rem; border-radius: 0.1875rem; background: var(--tone); } &.is-empty { color: var(--ts-muted); } } } .ts-flow__count { font-family: var(--ts-font-mono); font-weight: 600; } .ts-epics { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.25rem 0.75rem; } } .ts-epics__title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9375rem; } .ts-epics__meta { color: var(--ts-muted); font-family: var(--ts-font-mono); font-size: 0.75rem; } .ts-epics__bar { grid-column: 1 / -1; height: 0.5rem; border-radius: 0.25rem; background: var(--ts-sunken); overflow: hidden; span { display: block; height: 100%; background: var(--ts-brand); } } .ts-shared__footer { margin: 0; color: var(--ts-muted); font-size: 0.75rem; text-align: center; } `, providers: [provideSharedDashboardFacade()], }) export class SharedDashboardPage { /** Route param (`withComponentInputBinding`). */ readonly token = input.required(); protected readonly shared = inject(SharedDashboardFacade); protected readonly total = computed(() => (this.shared.dashboard()?.flow ?? []).reduce((n, s) => n + s.count, 0), ); protected readonly flowLabel = computed(() => (this.shared.dashboard()?.flow ?? []) .map((s) => `${s.name}: ${s.count}`) .join(', '), ); constructor() { effect(() => { const token = this.token(); untracked(() => this.shared.open(token)); }); } protected readonly toneOf = tone; protected time(iso: string): string { return new Date(iso).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit', }); } protected date(day: string): string { return new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', { day: 'numeric', month: 'short', timeZone: 'UTC', }); } }