import { ChangeDetectionStrategy, Component, computed, inject, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { ProjectRole, QA_FAILED, StatusCategory, } from '@task-stream/web/tracker/domain'; import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy'; /** "Seguimiento": the state of the whole project at a glance (live). */ @Component({ selector: 'ts-dashboard-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink, 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.

}

Equipo

{{ team().length }} {{ team().length === 1 ? 'persona' : 'personas' }} · 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); } /* Team: everyone in the project, busiest first; each row opens their board. */ .ts-team { display: grid; margin: 0 -0.5rem; padding: 0; list-style: none; li { margin: 0; } } .ts-team__row { display: grid; grid-template-columns: 1.75rem minmax(0, 1fr) minmax(4rem, 8rem) 2rem 1rem; align-items: center; gap: 0.75rem; min-height: 2.75rem; padding: 0.375rem 0.5rem; border-radius: 0.75rem; color: inherit; font-size: 0.875rem; text-decoration: none; transition: background-color 0.15s ease; } a.ts-team__row:hover { background: var(--pi-color-primary-25); .ts-team__go { opacity: 1; transform: none; } } a.ts-team__row:focus-visible { outline: 2px solid var(--ts-brand); outline-offset: 1px; } .ts-team__who { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem 0.5rem; min-width: 0; } .ts-team__go { color: var(--ts-brand); opacity: 0; transform: translateX(-3px); transition: opacity 0.15s ease, transform 0.15s ease; } @media (max-width: 520px) { /* On a phone the number says enough: give the room to name and role. */ .ts-team__row { grid-template-columns: 1.75rem minmax(0, 1fr) 2rem 1rem; } .ts-team__row .ts-bar { display: none; } } .ts-rows__count.is-zero { color: var(--ts-muted); font-weight: 400; } .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: string): number { return ( this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0 ); } /** Tickets in statuses of a category (whatever the project calls them). */ private countCategory(category: StatusCategory): number { const workflow = this.facade.workflow(); return (this.facade.stats()?.byStatus ?? []) .filter((row) => workflow.status(row.key).category === category) .reduce((sum, row) => sum + row.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 []; // Bugs: every type drawn with the bug icon. const workflow = this.facade.workflow(); const bugs = stats.byType .filter((t) => workflow.type(t.key).icon === 'bug') .reduce((sum, t) => sum + t.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: 'En QA', value: this.countCategory(StatusCategory.QA), icon: 'qa', color: 'highlighted', }, { label: 'QA fallidos', value: this.count(QA_FAILED), icon: 'exclamation', color: 'warn', }, { label: 'Hechos', value: this.countCategory(StatusCategory.DONE), icon: 'check-rounded', color: 'success', }, ]; }); /** * The project's statuses in board order, cancelled ones last. Archived (or * no longer configured) statuses only while tickets still sit in them. */ protected readonly flow = computed(() => { const workflow = this.facade.workflow(); const configured = workflow.settings.statuses.map((s) => s.id); const ids = [ ...configured, ...(this.facade.stats()?.byStatus ?? []) .map((row) => row.key) .filter((key) => !configured.includes(key)), ]; const cancelled = (id: string) => workflow.status(id).category === StatusCategory.CANCELLED; return [...ids.filter((id) => !cancelled(id)), ...ids.filter(cancelled)] .map((status) => ({ status, ...statusLook(workflow, status), archived: workflow.status(status).archived, count: this.count(status), })) .filter((row) => !row.archived || row.count > 0); }); 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]), ), ); /** * Every member with their open tickets (0 included), busiest first. People * who still have tickets but left the project are listed too. */ protected readonly team = computed(() => { const load = new Map(); for (const row of this.facade.stats()?.byAssignee ?? []) { if (row.user) load.set(row.user.id, { name: row.user.name, count: row.count }); } const members = this.facade.members().map((member) => ({ id: member.user.id, name: member.user.name, role: member.role as ProjectRole | null, open: load.get(member.user.id)?.count ?? 0, })); const former = [...load.entries()] .filter(([id]) => !members.some((m) => m.id === id)) .map(([id, { name, count }]) => ({ id, name, role: null, open: count, })); return [...members, ...former].sort( (a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'), ); }); protected readonly unassigned = computed( () => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0, ); protected readonly roleLabel = (role: ProjectRole) => ROLE[role]; protected readonly roleTone = (role: ProjectRole) => ROLE_TONE[role]; protected readonly typeOf = (key: string) => typeLook(this.facade.workflow(), key); }