import { ChangeDetectionStrategy, Component, computed, inject, } from '@angular/core'; import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { BOARD_COLUMNS, IssueStatus, IssueType, } from '@task-stream/web/tracker/domain'; import { initials, STATUS, TYPE } from '../ui/copy'; /** "Seguimiento": the state of the whole project at a glance (live). */ @Component({ selector: 'ts-dashboard-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [PiSkeletonComponent, PiStatCardComponent], template: ` @if (facade.stats(); as stats) {
@for (kpi of kpis(); track kpi.label) { }

Flujo

Dónde está cada ticket del proyecto
@if (stats.total) { } @else {

Todavía no hay tickets.

}

¿Quién está con qué?

Tickets abiertos

Por tipo

Todos los tickets
} @else { } `, styles: ` /* Same stat row as pireon.dev's hero. */ .ts-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 1.25rem; margin-bottom: 2rem; } .ts-panel { padding: 1.5rem; } .ts-panel__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.125rem; h2 { margin: 0; font-size: 1.125rem; font-weight: 700; } .ts-muted { font-size: 0.8125rem; } } .ts-flow { margin-bottom: 1.25rem; } .ts-flow__bar { display: flex; gap: 3px; height: 2.25rem; } .ts-flow__segment { flex-basis: 0; min-width: 0.5rem; border-radius: 0.375rem; background: var(--tone); } .ts-flow__legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin: 1rem 0 0; padding: 0; list-style: none; li { display: flex; align-items: baseline; gap: 0.4375rem; margin: 0; font-size: 0.8125rem; color: var(--ts-ink-soft); &::before { content: ''; align-self: center; width: 0.5rem; height: 0.5rem; border-radius: 2px; background: var(--tone); } } li.is-empty { opacity: 0.45; } } .ts-flow__count { font-family: var(--ts-font-mono); font-weight: 600; color: var(--ts-ink); } .ts-dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 1.25rem; } .ts-rows { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; li { display: grid; grid-template-columns: 1.75rem minmax(6rem, 9rem) 1fr 2rem; align-items: center; gap: 0.625rem; margin: 0; font-size: 0.875rem; } } .ts-rows--types li { grid-template-columns: minmax(6rem, 9rem) 1fr 2rem; } .ts-rows__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ts-rows__count { font-family: var(--ts-font-mono); font-weight: 600; text-align: right; } .ts-who { display: inline-grid; place-items: center; width: 1.75rem; height: 1.75rem; border-radius: 50%; background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface)); color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink)); font-family: var(--ts-font-mono); font-size: 0.625rem; font-weight: 600; } .ts-who--none { background: transparent; border: 1px dashed var(--ts-line-strong); color: var(--ts-muted); } .ts-bar { height: 0.625rem; border-radius: 0.25rem; background: var(--ts-sunken); overflow: hidden; span { display: block; height: 100%; border-radius: 0.25rem; background: var(--ts-brand); } } .ts-bar--tone span { background: var(--tone); } `, }) export class DashboardPage { protected readonly facade = inject(ProjectFacade); protected readonly initialsOf = initials; private count(status: IssueStatus): number { return ( this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0 ); } protected readonly kpis = computed< { label: string; value: number; icon: string; color: 'primary' | 'accent' | 'warn' | 'highlighted' | 'success'; }[] >(() => { const stats = this.facade.stats(); if (!stats) return []; const bugs = stats.byType.find((t) => t.key === IssueType.BUG)?.count ?? 0; return [ { label: 'Tickets', value: stats.total, icon: 'list', color: 'primary' }, { label: 'Abiertos', value: stats.open, icon: 'time-detail', color: 'accent', }, { label: 'Bugs (total)', value: bugs, icon: 'bug', color: 'warn' }, { label: 'Pendientes de QA', value: this.count(IssueStatus.READY_FOR_QA) + this.count(IssueStatus.IN_QA), icon: 'qa', color: 'highlighted', }, { label: 'QA fallidos', value: this.count(IssueStatus.QA_FAILED), icon: 'exclamation', color: 'warn', }, { label: 'Hechos', value: this.count(IssueStatus.DONE), icon: 'check-rounded', color: 'success', }, ]; }); /** Statuses in workflow order (cancelled at the end). */ protected readonly flow = computed(() => [...BOARD_COLUMNS, IssueStatus.CANCELLED].map((status) => ({ status, label: STATUS[status].label, tone: STATUS[status].tone, count: this.count(status), })), ); protected readonly flowLabel = computed(() => this.flow() .filter((s) => s.count) .map((s) => `${s.label}: ${s.count}`) .join(', '), ); protected readonly maxLoad = computed(() => Math.max( 1, ...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]), ), ); protected readonly typeOf = (key: string) => TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' }; }