On the project dashboard, a day with activity can be picked on the map: a side panel lists that day's moves grouped by ticket (each key opens it), with the time of each, and the project's own (sprints, releases, members) apart. The activity feed takes an `after` date (GraphQL, REST), so with `before` it reads a window such as a day; the MCP's get_project_activity takes a `day`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1047 lines
31 KiB
TypeScript
1047 lines
31 KiB
TypeScript
import {
|
|
afterRenderEffect,
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
DestroyRef,
|
|
DOCUMENT,
|
|
effect,
|
|
ElementRef,
|
|
HostListener,
|
|
inject,
|
|
signal,
|
|
TemplateRef,
|
|
untracked,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
|
import { RouterLink } from '@angular/router';
|
|
import {
|
|
PiNoteComponent,
|
|
PiSkeletonComponent,
|
|
PiStatCardComponent,
|
|
} from '@pi/ui';
|
|
import {
|
|
InsightsFacade,
|
|
ProjectFacade,
|
|
provideInsightsFacade,
|
|
} from '@task-stream/web/tracker/application';
|
|
import {
|
|
Activity,
|
|
ProjectRole,
|
|
QA_FAILED,
|
|
StatusCategory,
|
|
TeamLoad,
|
|
} from '@task-stream/web/tracker/domain';
|
|
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
|
|
import { AgingComponent } from '../ui/insights/aging';
|
|
import { BottlenecksComponent } from '../ui/insights/bottlenecks';
|
|
import { RisksComponent } from '../ui/insights/risks';
|
|
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
|
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
|
import { ChangesSinceComponent } from '../ui/insights/changes-since';
|
|
import { BacklogHealthComponent } from '../ui/insights/backlog-health';
|
|
import { takeLastVisit } from '../ui/last-visit';
|
|
import { QualityComponent } from '../ui/insights/quality';
|
|
import { FieldBreakdownComponent } from '../ui/insights/field-breakdown';
|
|
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
|
|
import { JourneyComponent } from '../ui/insights/journey';
|
|
import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
|
|
|
|
/** Seconds each block stays in «Modo pantalla». */
|
|
const SLIDE_SECONDS = 15;
|
|
|
|
/** "Seguimiento": the state of the whole project at a glance (live). */
|
|
@Component({
|
|
selector: 'ts-dashboard-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
RouterLink,
|
|
PiNoteComponent,
|
|
PiSkeletonComponent,
|
|
PiStatCardComponent,
|
|
SprintPulseComponent,
|
|
RisksComponent,
|
|
AgingComponent,
|
|
BottlenecksComponent,
|
|
ReleaseForecastComponent,
|
|
ChangesSinceComponent,
|
|
BacklogHealthComponent,
|
|
QualityComponent,
|
|
FieldBreakdownComponent,
|
|
ActivityHeatmapComponent,
|
|
JourneyComponent,
|
|
ProposalsFunnelComponent,
|
|
],
|
|
providers: [provideInsightsFacade()],
|
|
host: { '[class.is-screen]': 'screen()' },
|
|
template: `
|
|
<ng-template #dayPanel>
|
|
@if (insights.day(); as d) {
|
|
@if (!d.entries) {
|
|
<pi-skeleton shape="block" height="8rem" />
|
|
} @else if (d.error) {
|
|
<p class="ts-day__error" role="alert">{{ d.error }}</p>
|
|
} @else {
|
|
<p class="ts-muted ts-day__summary">
|
|
{{ d.entries.length }}
|
|
{{ d.entries.length === 1 ? 'movimiento' : 'movimientos' }}
|
|
@if (d.entries.length >= 200) {
|
|
(los 200 últimos)
|
|
}
|
|
· {{ dayTickets().length }}
|
|
{{ dayTickets().length === 1 ? 'ticket' : 'tickets' }}
|
|
</p>
|
|
@for (ticket of dayTickets(); track ticket.key) {
|
|
<section class="ts-day__ticket">
|
|
<a
|
|
class="ts-key"
|
|
[routerLink]="['../issues', ticket.key]"
|
|
(click)="drawer.closeAll()"
|
|
>{{ ticket.key }}</a
|
|
>
|
|
<ul>
|
|
@for (entry of ticket.entries; track entry.id) {
|
|
<li>
|
|
<time class="ts-mono">{{ time(entry.occurredAt) }}</time>
|
|
{{ entry.summary }}
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
@if (dayOthers().length) {
|
|
<section class="ts-day__ticket">
|
|
<h3>Del proyecto</h3>
|
|
<ul>
|
|
@for (entry of dayOthers(); track entry.id) {
|
|
<li>
|
|
<time class="ts-mono">{{ time(entry.occurredAt) }}</time>
|
|
{{ entry.summary }}
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
}
|
|
}
|
|
</ng-template>
|
|
|
|
@if (screen()) {
|
|
<header class="ts-screen-bar">
|
|
<span class="ts-screen-bar__project">
|
|
<strong>{{ facade.project()?.key }}</strong>
|
|
{{ facade.project()?.name }}
|
|
</span>
|
|
<span class="ts-screen-bar__dots">
|
|
@for (n of slideNumbers(); track n) {
|
|
<button
|
|
type="button"
|
|
[class.is-current]="n === current()"
|
|
[attr.aria-label]="'Ir al bloque ' + (n + 1)"
|
|
(click)="go(n)"
|
|
></button>
|
|
}
|
|
</span>
|
|
<span class="ts-screen-bar__live">
|
|
<span class="ts-screen-bar__dot" aria-hidden="true"></span>
|
|
En directo
|
|
</span>
|
|
<time class="ts-screen-bar__clock">{{ clock() }}</time>
|
|
<button type="button" class="ts-plain-link" (click)="togglePause()">
|
|
{{ paused() ? 'Seguir' : 'Pausa' }}
|
|
</button>
|
|
<button type="button" class="ts-plain-link" (click)="exitScreen()">
|
|
Salir
|
|
</button>
|
|
</header>
|
|
} @else {
|
|
<div class="ts-screen-open">
|
|
<button
|
|
type="button"
|
|
class="ts-plain-link"
|
|
title="A pantalla completa, un bloque cada vez, para una tele del equipo"
|
|
(click)="enterScreen()"
|
|
>
|
|
Modo pantalla
|
|
</button>
|
|
</div>
|
|
}
|
|
@if (facade.stats(); as stats) {
|
|
<section class="ts-kpis" aria-label="Resumen">
|
|
@for (kpi of kpis(); track kpi.label) {
|
|
<pi-stat-card
|
|
[icon]="kpi.icon"
|
|
[label]="kpi.label"
|
|
[value]="'' + kpi.value"
|
|
[color]="kpi.color"
|
|
/>
|
|
}
|
|
</section>
|
|
|
|
<section class="ts-panel ts-flow" aria-labelledby="flow-title">
|
|
<header class="ts-panel__head">
|
|
<h2 id="flow-title">Flujo</h2>
|
|
<span class="ts-muted">Dónde está cada ticket del proyecto</span>
|
|
</header>
|
|
@if (stats.total) {
|
|
<div class="ts-flow__bar" role="img" [attr.aria-label]="flowLabel()">
|
|
@for (segment of flow(); track segment.status) {
|
|
@if (segment.count) {
|
|
<span
|
|
class="ts-flow__segment"
|
|
[style.--tone]="segment.tone"
|
|
[style.flex-grow]="segment.count"
|
|
[attr.title]="segment.label + ': ' + segment.count"
|
|
></span>
|
|
}
|
|
}
|
|
</div>
|
|
<ul class="ts-flow__legend">
|
|
@for (segment of flow(); track segment.status) {
|
|
<li
|
|
[style.--tone]="segment.tone"
|
|
[class.is-empty]="!segment.count"
|
|
>
|
|
<span class="ts-flow__count">{{ segment.count }}</span>
|
|
<span>{{ segment.label }}</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
} @else {
|
|
<p class="ts-muted">Todavía no hay tickets.</p>
|
|
}
|
|
</section>
|
|
|
|
@if (insights.error(); as error) {
|
|
<pi-note
|
|
class="ts-insights-error"
|
|
gap="xs"
|
|
status="error"
|
|
appearance="fill"
|
|
type="status"
|
|
title="No se pudo leer el estado del proyecto"
|
|
[text]="error"
|
|
/>
|
|
} @else if (insights.insights(); as reading) {
|
|
<div class="ts-dashboard ts-insights">
|
|
@if (reading.changes; as changes) {
|
|
<ts-changes-since [changes]="changes" />
|
|
}
|
|
@if (reading.sprint; as sprint) {
|
|
<ts-sprint-pulse [sprint]="sprint" />
|
|
}
|
|
<ts-risks [risks]="reading.risks" />
|
|
@if (reading.releases.length) {
|
|
<ts-release-forecast [releases]="reading.releases" />
|
|
}
|
|
<ts-aging
|
|
[issues]="reading.aging.issues"
|
|
[thresholdDays]="reading.aging.thresholdDays"
|
|
/>
|
|
<ts-bottlenecks
|
|
[statuses]="reading.bottlenecks"
|
|
[workflow]="facade.workflow()"
|
|
/>
|
|
<ts-journey
|
|
[journey]="reading.journey"
|
|
[workflow]="facade.workflow()"
|
|
/>
|
|
<ts-activity-heatmap
|
|
[days]="reading.heatmap"
|
|
[selectable]="true"
|
|
(daySelected)="openDay($event)"
|
|
/>
|
|
<ts-backlog-health [health]="reading.backlog" />
|
|
<ts-quality
|
|
[quality]="reading.quality"
|
|
[workflow]="facade.workflow()"
|
|
/>
|
|
@if (reading.proposals; as proposals) {
|
|
<ts-proposals-funnel [funnel]="proposals" />
|
|
}
|
|
@if (reading.fields.length) {
|
|
<ts-field-breakdown
|
|
[fields]="reading.fields"
|
|
[workflow]="facade.workflow()"
|
|
/>
|
|
}
|
|
</div>
|
|
} @else {
|
|
<pi-skeleton class="ts-insights-loading" shape="block" height="10rem" />
|
|
}
|
|
|
|
<div class="ts-dashboard">
|
|
<section class="ts-panel" aria-labelledby="team-title">
|
|
<header class="ts-panel__head">
|
|
<h2 id="team-title">Equipo</h2>
|
|
<span class="ts-muted"
|
|
>{{ team().length }}
|
|
{{ team().length === 1 ? 'persona' : 'personas' }} · tickets
|
|
abiertos</span
|
|
>
|
|
</header>
|
|
<ul class="ts-team">
|
|
@for (person of team(); track person.id) {
|
|
<li>
|
|
<a
|
|
class="ts-team__row"
|
|
[routerLink]="['../board']"
|
|
[queryParams]="{ assignee: person.id }"
|
|
[attr.aria-label]="
|
|
person.name +
|
|
': ' +
|
|
person.open +
|
|
' tickets abiertos. Ver su tablero'
|
|
"
|
|
>
|
|
<span class="ts-who" aria-hidden="true">{{
|
|
initialsOf(person.name)
|
|
}}</span>
|
|
<span class="ts-team__who">
|
|
<span class="ts-rows__name">{{ person.name }}</span>
|
|
@if (person.role; as role) {
|
|
<span class="ts-pill" [style.--tone]="roleTone(role)">{{
|
|
roleLabel(role)
|
|
}}</span>
|
|
} @else {
|
|
<span class="ts-pill">Ya no es miembro</span>
|
|
}
|
|
@if (loadOf().get(person.id); as load) {
|
|
@if (load.overloaded) {
|
|
<span class="ts-pill ts-pill--warn">Sobrecarga</span>
|
|
}
|
|
<span class="ts-team__load">{{ loadText(load) }}</span>
|
|
}
|
|
</span>
|
|
<span class="ts-bar" aria-hidden="true"
|
|
><span
|
|
[style.width.%]="(person.open / maxLoad()) * 100"
|
|
></span
|
|
></span>
|
|
<span class="ts-rows__count" [class.is-zero]="!person.open">{{
|
|
person.open
|
|
}}</span>
|
|
<span class="ts-team__go" aria-hidden="true">→</span>
|
|
</a>
|
|
</li>
|
|
}
|
|
@if (unassigned()) {
|
|
<li>
|
|
<div class="ts-team__row ts-team__row--static">
|
|
<span class="ts-who ts-who--none" aria-hidden="true">—</span>
|
|
<span class="ts-team__who">
|
|
<span class="ts-rows__name ts-muted">Sin asignar</span>
|
|
</span>
|
|
<span class="ts-bar" aria-hidden="true"
|
|
><span
|
|
[style.width.%]="(unassigned() / maxLoad()) * 100"
|
|
></span
|
|
></span>
|
|
<span class="ts-rows__count">{{ unassigned() }}</span>
|
|
<span class="ts-team__go"></span>
|
|
</div>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
|
|
<section class="ts-panel" aria-labelledby="type-title">
|
|
<header class="ts-panel__head">
|
|
<h2 id="type-title">Por tipo</h2>
|
|
<span class="ts-muted">Todos los tickets</span>
|
|
</header>
|
|
<ul class="ts-rows ts-rows--types">
|
|
@for (row of stats.byType; track row.key) {
|
|
<li [style.--tone]="typeOf(row.key).tone">
|
|
<span class="ts-rows__name">{{ typeOf(row.key).label }}</span>
|
|
<span class="ts-bar ts-bar--tone"
|
|
><span
|
|
[style.width.%]="(row.count / stats.total) * 100"
|
|
></span
|
|
></span>
|
|
<span class="ts-rows__count">{{ row.count }}</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
} @else {
|
|
<pi-skeleton shape="block" height="12rem" />
|
|
}
|
|
`,
|
|
styles: `
|
|
.ts-day__summary {
|
|
margin: 0 0 1rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.ts-day__error {
|
|
color: var(--ts-c-red);
|
|
}
|
|
.ts-day__ticket {
|
|
display: grid;
|
|
gap: 0.375rem;
|
|
padding: 0.75rem 0;
|
|
border-top: 1px solid var(--ts-line);
|
|
h3 {
|
|
margin: 0;
|
|
color: var(--ts-muted);
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
.ts-key {
|
|
justify-self: start;
|
|
font-family: var(--ts-font-mono);
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--ts-brand);
|
|
text-decoration: none;
|
|
&:hover {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
ul {
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
font-size: 0.875rem;
|
|
}
|
|
time {
|
|
margin-right: 0.5rem;
|
|
color: var(--ts-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
}
|
|
.ts-screen-open {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 1rem;
|
|
}
|
|
/* Modo pantalla: over everything, one block at a time, larger. */
|
|
:host(.is-screen) {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 2000;
|
|
display: block;
|
|
overflow: auto;
|
|
padding: 1.25rem 2.5rem 2.5rem;
|
|
background: var(--ts-bg);
|
|
}
|
|
:host(.is-screen) .ts-dashboard,
|
|
:host(.is-screen) .ts-insights {
|
|
display: block;
|
|
}
|
|
:host(.is-screen) .ts-slide-off {
|
|
display: none !important;
|
|
}
|
|
:host(.is-screen) .ts-slide-on {
|
|
max-width: 64rem;
|
|
margin: 0 auto 1.25rem;
|
|
zoom: 1.5;
|
|
}
|
|
.ts-screen-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
margin-bottom: 1.5rem;
|
|
font-size: 1.125rem;
|
|
}
|
|
.ts-screen-bar__project {
|
|
margin-right: auto;
|
|
white-space: nowrap;
|
|
strong {
|
|
font-family: var(--ts-font-mono);
|
|
margin-right: 0.5rem;
|
|
}
|
|
}
|
|
.ts-screen-bar__dots {
|
|
display: flex;
|
|
gap: 0.375rem;
|
|
/* Plain dots: past the app's button styles. */
|
|
button {
|
|
all: unset;
|
|
width: 0.625rem !important;
|
|
min-width: 0 !important;
|
|
height: 0.625rem !important;
|
|
padding: 0 !important;
|
|
border-radius: 50%;
|
|
background: var(--ts-line-strong);
|
|
cursor: pointer;
|
|
&.is-current {
|
|
background: var(--ts-brand);
|
|
}
|
|
&:focus-visible {
|
|
outline: 2px solid var(--ts-brand);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
}
|
|
.ts-screen-bar__live {
|
|
display: inline-flex;
|
|
white-space: nowrap;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: var(--ts-muted);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
.ts-screen-bar__dot {
|
|
width: 0.5rem;
|
|
height: 0.5rem;
|
|
border-radius: 50%;
|
|
background: var(--ts-c-green);
|
|
}
|
|
.ts-screen-bar__clock {
|
|
font-family: var(--ts-font-mono);
|
|
font-weight: 600;
|
|
}
|
|
/* 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-insights,
|
|
.ts-insights-error,
|
|
.ts-insights-loading {
|
|
display: grid;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
.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__load {
|
|
flex-basis: 100%;
|
|
color: var(--ts-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
.ts-pill--warn {
|
|
--tone: var(--ts-c-red, #d14343);
|
|
}
|
|
.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 insights = inject(InsightsFacade);
|
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
protected readonly drawer = inject(DrawerService);
|
|
private readonly dayPanel =
|
|
viewChild.required<TemplateRef<unknown>>('dayPanel');
|
|
|
|
/** A day of the activity map, in a side panel: its tickets and moves. */
|
|
protected openDay(day: string): void {
|
|
const projectId = this.facade.project()?.id;
|
|
if (!projectId) return;
|
|
this.insights.openDay(projectId, day);
|
|
const label = new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
timeZone: 'UTC',
|
|
});
|
|
this.drawer.open(`Actividad del ${label}`, this.dayPanel());
|
|
}
|
|
|
|
/** That day's moves, ticket by ticket (most active first). */
|
|
protected readonly dayTickets = computed(() => {
|
|
const groups = new Map<string, Activity[]>();
|
|
for (const entry of this.insights.day()?.entries ?? [])
|
|
if (entry.issueKey)
|
|
groups.set(entry.issueKey, [
|
|
...(groups.get(entry.issueKey) ?? []),
|
|
entry,
|
|
]);
|
|
return [...groups.entries()]
|
|
.map(([key, entries]) => ({ key, entries }))
|
|
.sort((a, b) => b.entries.length - a.entries.length);
|
|
});
|
|
/** The moves that are not about one ticket (sprints, releases, members). */
|
|
protected readonly dayOthers = computed(() =>
|
|
(this.insights.day()?.entries ?? []).filter((e) => !e.issueKey),
|
|
);
|
|
|
|
protected time(iso: string): string {
|
|
return new Date(iso).toLocaleTimeString('es', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
private readonly document = inject(DOCUMENT);
|
|
|
|
/* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */
|
|
protected readonly screen = signal(false);
|
|
protected readonly paused = signal(false);
|
|
private readonly slide = signal(0);
|
|
private readonly slides = signal(0);
|
|
protected readonly current = computed(() =>
|
|
this.slides() ? this.slide() % this.slides() : 0,
|
|
);
|
|
protected readonly slideNumbers = computed(() =>
|
|
Array.from({ length: this.slides() }, (_, n) => n),
|
|
);
|
|
protected readonly clock = signal('');
|
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
private ticks = 0;
|
|
|
|
private readonly marks = afterRenderEffect(() => {
|
|
this.screen();
|
|
this.current();
|
|
this.insights.insights();
|
|
this.markSlides();
|
|
});
|
|
|
|
protected enterScreen(): void {
|
|
this.screen.set(true);
|
|
this.paused.set(false);
|
|
this.slide.set(0);
|
|
this.updateClock();
|
|
this.host.nativeElement.requestFullscreen?.().catch(() => undefined);
|
|
this.timer = setInterval(() => this.tick(), 1000);
|
|
}
|
|
|
|
protected exitScreen(): void {
|
|
if (!this.screen()) return;
|
|
this.screen.set(false);
|
|
if (this.timer) clearInterval(this.timer);
|
|
this.timer = null;
|
|
if (this.document.fullscreenElement)
|
|
this.document.exitFullscreen().catch(() => undefined);
|
|
}
|
|
|
|
protected togglePause(): void {
|
|
this.paused.update((p) => !p);
|
|
this.ticks = 0;
|
|
}
|
|
|
|
protected go(n: number): void {
|
|
this.slide.set(n);
|
|
this.ticks = 0;
|
|
}
|
|
|
|
/** Every second: the clock, and every 15 s (unless paused) the next block. */
|
|
private tick(): void {
|
|
this.updateClock();
|
|
if (this.paused()) return;
|
|
if (++this.ticks >= SLIDE_SECONDS) {
|
|
this.ticks = 0;
|
|
this.slide.update((n) => n + 1);
|
|
}
|
|
}
|
|
|
|
private updateClock(): void {
|
|
this.clock.set(
|
|
new Date().toLocaleTimeString('es', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The blocks to rotate: the figures and the flow together first, then
|
|
* every panel on its own. Shown or hidden by a class, so the live
|
|
* updates keep landing on the same elements.
|
|
*/
|
|
private markSlides(): void {
|
|
const root = this.host.nativeElement;
|
|
const groups: Element[][] = [];
|
|
const lead = [
|
|
...root.querySelectorAll(':scope > .ts-kpis, :scope > .ts-flow'),
|
|
];
|
|
if (lead.length) groups.push(lead);
|
|
root
|
|
.querySelectorAll(':scope > .ts-dashboard > *')
|
|
.forEach((panel) => groups.push([panel]));
|
|
this.slides.set(groups.length);
|
|
const on = this.screen();
|
|
groups.forEach((group, n) =>
|
|
group.forEach((el) => {
|
|
el.classList.toggle('ts-slide-on', on && n === this.current());
|
|
el.classList.toggle('ts-slide-off', on && n !== this.current());
|
|
}),
|
|
);
|
|
}
|
|
|
|
@HostListener('document:keydown', ['$event'])
|
|
protected onKey(event: KeyboardEvent): void {
|
|
if (!this.screen()) return;
|
|
if (event.key === 'ArrowRight') this.go(this.current() + 1);
|
|
else if (event.key === 'ArrowLeft')
|
|
this.go(
|
|
(this.current() - 1 + this.slides()) % Math.max(1, this.slides()),
|
|
);
|
|
else if (event.key === ' ') this.togglePause();
|
|
else if (event.key === 'Escape') this.exitScreen();
|
|
else return;
|
|
event.preventDefault();
|
|
}
|
|
|
|
/** Leaving the browser's full screen (Esc) leaves the mode too. */
|
|
@HostListener('document:fullscreenchange')
|
|
protected onFullscreenChange(): void {
|
|
if (!this.document.fullscreenElement) this.exitScreen();
|
|
}
|
|
|
|
/** Opened once per project: the project itself changes on every update. */
|
|
private openedFor: string | null = null;
|
|
|
|
constructor() {
|
|
inject(DestroyRef).onDestroy(() => {
|
|
if (this.timer) clearInterval(this.timer);
|
|
});
|
|
effect(() => {
|
|
const projectId = this.facade.project()?.id;
|
|
if (!projectId || projectId === this.openedFor) return;
|
|
this.openedFor = projectId;
|
|
untracked(() => this.insights.open(projectId, takeLastVisit(projectId)));
|
|
});
|
|
}
|
|
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<string, { name: string; count: number }>();
|
|
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'),
|
|
);
|
|
});
|
|
/** Open points and usual pace per person (from the insights). */
|
|
protected readonly loadOf = computed(
|
|
() =>
|
|
new Map(
|
|
(this.insights.insights()?.team ?? []).map((t) => [t.user.id, t]),
|
|
),
|
|
);
|
|
protected loadText(load: TeamLoad): string {
|
|
const parts = [`${load.openPoints} pts abiertos`];
|
|
if (load.pointsPerSprint !== null)
|
|
parts.push(
|
|
`cierra ${load.pointsPerSprint.toLocaleString('es')} por sprint`,
|
|
);
|
|
if (load.blocked)
|
|
parts.push(
|
|
`${load.blocked} ${load.blocked === 1 ? 'bloqueado' : 'bloqueados'}`,
|
|
);
|
|
return parts.join(' · ');
|
|
}
|
|
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);
|
|
}
|